A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Document the architecture in ARCHITECTURE.md

A tour of the moving pieces, complementing the user-facing crib in
AGENTS.md. Covers:

- on-disk layout (refs/tsk/* shared, <git-dir>/tsk/* per-clone)
- the two id layers (stable = SHA-1 of content blob, human = per-ns)
- module map (mermaid)
- task lifecycle (open/done/reopen, including reopen-on-duplicate)
- write order in `tsk push` and how gc_refs recovers partial writes
- git-pull reconciliation flow + per-ref-class strategy table
- wire formats (git refs vs. mbox patch series)
- per-clone overrides (-q flag + switch commands)
- length-prefix property value codec
- reconciliation matrix mapping each drift class to its detector
and fix

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