@getpipher/armory-todo 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,217 @@
1
+ # Workstream v0.3.1 — auto-prune on session_start + unified `Done` view
2
+
3
+ **Date:** 2026-07-21
4
+ **Status:** Shipped (v0.3.1, PR #5, 2026-07-21)
5
+ **Branch:** `feat/auto-prune-done-view` off `main`
6
+ **Predecessor:** v0.3.0 (title + notes split), shipped 2026-07-21 (PR #4)
7
+ **Target ship:** v0.3.1, auto-published via `release.yml` on `v0.3.1` tag (now also creates a GitHub Release)
8
+
9
+ ---
10
+
11
+ ## 1. Problem
12
+
13
+ v0.3.0 split title/notes and fixed per-todo injection bloat, but two friction points remain in the *lifecycle* of finished work:
14
+
15
+ 1. **Prune is 100% manual.** Done/cancelled todos sit in the live `todo.json` forever until someone runs `/todo prune`. On a busy stretch they pile up; the `session_start` bloat nudge only *suggests* pruning — it never does it. The user is the prune trigger.
16
+ 2. **"Done" is split across two stores after pruning.** Recent done (<7d) lives in the live store; older done lives in `todo-archive.json`. There's no single "show me my finished work" view — you query live and archive separately. The `/todo` panel's Archive tab shows only the archive file (sealed history: done + cancelled), not recent live done.
17
+
18
+ ## 2. Goals
19
+
20
+ - **Auto-prune:** done/cancelled todos older than `config.prune.defaultAgeDays` (default 7d) archive *themselves* on `session_start`, without the user asking. Deterministic (the extension does it, not the model remembering to) — but **silent when there's nothing stale**.
21
+ - **Transparency:** every prune (auto or manual) reports a **rich result** (ids + titles + age + `restore` hint), so the user is never blind to what moved — especially when the agent auto-pruned without an explicit trigger.
22
+ - **Unified `Done` view:** one command + one panel tab to see all finished work (`status: done`), spanning live (recent) + archive (old), annotated by location, filterable.
23
+ - **Preserve the injection contract unchanged:** only `open` + `in_progress` are auto-injected into the prompt. Parked / done / archive are not injected. v0.3.1 does not alter this boundary.
24
+
25
+ ## 3. Non-goals (deferred)
26
+
27
+ - **`cancelled`-in-live visibility** — a pre-existing gap (cancelled todos <7d are in live but shown in no panel tab). Cancelled shows in the Archive tab once pruned. Separate concern; not addressed here.
28
+ - **`prune --all` / `prune --hard`** — unchanged (manual only; `--hard` stays the only irreversible, gated action).
29
+ - **Auto-prune of `parked`** — parked is deferred-not-finished, not "done"; auto-prune targets `done`/`cancelled` only (same as manual `prune`).
30
+ - **Time-based/scheduled prune outside session_start** — no cron; auto-prune runs only on the `session_start` hook (cold start / resume / fork / reload).
31
+
32
+ ## 4. Decisions log (from brainstorm Q&A)
33
+
34
+ | # | Question | Decision |
35
+ |---|---|---|
36
+ | Q1 | Where does "done" live in the `/todo` panel? | **A** — new 5th box tab `Done` (status `done` only, unified live+archive). `done` (status) ≠ `archive` (location) — different axes; `Done` = workflow view, `Archive` = storage vault. |
37
+ | Q2 | Auto-prune trigger mechanism | Deterministic on `session_start` (extension code), age-gated (>`defaultAgeDays`), **never `--all`**. Not prompt-driven (the model doesn't have to remember). |
38
+ | Q3 | Confirm gate for auto-prune? | No gate — reversible + age-gated, not destructive. The `--hard` gate stays untouched. |
39
+ | Q4 | Visibility when prune runs | Rich result (ids + titles + age + `restore` hint) for BOTH auto and manual prune. |
40
+ | Q5 | Unified `done` listing | `todo list status:done` spans live+archive in one call; `/todo done` slash shortcut. |
41
+ | Q6 | Injection contract | **Unchanged** — only `open`+`in_progress` injected. Auto-prune moves done→archive (both non-injected); zero injection impact. The Done tab is a panel view only. |
42
+
43
+ ## 5. Auto-prune on `session_start`
44
+
45
+ ### 5.1 Trigger
46
+
47
+ The extension's existing `session_start` handler (in `extensions/todo.ts`) currently: loads open todos, computes `healthReport()`, and notifies `armory-todo: N open TODOs` (+ a `⚠ N bloat signals` suffix if flags). v0.3.1 inserts an **auto-prune step** before the notify:
48
+
49
+ 1. Load the live store.
50
+ 2. Compute the set of done/cancelled todos with `closedAt` older than `config.prune.defaultAgeDays` (default 7d) — i.e. exactly what `pruneTodos({})` (age-gated, no `--all`) would move.
51
+ 3. **If that set is non-empty → call `pruneTodos({})`** (age-gated; **never `--all`**) and capture the moved ids + todos.
52
+ 4. Notify once, with the rich format (§6).
53
+ 5. **If empty → skip** (just the normal `N open TODOs` notify; no prune noise).
54
+
55
+ This runs on every `session_start` (cold start / resume / fork / `/reload`). Because it's age-gated, it's a no-op on a clean store — no churn, no prune spam. Idempotent: once pruned, the todos are in the archive; the next start sees nothing stale.
56
+
57
+ ### 5.2 Why deterministic (not prompt-driven)
58
+
59
+ "Agent thinks" = the agent's *awareness* drives it, but relying on the model to remember to call `prune` every session is fragile. Wiring it into the `session_start` hook makes it **just happen** reliably — the extension acts on the user's behalf at session start, deterministically. The model still *sees* the result (via the notify) and can act on it (e.g. mention it, or `restore` if the user asks). This matches the user's intent ("automatically triggered") without depending on model compliance.
60
+
61
+ ### 5.3 Config
62
+
63
+ Reuses `config.prune.defaultAgeDays` (7) — already tunable via the Config tab / `todo.config.json`. **No new config key.** A future v0.4.x could add `config.prune.autoOnSessionStart: boolean` to disable auto-prune, but v0.3.1 ships it always-on (age-gated).
64
+
65
+ ### 5.4 Notify format
66
+
67
+ ```
68
+ armory-todo: 2 open TODOs · auto-pruned 3 stale done (>7d):
69
+ [td-…] done armory-todo v0.2.0 — Workstream A shipped…
70
+ [td-…] done Task 1: Store schema break…
71
+ [td-…] cancelled QA smoke v0.3.0
72
+ Undo any with: todo restore <id>
73
+ ```
74
+
75
+ (When nothing was pruned, just `armory-todo: N open TODOs` — unchanged from v0.3.0, optionally + the existing `⚠ bloat` suffix if health flags fire.)
76
+
77
+ The notify is a **transient `ctx.ui.notify` message**, NOT a system-prompt injection. The `## Open TODOs` prompt block is untouched.
78
+
79
+ ## 6. Rich prune result (auto + manual)
80
+
81
+ Both the auto-prune (§5) and manual `prune` (tool + `/todo prune` slash) produce the same structured output. New helper in `src/archive.ts`:
82
+
83
+ ```ts
84
+ export interface PruneDetail {
85
+ moved: number;
86
+ items: { id: string; status: "done" | "cancelled"; title: string; ageDays: number }[];
87
+ }
88
+ ```
89
+
90
+ `pruneTodos` gains an optional `detail: true` flag (default false for back-compat) that returns `items` + `ageDays` (computed from `closedAt`). When `detail` is true, the caller (extension) formats:
91
+
92
+ ```
93
+ Pruned 3 todos to archive:
94
+ [td-…] done <title> (was 9d old)
95
+ [td-…] cancelled <title> (was 12d old)
96
+ Undo any with: todo restore <id>
97
+ ```
98
+
99
+ - **Manual `prune`** (tool + slash): always uses `detail: true` → rich output.
100
+ - **Auto-prune** (session_start): uses `detail: true` → rich notify.
101
+ - The `--all` path: same rich format (age shown as `(--all)` or the real age; minor).
102
+
103
+ The existing `PruneResult { moved, ids }` shape stays (back-compat for any caller); `PruneDetail` is additive.
104
+
105
+ ## 7. Unified `Done` listing
106
+
107
+ ### 7.1 Store layer (`src/archive.ts` + `src/todo-store.ts`)
108
+
109
+ New `listDoneUnified(filter?): DoneItem[]` (in `archive.ts` — it already coordinates live + archive via `loadStore`/`loadArchive`):
110
+
111
+ ```ts
112
+ export interface DoneItem extends Todo {
113
+ location: "live" | "archive";
114
+ archivedAt: string | null; // closedAt for live; closedAt for archive (the prune time ≈ closedAt)
115
+ }
116
+ ```
117
+
118
+ - Pulls done todos from the live store (`loadStore().todos.filter(status === "done")`) tagged `location: "live"`, plus done todos from the archive (`loadArchive().todos.filter(status === "done")`) tagged `location: "archive"`.
119
+ - **Excludes `cancelled`** (Done = finished work; cancelled = abandoned → lives in the Archive tab only).
120
+ - Sorts newest-`closedAt` first.
121
+ - Filter: `text` (title|notes substring), `project`, `since`, `before`, `limit`, `page` (same shape as `listTodos`/`listArchived` filters).
122
+
123
+ ### 7.2 Tool (`extensions/todo.ts`)
124
+
125
+ - **`todo list status:done`** now returns the **unified** set (live + archive) via `listDoneUnified`, formatted:
126
+ ```
127
+ - [td-…] done <title> [live 3d]
128
+ - [td-…] done <title> [archived 2026-07-15]
129
+ ```
130
+ (When `status:done` is set, the `archived` param is ignored — `done` is always unified. Other status values keep current behavior: `archived:true` queries the archive, default queries live.)
131
+
132
+ ### 7.3 Slash (`/todo done`)
133
+
134
+ New slash subcommand: `/todo done` → prints the unified done list (recent first), same row format as §7.2. `/todo done project:bug-bounty` → filtered.
135
+
136
+ ## 8. `/todo` panel — new `Done` box tab
137
+
138
+ ### 8.1 Tabs
139
+
140
+ `BOXES` becomes `["active", "parked", "done", "archive", "config"]` (5 tabs).
141
+
142
+ ### 8.2 Done tab content
143
+
144
+ - Uses `listDoneUnified({ text: filter })` (the panel's filter input searches title|notes across the unified set).
145
+ - `todoToItem` rows: `[id] (done) (project) • <title>` + a location tag suffix `[live N]` or `[archived <YYYY-MM-DD>]`. (Add a `todoDoneItem` helper in `panel-data.ts` that extends `todoToItem` with the location tag, or add an optional `location` param to `todoToItem`.)
146
+ - Sorted newest-closed first.
147
+ - Action submenu on Enter: **View detail** + (if location is archive) **Restore** (brings it back as open). Live done rows offer **View detail** only (no Restore — they are already in the live store; use `todo update <id> status:open` to reopen, or leave as done). No **Delete** for done rows (already finished; deletion/cancel is for open todos).
148
+
149
+ ### 8.3 Archive tab — unchanged
150
+
151
+ Still the sealed-vault browse view: summary-first (counts by project + month across done + cancelled), drill-down with a filter. Unchanged from v0.3.0.
152
+
153
+ ### 8.4 Footer hint
154
+
155
+ Update the panel footer to reflect 5 tabs: `↑↓ navigate • enter select/action • tab switch box • esc done`.
156
+
157
+ ## 9. Injection contract (unchanged — explicit)
158
+
159
+ `renderOpenBlock()` still calls `listTodos()` with the default filter (`status === "open" || status === "in_progress"`). v0.3.1 does NOT touch `renderOpenBlock` or `listTodos`'s default. Therefore:
160
+
161
+ - Only `open` + `in_progress` are auto-injected (title + `•`, capped 15).
162
+ - Parked / done / archive are NOT injected — available via `list`/`get`/panel only.
163
+ - Auto-prune moves done (not injected) → archive (not injected): **zero injection impact**.
164
+ - The Done tab is a panel view; it does not inject.
165
+
166
+ The only new thing the *agent* sees at session start is the transient `ctx.ui.notify` (§5.4), not a system-prompt change.
167
+
168
+ ## 10. Tests
169
+
170
+ Baseline: 220 across 8 suites. Target: ~240+.
171
+
172
+ ### 10.1 New suite `test/todo-auto-prune.test.mts`
173
+ - `session_start` auto-prune: stale done (>7d) → moved to archive; fresh done (<7d) → stays in live; open/in_progress/parked untouched.
174
+ - Auto-prune never uses `--all` (fresh done <7d stays even if `--all` would move it).
175
+ - Auto-prune is a no-op when nothing is stale (live store unchanged, no prune notify payload).
176
+ - Idempotent: running twice (two session_starts) → second is a no-op.
177
+ - `cancelled` also auto-pruned when stale.
178
+ - Rich result: `PruneDetail.items` has id + status + title + ageDays.
179
+
180
+ ### 10.2 Extend `test/todo-archive.test.mts`
181
+ - `pruneTodos({ detail: true })` returns `items` with `ageDays` computed from `closedAt`.
182
+ - `listDoneUnified`: live done + archived done merged, `cancelled` excluded, sorted newest-closed first, `location` tag correct, filters (text/project/since/before/limit/page) work.
183
+ - `listDoneUnified` empty when no done todos.
184
+
185
+ ### 10.3 Extend `test/panel-data.test.mts`
186
+ - `todoDoneItem` (or `todoToItem` with location): label includes `[live N]` / `[archived <date>]`.
187
+
188
+ ### 10.4 Extend `test/todo-store.test.mts` (if `list status:done` routing changes)
189
+ - `listTodos({ status: "done" })` still returns live done (back-compat) — the unification happens at the extension layer for the `todo list status:done` tool call, OR move unification into the store. **Decision:** keep `listTodos({status:"done"})` as live-only (back-compat); the unification lives in `listDoneUnified` (archive.ts), called by the extension for `todo list status:done`. So no `todo-store` change for routing — test that `listTodos({status:"done"})` is unchanged.
190
+
191
+ ### 10.5 Manual-gate (no unit test)
192
+ - Extension `session_start` auto-prune + notify format.
193
+ - `/todo` panel Done tab (rendering, filter, action submenu, restore-from-archive).
194
+ - `/todo done` slash.
195
+ - `todo list status:done` unified output.
196
+
197
+ Verified by RECTOR in a real pi session before merge (same gate as v0.3.0).
198
+
199
+ ## 11. Branch + ship
200
+
201
+ - Branch `feat/auto-prune-done-view` off `main`.
202
+ - Commits: `feat(auto-prune): ...`, `feat(prune): rich result ...`, `feat(done): unified listing ...`, `feat(panel): Done tab ...`, `test: ...`, `docs: ...` — one logical change per commit.
203
+ - PR to `main`, `--merge --delete-branch`. No GitLab mirror (getpipher).
204
+ - **QA gate:** RECTOR tests in a real pi session: stale done auto-prunes on start (fresh done stays); the notify shows the rich result; `/todo done` + the Done tab show unified done (live + archive, tagged); `todo restore <id>` undoes an auto-prune; injection unchanged (only active injected).
205
+ - Tag `v0.3.1` → CI auto-publish (npm) + auto-create GitHub Release (the v0.3.0-post workflow change).
206
+ - Post-ship: bump README/AGENTS test count + add the Done tab / auto-prune to the docs; mark this spec shipped.
207
+
208
+ ## 12. Open question (resolve at implementation)
209
+
210
+ - **`pruneTodos` `detail` flag vs always-rich:** simplest is to make the rich `items` always returned (no flag) and have callers that only need `{moved, ids}` ignore `items`. Decide during implementation — leaning toward always-return for simplicity, since the cost is computing `ageDays` (trivial) per moved todo.
211
+
212
+ ## 13. Out of scope (future)
213
+
214
+ - **`cancelled`-in-live panel visibility** — a pre-existing gap; could add a `Cancelled` filter or fold into the Archive tab's live portion in a future workstream.
215
+ - **`config.prune.autoOnSessionStart` toggle** — disable auto-prune (v0.4.x).
216
+ - **Auto-prune notify dedup** — if `/reload` fires multiple times in a session, the notify repeats; acceptable for v0.3.1 (it's a transient inform).
217
+ - **Workstream C (v0.4.0):** preventive caps-on-add (notes length cap + project registry) — still the next major milestone.
@@ -0,0 +1,319 @@
1
+ # Project-Scope Management — Design (v0.4.0, Workstream C / Feature A)
2
+
3
+ **Date:** 2026-07-21
4
+ **Workstream:** C → Feature A (issue #1's project-scope half)
5
+ **Release:** v0.4.0
6
+ **Predecessors:** v0.3.1 (auto-prune + unified Done view) — 255/255 tests, 9 suites
7
+ **Successor:** v0.5.0 — Feature B (caps enforcement: count cap + notes cap + over-cap injection summary). This spec is **advisory-only**; no enforcement ships here.
8
+
9
+ **Branch:** `feat/project-scope-management` off `main`
10
+ **Commits:** `feat(scope): ...` per task. PR → `--merge --delete-branch`. RECTOR QA gate → tag `v0.4.0` → CI auto-publishes npm + GitHub Release (now automatic).
11
+
12
+ ---
13
+
14
+ ## 1. Problem (the real one, restated)
15
+
16
+ The injected `## Open TODOs (N)` block is already bounded (v0.3.0 title-only injection + `renderOpenBlock(max=15)` + v0.3.1 auto-prune). What's **still unsolved**:
17
+
18
+ 1. **No forcing function on the *count*.** Open can grow to 30/50/100 with no pushback; the injection *hides* the rot behind `+N more`. A cap is a triage signal, not a bytes fix.
19
+ 2. **No scope overview.** `project` is free-text, un-aggregated. Working across ZeroClaw, getpipher, vision, etc., you can't see "this project owns 18 of my 22 open" — can't triage *where* to cut. The bigger daily pain than the cap.
20
+ 3. **No per-project bloat signal in `health`.** `ACTIVE_LARGE` fires globally at >15; can't say *which* project is over budget.
21
+ 4. **No curation surface.** A typo'd project string (`getpither` vs `getpipher`) silently becomes its own "project" with no way to merge.
22
+
23
+ v0.4.0 is the **triage visibility** release: see the backlog by project + know when one's over budget + fix typos. **No enforcement** — that's v0.5.0's "caps release" (count + notes + injection, all block-on-add).
24
+
25
+ ---
26
+
27
+ ## 2. Scope (locked decisions)
28
+
29
+ | Decision | Choice | Rationale |
30
+ |---|---|---|
31
+ | **Release slice** (Q1) | B — v0.4.0 = Feature A only; v0.5.0 = Feature B (caps) | Lands triage visibility (the daily pain) before the cap that needs it. v0.4.0 is zero-enforcement-risk. |
32
+ | **Registry vs dynamic** (Q2) | C — full registry with per-project config slots | Forward-looking for armory-todo; canonical name list + rename/merge (the point of a registry over dynamic derivation). |
33
+ | **`maxOpen` slot behavior in v0.4.0** (Q3) | A — advisory `health` flag now; enforcement (block-on-add) in v0.5.0 | Slot is never inert: graduates advisory → hard block. No lying UI. |
34
+ | **Notes cap in v0.4.0?** (Q4) | A — defer to v0.5.0 | v0.4.0 is purely the project-scope axis; v0.5.0 is "the caps release" (count + notes + injection). |
35
+ | **Registry storage** (Q5) | B — sibling `~/.pi/agent/todo/projects.json` | Registry is *state* (grows, auto-seeds); config is *settings*. Separation avoids a config schema bump + the risky live-store migration. |
36
+ | **Registry entry shape** (Q6) | A — minimal `{ name, maxOpen, createdAt, updatedAt }` | Every field earns its weight; `lastSeenAt`/reserved slots re-introduce the inert-field hazard. |
37
+ | **Registry sync** (Q7) | A — lazy sync-on-read | No writes on `add`/`update`; `projects`/`health` reconcile first. First read seeds; later reads self-heal. No cross-path risk (file lives under `TODO_DIR`). |
38
+ | **Rename/merge** (Q8) | B — included, full consistency (live + archive + registry) | Rename is the point of a registry; half-way (archive sealed) re-splits the project in the view. |
39
+ | **`projects` output** (Q9) | B — counts + cap signal + typo marker | One-stop triage; staleness/notesBytes stay in `health` (whole-store concerns). |
40
+ | **`health` per-project flags** (Q10) | C — `PROJECT_OVER` + `PROJECT_TYPO` + `PROJECT_LARGE` + `PROJECT_STALE` | `PROJECT_LARGE` (global default threshold) makes the per-project signal useful on day 1, not gated behind per-project config. All advisory. |
41
+ | **Action API + slash naming** (Q11) | A — tool `action:'projects'` + `action:'project_rename'`; slash `/todo projects` (thin mirror) | Plural=list, singular=action namespace. No `/todo project rename` slash — rename is panel-only (Q12). |
42
+ | **Panel surface** (Q12) | A — 6th tab `projects` + per-project action submenu | Panel is the primary human surface (getpither UX mental model: interactive first, CLI-style for the agent). |
43
+ | **UX mental model** | Documented in `~/local-dev/getpipher/AGENTS.md` (cross-cutting, all getpither extensions) | Panel-first design order; tool action + slash are secondary/derived. |
44
+
45
+ **Defaults (Q13):** `health.perProjectDefaultMax = 8`; `maxOpen` per-project default `null`; `PROJECT_STALE` reuses `activeStaleDays` (30d); `PROJECT_TYPO` = exactly 1 todo in project (live + archived done); seed scope = live + archive; empty-project group = `(no project)` (excluded from typo detection, no `maxOpen`); rename is best-effort multi-file (live → archive → registry, backup-on-corrupt per file, no cross-file WAL).
46
+
47
+ ---
48
+
49
+ ## 3. Architecture
50
+
51
+ ### 3.1 New file: `projects.json` (registry state)
52
+
53
+ `~/.pi/agent/todo/projects.json` — the project registry. Auto-seeded on first read, lazy-synced on every registry touch.
54
+
55
+ ```json
56
+ {
57
+ "version": 1,
58
+ "updatedAt": "2026-07-21T12:00:00.000Z",
59
+ "projects": [
60
+ {
61
+ "name": "getpipher",
62
+ "maxOpen": 5,
63
+ "createdAt": "2026-07-21T12:00:00.000Z",
64
+ "updatedAt": "2026-07-21T12:00:00.000Z"
65
+ }
66
+ ]
67
+ }
68
+ ```
69
+
70
+ - **`version: 1`** — bump on future schema changes (e.g. v0.5.0 may add per-project `maxNotesBytes`).
71
+ - **`maxOpen: number | null`** — advisory cap slot. `null` (default) → no `PROJECT_OVER` flag for this project. v0.4.0: drives `health` flag only. v0.5.0: enforcement (block-on-add).
72
+ - **`name`** — canonical project string. Unique within `projects[]`. Empty string `""` is NOT a registry entry (the `(no project)` group is implicit, never registered).
73
+ - Atomic 0600 write (tmp + rename), same pattern as `saveStore`/`saveConfig`.
74
+
75
+ ### 3.2 New module: `src/registry.ts`
76
+
77
+ Pure, pi-independent (like the other `src/` modules). No new runtime deps.
78
+
79
+ ```ts
80
+ export interface ProjectEntry { name: string; maxOpen: number | null; createdAt: string; updatedAt: string; }
81
+ export interface ProjectRegistry { version: 1; updatedAt: string; projects: ProjectEntry[]; }
82
+
83
+ export function getRegistryPath(): string; // <TODO_DIR>/projects.json
84
+ export function loadRegistry(): ProjectRegistry; // corrupt → backup .bad-<ts>, fresh
85
+ export function saveRegistry(reg: ProjectRegistry): void; // atomic 0600
86
+ export function reconcileRegistry(reg, liveTodos, archivedTodos): { reg, changed }; // lazy sync: append unknown project strings with maxOpen:null
87
+ export function getProjectEntry(reg, name): ProjectEntry | undefined;
88
+ export function setProjectMaxOpen(reg: ProjectRegistry, name: string, max: number | null): ProjectEntry; // max=null clears; creates entry if unknown; throws if name === "" (no-project group can't be capped)
89
+ export function renameProject(reg, oldName, newName): { reg, liveTodos, archivedTodos, liveChanged, archivedChanged }; // rewrites registry + both stores in place
90
+ ```
91
+
92
+ **Lazy sync (`reconcileRegistry`):** collect distinct non-empty `project` strings across `liveTodos` + `archivedTodos`; for any not in `reg.projects`, append `{ name, maxOpen: null, createdAt: now, updatedAt: now }`. Bump `reg.updatedAt` iff changed. Caller persists iff `changed`.
93
+
94
+ **Seed:** `loadRegistry()` on a missing file returns an empty registry (`{ version: 1, updatedAt: now, projects: [] }`) and does NOT seed. Seeding happens on the first `reconcileRegistry` call (inside `projectsOverview`/`healthReport`), which persists. This keeps `loadRegistry` side-effect-free (matches `loadConfig`'s "missing → write defaults" pattern is NOT used here — we seed lazily to keep load pure + avoid a write on a bare load).
95
+
96
+ **No env guard:** `projects.json` always lives under `TODO_DIR` (temp dir in tests), so no cross-path migration risk (unlike v1→v2 file-move). Tests get isolated registries for free.
97
+
98
+ ### 3.3 New module: `src/projects.ts` (overview)
99
+
100
+ Pure read + reconcile. The `projects` action's brain.
101
+
102
+ ```ts
103
+ export interface ProjectOverviewRow {
104
+ name: string;
105
+ open: number;
106
+ in_progress: number;
107
+ parked: number;
108
+ done: number; // live done + archived done
109
+ total: number; // open + in_progress + parked + done
110
+ maxOpen: number | null;
111
+ over: boolean; // open > maxOpen (only when maxOpen !== null)
112
+ typo: boolean; // exactly 1 todo (live + archived done) in the project
113
+ lastUpdated: string; // max updatedAt across the project's live todos (ISO), or "" if none live
114
+ }
115
+
116
+ export interface ProjectsOverview {
117
+ rows: ProjectOverviewRow[]; // sorted: open desc → total desc → name asc
118
+ totalTodos: number; // sum of all rows' total
119
+ noProject: { count: number; open: number }; // the (no project) bucket — not a row
120
+ }
121
+
122
+ export function projectsOverview(): ProjectsOverview;
123
+ ```
124
+
125
+ **Typo nearest-sibling:** `typo: true` rows are reported in `health` suggestions with a nearest-sibling guess (Levenshtein ≤ 2 among other registry names). The `projects` row carries `typo: true`; the suggestion text carries the guess. (Edit-distance helper lives in `projects.ts` or a tiny `src/levenshtein.ts` — small enough to inline.)
126
+
127
+ **`(no project)` bucket:** todos with `project === ""` are aggregated into `noProject`, NOT a row (no `maxOpen`, no typo, no rename target). Surfaced in the overview summary + `health`.
128
+
129
+ ### 3.4 `src/health.ts` — extend with per-project flags
130
+
131
+ New flags + a new config field (forward-compatible merge, no schema bump):
132
+
133
+ ```ts
134
+ export type HealthFlag =
135
+ | "ACTIVE_LARGE" | "ACTIVE_STALE"
136
+ | "PARKED_LARGE" | "PARKED_STALE"
137
+ | "ARCHIVE_LARGE" | "ARCHIVE_OLD"
138
+ | "PROJECT_OVER" | "PROJECT_TYPO" | "PROJECT_LARGE" | "PROJECT_STALE"; // new
139
+ ```
140
+
141
+ `HealthConfig` gains `perProjectDefaultMax: number` (default 8). `loadConfig`'s existing merge (`{ ...DEFAULT_CONFIG.health, ...parsed.health }`) fills it for old configs — no migration.
142
+
143
+ `HealthReport` gains:
144
+
145
+ ```ts
146
+ export interface ProjectHealth { name: string; open: number; maxOpen: number | null; over: boolean; typo: boolean; large: boolean; stale: boolean; lastUpdated: string; }
147
+ export interface HealthReport {
148
+ // ...existing fields...
149
+ projects: ProjectHealth[]; // only projects with ≥1 flag, sorted open desc
150
+ noProject: { open: number }; // (no project) open count, for context
151
+ }
152
+ ```
153
+
154
+ Flag logic per project (live open count is the `open` figure):
155
+ - `PROJECT_OVER` — `maxOpen !== null && open > maxOpen`
156
+ - `PROJECT_LARGE` — `open > config.health.perProjectDefaultMax` (default 8; fires even when `maxOpen` is null — the day-1 signal)
157
+ - `PROJECT_STALE` — `lastUpdated !== "" && daysAgo(lastUpdated) > config.health.activeStaleDays` (30d)
158
+ - `PROJECT_TYPO` — total todos (live + archived done) in the project === 1 AND a near-named sibling (Levenshtein ≤ 2) exists in the registry
159
+
160
+ `healthReport()` calls `reconcileRegistry` first (so the registry is current), persists iff changed, then computes. Suggestions gain per-project actionable lines, e.g.:
161
+ - `project 'getpither' has 1 todo — possible typo of 'getpipher'? → todo project rename getpither getpipher`
162
+ - `project 'getpipher' 12 open (maxOpen 5) → close/park some, or raise maxOpen`
163
+ - `project 'bug-bounty' 9 open (per-project default max 8) → over budget`
164
+ - `project 'vision' untouched 45d → stale, park or close`
165
+
166
+ ### 3.5 `src/todo-store.ts` — no schema change, no new writes
167
+
168
+ `addTodo`/`updateTodo` are **unchanged** (no registry write — lazy sync, Q7=A). The live store stays `Store.version: 3`. `Todo.project` stays free-text. No migration.
169
+
170
+ ### 3.6 `extensions/todo.ts` — new tool actions + panel tab + thin slash
171
+
172
+ #### Tool actions (agent surface)
173
+ - `action: 'projects'` → returns `ProjectsOverview` (structured). No params.
174
+ - `action: 'project_rename'` → params `oldName: string`, `newName: string`. Returns `{ liveRenamed: number, archivedRenamed: number, merged: boolean, newName: string }`. Throws `TodoError` if `oldName` not in registry. `newName` may equal an existing different project — rename-onto-existing is a **merge** (consolidates `oldName` todos into `newName`, removes the `oldName` registry entry, keeps `newName`'s entry; `merged: true`). This is the typo-cleanup path (`getpither` → `getpipher` where `getpipher` already exists). Self-rename (`newName === oldName`) is a no-op success (`{ liveRenamed: 0, archivedRenamed: 0, merged: false, newName }`).
175
+
176
+ #### Panel (human surface — primary)
177
+ New 6th tab `projects` in the `/todo` panel (existing 5: active/parked/done/archive/config → now 6). Tab label `Projects`.
178
+
179
+ - **Rows:** `ProjectOverviewRow` rendered as: `name open/in_progress/parked/done (total) [max:N or —] OVER? ?typo · lastUpdated`. Box-draw to match existing tab style.
180
+ - **Sort:** open desc → total desc → name asc (Q9).
181
+ - **Action submenu** (per project, via `openActionSubmenu`): `Rename` / `Set maxOpen` / `Filter active to project`.
182
+ - **Rename** — inline `Input` (single-line; pi-tui can't nest `ctx.ui.editor()` inside `ctx.ui.custom()`). Validates `newName` non-empty + not equal to current. Calls `renameProject`. Confirms merge if target exists. Notify on success (`Renamed getpither → getpipher (3 live + 1 archived)`).
183
+ - **Set maxOpen** — inline `Input`, accepts a positive integer or `clear` (→ `null`). Calls `setProjectMaxOpen`. Notify (`getpipher maxOpen = 5` or `getpipher maxOpen cleared`).
184
+ - **Filter active to project** — jumps to the `active` tab with a `project` filter applied (existing list filter already supports `project`).
185
+ - **`(no project)` bucket** — shown as a non-selectable summary row at the top or footer (`(no project): N open`), no submenu.
186
+
187
+ #### Slash (thin mirror only)
188
+ - `/todo projects` — prints the `ProjectsOverview` as text (mirrors `/todo health`'s text-report style). One new sub. **No** `/todo project rename` slash — rename is panel-only (Q12=A + getpither UX mental model).
189
+
190
+ ### 3.7 `src/config.ts` — one new field, no schema bump
191
+
192
+ `HealthConfig` gains `perProjectDefaultMax: number` (default 8). `DEFAULT_CONFIG.health.perProjectDefaultMax = 8`. `loadConfig`'s merge fills it for old configs. `TodoConfig.version` stays `1`.
193
+
194
+ ### 3.8 No injection change
195
+
196
+ `renderOpenBlock` is **unchanged** in v0.4.0 (no caps, no over-cap summary — that's v0.5.0). The `## Open TODOs (N)` block keeps its current shape (≤15 title-only rows + `+N more`).
197
+
198
+ ---
199
+
200
+ ## 4. Data flow
201
+
202
+ ### 4.1 `todo projects` (tool) / `/todo projects` (slash) / Projects tab (panel)
203
+ 1. `loadStore()` (live) + `loadArchive()` (archived done).
204
+ 2. `loadRegistry()` → `reconcileRegistry(reg, live, archive)` → persist iff changed.
205
+ 3. `projectsOverview()` computes rows from live + archive + registry.
206
+ 4. Return (tool) / render (panel) / print (slash).
207
+
208
+ ### 4.2 `todo health`
209
+ 1. Same load + reconcile as 4.1.
210
+ 2. `healthReport()` extends: existing box diagnostics + new `projects[]` (per-project flags) + `noProject`.
211
+ 3. Render (existing text format + new `projects:` section).
212
+
213
+ ### 4.3 `todo project_rename` (tool) / panel Rename
214
+ 1. `loadRegistry()`, find `oldName` entry (throw if missing).
215
+ 2. `loadStore()` + `loadArchive()`.
216
+ 3. Validate `newName` (non-empty, trimmed; if equal to `oldName` → no-op success).
217
+ 4. Rewrite live todos (`project === oldName` → `newName`, bump `updatedAt`), save store iff changed.
218
+ 5. Rewrite archived todos (`project === oldName` → `newName`, bump archive `updatedAt`), save archive iff changed.
219
+ 6. Registry: remove `oldName` entry, ensure `newName` entry exists (create if merge target was absent, keep if merge), bump `updatedAt`. Save registry.
220
+ 7. Return `{ liveRenamed, archivedRenamed, merged, newName }`.
221
+ 8. **Failure semantics:** best-effort, not cross-file transactional (no WAL). Each file write uses the existing backup-on-corrupt pattern. If step N fails, prior writes stand; the result reports what happened. A later `reconcileRegistry` self-heals any drift.
222
+
223
+ ### 4.4 `todo update` / `todo add` with a project
224
+ - **Unchanged.** No registry write. The new project string is picked up on the next `projects`/`health` read (lazy sync).
225
+
226
+ ---
227
+
228
+ ## 5. Edge cases
229
+
230
+ - **Empty store** — `projectsOverview` returns `{ rows: [], totalTodos: 0, noProject: { count: 0, open: 0 } }`. `healthReport().projects = []`. Panel shows `(no projects)`.
231
+ - **All-done project** — seeded from archive (live has 0 todos for it). Row: `name 0/0/0/N (N) [max:null] · lastUpdated:""`. Not typo (total ≥ 1 but if total === 1 and it's archived done, still typo-eligible — a single archived todo under a near-typo'd name is still a typo). Typo counts live + archived done.
232
+ - **Rename onto self** — no-op success (`{ liveRenamed: 0, archivedRenamed: 0, merged: false, newName }`).
233
+ - **Rename onto existing (merge)** — allowed; `merged: true`; `oldName` entry removed, `newName` entry kept.
234
+ - **Rename to a name that only differs by case** (`getpipher` → `Getpipher`) — allowed (case-sensitive `project` is the existing contract); flags nothing special.
235
+ - **`(no project)` rename** — not a rename target (no registry entry for `""`). `setProjectMaxOpen("")` → throws `TodoError` (no project group can't have a cap).
236
+ - **Corrupt `projects.json`** — `loadRegistry` backs up to `projects.json.bad-<ts>` and returns a fresh empty registry; next reconcile re-seeds. No data loss (todos are the source of truth; the registry is derived + the maxOpen slots — which are the only non-derived data — are lost on corrupt, acceptable, same tradeoff as `todo.config.json`).
237
+ - **`maxOpen = 0`** — semantically "no open todos allowed for this project." v0.4.0: `PROJECT_OVER` fires if open > 0. v0.5.0: blocks any add. Valid (edge but meaningful — "this project is closed").
238
+ - **Two projects with the same name after a case-only rename** — can't happen (rename rewrites all todos to one casing; the registry has one entry per unique name).
239
+
240
+ ---
241
+
242
+ ## 6. Testing
243
+
244
+ New suite `test/registry.test.mts` + `test/projects.test.mts`; extend `test/todo-health.test.mts` + `test/panel-data.test.mts`. Baseline 255/255 → target ~300+.
245
+
246
+ ### `test/registry.test.mts` (new, ~20)
247
+ - `loadRegistry` missing → empty registry, no file write (lazy seed).
248
+ - `loadRegistry` corrupt → backup `.bad-<ts>`, fresh empty.
249
+ - `saveRegistry` atomic + 0600.
250
+ - `reconcileRegistry` appends unknown (live + archive), bumps `updatedAt` iff changed, idempotent (no change on second call).
251
+ - `reconcileRegistry` ignores empty-string project (not registered).
252
+ - `getProjectEntry` hit/miss.
253
+ - `setProjectMaxOpen` create-if-unknown, set number, `null` clears.
254
+ - `renameProject` rewrites live + archive + registry, removes old, keeps/creates new, `merged` flag, no-op self-rename, throws on unknown old.
255
+
256
+ ### `test/projects.test.mts` (new, ~15)
257
+ - `projectsOverview` row counts (open/in_progress/parked/done/total) from live + archive.
258
+ - `maxOpen` carried from registry; `over` only when `maxOpen !== null && open > maxOpen`.
259
+ - `typo` true iff total (live + archived done) === 1 AND near-sibling Levenshtein ≤ 2 exists.
260
+ - `(no project)` bucket aggregated, not a row.
261
+ - Sort: open desc → total desc → name asc.
262
+ - `lastUpdated` = max live `updatedAt`, `""` when no live todos.
263
+ - Empty store → empty overview.
264
+
265
+ ### `test/todo-health.test.mts` (extend, +~15)
266
+ - `perProjectDefaultMax` default 8 + override via config.
267
+ - `PROJECT_OVER` (maxOpen set + exceeded), `PROJECT_LARGE` (default threshold, maxOpen null), `PROJECT_STALE` (lastUpdated > activeStaleDays), `PROJECT_TYPO` (1 todo + near-sibling).
268
+ - `healthReport().projects` only includes projects with ≥1 flag, sorted open desc.
269
+ - `noProject` reported.
270
+ - Reconcile runs inside health (registry seeded on first health call).
271
+ - Suggestions actionable (rename hint for typo, maxOpen hint for over).
272
+
273
+ ### `test/panel-data.test.mts` (extend, +~10)
274
+ - `projectsOverview` → panel rows rendering (markers `OVER`, `?typo`).
275
+ - Action submenu options per project (Rename/Set maxOpen/Filter).
276
+ - `(no project)` summary row, no submenu.
277
+ - (Panel interactive flows — Rename via inline Input, Set maxOpen — are NOT unit-tested; covered by the autonomous tmux QA harness per v0.3.1 pattern. `panel.ts` is the only non-unit-tested component, same as v0.3.0/v0.3.1.)
278
+
279
+ ### Existing suites — regression
280
+ - `todo-store` (44), `todo-title-notes` (31), `todo-archive` (55), `todo-config` (15), `todo-migrate` (26), `todo-hard-prune` (16), `todo-auto-prune` (12) — **unchanged logic**, must stay green. `todo-config` gains the new `perProjectDefaultMax` field assertion (default 8 + merge for old configs).
281
+
282
+ ---
283
+
284
+ ## 7. Implementation plan (high-level, for writing-plans)
285
+
286
+ 1. `src/registry.ts` + `test/registry.test.mts` — registry load/save/reconcile/setMaxOpen/rename. Pure, no pi.
287
+ 2. `src/projects.ts` + `test/projects.test.mts` — overview + Levenshtein typo helper.
288
+ 3. `src/config.ts` — add `perProjectDefaultMax` (default 8) + `DEFAULT_CONFIG` + merge test.
289
+ 4. `src/health.ts` + extend `test/todo-health.test.mts` — 4 per-project flags + `projects[]` + `noProject` + actionable suggestions; reconcile-first.
290
+ 5. `extensions/todo.ts` — tool `projects` + `project_rename` actions; `/todo projects` slash; panel `Projects` tab (rows, action submenu, inline Rename/Set maxOpen/Filter).
291
+ 6. `src/panel-data.ts` + extend `test/panel-data.test.mts` — `projectsOverview` → panel row helpers, action-submenu options, `(no project)` row.
292
+ 7. `README.md` + `AGENTS.md` — v0.4.0 section (projects view, rename, per-project health flags, `maxOpen` advisory); Known issues (no enforcement until v0.5.0).
293
+ 8. RECTOR QA gate (autonomous tmux harness per v0.3.1): panel Projects tab, Rename (incl. merge), Set maxOpen, `/todo projects`, `todo health` per-project section, `/todo project*` slash absence.
294
+ 9. Merge → tag `v0.4.0` → CI auto-publish npm + GitHub Release.
295
+
296
+ ---
297
+
298
+ ## 8. Out of scope (v0.5.0 / later)
299
+
300
+ - **Enforcement** — block-on-add when open > maxOpen (v0.5.0 graduates the `PROJECT_OVER` flag → throw).
301
+ - **Notes cap** — `maxNotesBytes` config + reject oversize notes at `add`/`update` (v0.5.0).
302
+ - **Over-cap injection summary** — `renderOpenBlock` over-cap truncation (counts + over-budget projects instead of the 15-row list) (v0.5.0).
303
+ - **Project merge as a distinct action** — v0.4.0 merge is a side-effect of rename-onto-existing; a dedicated `project_merge` (merge N → 1) is later if needed.
304
+ - **Project delete/purge** — removing a registry entry without renaming (orphaned todos keep their `project` string; re-registered on next read). Later.
305
+ - **Per-project `maxNotesBytes`** — v0.5.0 may add this to the registry entry (schema v2).
306
+
307
+ ---
308
+
309
+ ## 9. Backwards compatibility
310
+
311
+ - **Store:** `Store.version: 3` unchanged. No migration. Existing `todo.json` + `todo-archive.json` load as-is.
312
+ - **Config:** `TodoConfig.version: 1` unchanged. `perProjectDefaultMax` added via forward-compatible merge (old configs get the default 8).
313
+ - **Registry:** new file; missing on first load → empty → seeded lazily. No user action.
314
+ - **`project` free-text:** unchanged. Existing todos with any `project` string keep working; the registry picks them up on first read.
315
+ - **Injection (`## Open TODOs`):** unchanged in v0.4.0.
316
+ - **Tool API:** two new `action` values (`projects`, `project_rename`); no existing action changes.
317
+ - **Slash:** one new sub (`/todo projects`); no existing sub changes.
318
+
319
+ No breaking changes. v0.3.1 → v0.4.0 is a safe in-place upgrade.
@@ -1,7 +1,7 @@
1
1
  # Workstream B — `title` + `notes` schema split (v0.3.0)
2
2
 
3
3
  **Date:** 2026-07-21
4
- **Status:** Design approved pending implementation plan
4
+ **Status:** Shipped (v0.3.0, PR #4, 2026-07-21)
5
5
  **Branch:** `feat/title-notes-split` off `main`
6
6
  **Predecessor:** v0.2.0 (Workstream A — lifecycle boxes + prune + health + TUI panel), PR #3, shipped 2026-07-21
7
7
  **Supersedes (for this scope):** `2026-07-20-lifecycle-boxes-prune-design.md` §14 (which deferred this exact split to Workstream B)