A file-based task manager
1# Architecture
2
3`tsk` is a task tracker that stores everything in git refs under
4`refs/tsk/*` of an enclosing repository. There are no working-tree
5files — pushing the host repo's refs carries the task state along
6with it. This document is a tour of the moving pieces; the user-
7facing crib sheet lives in [AGENTS.md](AGENTS.md).
8
9## On-disk layout
10
11Everything lives inside the host repo's `.git` directory. Two
12classes:
13
14- **Shared, pushed**: `refs/tsk/*` — the actual task data. Visible
15 to every clone via standard `git push` / `git fetch` once the
16 refspec is set up.
17- **Per-clone, not pushed**: `<git-dir>/tsk/{namespace,queue}` —
18 two tiny text files holding the active selectors. Each clone
19 picks its own.
20
21```
22<git-dir>/
23├── refs/
24│ └── tsk/
25│ ├── tasks/<stable-id> # one ref per task; commit chain = task history
26│ ├── namespaces/<name> # tree: { next, ids/<human-id> → <stable-id> }
27│ ├── queues/<name> # tree: { index, can-pull, inbox/<key> → <stable-id> }
28│ └── properties/<key> # tree: { <stable-id> → blob of values }
29└── tsk/
30 ├── namespace # active namespace (defaults to "tsk")
31 ├── queue # active queue (defaults to "tsk")
32 └── remote # default git remote for sync (defaults to "origin")
33```
34
35Every category except `tasks` is a tree; per-task histories are
36their own commit chain so each task's edit log stands on its own.
37
38## Identifiers
39
40Two layers of id, related but distinct:
41
42- **Stable id** = SHA-1 of the task's initial `content` blob. Same
43 body → same id, on every clone, forever. Used as the ref name
44 (`refs/tsk/tasks/<stable-id>`) and as the cross-namespace handle.
45- **Human id** = `tsk-<N>`. A small integer minted per-namespace by
46 the namespace's `next` counter. Always namespace-scoped: `tsk-5`
47 in namespace `alpha` is unrelated to `tsk-5` in namespace `tsk`.
48
49A namespace is essentially `human → stable` plus the `next` counter.
50
51```
52namespace tsk namespace claude
53┌─ tsk-1 → 8f77… ┐ ┌─ tsk-1 → 5c43… ┐
54├─ tsk-2 → 2a09… │ ├─ tsk-2 → 8f77… │ ← same stable, different humans
55├─ tsk-3 → 0a2d… │ └────────────────┘
56└─ next: 4 ┘
57```
58
59## Module map
60
61```mermaid
62graph TD
63 bin[bin/tsk + bin/git-tsk] --> lib[lib.rs<br/>CLI dispatch]
64 lib --> workspace
65 lib --> task[task.rs<br/>rich-text parser]
66 lib --> fzf[fzf.rs<br/>picker wrapper]
67 lib --> errors
68
69 workspace --> object[object.rs<br/>task tree<br/>+ shared signature()]
70 workspace --> namespace[namespace.rs<br/>human ↔ stable]
71 workspace --> queue[queue.rs<br/>index + inbox]
72 workspace --> properties[properties.rs<br/>per-key indices]
73 workspace --> patch[patch.rs<br/>mbox export/import]
74 workspace --> merge[merge.rs<br/>3-way reconcile]
75
76 object --> propvalue[propvalue.rs<br/>length-prefix codec]
77 properties --> propvalue
78 merge --> namespace
79 merge --> queue
80 merge --> object
81 patch --> object
82```
83
84`object.rs` is the choke point for git plumbing — every other
85storage module reuses its `signature()` helper so all writes carry
86the same author/committer.
87
88## Task lifecycle
89
90```
91 tsk push tsk drop tsk reopen
92no refs ──► open ─────────────────► done ─────────────────► open
93 │ │ │
94 │ tsk push <same body> │ │
95 └──────────────────────┘ (reopen-on-duplicate)
96```
97
98`status` is an auto-managed property: `open` on creation, flipped to
99`done` on `tsk drop`. The task ref's commit chain is **never**
100deleted — `done` is just a property and the binding stays addressable
101(`tsk show -T tsk-N` keeps working). `tsk reopen` flips status back
102and re-pushes onto the active queue.
103
104Pushing a body whose stable id already exists short-circuits to a
105reopen instead of clobbering — see `Workspace::new_task`.
106
107## Operation flow: `tsk push "..."`
108
109```
110new_task(title, body)
111 │
112 ▼ ① compute stable id (= SHA of content blob)
113 ▼ ② object::create → refs/tsk/tasks/<sha>
114 ▼ ③ properties::reindex_task → refs/tsk/properties/status
115 ▼ ④ namespace::assign_id → refs/tsk/namespaces/<active>
116push_task(task)
117 ▼ ⑤ queue::push_top → refs/tsk/queues/<active>
118```
119
120The order matters: a crash between any two steps leaves recoverable
121state because the most-derived ref (queue) is written last.
122`tsk fix-up` (see `Workspace::gc_refs`) is the idempotent cleanup
123pass that drops orphan property entries, ghost namespace bindings,
124empty queues, and dangling queue index entries.
125
126## Sync flow: `tsk git-pull`
127
128```mermaid
129sequenceDiagram
130 participant Local
131 participant Remote
132
133 Local->>Remote: git fetch --refmap= +refs/tsk/*:refs/tsk-fetched/<remote>/*
134 Note over Local: local refs/tsk/* untouched
135
136 loop tasks
137 Local->>Local: reconcile_task_refs<br/>(merge or rebase strategy)
138 end
139 loop namespaces
140 Local->>Local: reconcile_namespace_refs<br/>(remote-wins; auto-renumber on collision)
141 end
142 loop queues
143 Local->>Local: reconcile_queue_refs<br/>(3-way set/map merge)
144 end
145 Local->>Local: fast_forward_non_task_refs<br/>(properties, etc.)
146```
147
148Why the shadow `refs/tsk-fetched/<remote>/*` namespace? So the local
149`refs/tsk/*` aren't clobbered before the reconcile pass gets a
150chance to run. `--refmap=` disables the configured fetch refspec so
151git doesn't sneakily perform the default mapping behind our back.
152
153Reconciliation per ref-class:
154
155| Ref class | Strategy |
156| -------------- | ---------------------------------------------------------------- |
157| `tasks/*` | git2 `merge_trees` 3-way; clean → merge commit, conflict → abort that task. `--rebase` replays local commits onto remote tip preserving authorship. |
158| `namespaces/*` | data-aware: remote wins on `human → stable` collision, local binding renumbered to a fresh id past `max(local.next, remote.next)`. |
159| `queues/*` | per-stable 3-way set merge for `index`, per-key 3-way map merge for `inbox`, 3-way bool for `can_pull`. Empty base when no common ancestor. |
160| everything else (`properties/*`) | force-update from fetched (concurrent edits on the same key/task are rare and `tsk fix-up` reindexes anyway). |
161
162## Wire formats
163
164Two ways to ship state between repos:
165
166- **Standard git transport** (`tsk git-push` / `tsk git-pull`,
167 configured via `tsk git-setup`). Carries every `refs/tsk/*`
168 alongside ordinary branches.
169- **mbox patch series** (`tsk export` / `tsk import`). Each task
170 commit becomes one mbox entry: standard `From <sha>` separator,
171 RFC-822 headers including `X-Tsk-Stable-Id`, `X-Tsk-Parent`,
172 and an optional `X-Tsk-Namespace` binding hint, then a
173 size-prefixed dump of every file in the task tree between
174 `---tsk-tree---` and `---end---`. Survives strict mbox parsers
175 via standard mboxrd `From `-mangling. Stable id is verified on
176 import — tampered patches are rejected.
177
178## Per-clone overrides
179
180The active selectors live in `<git-dir>/tsk/{namespace,queue}`. Two
181ways to override per-invocation:
182
183- `tsk -q <queue> <command>` — global flag. A `OnceLock` set in
184 `dispatch` is consulted by `Workspace::queue()` before falling
185 back to the file.
186- `tsk switch <ns>` / `tsk queue switch <q>` — persists by
187 rewriting the file.
188
189## Property values
190
191Each property is a list of bytes-strings. Storage is length-prefixed
192blocks (`size: N\n<N bytes>\n`) inside the per-key blob, mirroring
193the patch wire format. This means values can contain anything,
194including newlines. The reader still tolerates the legacy
195line-split format and `tsk fix-up` migrates blobs on next save.
196
197## Reconciliation matrix
198
199| Drift class | Detected by | Fixed by |
200| ------------------------------------------------------ | ------------------- | --------------------------- |
201| Empty non-default queue | `gc_refs` | delete the ref |
202| Property index entry → missing task ref | `gc_refs` | `properties::set(empty)` |
203| Namespace binding → missing task ref ("ghost") | `gc_refs` | rewrite namespace tree |
204| Queue index entry → missing task ref ("orphan") | `gc_refs` | rewrite queue tree |
205| Two clones diverged on the same task ref | `git-pull` (tasks) | merge or rebase |
206| Two clones allocated the same human id | `git-pull` (ns) | auto-renumber local |
207| Two clones edited the same queue concurrently | `git-pull` (queues) | 3-way set/map merge |
208| Property blob in legacy line-split format | `tsk fix-up` | re-save with new codec |
209
210`tsk fix-up` is the umbrella command that runs every one-shot
211migration plus `gc_refs`; safe to run repeatedly.