commits
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>
The body_candidates portion of test_property_candidate_queries used
`see <https://x.example>` — but the URL parser only finalizes a
`<...>` raw link when the closing `>` is followed by a boundary char,
which doesn't happen at end-of-string. Add trailing text so the URL is
recognized.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tsk prop set <id> — fzf-pick key from existing keys, then value
from existing values for that key
tsk prop set <id> <key> — fzf-pick value only
tsk prop set <id> <key> <v> — direct (existing behavior)
tsk prop set <id> -l — also include URLs and [[tsk-N]] refs parsed
from the task body as value candidates
The fzf list always carries a `<new>` sentinel so the user can type a
fresh string at a prompt instead of picking from history. Helpful when
seeding a new property name or a value that hasn't been used before.
Tests cover the underlying candidate queries (keys, values-for-key,
body candidates) on both backends; the fzf interaction itself remains
manual.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk prop set <id> parent [[tsk-X]]` now wires up the reverse half:
the new parent gets `[[tsk-id]]` appended to its `children` property
(comma-separated link list). Re-parenting moves the entry; unsetting
removes it; deleting the only child clears the property entirely.
Guards:
- self-parent rejected
- cycles rejected (walks up the parent chain from the proposed parent)
Non-link values for `parent` are stored as-is with no inverse
maintenance, since there's nothing to point back at. Foreign-link
parents (`[[ns/tsk-N]]`) intentionally don't trigger the inverse —
the bidirectional invariant only makes sense intra-namespace.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previously-separate `tsk links` command duplicated tsk follow's
resolve+open logic. Drop it; instead extend follow:
tsk follow -T <id> list links and exit
tsk follow -T <id> -l N open link N (existing behavior)
tsk follow -T <id> -s fzf-pick a link, then open it
URLs go to the system handler, [[tsk-N]] internal links are shown,
foreign refs resolve through the configured remote — same paths as
before, just exposed as different invocations of one command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces every link parsed from a task body — URLs (raw and markdown),
internal [[tsk-N]] refs, foreign [[ns-N]] refs — as a numbered list:
tsk links -T tsk-12
With -s, the list is piped through fzf and the picked link is opened
via the existing tsk follow path: URLs go to the system handler,
internal links are shown, foreign refs resolve through the configured
remote.
Test exercises the link parser directly to confirm all four link kinds
appear in the right order.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Push and pull now reconcile through a per-remote shadow at
refs/remotes-tsk/<remote>/* and refuse to silently overwrite a
concurrent edit.
Push:
- Fetch the remote into the shadow first so leases match its current
state.
- For each refs/tsk/* ref, skip when local matches shadow; otherwise
push with `--force-with-lease=<ref>:<shadow-oid>` so a concurrent
push fails the lease and aborts ours.
- After a successful push, refresh the shadow.
Pull:
- Fetch into refs/remotes-tsk/<remote>/* (force, since this is our
private mirror).
- For each fetched ref, look at three OIDs — local, the shadow's
pre-fetch value (the merge base), and the new remote — and decide:
local missing → take remote
local == new remote → no-op
local unchanged from base → take remote
remote unchanged from base → keep local
both moved + mergeable → 3-way merge
both moved + not mergeable → conflict
- Mergeable refs are `log/*` (union sorted by leading timestamp) and
`index` (union preserving local order, append remote-only items).
- Conflicts surface as a clear error listing the divergent refs;
local state is preserved so the user can resolve and retry.
Test covers the happy path (A pushes a new task, B edits a different
task locally, B pulls and gets both sets of changes), and the conflict
path (A and B both edit the same task body, B pulls and gets a
conflict naming the offending ref while keeping their local edit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tasks can now be sent to another namespace's inbox in the same git repo
and accepted there as new local tasks.
Storage: each pending item lives at refs/tsk/<dest-ns>/inbox/<src-ns>-<src-id>
as a single blob with the source coordinates, attrs, title, and body.
Stable inbox key means re-exporting overwrites the same slot.
Workflow:
- tsk export <target-ns> [-T <id>] send a task; sets `assigned=[[<target-ns>/tsk-N]]` on the source
- tsk inbox list pending items in the current namespace
- tsk accept [<key>] create a local task from inbox; copies title/body/attrs and sets `source=[[<src-ns>/tsk-N]]`. Removes the inbox blob.
Renamed the old zip-export command to `tsk bundle` so `tsk export` is
free for the cross-namespace sense the user describes.
Logs: source gets an `exported` entry pointing at the assignee, accepted
copy gets `accepted` pointing at the source. Inbox blobs are part of
all_keys so they roll into bundle/migrate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every workspace mutation now appends a line to log/<id>: created,
edited, prop-set <key>, prop-unset <key>, archived, reopened, and
links-changed (only when the link set actually changed). Log entries
include a unix timestamp and the author from the user's git config
(user.name <user.email>) so multi-contributor workflows can attribute
changes.
CLI:
- tsk log -T <tsk-id> — log for a single task, newest first
- tsk log — every event in the current namespace, newest
first, in git-log style (event/author/date/detail)
Logs are part of all_keys, so they're included in tsk export and
tsk migrate. Tests cover all event kinds plus the export round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tasks now have arbitrary key/value properties (built on the existing
attrs storage), plus four synthetic properties computed on read:
- state: open | archived (from task location)
- has-links: true | false
- references: comma-separated [[tsk-N]] links found in body
- referenced-by: comma-separated [[tsk-N]] backlinks
CLI:
- tsk prop list <id> list every property on a task
- tsk prop set <id> <k> [v] set a property (value optional for unary)
- tsk prop unset <id> <k> remove a property
- tsk prop find <k> [v] print task ids with property k (matching v
if provided). Includes synthetic props,
so e.g. `tsk prop find state archived`
works.
Tests cover round-tripping stored properties, presence-of-key search,
synthetic property visibility, and unset. Run against both backends.
Tasks for the example "calculated" properties (parent/child-of,
duplicates, source, assigned) are pushed onto the stack and
deprioritized to the bottom; they need their own bidirectional
maintenance logic and are scoped separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A single git repo can now host multiple isolated tsk workspaces.
Refs live under refs/tsk/<namespace>/... — different contributors
can share a repo without sharing tasks.
Storage:
- GitStore now carries a namespace string; every ref it reads/writes
is prefixed with refs/tsk/<namespace>/.
- The current namespace is stored in .tsk/namespace; absent or empty
means the "default" namespace.
- On open, any pre-existing non-namespaced refs (refs/tsk/tasks/<id>,
refs/tsk/index, etc.) are renamed under refs/tsk/default/ so older
workspaces self-heal.
CLI:
- tsk namespace list — list every namespace, marking the current one
- tsk namespace current
- tsk namespace switch <name> / tsk namespace create <name>
- tsk switch <name> — shorthand for switch
- tsk namespace delete <name> [-y] — refuses to delete the active
namespace; prompts for confirmation when the namespace has refs.
Tests cover full round-trip (state isolation, switch back, list,
delete) and the legacy non-namespaced ref migration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
[[ns/tsk-12]] previously parsed as a Foreign link with prefix "ns/tsk",
which isn't a valid namespace. Restrict the foreign-link prefix to
[A-Za-z0-9_]+; anything else falls through to plain text. Adds a
regression test.
Also: remove panic sites from main.rs.
- default_dir now returns Result; cwd-resolution failures propagate as
proper errors instead of panicking.
- Commands::Rot / Commands::Tor used Workspace::from_path(dir).unwrap()
which would panic on an uninitialized workspace; replaced with `?` so
they surface the same error every other command does.
- Restructured main() into a small wrapper around run() -> Result so the
Cli is the only thing parsed at the top level.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
[[some text]] that doesn't parse as either an internal id (tsk-N) or a
foreign id (prefix-N) used to crash the parser. tsk edit / show / list
all run task::parse, so any task body containing one such bracketed
phrase made the binary unusable.
Now we leave the bracketed text in the output unchanged and don't
register a link. Adds two regression tests, and flips the existing
test_foreign_link_bad_no_number from #[should_panic] to assert the
non-panicking contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend: extract read_text/write_or_delete helpers; reduce read_attrs,
write_attrs, read_backlinks, write_backlinks, read_remotes,
write_remotes to a few iterator chains each.
- workspace: factor BODY_ARGS/ID_ARGS constants out of search; replace
LazyTaskLoader with a stack.into_iter().filter_map(...) chain. Extract
Workspace::all_keys helper used by both export_zip and migrate_to_git.
Add git_cmd helper so configure_git_remote_refspecs reuses it.
- stack: parse/serialize as iterator chains; drop empty().
Tests untouched; all 46 still pass. ~150 prod lines removed in this pass
on top of the prior refactor. cargo fmt + cargo clippy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Replace the Attrs (written/updated split) struct with a plain
BTreeMap<String, String>. The split was an optimization for the old xattr
storage; with refs/files as blobs, every save rewrites the whole map and
the indirection is just noise. Tests use the same insert/get/iter API
unchanged.
- Add Workspace::mutate_stack helper and use it for push_task, append_task,
swap_top, prioritize, deprioritize. Merge rot/tor into rotate_top3 with
a flag, prioritize/deprioritize into move_in_stack.
- Add From<git2::Error> for Error so GitStore methods use ? directly
instead of mapping each error site. Add try_ref helper for the common
"find_reference, NotFound → None" pattern.
- Drop unused: Id::filename, TaskStack::refresh_titles, From<Task> for
StackItem.
Functionality preserved; all 46 tests pass without modification. Net
~120 lines removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverts 4f72dc9: keep the tab separator between id and title in tsk list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The tab separator between id and title rendered as zero spaces on
terminals where the tab stop happened to land on the column right after
the id (e.g. width=6 tab stops with `tsk-15`). Replace with fixed-width
space padding so the title column lines up regardless of tab stops.
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>
The body_candidates portion of test_property_candidate_queries used
`see <https://x.example>` — but the URL parser only finalizes a
`<...>` raw link when the closing `>` is followed by a boundary char,
which doesn't happen at end-of-string. Add trailing text so the URL is
recognized.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tsk prop set <id> — fzf-pick key from existing keys, then value
from existing values for that key
tsk prop set <id> <key> — fzf-pick value only
tsk prop set <id> <key> <v> — direct (existing behavior)
tsk prop set <id> -l — also include URLs and [[tsk-N]] refs parsed
from the task body as value candidates
The fzf list always carries a `<new>` sentinel so the user can type a
fresh string at a prompt instead of picking from history. Helpful when
seeding a new property name or a value that hasn't been used before.
Tests cover the underlying candidate queries (keys, values-for-key,
body candidates) on both backends; the fzf interaction itself remains
manual.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsk prop set <id> parent [[tsk-X]]` now wires up the reverse half:
the new parent gets `[[tsk-id]]` appended to its `children` property
(comma-separated link list). Re-parenting moves the entry; unsetting
removes it; deleting the only child clears the property entirely.
Guards:
- self-parent rejected
- cycles rejected (walks up the parent chain from the proposed parent)
Non-link values for `parent` are stored as-is with no inverse
maintenance, since there's nothing to point back at. Foreign-link
parents (`[[ns/tsk-N]]`) intentionally don't trigger the inverse —
the bidirectional invariant only makes sense intra-namespace.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previously-separate `tsk links` command duplicated tsk follow's
resolve+open logic. Drop it; instead extend follow:
tsk follow -T <id> list links and exit
tsk follow -T <id> -l N open link N (existing behavior)
tsk follow -T <id> -s fzf-pick a link, then open it
URLs go to the system handler, [[tsk-N]] internal links are shown,
foreign refs resolve through the configured remote — same paths as
before, just exposed as different invocations of one command.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces every link parsed from a task body — URLs (raw and markdown),
internal [[tsk-N]] refs, foreign [[ns-N]] refs — as a numbered list:
tsk links -T tsk-12
With -s, the list is piped through fzf and the picked link is opened
via the existing tsk follow path: URLs go to the system handler,
internal links are shown, foreign refs resolve through the configured
remote.
Test exercises the link parser directly to confirm all four link kinds
appear in the right order.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Push and pull now reconcile through a per-remote shadow at
refs/remotes-tsk/<remote>/* and refuse to silently overwrite a
concurrent edit.
Push:
- Fetch the remote into the shadow first so leases match its current
state.
- For each refs/tsk/* ref, skip when local matches shadow; otherwise
push with `--force-with-lease=<ref>:<shadow-oid>` so a concurrent
push fails the lease and aborts ours.
- After a successful push, refresh the shadow.
Pull:
- Fetch into refs/remotes-tsk/<remote>/* (force, since this is our
private mirror).
- For each fetched ref, look at three OIDs — local, the shadow's
pre-fetch value (the merge base), and the new remote — and decide:
local missing → take remote
local == new remote → no-op
local unchanged from base → take remote
remote unchanged from base → keep local
both moved + mergeable → 3-way merge
both moved + not mergeable → conflict
- Mergeable refs are `log/*` (union sorted by leading timestamp) and
`index` (union preserving local order, append remote-only items).
- Conflicts surface as a clear error listing the divergent refs;
local state is preserved so the user can resolve and retry.
Test covers the happy path (A pushes a new task, B edits a different
task locally, B pulls and gets both sets of changes), and the conflict
path (A and B both edit the same task body, B pulls and gets a
conflict naming the offending ref while keeping their local edit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tasks can now be sent to another namespace's inbox in the same git repo
and accepted there as new local tasks.
Storage: each pending item lives at refs/tsk/<dest-ns>/inbox/<src-ns>-<src-id>
as a single blob with the source coordinates, attrs, title, and body.
Stable inbox key means re-exporting overwrites the same slot.
Workflow:
- tsk export <target-ns> [-T <id>] send a task; sets `assigned=[[<target-ns>/tsk-N]]` on the source
- tsk inbox list pending items in the current namespace
- tsk accept [<key>] create a local task from inbox; copies title/body/attrs and sets `source=[[<src-ns>/tsk-N]]`. Removes the inbox blob.
Renamed the old zip-export command to `tsk bundle` so `tsk export` is
free for the cross-namespace sense the user describes.
Logs: source gets an `exported` entry pointing at the assignee, accepted
copy gets `accepted` pointing at the source. Inbox blobs are part of
all_keys so they roll into bundle/migrate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every workspace mutation now appends a line to log/<id>: created,
edited, prop-set <key>, prop-unset <key>, archived, reopened, and
links-changed (only when the link set actually changed). Log entries
include a unix timestamp and the author from the user's git config
(user.name <user.email>) so multi-contributor workflows can attribute
changes.
CLI:
- tsk log -T <tsk-id> — log for a single task, newest first
- tsk log — every event in the current namespace, newest
first, in git-log style (event/author/date/detail)
Logs are part of all_keys, so they're included in tsk export and
tsk migrate. Tests cover all event kinds plus the export round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tasks now have arbitrary key/value properties (built on the existing
attrs storage), plus four synthetic properties computed on read:
- state: open | archived (from task location)
- has-links: true | false
- references: comma-separated [[tsk-N]] links found in body
- referenced-by: comma-separated [[tsk-N]] backlinks
CLI:
- tsk prop list <id> list every property on a task
- tsk prop set <id> <k> [v] set a property (value optional for unary)
- tsk prop unset <id> <k> remove a property
- tsk prop find <k> [v] print task ids with property k (matching v
if provided). Includes synthetic props,
so e.g. `tsk prop find state archived`
works.
Tests cover round-tripping stored properties, presence-of-key search,
synthetic property visibility, and unset. Run against both backends.
Tasks for the example "calculated" properties (parent/child-of,
duplicates, source, assigned) are pushed onto the stack and
deprioritized to the bottom; they need their own bidirectional
maintenance logic and are scoped separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A single git repo can now host multiple isolated tsk workspaces.
Refs live under refs/tsk/<namespace>/... — different contributors
can share a repo without sharing tasks.
Storage:
- GitStore now carries a namespace string; every ref it reads/writes
is prefixed with refs/tsk/<namespace>/.
- The current namespace is stored in .tsk/namespace; absent or empty
means the "default" namespace.
- On open, any pre-existing non-namespaced refs (refs/tsk/tasks/<id>,
refs/tsk/index, etc.) are renamed under refs/tsk/default/ so older
workspaces self-heal.
CLI:
- tsk namespace list — list every namespace, marking the current one
- tsk namespace current
- tsk namespace switch <name> / tsk namespace create <name>
- tsk switch <name> — shorthand for switch
- tsk namespace delete <name> [-y] — refuses to delete the active
namespace; prompts for confirmation when the namespace has refs.
Tests cover full round-trip (state isolation, switch back, list,
delete) and the legacy non-namespaced ref migration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
[[ns/tsk-12]] previously parsed as a Foreign link with prefix "ns/tsk",
which isn't a valid namespace. Restrict the foreign-link prefix to
[A-Za-z0-9_]+; anything else falls through to plain text. Adds a
regression test.
Also: remove panic sites from main.rs.
- default_dir now returns Result; cwd-resolution failures propagate as
proper errors instead of panicking.
- Commands::Rot / Commands::Tor used Workspace::from_path(dir).unwrap()
which would panic on an uninitialized workspace; replaced with `?` so
they surface the same error every other command does.
- Restructured main() into a small wrapper around run() -> Result so the
Cli is the only thing parsed at the top level.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
[[some text]] that doesn't parse as either an internal id (tsk-N) or a
foreign id (prefix-N) used to crash the parser. tsk edit / show / list
all run task::parse, so any task body containing one such bracketed
phrase made the binary unusable.
Now we leave the bracketed text in the output unchanged and don't
register a link. Adds two regression tests, and flips the existing
test_foreign_link_bad_no_number from #[should_panic] to assert the
non-panicking contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend: extract read_text/write_or_delete helpers; reduce read_attrs,
write_attrs, read_backlinks, write_backlinks, read_remotes,
write_remotes to a few iterator chains each.
- workspace: factor BODY_ARGS/ID_ARGS constants out of search; replace
LazyTaskLoader with a stack.into_iter().filter_map(...) chain. Extract
Workspace::all_keys helper used by both export_zip and migrate_to_git.
Add git_cmd helper so configure_git_remote_refspecs reuses it.
- stack: parse/serialize as iterator chains; drop empty().
Tests untouched; all 46 still pass. ~150 prod lines removed in this pass
on top of the prior refactor. cargo fmt + cargo clippy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Replace the Attrs (written/updated split) struct with a plain
BTreeMap<String, String>. The split was an optimization for the old xattr
storage; with refs/files as blobs, every save rewrites the whole map and
the indirection is just noise. Tests use the same insert/get/iter API
unchanged.
- Add Workspace::mutate_stack helper and use it for push_task, append_task,
swap_top, prioritize, deprioritize. Merge rot/tor into rotate_top3 with
a flag, prioritize/deprioritize into move_in_stack.
- Add From<git2::Error> for Error so GitStore methods use ? directly
instead of mapping each error site. Add try_ref helper for the common
"find_reference, NotFound → None" pattern.
- Drop unused: Id::filename, TaskStack::refresh_titles, From<Task> for
StackItem.
Functionality preserved; all 46 tests pass without modification. Net
~120 lines removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The tab separator between id and title rendered as zero spaces on
terminals where the tab stop happened to land on the column right after
the id (e.g. width=6 tab stops with `tsk-15`). Replace with fixed-width
space padding so the title column lines up regardless of tab stops.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>