commits
`tsk assign` target arg is now optional; omit it to fzf-pick from the
existing queues (active queue is excluded since assign refuses
self-targets anyway).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks the queue ref's commit chain (pushes, drops, inbox moves), same
oneline-style output as `tsk log task` and `tsk log namespace`.
Defaults to the active queue when no name is given.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors `tsk switch` (namespace) — `tsk queue switch` now takes an
optional name and fzf-picks when omitted, with a `<new>` sentinel that
creates the queue (can-pull=false) on the fly. Generalized the picker
helper so namespace and queue share one code path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chose option A from the design discussion: don't auto-mutate the
user's git config, just document the opt-in path more clearly. The
trade-offs that ruled out auto-setup:
- Setting `remote.<name>.push` overrides `push.default`. Once we
configure it, plain `git push` no longer pushes branches the way
the user expects — it pushes whatever's in the configured refspec.
Adding `HEAD` softens but doesn't eliminate the surprise (anyone
on push.default=matching is silently narrowed).
- Mutating `.git/config` from `tsk show` / `tsk list` is invasive.
Some users may not want tsk refs riding along on their pushes.
So `tsk git-setup` stays explicit. AGENTS.md gains a note documenting
the trade-off so users aren't surprised when running it changes their
push behavior; the recommended path for users who don't want that is
to keep using `tsk git-push` / `tsk git-pull` and skip git-setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per follow-up: `tsk remote add` and `tsk remote remove` were thin
wrappers around git's own commands; users can run `git remote add`
themselves and follow with `tsk git-setup -r <name>` when they want
the tsk refspecs configured. `tsk remote list` was equivalent to
`git remote`. All three are gone.
What's left is the tsk-specific state:
- `tsk remote default` — print the persisted default
- `tsk remote set-default <n>` — persist `<n>` as the default;
validates against `git remote` first so we can't be pointed at a
remote the host repo doesn't know about.
Workspace::git_remotes survives as the validation helper; the
git_remote_add / git_remote_remove wrappers are gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk remote` subcommands:
- `list` — wraps `git remote`
- `default` — print the active default
- `set-default <n>` — persist the default to <git-dir>/tsk/remote
- `add <name> <url>` — `git remote add` + configure tsk refspecs
- `remove <name>` — `git remote remove`
The default remote is a per-clone selector parallel to `namespace`
and `queue`, stored at `<git-dir>/tsk/remote`. Falls back to
`origin` when absent. `tsk git-push`, `tsk git-pull`, `tsk
git-setup`, and the auto-push paths after assign/accept/reject all
consult it when no explicit `<remote>` / `-R <remote>` is given.
ARCHITECTURE.md and AGENTS.md updated to mention the new selector
and the new commands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A tour of the moving pieces, complementing the user-facing crib in
AGENTS.md. Covers:
- on-disk layout (refs/tsk/* shared, <git-dir>/tsk/* per-clone)
- the two id layers (stable = SHA-1 of content blob, human = per-ns)
- module map (mermaid)
- task lifecycle (open/done/reopen, including reopen-on-duplicate)
- write order in `tsk push` and how gc_refs recovers partial writes
- git-pull reconciliation flow + per-ref-class strategy table
- wire formats (git refs vs. mbox patch series)
- per-clone overrides (-q flag + switch commands)
- length-prefix property value codec
- reconciliation matrix mapping each drift class to its detector
and fix
Concurrent pushes used to force-overwrite refs/tsk/queues/*; only the
namespace renumber and the task ref reconciler had real merging.
git-pull now does a 3-way merge of queue trees too:
- index: per-stable 3-way set merge. Entries present in base survive
only if both sides keep them; entries added on either side are
included; removals on either side are honored. Order is
remote-first, then local additions appended.
- inbox: per-key 3-way map merge. Removals on either side win;
remote wins on simultaneous-add conflicts (per-source seq keys make
that impossible in practice).
- can_pull: 3-way bool with local-change-wins.
When the two sides have no common ancestor (each clone independently
rooted its queue ref before any sync), the base is treated as empty
— same handling we should apply to other reconcile paths if their
own first-pull case ever bites.
queue::read_at_commit and queue::build_tree are now `pub` so the
merge driver can read divergent tips and write the merged tree
without going through the active-ref-only `read`/`write` API.
fast_forward_non_task_refs skips queues now that they're properly
reconciled.
Updates concurrent_pushes_dont_clobber to assert the new contract:
both alice's and bob's tasks survive the pull (the test previously
documented the absence of this driver).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The markup parser already produces ParsedLink entries with the right
classification (Internal / Namespaced / Foreign / External) and tags
each occurrence with a superscript footnote number in the inline
output. command_show now renders the matching footnote section after
the body:
- Internal(id) → look up in the active namespace; emit
`tsk-N: <title>` if bound, `tsk-N: <not bound in '<ns>'>` otherwise.
- Namespaced{ns, id} → echo `<ns>/tsk-N` (cross-namespace title
resolution + stable-id rewriting on share is the deferred half of
this task — left for when we actually do cross-namespace renders).
- Foreign{prefix, id} → `<prefix>-<id> (foreign)`.
- External(url) → the URL.
super_num() is now `pub(crate)` so command_show can produce matching
markers for the footnote section.
Smoke-tested with a body containing one resolvable link, one missing
id, and one namespaced link — all three rendered with the expected
suffix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk reopen -T tsk-N` (or `-r`) sets status=open on the task and
pushes its stable id to the top of the active queue. Idempotent on
already-open tasks (`save_task` is a no-op on an unchanged tree;
`queue::push_top` deduplicates).
Pushing duplicate content already triggered the same reopen path
(tsk-28); this gives the explicit by-id version the original task
asked for.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
new_task / accept_inbox / pull_from_queue write to several refs in
sequence (task object → property index → namespace binding → queue
index). The order is intentional — an unfinished operation is always
recoverable from the refs that did land — but until now nothing
actively reconciled the drift. tsk-25 picks the "ordered writes +
idempotent reconcile" combo from the original task description.
Write ordering is already correct, so no behavior change there. The
reconcile pass extends `Workspace::gc_refs` (already invoked by
`tsk fix-up`) with two new classes of cleanup:
- ghost namespace bindings: `human → stable` mappings whose stable id
has no task ref. Left behind by a crash between `object::create`
and a later step, or by a force-pushed namespace ref that arrived
ahead of its task refs.
- orphan queue index entries: stables in a queue's index that no
longer resolve to a task ref. Same root cause; previously only the
active queue could be cleaned via `tsk clean`.
Return type widens from `(usize, usize)` → `(usize, usize, usize, usize)`;
the fix-up printer reports each class. Idempotent — second pass is a
no-op (verified by test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Workspace::gc_refs(), invoked as a third stage of `tsk fix-up`
after backfill-status and migrate-property-encoding:
- Walks `refs/tsk/queues/*`, deletes any queue whose index AND inbox
are empty (skips the default `tsk` queue, which is conventional).
- Walks `refs/tsk/properties/*` and drops any entry whose stable id
no longer resolves to a task ref; properties::set already deletes
the index ref when its last entry goes.
Task object refs and namespace refs are left alone — task history is
preserved by design, and namespace `next` counters remain valid even
when no live binding uses the latest id.
Verified idempotent on the live repo (one empty queue pruned on first
run, zero on the second). Unit test covers both pruning paths plus
the second-pass no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `title` blob in each task's tree was a cache of `content`'s first
line — `Task::title()` already computes that for free, so the cache
added nothing while creating drift risk if anything ever wrote one
without the other. patch::write_entry already skipped the blob on
export.
Changes:
- build_tree no longer takes or inserts a `title` entry.
- read still tolerates the field for backward compatibility (it's
matched and discarded).
- Existing trees migrate on next save: `tsk fix-up`'s
`migrate_property_encoding` already re-saves every task, and the
new build_tree drops the title blob in the rewritten tree (smoke-
tested on the live repo: 35 tasks rewritten, ls-tree on the new
tip shows only `content` + property blobs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a top-level `-q <queue>` / `--queue <queue>` flag (clap `global =
true`) that overrides the per-clone active queue selector for the
duration of one command. Affects every command that reads or writes
the active queue — push/drop/list/swap/rot/tor/prioritize/inbox/
assign/accept/reject/export/queue current/etc. — without changing
the on-disk selector.
Implementation is one process-wide `OnceLock<Option<String>>` set by
`dispatch` from `cli.queue`; `Workspace::queue()` consults it before
reading `<git-dir>/tsk/queue`. No per-command plumbing.
Smoke-tested: `tsk -q tsk queue current` → `tsk`; with active queue
`claude`, `tsk list` lists claude tasks while `tsk -q tsk list` shows
the empty tsk queue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
object::update was already idempotent — it short-circuits when the
proposed tree equals the current tip's. Surfacing that fact through
the return type lets migrate_property_encoding drop its before/after
ref-OID dance:
let head_before = repo.find_reference(...).target();
self.save_task(&task)?;
let head_after = repo.find_reference(...).target();
if head_before != head_after { rewritten += 1; }
becomes
if self.save_task(&task)? { rewritten += 1; }
The three property-mutation methods (add/set/unset_property) want
unit results, so they now `?;` the bool and return `Ok(())`. Tests
that called `ws.save_task(...).unwrap();` keep compiling unchanged
(the returned bool is discarded as a statement).
Net −4 src lines; 98 tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five more shared helpers that pull repeated boilerplate out of the
big call sites:
- workspace::title_for(repo, stable): the
`object::read(repo, &stable)?.map(|t| t.title()...).unwrap_or_default()`
trio appeared 3× in find_by_property / read_stack / list_namespace_tasks
/ list_inbox.
- workspace::ns_reverse(ns): the `BTreeMap<&StableId, u32>` reverse map
hand-built in two functions; one helper, one collector.
- namespace::ensure_bound(repo, name, stable, msg): the
`match human_for { Some(h) => h, None => assign_id(...) }` shape that
lived in import / accept / pull paths.
- patch::pop_line(rest, msg): the
`position(\n) + from_utf8 + trim('\r') + advance` ritual repeated
twice per file-block in patch parsing.
- lib::auto_push_refs(ws, remote, refs): the
`if let Some(r) = effective_remote(remote) { let _ = git_push_refs(...) }`
block at the tail of assign / accept / reject.
- lib::print_lines(items): collapses several `for x in ws.foo()? { println!("{x}") }`
loops in command_namespace / command_queue / prop keys / prop values.
Also folds command_reject's two passes through `key.rsplit_once('-')`
into one, and uses `TaskId::default()` for the empty-flags picker
construction in command_export (one new derive on TaskId).
Net −21 src lines on top of the previous −54 (cumulative −75 since
the bloat-reduction task started). 98 tests still green; no test
churn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six small consolidations across the non-test code, each replacing
near-duplicate boilerplate with a single shared helper:
- 5 byte-identical `signature(repo)` helpers (object/namespace/queue/
properties/merge.rs and inline in patch.rs) → one pub(crate) in
object.rs that the others call.
- workspace::namespace/queue: extract `read_selector(file, default)`.
- workspace::new_task: 3 nearly-identical `Task { ... }` constructors
+ 1 in `task()` → one `make_task(id, stable, obj)` plus a
`read_task_obj` lookup helper.
- workspace's git-shell-out methods (configure_git_remote_refspecs,
git_push, git_push_refs, git_fetch_refs, git_pull_with_strategy):
share a `self.git()` builder that pre-fills `--git-dir`.
- lib.rs accept/reject inbox-key fallback: extract `pick_inbox_key`.
- lib.rs `command_namespace_switch` was a 3-line forwarder; inline.
Also drops the dead `_silence_unused` placeholder.
98 tests still pass; tests intentionally untouched (one new
`use git2::Signature` in merge.rs's test mod compensates for the
parent module no longer importing it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stable ids are SHA-1 of content, so two `tsk push`es with the same
body resolve to the same task ref. The old new_task path called
object::create unconditionally, which silently overwrote the existing
ref's history pointer.
new_task now hashes content first and branches on the existing state:
- ref doesn't exist → fresh create (unchanged).
- bound in active namespace, status=done → flip status back to open
and return the existing human id (reopen-via-push).
- bound in active namespace, status=open → idempotent; return the
existing id without touching the tree.
- bound only in another namespace → error with a hint to use
`tsk share` or `tsk reopen -T <id>`.
- unbound everywhere → bind in active namespace with a fresh id.
Four unit tests cover each branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk export` now accepts multiple selectors and concatenates the
resulting mboxes:
- `-T tsk-N` is repeatable (clap Vec<Id>)
- `--where KEY=VALUE` adds every task with that property
- `--all` adds every binding in the active namespace
- no selector still drops into the fzf single-pick
Selectors are unioned; duplicates are removed before emission.
`tsk import` parses the concatenated mboxes via patch::import_mbox,
which groups entries by their X-Tsk-Stable-Id and rebuilds each
chain in order. The single-task `import_task` is now a thin wrapper
that errors if more than one chain shows up.
Smoke test: `--all` and `--where status=open` against the live repo
both produced the expected entry counts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk export` with no -t/-T/-r drops into fzf showing every task in
the active namespace as `tsk-N\ttitle`; the picked id flows through
the existing export path.
Implementation reuses the shared TaskId clap struct rather than
adding bespoke per-command fields:
- TaskId.relative_id is now Option<u32> so "no flag passed at all"
is distinguishable from "explicitly -r 0".
- New TaskId::resolve_or_pick(ws) drops into fzf when nothing was
given. Existing commands that prefer "top of stack" silently keep
that behavior via TaskId → TaskIdentifier (relative_id defaults to 0
on conversion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
patch::import_task was using the parsed From: header for both the
author and committer of each rebuilt commit, so the act of importing
masqueraded as the original sender. Match git rebase semantics
instead: author = parsed sender, committer = local user.
Two new tests cover:
- single import: Alice creates → Bob imports → root commit author is
Alice, committer is Bob.
- two-hop chain: Alice creates → Bob imports + edits + exports →
Alice imports Bob's mbox; root still authored by Alice, edit
authored by Bob, both committed by the importing party.
Local edits (object::update) already pick up the local signature for
both fields, which is correct for a fresh local commit; no change
needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strict mbox readers split messages on lines starting with `From `.
patch::write_entry was emitting commit messages and file content
verbatim, so a body containing `From the desk of...` could be
mis-split by procmail/mailx even though tsk's own importer didn't
care.
Apply standard mboxrd From-mangling: any line matching `^>*From `
gets one extra `>` on export; the inverse runs on import. `size:`
headers count post-mangling bytes so the importer reads the right
length verbatim before unmangling.
Test exports a task with two `From ` lines in its body and asserts
zero interior `\nFrom ` boundaries in the resulting mbox.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
git-pull now reconciles namespace refs the same way it does task refs:
fast-forward when one side is an ancestor, three-way merge when they
diverge. The merge is data-aware, not tree-aware:
- conflicting bindings (same human id, different stable id): the
remote keeps the id, and the local binding is auto-renumbered to a
fresh id past max(local.next, remote.next). The pull output prints
`<ns>-<old> → <ns>-<new> (conflict with <remote>)` so the user
sees the move.
- local-only bindings: preserved at their current id (no collision is
possible since the remote lacks that id).
- remote-only bindings: added verbatim.
Result is committed to the namespace ref with two parents (local +
remote tip), so future pulls fast-forward instead of detecting another
divergence.
Queues already store stable ids, not human ids, so the renumber
doesn't require any queue index update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Property values were stored as one-per-line in a blob, so any value
containing a newline was silently corrupted. Switch the on-disk format
to size-prefixed blocks: `size: N\n<N bytes>\n` per value, mirroring
the patch wire format. Values may now contain any bytes, including
newlines.
The reader detects the new format via the `size: ` prefix and falls
back to legacy line-split decoding for older blobs, so existing
workspaces keep reading. New writes always use the new format, and
`tsk fix-up` gains a one-shot pass that re-saves every task to migrate
its property tree onto the new encoding.
Per-key property indices (refs/tsk/properties/*) use the same codec
under the hood.
Higher-level value typing (Date / TaskRef / Enum) is intentionally
deferred — values stay as plain strings; any further parsing belongs
in the caller.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-push and auto-pull paths around assign/accept/reject/inbox no
longer ship the entire refs/tsk/* namespace. Each operation declares
the minimal set of refs it actually touches:
- assign-out: target queue + task ref + property indices that already
reference the task
- accept-inbox: active queue + active namespace
- reject-inbox: active queue + source queue (the bounce target)
- inbox auto-pull: just the active queue
Full `tsk git-push` / `tsk git-pull` continue to sync everything.
Also adds a -R remote flag to `tsk accept` for symmetry with assign /
reject (default "origin", empty string skips).
Integration test inspects the bare origin's refs after an assign and
asserts the active queue and namespace were *not* pushed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
git-pull now fetches into a non-clobbering shadow namespace
(refs/tsk-fetched/<remote>/*) and reconciles each task ref in two-way
git2 merge_trees against the common ancestor:
- merge (default): clean 3-way merge → single merge commit with two
parents. True conflicts abort that one task; local ref unchanged.
- --rebase: replay each local-only commit on top of the remote tip
via merge_trees, preserving each commit's original author and
setting committer to the local user (mirrors git rebase).
The --refmap= flag on the underlying git fetch is essential: without
it the remote's configured fetch refspec also runs, clobbering local
refs before reconciliation gets a chance.
Non-task refs (namespaces / queues / property indices) still
fast-forward from the remote — better merging for those is tracked
separately (queue merge driver, namespace renumber, etc.).
Tests: 5 unit tests in merge.rs covering merge / rebase / conflict /
fast-forward / new-remote, plus 2 multi-clone integration tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-rewrite file-based workspace lived under `<repo>/.tsk/`. Its
auto-migration into `<git-dir>/tsk/` has been in place long enough that
no live workspace still needs it; drop the migration block from
Workspace::init and its accompanying test. Update workspace and queue
module docs to describe the current ref-only storage.
Replace the blanket #![allow(dead_code)] on workspace.rs with targeted
field-level allows on Task.id/Task.stable/InboxItem.stable — those are
exposed API even though nothing reads them yet, but the rest of the
file is now warning-clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each task commit becomes one mbox entry: standard `From <sha>` separator,
RFC-822 headers (including X-Tsk-Stable-Id, X-Tsk-Parent, optional
X-Tsk-Namespace), commit message, and a length-prefixed dump of every
file in the task tree between `---tsk-tree---` and `---end---`. Length
prefixes avoid any escaping of mbox `From ` lines inside content.
Stable ids are SHA-1 of the root content blob, so import re-hashes and
rejects mismatches — tampered patches don't go through. Recipient opts
in to namespace binding via `tsk import --bind`; the sender's namespace
hint is parsed but not auto-applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assign_id and unassign_id now append `<ns>-<human>` to the commit
message they write, so `tsk log namespace` shows which task each entry
refers to (e.g. "assign-id tsk-5", "share alpha-3") instead of the
ambiguous bare verb.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
reject_inbox previously discarded the stable id, leaving the sender
unaware. Now it parses the source queue out of the inbox key and
re-inboxes the task there with a return key (`<receiver>-<seq>`), so
each round-trip is uniquely identified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Complement to tsk list (which only shows the active queue): walks the
namespace's id mapping and prints every (human id, title) bound there,
including dropped tasks. Defaults to the active namespace; an optional
positional name targets a specific one.
Tests: workspace test confirms dropped tasks remain visible since
their namespace binding is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks the commit chain on a tsk ref and prints it newest-first in a
git-log-style oneline + author + relative-time layout. tsk log task
takes a -T tsk-N; tsk log namespace defaults to the active namespace.
The same log_ref helper covers any future per-ref view (queue, etc.).
Tests: workspace tests for both task and namespace history; lib test
covers the relative_time formatter at every breakpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
backfill-status was a one-off; replace it with tsk fix-up that runs
every known one-shot migration in sequence. Today that's just the
status backfill; future migrations land in the same handler. Each
migration must stay idempotent so re-running fix-up is safe. AGENTS.md
documents the new command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A short, operational reference for agents working in this repo: the
commands they'll actually use, the namespace/queue convention (tsk vs
claude), how to record TODOs in the claude queue without polluting the
user's stack, and the markup grammar for tsk show.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks the active namespace's mapping and sets status=open on any task
whose tree carries no status property yet. Tasks already marked done
are skipped, so re-running is a no-op.
Tests: workspace::backfill_status_marks_legacy_tasks_open_and_skips_done
covers the legacy + fresh-open + dropped mix and the idempotency
property.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
new_task initialises status=open in the task tree and the per-key index;
drop flips it to status=done and removes the task from the queue while
keeping the namespace binding so the human id stays addressable. Means
tsk prop find status {open,done} now lists in-progress vs completed
tasks across the workspace.
Tests: workspace::new_task_starts_open_drop_marks_done covers the full
round trip via the property index.
Note: tasks created before this commit have no status property and will
not appear in either filter until they are next saved or explicitly set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new command_show was printing the raw task body verbatim, ignoring
the existing parser. Pipe the body through task::parse so bold, italic,
underline, strike, highlight, inline code, links, and wiki-links render
as ANSI escapes (auto-suppressed when stdout isn't a tty, per the
colored crate). Adds -R / raw to skip the parser when you need the
unprocessed bytes.
Tests: integration test forces CLICOLOR_FORCE=1 and asserts markup
characters are stripped and ANSI escapes are emitted, plus -R round-
trips the original bytes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .tsk/ directory was tracked by git, so switching namespace or queue
showed up as a modified file in every clone — and the per-clone choice
leaked into the shared history. State now lives in <git-dir>/tsk/, which
git doesn't track by definition. tsk init is idempotent and migrates any
existing .tsk/ namespace and queue files into the new location, then
removes the legacy directory so it stops appearing in git status.
Drops the now-redundant util module (the old .tsk-discovery walk is gone).
Tests: 2 new workspace tests cover (1) init creating nothing in the
working tree and (2) the legacy migration path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Move CLI logic into src/lib.rs as `pub fn run() -> i32` so the tsk and
git-tsk binaries can each be a tiny shim under src/bin/, dropping
Cargo's "source file present in multiple build targets" warning.
- Delete unused Error::NotSelected variant.
- Delete unused properties::replace_all helper.
cargo build is now warning-free; cargo test still 61 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Properties are now stored as plain blobs in the task tree (one file per
key, lines are values), so reads/writes go through the same per-task
commit history as content. A new properties module maintains a per-key
index ref at refs/tsk/properties/<key> with a tree of <stable-id> blobs;
each key gets its own commit history so concurrent edits to different
keys cannot conflict.
CLI: tsk prop {list,add,set,unset,keys,values,find}. find accepts an
optional key/value and falls through to fzf for whichever is missing,
with a <any> sentinel to skip value-narrowing.
Validation: 4 new unit tests in properties.rs, 2 new integration tests
in multi_user.rs covering the binary CLI plus push/pull visibility of
the index across clones.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements tsk-3: switching with no argument lists existing namespaces
through fzf with the active one marked, plus a <new> sentinel for
creating one on the fly. namespace switch behaves the same way.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each task is now a tree {content, title, <prop>...} with its own commit
history at refs/tsk/tasks/<sha-of-initial-content>. Namespaces are trees
mapping human ids to stable ids at refs/tsk/namespaces/<name>. Queues
hold the index, can-pull marker, and inbox at refs/tsk/queues/<name>.
New commands: share (cross-namespace task binding), queue (list/current/
create/switch), pull (pull a task from another queue when can-pull).
Adds git-tsk binary alongside tsk so the tool can be invoked as a git
subcommand. Drops the file-backed mode; tsk now requires a git repo.
Validation: 48 unit tests across object/namespace/queue/workspace plus
tests/multi_user.rs spinning bare origin + two clones for share, assign,
concurrent push, and namespace round-trip.
Out of scope (left as TODOs): prop, follow, find/fzf, bundle, migrate,
log, reopen, foreign remote, internal link translation between human
and stable ids, merge driver for refs/tsk/queues/* on concurrent push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
git_push_refs now pushes deletion refspecs for refs present in the
shadow but missing locally, and update_remote_shadow prunes shadow
entries that no longer exist locally — without this a subsequent pull
would see the still-shadowed remote OID and recreate the local ref.
git_pull_refs fetches with --prune so the shadow tracks remote
deletions, and applies those deletions locally when the local ref
hasn't moved since the last sync (otherwise: conflict).
Surfaced by `tsk reject`: the inbox blob deletion was being undone by
the auto-pull on the next `tsk inbox`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reject mirrors assign: pushes refs to "origin" by default after writing
the rejection event into the source's log. `-r NAME` selects a different
remote; `-r ""` skips.
`tsk git-push` and `tsk git-pull` now treat the remote argument as
optional, defaulting to "origin" when configured. Errors out when no
remote is supplied and no origin is set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the auto-pull on `tsk inbox`: assign now pushes refs to "origin"
by default after writing the cross-namespace inbox blob, so the assignee
sees the item on their next inbox check. `-r NAME` selects a different
remote; `-r ""` skips the push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the inbox blob without creating a local task and writes a
`rejected` event to the source's event log so the assignor can see it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inbox listings now reflect what other clones have sent: tsk inbox
runs git_pull_refs first against "origin" if that remote is
configured, then prints the result.
Pass -r <name> to use a different remote, or -r "" to explicitly skip
the pull (useful when offline or for testing). File-backed workspaces
and git workspaces without an "origin" remote skip silently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removed the parameterless `write_task` / `write_attrs` convenience
wrappers (only ever used from tests after the rich-message refactor)
and merged them into a single function each that always takes
event/detail. Test call sites pass `"write", None` explicitly. The
write_task_with_event / write_attrs_with_event names go away.
Also:
- Replace two clippy `get(k).is_none()` warnings with `!contains_key(k)`.
Build is now warning-free; clippy --all-targets is silent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tsk prop find — fzf-pick key, then value (with <any> to skip)
tsk prop find KEY — list every task with KEY set (existing semantics)
tsk prop find KEY VAL — direct match (existing)
Mirrors the recent prop set ergonomics. The <any> sentinel in the
value picker means "match any value for this key" so the user can
still narrow on key alone via fzf.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the command rename (tsk export → tsk assign) and the
`assigned=[[ns/tsk-N]]` property name. The destination side keeps its
`accepted` event on accept; both sides now produce a clearly-named
event when a cross-namespace assignment happens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fzf appends a trailing newline to its stdout. The String FromStr impl
is just a clone, so picked-string callers (`pick_namespace`,
`PropAction::Set` value/key picker) saw "noah\n" and forwarded that
through to validate_namespace, which rightly rejected the embedded
newline.
Also affected non-string callers in principle (Id from "tsk-1\n"
parses the int part as "1\n", which fails) but no live caller hit
that yet.
Trim once in fzf::select so every caller benefits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk switch` (and `tsk namespace switch`) now accept the name as
optional. When omitted, fzf-pick from existing namespaces with a
`<new>` sentinel for entering a fresh name at a prompt.
`tsk namespace create <name>` is unchanged (always requires a name).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Better matches the existing `assigned` property semantics — exporting
a task to another namespace is functionally an assignment, and the
two commands now use the same vocabulary.
`tsk bundle` is unaffected (zip export was renamed earlier; this only
touches the cross-namespace command).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each refs/tsk/<ns>/<key> ref now points at a commit chain instead of a
single blob. The commit's tree carries one file (`content`) holding the
blob; future writes append commits with the prior tip as parent. `git
log refs/tsk/<ns>/tasks/<id>` walks the audit trail; `git show
refs/tsk/<ns>/tasks/<id>:content` reads the current state.
Inbox refs (`inbox/<src>-<id>`) intentionally stay blob-backed — they
are short-lived (deleted on accept) and gain nothing from history.
Storage:
- Store gains `write_with_meta(key, data, event, detail)` (with `write`
now defaulting to `event="write"`). FileStore ignores the meta;
GitStore turns it into a commit message via format_commit_message,
which is a single function for easy format changes later.
- GitStore::read peels through commit→tree→`content`, falling back to
raw blob so it keeps reading legacy/inbox refs unchanged.
- merge_blob produces a merge commit (parents = local + remote) when
either side is commit-backed; falls back to a blob OID otherwise.
- Cross-namespace link rewrites in the id-collision rebase now go
through write_with_meta so each ref-update lands as a commit.
Migration: `tsk migrate-history` walks every refs/tsk/<ns>/<key>
(except inbox/*) and wraps any remaining blob ref in a single
"migrated" commit. Idempotent — already-commit refs are skipped.
A few high-traffic call sites pass rich event metadata through:
- new_task → `tsk(tasks/N): created`
- save_task → `tsk(tasks/N): edited` / `tsk(attrs/N): edited`
- set_property / unset_property → `tsk(attrs/N): prop-set <key>` etc.
Other writes still get the generic "write" subject; opting another
caller into rich messages is a one-call-site change.
Tests cover the commit-chain shape (4 commits after a create + 3
edits), inbox refs staying blob-backed, the migration converting
legacy blob refs, and idempotency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When two clones offline both pick the same id and create different
tasks at it, pull now resolves the collision automatically by
renumbering the loser past the highest known id on either side and
rewriting every reference.
Detection: at the start of the pull reconcile pass, scan the post-fetch
shadow for tasks/<id> or archive/<id> refs that exist on both sides at
different OIDs. Compare the `created` log line byte-wise — if equal
it's the same task edited in two places (regular conflict path); if
different it's an id collision. Earlier `created` timestamp keeps the
id; tie-break on lexicographically-smaller blob OID.
Renumber actions:
- LocalLoser: move our blobs (tasks/archive/attrs/backlinks/log) from
<old> to <new>; rewrite [[tsk-<old>]] across our task content, attrs,
log details, backlinks, and index titles; rewrite [[<our-ns>/tsk-<old>]]
across every other namespace's blobs; bump our `next` past <new>.
After this, our <old> is vacated and the regular reconcile pass takes
remote's <old> normally.
- RemoteLoser: copy remote's <old> blobs from the shadow to our local
<new> (preserving bucket); add to the index if it was active on the
remote; suppress reconcile for the remote's <old> blobs so they don't
clobber our local winner.
In both cases a `renumbered\tfrom tsk-<old>...` log entry is appended
on <new> via the existing per-task log so history is recoverable.
Also: `next` is now mergeable (take max of either side) so two clones
calling next_id offline don't deadlock on a non-FF push of `next`.
Test simulates both cases: A creates tsk-1, B sleeps 2s and creates
tsk-1 with a referencing tsk-2, B pulls. Asserts tsk-1 = A's content,
B's original moved past tsk-2, B's body link rewritten, and a
renumbered log entry exists on the new id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cross-namespace assignment command in question is `tsk export`,
which (since the export/accept feature commits) sets
`assigned=[[<target-ns>/tsk-N]]` on the source after a successful
write. With tsk-41's namespaced-link parser landed, the value also
renders with a numbered superscript in `tsk show` and is followable
via `tsk follow -l N`. The accept side strips `assigned` from the
copied attrs so the new local task isn't itself flagged as assigned.
No code change needed; this commit is just the bookkeeping drop.
The `tsk accept` flow already sets `source=[[<src-ns>/tsk-N]]` on the
local copy, but those links weren't parseable: the link parser rejected
prefixes containing `/`, so neither tsk show's superscript rendering
nor tsk follow could navigate them.
Add a third link kind `Namespaced { namespace, id }` to the parser and
wire it through:
- task::parse recognises `[[<ident>/tsk-N]]` and registers Namespaced.
- Workspace::resolve_namespaced_link reads the matching task from a
sibling namespace's GitStore in the same repo (file-backed
workspaces error out).
- tsk follow handles the new kind by printing the resolved task's
content; editing across namespaces is refused (ergonomic enough
given the existing tsk switch + edit flow).
Source values like `[[default/tsk-3]]` now show up with a numbered
superscript in `tsk show` and can be opened with `tsk follow -l N` or
`tsk follow -s`. Same path works for `assigned` values.
Tests cover parser output for the new kind and invalid namespace
characters, and resolve_namespaced_link's positive/missing-namespace/
missing-id paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tsk prop set <id> duplicates [[tsk-X]] now wires up the reverse half:
the original X gets `[[tsk-id]]` appended to its `duplicated-by`
property (comma-separated link list). Unsetting or re-pointing the
duplicate removes it. Self-reference and cycles are rejected.
Refactored the parent/children logic into a small `InversePair`
registry so `parent`/`children` and `duplicates`/`duplicated-by` share
the same implementation. Adding more pairs in the future is one line.
CLI: when a duplicate and its original are both still on the stack,
`tsk prop set ... duplicates ...` prompts `Drop tsk-N? [y/N]` and drops
the duplicate on `y`. Idempotent — answering n leaves both open.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk assign` target arg is now optional; omit it to fzf-pick from the
existing queues (active queue is excluded since assign refuses
self-targets anyway).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors `tsk switch` (namespace) — `tsk queue switch` now takes an
optional name and fzf-picks when omitted, with a `<new>` sentinel that
creates the queue (can-pull=false) on the fly. Generalized the picker
helper so namespace and queue share one code path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chose option A from the design discussion: don't auto-mutate the
user's git config, just document the opt-in path more clearly. The
trade-offs that ruled out auto-setup:
- Setting `remote.<name>.push` overrides `push.default`. Once we
configure it, plain `git push` no longer pushes branches the way
the user expects — it pushes whatever's in the configured refspec.
Adding `HEAD` softens but doesn't eliminate the surprise (anyone
on push.default=matching is silently narrowed).
- Mutating `.git/config` from `tsk show` / `tsk list` is invasive.
Some users may not want tsk refs riding along on their pushes.
So `tsk git-setup` stays explicit. AGENTS.md gains a note documenting
the trade-off so users aren't surprised when running it changes their
push behavior; the recommended path for users who don't want that is
to keep using `tsk git-push` / `tsk git-pull` and skip git-setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per follow-up: `tsk remote add` and `tsk remote remove` were thin
wrappers around git's own commands; users can run `git remote add`
themselves and follow with `tsk git-setup -r <name>` when they want
the tsk refspecs configured. `tsk remote list` was equivalent to
`git remote`. All three are gone.
What's left is the tsk-specific state:
- `tsk remote default` — print the persisted default
- `tsk remote set-default <n>` — persist `<n>` as the default;
validates against `git remote` first so we can't be pointed at a
remote the host repo doesn't know about.
Workspace::git_remotes survives as the validation helper; the
git_remote_add / git_remote_remove wrappers are gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk remote` subcommands:
- `list` — wraps `git remote`
- `default` — print the active default
- `set-default <n>` — persist the default to <git-dir>/tsk/remote
- `add <name> <url>` — `git remote add` + configure tsk refspecs
- `remove <name>` — `git remote remove`
The default remote is a per-clone selector parallel to `namespace`
and `queue`, stored at `<git-dir>/tsk/remote`. Falls back to
`origin` when absent. `tsk git-push`, `tsk git-pull`, `tsk
git-setup`, and the auto-push paths after assign/accept/reject all
consult it when no explicit `<remote>` / `-R <remote>` is given.
ARCHITECTURE.md and AGENTS.md updated to mention the new selector
and the new commands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A tour of the moving pieces, complementing the user-facing crib in
AGENTS.md. Covers:
- on-disk layout (refs/tsk/* shared, <git-dir>/tsk/* per-clone)
- the two id layers (stable = SHA-1 of content blob, human = per-ns)
- module map (mermaid)
- task lifecycle (open/done/reopen, including reopen-on-duplicate)
- write order in `tsk push` and how gc_refs recovers partial writes
- git-pull reconciliation flow + per-ref-class strategy table
- wire formats (git refs vs. mbox patch series)
- per-clone overrides (-q flag + switch commands)
- length-prefix property value codec
- reconciliation matrix mapping each drift class to its detector
and fix
Concurrent pushes used to force-overwrite refs/tsk/queues/*; only the
namespace renumber and the task ref reconciler had real merging.
git-pull now does a 3-way merge of queue trees too:
- index: per-stable 3-way set merge. Entries present in base survive
only if both sides keep them; entries added on either side are
included; removals on either side are honored. Order is
remote-first, then local additions appended.
- inbox: per-key 3-way map merge. Removals on either side win;
remote wins on simultaneous-add conflicts (per-source seq keys make
that impossible in practice).
- can_pull: 3-way bool with local-change-wins.
When the two sides have no common ancestor (each clone independently
rooted its queue ref before any sync), the base is treated as empty
— same handling we should apply to other reconcile paths if their
own first-pull case ever bites.
queue::read_at_commit and queue::build_tree are now `pub` so the
merge driver can read divergent tips and write the merged tree
without going through the active-ref-only `read`/`write` API.
fast_forward_non_task_refs skips queues now that they're properly
reconciled.
Updates concurrent_pushes_dont_clobber to assert the new contract:
both alice's and bob's tasks survive the pull (the test previously
documented the absence of this driver).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The markup parser already produces ParsedLink entries with the right
classification (Internal / Namespaced / Foreign / External) and tags
each occurrence with a superscript footnote number in the inline
output. command_show now renders the matching footnote section after
the body:
- Internal(id) → look up in the active namespace; emit
`tsk-N: <title>` if bound, `tsk-N: <not bound in '<ns>'>` otherwise.
- Namespaced{ns, id} → echo `<ns>/tsk-N` (cross-namespace title
resolution + stable-id rewriting on share is the deferred half of
this task — left for when we actually do cross-namespace renders).
- Foreign{prefix, id} → `<prefix>-<id> (foreign)`.
- External(url) → the URL.
super_num() is now `pub(crate)` so command_show can produce matching
markers for the footnote section.
Smoke-tested with a body containing one resolvable link, one missing
id, and one namespaced link — all three rendered with the expected
suffix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk reopen -T tsk-N` (or `-r`) sets status=open on the task and
pushes its stable id to the top of the active queue. Idempotent on
already-open tasks (`save_task` is a no-op on an unchanged tree;
`queue::push_top` deduplicates).
Pushing duplicate content already triggered the same reopen path
(tsk-28); this gives the explicit by-id version the original task
asked for.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
new_task / accept_inbox / pull_from_queue write to several refs in
sequence (task object → property index → namespace binding → queue
index). The order is intentional — an unfinished operation is always
recoverable from the refs that did land — but until now nothing
actively reconciled the drift. tsk-25 picks the "ordered writes +
idempotent reconcile" combo from the original task description.
Write ordering is already correct, so no behavior change there. The
reconcile pass extends `Workspace::gc_refs` (already invoked by
`tsk fix-up`) with two new classes of cleanup:
- ghost namespace bindings: `human → stable` mappings whose stable id
has no task ref. Left behind by a crash between `object::create`
and a later step, or by a force-pushed namespace ref that arrived
ahead of its task refs.
- orphan queue index entries: stables in a queue's index that no
longer resolve to a task ref. Same root cause; previously only the
active queue could be cleaned via `tsk clean`.
Return type widens from `(usize, usize)` → `(usize, usize, usize, usize)`;
the fix-up printer reports each class. Idempotent — second pass is a
no-op (verified by test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Workspace::gc_refs(), invoked as a third stage of `tsk fix-up`
after backfill-status and migrate-property-encoding:
- Walks `refs/tsk/queues/*`, deletes any queue whose index AND inbox
are empty (skips the default `tsk` queue, which is conventional).
- Walks `refs/tsk/properties/*` and drops any entry whose stable id
no longer resolves to a task ref; properties::set already deletes
the index ref when its last entry goes.
Task object refs and namespace refs are left alone — task history is
preserved by design, and namespace `next` counters remain valid even
when no live binding uses the latest id.
Verified idempotent on the live repo (one empty queue pruned on first
run, zero on the second). Unit test covers both pruning paths plus
the second-pass no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `title` blob in each task's tree was a cache of `content`'s first
line — `Task::title()` already computes that for free, so the cache
added nothing while creating drift risk if anything ever wrote one
without the other. patch::write_entry already skipped the blob on
export.
Changes:
- build_tree no longer takes or inserts a `title` entry.
- read still tolerates the field for backward compatibility (it's
matched and discarded).
- Existing trees migrate on next save: `tsk fix-up`'s
`migrate_property_encoding` already re-saves every task, and the
new build_tree drops the title blob in the rewritten tree (smoke-
tested on the live repo: 35 tasks rewritten, ls-tree on the new
tip shows only `content` + property blobs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a top-level `-q <queue>` / `--queue <queue>` flag (clap `global =
true`) that overrides the per-clone active queue selector for the
duration of one command. Affects every command that reads or writes
the active queue — push/drop/list/swap/rot/tor/prioritize/inbox/
assign/accept/reject/export/queue current/etc. — without changing
the on-disk selector.
Implementation is one process-wide `OnceLock<Option<String>>` set by
`dispatch` from `cli.queue`; `Workspace::queue()` consults it before
reading `<git-dir>/tsk/queue`. No per-command plumbing.
Smoke-tested: `tsk -q tsk queue current` → `tsk`; with active queue
`claude`, `tsk list` lists claude tasks while `tsk -q tsk list` shows
the empty tsk queue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
object::update was already idempotent — it short-circuits when the
proposed tree equals the current tip's. Surfacing that fact through
the return type lets migrate_property_encoding drop its before/after
ref-OID dance:
let head_before = repo.find_reference(...).target();
self.save_task(&task)?;
let head_after = repo.find_reference(...).target();
if head_before != head_after { rewritten += 1; }
becomes
if self.save_task(&task)? { rewritten += 1; }
The three property-mutation methods (add/set/unset_property) want
unit results, so they now `?;` the bool and return `Ok(())`. Tests
that called `ws.save_task(...).unwrap();` keep compiling unchanged
(the returned bool is discarded as a statement).
Net −4 src lines; 98 tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five more shared helpers that pull repeated boilerplate out of the
big call sites:
- workspace::title_for(repo, stable): the
`object::read(repo, &stable)?.map(|t| t.title()...).unwrap_or_default()`
trio appeared 3× in find_by_property / read_stack / list_namespace_tasks
/ list_inbox.
- workspace::ns_reverse(ns): the `BTreeMap<&StableId, u32>` reverse map
hand-built in two functions; one helper, one collector.
- namespace::ensure_bound(repo, name, stable, msg): the
`match human_for { Some(h) => h, None => assign_id(...) }` shape that
lived in import / accept / pull paths.
- patch::pop_line(rest, msg): the
`position(\n) + from_utf8 + trim('\r') + advance` ritual repeated
twice per file-block in patch parsing.
- lib::auto_push_refs(ws, remote, refs): the
`if let Some(r) = effective_remote(remote) { let _ = git_push_refs(...) }`
block at the tail of assign / accept / reject.
- lib::print_lines(items): collapses several `for x in ws.foo()? { println!("{x}") }`
loops in command_namespace / command_queue / prop keys / prop values.
Also folds command_reject's two passes through `key.rsplit_once('-')`
into one, and uses `TaskId::default()` for the empty-flags picker
construction in command_export (one new derive on TaskId).
Net −21 src lines on top of the previous −54 (cumulative −75 since
the bloat-reduction task started). 98 tests still green; no test
churn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six small consolidations across the non-test code, each replacing
near-duplicate boilerplate with a single shared helper:
- 5 byte-identical `signature(repo)` helpers (object/namespace/queue/
properties/merge.rs and inline in patch.rs) → one pub(crate) in
object.rs that the others call.
- workspace::namespace/queue: extract `read_selector(file, default)`.
- workspace::new_task: 3 nearly-identical `Task { ... }` constructors
+ 1 in `task()` → one `make_task(id, stable, obj)` plus a
`read_task_obj` lookup helper.
- workspace's git-shell-out methods (configure_git_remote_refspecs,
git_push, git_push_refs, git_fetch_refs, git_pull_with_strategy):
share a `self.git()` builder that pre-fills `--git-dir`.
- lib.rs accept/reject inbox-key fallback: extract `pick_inbox_key`.
- lib.rs `command_namespace_switch` was a 3-line forwarder; inline.
Also drops the dead `_silence_unused` placeholder.
98 tests still pass; tests intentionally untouched (one new
`use git2::Signature` in merge.rs's test mod compensates for the
parent module no longer importing it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stable ids are SHA-1 of content, so two `tsk push`es with the same
body resolve to the same task ref. The old new_task path called
object::create unconditionally, which silently overwrote the existing
ref's history pointer.
new_task now hashes content first and branches on the existing state:
- ref doesn't exist → fresh create (unchanged).
- bound in active namespace, status=done → flip status back to open
and return the existing human id (reopen-via-push).
- bound in active namespace, status=open → idempotent; return the
existing id without touching the tree.
- bound only in another namespace → error with a hint to use
`tsk share` or `tsk reopen -T <id>`.
- unbound everywhere → bind in active namespace with a fresh id.
Four unit tests cover each branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk export` now accepts multiple selectors and concatenates the
resulting mboxes:
- `-T tsk-N` is repeatable (clap Vec<Id>)
- `--where KEY=VALUE` adds every task with that property
- `--all` adds every binding in the active namespace
- no selector still drops into the fzf single-pick
Selectors are unioned; duplicates are removed before emission.
`tsk import` parses the concatenated mboxes via patch::import_mbox,
which groups entries by their X-Tsk-Stable-Id and rebuilds each
chain in order. The single-task `import_task` is now a thin wrapper
that errors if more than one chain shows up.
Smoke test: `--all` and `--where status=open` against the live repo
both produced the expected entry counts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk export` with no -t/-T/-r drops into fzf showing every task in
the active namespace as `tsk-N\ttitle`; the picked id flows through
the existing export path.
Implementation reuses the shared TaskId clap struct rather than
adding bespoke per-command fields:
- TaskId.relative_id is now Option<u32> so "no flag passed at all"
is distinguishable from "explicitly -r 0".
- New TaskId::resolve_or_pick(ws) drops into fzf when nothing was
given. Existing commands that prefer "top of stack" silently keep
that behavior via TaskId → TaskIdentifier (relative_id defaults to 0
on conversion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
patch::import_task was using the parsed From: header for both the
author and committer of each rebuilt commit, so the act of importing
masqueraded as the original sender. Match git rebase semantics
instead: author = parsed sender, committer = local user.
Two new tests cover:
- single import: Alice creates → Bob imports → root commit author is
Alice, committer is Bob.
- two-hop chain: Alice creates → Bob imports + edits + exports →
Alice imports Bob's mbox; root still authored by Alice, edit
authored by Bob, both committed by the importing party.
Local edits (object::update) already pick up the local signature for
both fields, which is correct for a fresh local commit; no change
needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strict mbox readers split messages on lines starting with `From `.
patch::write_entry was emitting commit messages and file content
verbatim, so a body containing `From the desk of...` could be
mis-split by procmail/mailx even though tsk's own importer didn't
care.
Apply standard mboxrd From-mangling: any line matching `^>*From `
gets one extra `>` on export; the inverse runs on import. `size:`
headers count post-mangling bytes so the importer reads the right
length verbatim before unmangling.
Test exports a task with two `From ` lines in its body and asserts
zero interior `\nFrom ` boundaries in the resulting mbox.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
git-pull now reconciles namespace refs the same way it does task refs:
fast-forward when one side is an ancestor, three-way merge when they
diverge. The merge is data-aware, not tree-aware:
- conflicting bindings (same human id, different stable id): the
remote keeps the id, and the local binding is auto-renumbered to a
fresh id past max(local.next, remote.next). The pull output prints
`<ns>-<old> → <ns>-<new> (conflict with <remote>)` so the user
sees the move.
- local-only bindings: preserved at their current id (no collision is
possible since the remote lacks that id).
- remote-only bindings: added verbatim.
Result is committed to the namespace ref with two parents (local +
remote tip), so future pulls fast-forward instead of detecting another
divergence.
Queues already store stable ids, not human ids, so the renumber
doesn't require any queue index update.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Property values were stored as one-per-line in a blob, so any value
containing a newline was silently corrupted. Switch the on-disk format
to size-prefixed blocks: `size: N\n<N bytes>\n` per value, mirroring
the patch wire format. Values may now contain any bytes, including
newlines.
The reader detects the new format via the `size: ` prefix and falls
back to legacy line-split decoding for older blobs, so existing
workspaces keep reading. New writes always use the new format, and
`tsk fix-up` gains a one-shot pass that re-saves every task to migrate
its property tree onto the new encoding.
Per-key property indices (refs/tsk/properties/*) use the same codec
under the hood.
Higher-level value typing (Date / TaskRef / Enum) is intentionally
deferred — values stay as plain strings; any further parsing belongs
in the caller.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-push and auto-pull paths around assign/accept/reject/inbox no
longer ship the entire refs/tsk/* namespace. Each operation declares
the minimal set of refs it actually touches:
- assign-out: target queue + task ref + property indices that already
reference the task
- accept-inbox: active queue + active namespace
- reject-inbox: active queue + source queue (the bounce target)
- inbox auto-pull: just the active queue
Full `tsk git-push` / `tsk git-pull` continue to sync everything.
Also adds a -R remote flag to `tsk accept` for symmetry with assign /
reject (default "origin", empty string skips).
Integration test inspects the bare origin's refs after an assign and
asserts the active queue and namespace were *not* pushed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
git-pull now fetches into a non-clobbering shadow namespace
(refs/tsk-fetched/<remote>/*) and reconciles each task ref in two-way
git2 merge_trees against the common ancestor:
- merge (default): clean 3-way merge → single merge commit with two
parents. True conflicts abort that one task; local ref unchanged.
- --rebase: replay each local-only commit on top of the remote tip
via merge_trees, preserving each commit's original author and
setting committer to the local user (mirrors git rebase).
The --refmap= flag on the underlying git fetch is essential: without
it the remote's configured fetch refspec also runs, clobbering local
refs before reconciliation gets a chance.
Non-task refs (namespaces / queues / property indices) still
fast-forward from the remote — better merging for those is tracked
separately (queue merge driver, namespace renumber, etc.).
Tests: 5 unit tests in merge.rs covering merge / rebase / conflict /
fast-forward / new-remote, plus 2 multi-clone integration tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-rewrite file-based workspace lived under `<repo>/.tsk/`. Its
auto-migration into `<git-dir>/tsk/` has been in place long enough that
no live workspace still needs it; drop the migration block from
Workspace::init and its accompanying test. Update workspace and queue
module docs to describe the current ref-only storage.
Replace the blanket #![allow(dead_code)] on workspace.rs with targeted
field-level allows on Task.id/Task.stable/InboxItem.stable — those are
exposed API even though nothing reads them yet, but the rest of the
file is now warning-clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each task commit becomes one mbox entry: standard `From <sha>` separator,
RFC-822 headers (including X-Tsk-Stable-Id, X-Tsk-Parent, optional
X-Tsk-Namespace), commit message, and a length-prefixed dump of every
file in the task tree between `---tsk-tree---` and `---end---`. Length
prefixes avoid any escaping of mbox `From ` lines inside content.
Stable ids are SHA-1 of the root content blob, so import re-hashes and
rejects mismatches — tampered patches don't go through. Recipient opts
in to namespace binding via `tsk import --bind`; the sender's namespace
hint is parsed but not auto-applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
reject_inbox previously discarded the stable id, leaving the sender
unaware. Now it parses the source queue out of the inbox key and
re-inboxes the task there with a return key (`<receiver>-<seq>`), so
each round-trip is uniquely identified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Complement to tsk list (which only shows the active queue): walks the
namespace's id mapping and prints every (human id, title) bound there,
including dropped tasks. Defaults to the active namespace; an optional
positional name targets a specific one.
Tests: workspace test confirms dropped tasks remain visible since
their namespace binding is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks the commit chain on a tsk ref and prints it newest-first in a
git-log-style oneline + author + relative-time layout. tsk log task
takes a -T tsk-N; tsk log namespace defaults to the active namespace.
The same log_ref helper covers any future per-ref view (queue, etc.).
Tests: workspace tests for both task and namespace history; lib test
covers the relative_time formatter at every breakpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
backfill-status was a one-off; replace it with tsk fix-up that runs
every known one-shot migration in sequence. Today that's just the
status backfill; future migrations land in the same handler. Each
migration must stay idempotent so re-running fix-up is safe. AGENTS.md
documents the new command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A short, operational reference for agents working in this repo: the
commands they'll actually use, the namespace/queue convention (tsk vs
claude), how to record TODOs in the claude queue without polluting the
user's stack, and the markup grammar for tsk show.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks the active namespace's mapping and sets status=open on any task
whose tree carries no status property yet. Tasks already marked done
are skipped, so re-running is a no-op.
Tests: workspace::backfill_status_marks_legacy_tasks_open_and_skips_done
covers the legacy + fresh-open + dropped mix and the idempotency
property.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
new_task initialises status=open in the task tree and the per-key index;
drop flips it to status=done and removes the task from the queue while
keeping the namespace binding so the human id stays addressable. Means
tsk prop find status {open,done} now lists in-progress vs completed
tasks across the workspace.
Tests: workspace::new_task_starts_open_drop_marks_done covers the full
round trip via the property index.
Note: tasks created before this commit have no status property and will
not appear in either filter until they are next saved or explicitly set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new command_show was printing the raw task body verbatim, ignoring
the existing parser. Pipe the body through task::parse so bold, italic,
underline, strike, highlight, inline code, links, and wiki-links render
as ANSI escapes (auto-suppressed when stdout isn't a tty, per the
colored crate). Adds -R / raw to skip the parser when you need the
unprocessed bytes.
Tests: integration test forces CLICOLOR_FORCE=1 and asserts markup
characters are stripped and ANSI escapes are emitted, plus -R round-
trips the original bytes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .tsk/ directory was tracked by git, so switching namespace or queue
showed up as a modified file in every clone — and the per-clone choice
leaked into the shared history. State now lives in <git-dir>/tsk/, which
git doesn't track by definition. tsk init is idempotent and migrates any
existing .tsk/ namespace and queue files into the new location, then
removes the legacy directory so it stops appearing in git status.
Drops the now-redundant util module (the old .tsk-discovery walk is gone).
Tests: 2 new workspace tests cover (1) init creating nothing in the
working tree and (2) the legacy migration path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Move CLI logic into src/lib.rs as `pub fn run() -> i32` so the tsk and
git-tsk binaries can each be a tiny shim under src/bin/, dropping
Cargo's "source file present in multiple build targets" warning.
- Delete unused Error::NotSelected variant.
- Delete unused properties::replace_all helper.
cargo build is now warning-free; cargo test still 61 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Properties are now stored as plain blobs in the task tree (one file per
key, lines are values), so reads/writes go through the same per-task
commit history as content. A new properties module maintains a per-key
index ref at refs/tsk/properties/<key> with a tree of <stable-id> blobs;
each key gets its own commit history so concurrent edits to different
keys cannot conflict.
CLI: tsk prop {list,add,set,unset,keys,values,find}. find accepts an
optional key/value and falls through to fzf for whichever is missing,
with a <any> sentinel to skip value-narrowing.
Validation: 4 new unit tests in properties.rs, 2 new integration tests
in multi_user.rs covering the binary CLI plus push/pull visibility of
the index across clones.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each task is now a tree {content, title, <prop>...} with its own commit
history at refs/tsk/tasks/<sha-of-initial-content>. Namespaces are trees
mapping human ids to stable ids at refs/tsk/namespaces/<name>. Queues
hold the index, can-pull marker, and inbox at refs/tsk/queues/<name>.
New commands: share (cross-namespace task binding), queue (list/current/
create/switch), pull (pull a task from another queue when can-pull).
Adds git-tsk binary alongside tsk so the tool can be invoked as a git
subcommand. Drops the file-backed mode; tsk now requires a git repo.
Validation: 48 unit tests across object/namespace/queue/workspace plus
tests/multi_user.rs spinning bare origin + two clones for share, assign,
concurrent push, and namespace round-trip.
Out of scope (left as TODOs): prop, follow, find/fzf, bundle, migrate,
log, reopen, foreign remote, internal link translation between human
and stable ids, merge driver for refs/tsk/queues/* on concurrent push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
git_push_refs now pushes deletion refspecs for refs present in the
shadow but missing locally, and update_remote_shadow prunes shadow
entries that no longer exist locally — without this a subsequent pull
would see the still-shadowed remote OID and recreate the local ref.
git_pull_refs fetches with --prune so the shadow tracks remote
deletions, and applies those deletions locally when the local ref
hasn't moved since the last sync (otherwise: conflict).
Surfaced by `tsk reject`: the inbox blob deletion was being undone by
the auto-pull on the next `tsk inbox`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reject mirrors assign: pushes refs to "origin" by default after writing
the rejection event into the source's log. `-r NAME` selects a different
remote; `-r ""` skips.
`tsk git-push` and `tsk git-pull` now treat the remote argument as
optional, defaulting to "origin" when configured. Errors out when no
remote is supplied and no origin is set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the auto-pull on `tsk inbox`: assign now pushes refs to "origin"
by default after writing the cross-namespace inbox blob, so the assignee
sees the item on their next inbox check. `-r NAME` selects a different
remote; `-r ""` skips the push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inbox listings now reflect what other clones have sent: tsk inbox
runs git_pull_refs first against "origin" if that remote is
configured, then prints the result.
Pass -r <name> to use a different remote, or -r "" to explicitly skip
the pull (useful when offline or for testing). File-backed workspaces
and git workspaces without an "origin" remote skip silently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removed the parameterless `write_task` / `write_attrs` convenience
wrappers (only ever used from tests after the rich-message refactor)
and merged them into a single function each that always takes
event/detail. Test call sites pass `"write", None` explicitly. The
write_task_with_event / write_attrs_with_event names go away.
Also:
- Replace two clippy `get(k).is_none()` warnings with `!contains_key(k)`.
Build is now warning-free; clippy --all-targets is silent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tsk prop find — fzf-pick key, then value (with <any> to skip)
tsk prop find KEY — list every task with KEY set (existing semantics)
tsk prop find KEY VAL — direct match (existing)
Mirrors the recent prop set ergonomics. The <any> sentinel in the
value picker means "match any value for this key" so the user can
still narrow on key alone via fzf.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the command rename (tsk export → tsk assign) and the
`assigned=[[ns/tsk-N]]` property name. The destination side keeps its
`accepted` event on accept; both sides now produce a clearly-named
event when a cross-namespace assignment happens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fzf appends a trailing newline to its stdout. The String FromStr impl
is just a clone, so picked-string callers (`pick_namespace`,
`PropAction::Set` value/key picker) saw "noah\n" and forwarded that
through to validate_namespace, which rightly rejected the embedded
newline.
Also affected non-string callers in principle (Id from "tsk-1\n"
parses the int part as "1\n", which fails) but no live caller hit
that yet.
Trim once in fzf::select so every caller benefits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk switch` (and `tsk namespace switch`) now accept the name as
optional. When omitted, fzf-pick from existing namespaces with a
`<new>` sentinel for entering a fresh name at a prompt.
`tsk namespace create <name>` is unchanged (always requires a name).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Better matches the existing `assigned` property semantics — exporting
a task to another namespace is functionally an assignment, and the
two commands now use the same vocabulary.
`tsk bundle` is unaffected (zip export was renamed earlier; this only
touches the cross-namespace command).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each refs/tsk/<ns>/<key> ref now points at a commit chain instead of a
single blob. The commit's tree carries one file (`content`) holding the
blob; future writes append commits with the prior tip as parent. `git
log refs/tsk/<ns>/tasks/<id>` walks the audit trail; `git show
refs/tsk/<ns>/tasks/<id>:content` reads the current state.
Inbox refs (`inbox/<src>-<id>`) intentionally stay blob-backed — they
are short-lived (deleted on accept) and gain nothing from history.
Storage:
- Store gains `write_with_meta(key, data, event, detail)` (with `write`
now defaulting to `event="write"`). FileStore ignores the meta;
GitStore turns it into a commit message via format_commit_message,
which is a single function for easy format changes later.
- GitStore::read peels through commit→tree→`content`, falling back to
raw blob so it keeps reading legacy/inbox refs unchanged.
- merge_blob produces a merge commit (parents = local + remote) when
either side is commit-backed; falls back to a blob OID otherwise.
- Cross-namespace link rewrites in the id-collision rebase now go
through write_with_meta so each ref-update lands as a commit.
Migration: `tsk migrate-history` walks every refs/tsk/<ns>/<key>
(except inbox/*) and wraps any remaining blob ref in a single
"migrated" commit. Idempotent — already-commit refs are skipped.
A few high-traffic call sites pass rich event metadata through:
- new_task → `tsk(tasks/N): created`
- save_task → `tsk(tasks/N): edited` / `tsk(attrs/N): edited`
- set_property / unset_property → `tsk(attrs/N): prop-set <key>` etc.
Other writes still get the generic "write" subject; opting another
caller into rich messages is a one-call-site change.
Tests cover the commit-chain shape (4 commits after a create + 3
edits), inbox refs staying blob-backed, the migration converting
legacy blob refs, and idempotency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When two clones offline both pick the same id and create different
tasks at it, pull now resolves the collision automatically by
renumbering the loser past the highest known id on either side and
rewriting every reference.
Detection: at the start of the pull reconcile pass, scan the post-fetch
shadow for tasks/<id> or archive/<id> refs that exist on both sides at
different OIDs. Compare the `created` log line byte-wise — if equal
it's the same task edited in two places (regular conflict path); if
different it's an id collision. Earlier `created` timestamp keeps the
id; tie-break on lexicographically-smaller blob OID.
Renumber actions:
- LocalLoser: move our blobs (tasks/archive/attrs/backlinks/log) from
<old> to <new>; rewrite [[tsk-<old>]] across our task content, attrs,
log details, backlinks, and index titles; rewrite [[<our-ns>/tsk-<old>]]
across every other namespace's blobs; bump our `next` past <new>.
After this, our <old> is vacated and the regular reconcile pass takes
remote's <old> normally.
- RemoteLoser: copy remote's <old> blobs from the shadow to our local
<new> (preserving bucket); add to the index if it was active on the
remote; suppress reconcile for the remote's <old> blobs so they don't
clobber our local winner.
In both cases a `renumbered\tfrom tsk-<old>...` log entry is appended
on <new> via the existing per-task log so history is recoverable.
Also: `next` is now mergeable (take max of either side) so two clones
calling next_id offline don't deadlock on a non-FF push of `next`.
Test simulates both cases: A creates tsk-1, B sleeps 2s and creates
tsk-1 with a referencing tsk-2, B pulls. Asserts tsk-1 = A's content,
B's original moved past tsk-2, B's body link rewritten, and a
renumbered log entry exists on the new id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cross-namespace assignment command in question is `tsk export`,
which (since the export/accept feature commits) sets
`assigned=[[<target-ns>/tsk-N]]` on the source after a successful
write. With tsk-41's namespaced-link parser landed, the value also
renders with a numbered superscript in `tsk show` and is followable
via `tsk follow -l N`. The accept side strips `assigned` from the
copied attrs so the new local task isn't itself flagged as assigned.
No code change needed; this commit is just the bookkeeping drop.
The `tsk accept` flow already sets `source=[[<src-ns>/tsk-N]]` on the
local copy, but those links weren't parseable: the link parser rejected
prefixes containing `/`, so neither tsk show's superscript rendering
nor tsk follow could navigate them.
Add a third link kind `Namespaced { namespace, id }` to the parser and
wire it through:
- task::parse recognises `[[<ident>/tsk-N]]` and registers Namespaced.
- Workspace::resolve_namespaced_link reads the matching task from a
sibling namespace's GitStore in the same repo (file-backed
workspaces error out).
- tsk follow handles the new kind by printing the resolved task's
content; editing across namespaces is refused (ergonomic enough
given the existing tsk switch + edit flow).
Source values like `[[default/tsk-3]]` now show up with a numbered
superscript in `tsk show` and can be opened with `tsk follow -l N` or
`tsk follow -s`. Same path works for `assigned` values.
Tests cover parser output for the new kind and invalid namespace
characters, and resolve_namespaced_link's positive/missing-namespace/
missing-id paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tsk prop set <id> duplicates [[tsk-X]] now wires up the reverse half:
the original X gets `[[tsk-id]]` appended to its `duplicated-by`
property (comma-separated link list). Unsetting or re-pointing the
duplicate removes it. Self-reference and cycles are rejected.
Refactored the parent/children logic into a small `InversePair`
registry so `parent`/`children` and `duplicates`/`duplicated-by` share
the same implementation. Adding more pairs in the future is one line.
CLI: when a duplicate and its original are both still on the stack,
`tsk prop set ... duplicates ...` prompts `Drop tsk-N? [y/N]` and drops
the duplicate on `y`. Idempotent — answering n leaves both open.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>