commits
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>
Custom refs under refs/tsk/* aren't included in git push/fetch by default.
Add three integration points so they can be synced across clones:
- tsk git-push <remote>: shells out to git push <remote> refs/tsk/*:refs/tsk/*
- tsk git-pull <remote>: git fetch <remote> +refs/tsk/*:refs/tsk/*
- tsk git-setup -r <remote>: appends push/fetch refspec config so plain
git push <remote> / git fetch <remote> include refs/tsk/* going forward.
Idempotent: running twice does not duplicate the entries.
Test: a real bare git remote round-trip — push from one workspace, pull
into a fresh workspace, confirm task content + stack survive the trip and
configure_git_remote_refspecs is idempotent. Errors are surfaced when any
of the three are invoked on a file-backed workspace.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier iterations of the git backend named per-task refs after the file
backend's filenames (refs/tsk/tasks/tsk-N.tsk). The current scheme uses just
the integer id (refs/tsk/tasks/N). Workspaces created or migrated under the
old scheme were unreadable: tsk list worked (the index ref kept the same
key) but tsk show/edit/drop failed because read_task looked under the new
key and saw nothing.
Add a one-shot rename pass in store_for that runs every time a git-backed
workspace is opened. Legacy keys (tsk-N.tsk) are renamed to the new scheme
(N); if both already exist, the legacy one is dropped.
Also adds a comprehensive command-flow test suite (run_every_command) that
mirrors each command_*'s workspace logic and runs against both backends.
This would have caught the migration bug had a legacy ref slipped into a
test fixture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tsk export writes every blob in the workspace — tasks, archive, attrs,
backlinks, index, next, remotes — to a zip file. Defaults to ./tsk.zip;
override with -o <path>. Works against either backend by iterating the
Store's logical key namespace, so the on-disk layout in the zip is the
same regardless of whether the source is file-backed or git-backed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run tsk migrate when an existing file-backed workspace is now inside a git
repository. All blobs (tasks, archive, attrs, backlinks, index, next,
remotes) are copied into refs/tsk/* and the on-disk task data under .tsk/
is removed, leaving only the git-backed marker. The command refuses to run
if the workspace is already git-backed or if no enclosing git repo exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When tsk init runs inside a git repository, all workspace state — tasks,
index, attrs, backlinks, remotes — is stored as git blobs addressed by refs
under refs/tsk/. No file cache is kept on disk; only a marker file in .tsk/
records that this is a git-backed workspace.
Outside of git repositories, the file-based backend remains and continues to
keep state in .tsk/.
Architecture: a Store trait (src/backend.rs) exposes a tiny logical blob
key/value API. FileStore writes files; GitStore writes git blobs via git2
and addresses them by ref. High-level operations (next_id, read/write_task,
attrs, backlinks, remotes, move_task) are free functions over &dyn Store so
both backends share a single implementation.
Storage changes:
- Per-task attrs and backlinks are now their own blobs (attrs/<id>,
backlinks/<id>) instead of filesystem xattrs — uniform across backends.
- Active vs archived tasks live in distinct ref/path namespaces (tasks/<id>
vs archive/<id>); drop and reopen now move the blob rather than maintain
symlinks.
- Removed nix flock dependency along with the now-unused locking helper.
Tests: both backends exercised through a shared lifecycle suite, plus
backend-level round-trip tests for blob ops, listing, attrs, backlinks,
remotes, and active/archive helpers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When tsk init runs inside a git repository, write a .tsk/git-backed marker
pointing at the .git directory. After every successful command, mirror task
files and metadata into refs/tsk/{tasks,archive,meta}/* as blobs. The on-disk
files remain authoritative; git refs are an additive durable mirror so the
file-based workflow keeps working unchanged outside of git repos.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- tsk-17: Add tsk reopen command to recreate symlinks and re-add archived tasks to the stack
- Add tests for reopen: successful reopen, nonexistent task, already open task
- tsk-21: Add tsk git-setup command with -g flag for .gitignore support
- Add tests for git setup functionality
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>
Custom refs under refs/tsk/* aren't included in git push/fetch by default.
Add three integration points so they can be synced across clones:
- tsk git-push <remote>: shells out to git push <remote> refs/tsk/*:refs/tsk/*
- tsk git-pull <remote>: git fetch <remote> +refs/tsk/*:refs/tsk/*
- tsk git-setup -r <remote>: appends push/fetch refspec config so plain
git push <remote> / git fetch <remote> include refs/tsk/* going forward.
Idempotent: running twice does not duplicate the entries.
Test: a real bare git remote round-trip — push from one workspace, pull
into a fresh workspace, confirm task content + stack survive the trip and
configure_git_remote_refspecs is idempotent. Errors are surfaced when any
of the three are invoked on a file-backed workspace.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier iterations of the git backend named per-task refs after the file
backend's filenames (refs/tsk/tasks/tsk-N.tsk). The current scheme uses just
the integer id (refs/tsk/tasks/N). Workspaces created or migrated under the
old scheme were unreadable: tsk list worked (the index ref kept the same
key) but tsk show/edit/drop failed because read_task looked under the new
key and saw nothing.
Add a one-shot rename pass in store_for that runs every time a git-backed
workspace is opened. Legacy keys (tsk-N.tsk) are renamed to the new scheme
(N); if both already exist, the legacy one is dropped.
Also adds a comprehensive command-flow test suite (run_every_command) that
mirrors each command_*'s workspace logic and runs against both backends.
This would have caught the migration bug had a legacy ref slipped into a
test fixture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tsk export writes every blob in the workspace — tasks, archive, attrs,
backlinks, index, next, remotes — to a zip file. Defaults to ./tsk.zip;
override with -o <path>. Works against either backend by iterating the
Store's logical key namespace, so the on-disk layout in the zip is the
same regardless of whether the source is file-backed or git-backed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run tsk migrate when an existing file-backed workspace is now inside a git
repository. All blobs (tasks, archive, attrs, backlinks, index, next,
remotes) are copied into refs/tsk/* and the on-disk task data under .tsk/
is removed, leaving only the git-backed marker. The command refuses to run
if the workspace is already git-backed or if no enclosing git repo exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When tsk init runs inside a git repository, all workspace state — tasks,
index, attrs, backlinks, remotes — is stored as git blobs addressed by refs
under refs/tsk/. No file cache is kept on disk; only a marker file in .tsk/
records that this is a git-backed workspace.
Outside of git repositories, the file-based backend remains and continues to
keep state in .tsk/.
Architecture: a Store trait (src/backend.rs) exposes a tiny logical blob
key/value API. FileStore writes files; GitStore writes git blobs via git2
and addresses them by ref. High-level operations (next_id, read/write_task,
attrs, backlinks, remotes, move_task) are free functions over &dyn Store so
both backends share a single implementation.
Storage changes:
- Per-task attrs and backlinks are now their own blobs (attrs/<id>,
backlinks/<id>) instead of filesystem xattrs — uniform across backends.
- Active vs archived tasks live in distinct ref/path namespaces (tasks/<id>
vs archive/<id>); drop and reopen now move the blob rather than maintain
symlinks.
- Removed nix flock dependency along with the now-unused locking helper.
Tests: both backends exercised through a shared lifecycle suite, plus
backend-level round-trip tests for blob ops, listing, attrs, backlinks,
remotes, and active/archive helpers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When tsk init runs inside a git repository, write a .tsk/git-backed marker
pointing at the .git directory. After every successful command, mirror task
files and metadata into refs/tsk/{tasks,archive,meta}/* as blobs. The on-disk
files remain authoritative; git refs are an additive durable mirror so the
file-based workflow keeps working unchanged outside of git repos.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>