@getpipher/armory-todo 0.3.1 → 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.
@@ -1,7 +1,7 @@
1
1
  # Workstream v0.3.1 — auto-prune on session_start + unified `Done` view
2
2
 
3
3
  **Date:** 2026-07-21
4
- **Status:** Design approved pending implementation plan
4
+ **Status:** Shipped (v0.3.1, PR #5, 2026-07-21)
5
5
  **Branch:** `feat/auto-prune-done-view` off `main`
6
6
  **Predecessor:** v0.3.0 (title + notes split), shipped 2026-07-21 (PR #4)
7
7
  **Target ship:** v0.3.1, auto-published via `release.yml` on `v0.3.1` tag (now also creates a GitHub Release)
@@ -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.
@@ -40,8 +40,10 @@ import { hardPrune } from "../src/hard-prune";
40
40
  import { TodoPanel } from "../src/panel";
41
41
  import { autoPruneOnSessionStart } from "../src/auto-prune";
42
42
  import { loadConfig } from "../src/config";
43
+ import { projectsOverview } from "../src/projects";
44
+ import { renameProject } from "../src/registry";
43
45
 
44
- const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
46
+ const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health", "projects", "project_rename"] as const;
45
47
 
46
48
  function fmt(t: ReturnType<typeof listTodos>[number]): string {
47
49
  const tag = t.project ? ` (${t.project})` : "";
@@ -143,6 +145,8 @@ export default function (pi: ExtensionAPI) {
143
145
  "Use todo (action:'list', archived:true) to query the archive; bare call returns a summary, add a filter (project/text/since) for specific items.",
144
146
  "Use todo (action:'health') to check bloat across all boxes (counts + flags + suggestions). Run when the user asks about hygiene/bloat or before any hard-prune.",
145
147
  "Use todo (action:'prune', hard:true, confirm:true, box?, olderThan?) for PERMANENT deletion (the only irreversible action). ALWAYS run health first, show the user the report + the exact proposed command, and wait for an explicit yes before passing confirm:true. Never hard-prune without explicit user confirmation.",
148
+ "Use todo (action:'projects') for a per-project scope overview (open/in_progress/parked/done counts + maxOpen + OVER/?typo markers). Run when the user asks 'which projects have open work' or to see backlog shape by project.",
149
+ "Use todo (action:'project_rename', oldName, newName) to rename or merge a project (rewrites live + archive + registry). Use it to fix typo'd project strings (e.g. getpither → getpipher). Rename onto an existing name merges (consolidates the old project into the new). Advisory maxOpen caps are NOT enforced in v0.4.0 — they only drive a health flag; enforcement lands in v0.5.0.",
146
150
  ],
147
151
  parameters: Type.Object({
148
152
  action: StringEnum(ACTIONS),
@@ -171,6 +175,9 @@ export default function (pi: ExtensionAPI) {
171
175
  confirm: Type.Optional(Type.Boolean({ description: "hard-prune: must be true to execute. Always surface the health report + proposed command and wait for explicit user confirmation first." })),
172
176
  box: Type.Optional(StringEnum(["archive", "active", "parked"] as const, { description: "hard-prune: which box to target (default archive)" })),
173
177
  olderThan: Type.Optional(Type.Number({ description: "hard-prune: delete items older than this many days (by closedAt for archive, updatedAt for active/parked)" })),
178
+ // project actions (v0.4.0)
179
+ oldName: Type.Optional(Type.String({ description: "project_rename: current project name" })),
180
+ newName: Type.Optional(Type.String({ description: "project_rename: new project name (merge if it already exists)" })),
174
181
  }),
175
182
  async execute(_toolCallId, params) {
176
183
  try {
@@ -296,13 +303,22 @@ export default function (pi: ExtensionAPI) {
296
303
  }
297
304
  case "health": {
298
305
  const report = healthReport();
306
+ const projLines = report.projects.length
307
+ ? [`projects:`, ...report.projects.map((p) => {
308
+ const cap = p.maxOpen !== null ? ` [max:${p.maxOpen}]` : "";
309
+ const flags = [p.over && "OVER", p.large && "LARGE", p.stale && "STALE", p.typo && "TYPO"].filter(Boolean).join(" ");
310
+ return ` ${p.name} ${p.open} open${cap}${flags ? ` ${flags}` : ""}`;
311
+ })]
312
+ : [];
299
313
  const lines = [
300
314
  `## TODO Health Report`,
301
315
  `active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
302
316
  `parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
303
317
  `archive: ${report.archive.count} (${report.archive.older_180d} old)`,
304
318
  `notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
319
+ `(no project): ${report.noProject.open} open`,
305
320
  report.flags.length ? `flags: ${report.flags.join(", ")}` : "flags: (none — healthy)",
321
+ ...projLines,
306
322
  ...report.suggestions.map((s) => ` → ${s}`),
307
323
  ];
308
324
  return { content: [{ type: "text" as const, text: lines.join("\n") }] };
@@ -316,6 +332,25 @@ export default function (pi: ExtensionAPI) {
316
332
  const n = clearTodos((params.status as any) ?? "done");
317
333
  return { content: [{ type: "text" as const, text: `Cleared ${n} '${params.status ?? "done"}' TODOs.` }] };
318
334
  }
335
+ case "projects": {
336
+ const o = projectsOverview();
337
+ const rows = o.rows.map((r) => {
338
+ const cap = r.maxOpen !== null ? ` [max:${r.maxOpen}]` : "";
339
+ const over = r.over ? " OVER" : "";
340
+ const typo = r.typo ? " ?typo" : "";
341
+ return ` ${r.name} ${r.open}o/${r.in_progress}i/${r.parked}p/${r.done}d (total ${r.total})${cap}${over}${typo}`;
342
+ });
343
+ const np = `(no project): ${o.noProject.count} total · ${o.noProject.open} open`;
344
+ const text = rows.length ? `Projects (${o.rows.length}):\n${rows.join("\n")}\n${np}` : `Projects: (none)\n${np}`;
345
+ return { content: [{ type: "text" as const, text }] };
346
+ }
347
+ case "project_rename": {
348
+ if (!params.oldName || !params.newName) {
349
+ return { content: [{ type: "text" as const, text: "Error: `oldName` and `newName` are required for project_rename." }] };
350
+ }
351
+ const r = renameProject(params.oldName, params.newName);
352
+ return { content: [{ type: "text" as const, text: `Renamed ${params.oldName} → ${r.newName}: ${r.liveRenamed} live + ${r.archivedRenamed} archived${r.merged ? " (merged)" : ""}` }] };
353
+ }
319
354
  default:
320
355
  return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }] };
321
356
  }
@@ -331,7 +366,7 @@ export default function (pi: ExtensionAPI) {
331
366
  "Global cross-session TODO list. " +
332
367
  "/todo / /todo all / /todo add <title> / /todo done <id> / /todo rm <id> / " +
333
368
  "/todo park <id> / /todo restore <id> / /todo prune [--all|--hard --box <b> --older-than <d>] / " +
334
- "/todo archive [project:X|text:Y] / /todo finished / /todo health / /todo clean / /todo path",
369
+ "/todo archive [project:X|text:Y] / /todo finished / /todo projects / /todo health / /todo clean / /todo path",
335
370
  handler: async (args, ctx) => {
336
371
  const a = (args ?? "").trim();
337
372
  const [sub, ...rest] = a.split(/\s+/);
@@ -410,13 +445,22 @@ export default function (pi: ExtensionAPI) {
410
445
  }
411
446
  if (sub === "health") {
412
447
  const report = healthReport();
448
+ const projLines = report.projects.length
449
+ ? [` projects:`, ...report.projects.map((p) => {
450
+ const cap = p.maxOpen !== null ? ` [max:${p.maxOpen}]` : "";
451
+ const flags = [p.over && "OVER", p.large && "LARGE", p.stale && "STALE", p.typo && "TYPO"].filter(Boolean).join(" ");
452
+ return ` ${p.name} ${p.open} open${cap}${flags ? ` ${flags}` : ""}`;
453
+ })]
454
+ : [];
413
455
  const lines = [
414
456
  `TODO Health:`,
415
457
  ` active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
416
458
  ` parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
417
459
  ` archive: ${report.archive.count} (${report.archive.older_180d} old)`,
418
460
  ` notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
461
+ ` (no project): ${report.noProject.open} open`,
419
462
  report.flags.length ? ` ⚠ ${report.flags.join(", ")}` : " ✅ healthy",
463
+ ...projLines,
420
464
  ...report.suggestions.map((s) => ` → ${s}`),
421
465
  ];
422
466
  if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
@@ -463,6 +507,19 @@ export default function (pi: ExtensionAPI) {
463
507
  if (ctx.hasUI) ctx.ui.notify(`store: ${getStorePath()}`, "info");
464
508
  return;
465
509
  }
510
+ if (sub === "projects") {
511
+ const o = projectsOverview();
512
+ const rows = o.rows.map((r) => {
513
+ const cap = r.maxOpen !== null ? ` [max:${r.maxOpen}]` : "";
514
+ const over = r.over ? " OVER" : "";
515
+ const typo = r.typo ? " ?typo" : "";
516
+ return ` ${r.name} ${r.open}o/${r.in_progress}i/${r.parked}p/${r.done}d (total ${r.total})${cap}${over}${typo}`;
517
+ });
518
+ const np = `(no project): ${o.noProject.count} total · ${o.noProject.open} open`;
519
+ const msg = rows.length ? `Projects (${o.rows.length}):\n${rows.join("\n")}\n${np}` : `Projects: (none)\n${np}`;
520
+ if (ctx.hasUI) ctx.ui.notify(msg, "info");
521
+ return;
522
+ }
466
523
  // default: open the interactive panel (TUI) or list open (non-TUI)
467
524
  if (ctx.mode === "tui") {
468
525
  await ctx.ui.custom<boolean>((_tui, theme, _kb, done) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-todo",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Global, cross-session TODO for pi — persists across all sessions and is auto-injected into every prompt. The disk-backed counterpart to branch-scoped pi todo extensions.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "scripts": {
38
- "test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune panel-data; do node test/$t.test.mts || exit 1; done"
38
+ "test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune registry projects panel-data; do node test/$t.test.mts || exit 1; done"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@earendil-works/pi-ai": "*",
package/src/config.ts CHANGED
@@ -24,6 +24,7 @@ export interface HealthConfig {
24
24
  parkedStaleDays: number;
25
25
  archiveMax: number;
26
26
  archiveOldDays: number;
27
+ perProjectDefaultMax: number; // v0.4.0: per-project PROJECT_LARGE threshold (advisory)
27
28
  }
28
29
 
29
30
  export interface TodoConfig {
@@ -46,6 +47,7 @@ export const DEFAULT_CONFIG: TodoConfig = {
46
47
  parkedStaleDays: 60,
47
48
  archiveMax: 200,
48
49
  archiveOldDays: 180,
50
+ perProjectDefaultMax: 8,
49
51
  },
50
52
  };
51
53
 
@@ -68,10 +70,12 @@ export function loadConfig(): TodoConfig {
68
70
  throw new Error("invalid config shape");
69
71
  }
70
72
  // Merge with defaults so new fields get filled in on upgrade.
73
+ const health = { ...DEFAULT_CONFIG.health, ...parsed.health };
74
+ if (health.perProjectDefaultMax === undefined) health.perProjectDefaultMax = DEFAULT_CONFIG.health.perProjectDefaultMax;
71
75
  return {
72
76
  version: 1,
73
77
  prune: { ...DEFAULT_CONFIG.prune, ...parsed.prune },
74
- health: { ...DEFAULT_CONFIG.health, ...parsed.health },
78
+ health,
75
79
  };
76
80
  } catch {
77
81
  try {
package/src/health.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  import { loadStore } from "./todo-store.ts";
7
7
  import { loadArchive } from "./archive.ts";
8
8
  import { loadConfig } from "./config.ts";
9
+ import { loadRegistry, reconcileRegistry, saveRegistry, getProjectEntry } from "./registry.ts";
10
+ import { levenshtein } from "./levenshtein.ts";
9
11
 
10
12
  export interface ActiveHealth {
11
13
  open: number;
@@ -32,7 +34,19 @@ export interface NotesBytes {
32
34
  export type HealthFlag =
33
35
  | "ACTIVE_LARGE" | "ACTIVE_STALE"
34
36
  | "PARKED_LARGE" | "PARKED_STALE"
35
- | "ARCHIVE_LARGE" | "ARCHIVE_OLD";
37
+ | "ARCHIVE_LARGE" | "ARCHIVE_OLD"
38
+ | "PROJECT_OVER" | "PROJECT_TYPO" | "PROJECT_LARGE" | "PROJECT_STALE";
39
+
40
+ export interface ProjectHealth {
41
+ name: string;
42
+ open: number;
43
+ maxOpen: number | null;
44
+ over: boolean;
45
+ typo: boolean;
46
+ large: boolean;
47
+ stale: boolean;
48
+ lastUpdated: string;
49
+ }
36
50
 
37
51
  export interface HealthReport {
38
52
  active: ActiveHealth;
@@ -41,6 +55,8 @@ export interface HealthReport {
41
55
  notesBytes: NotesBytes;
42
56
  flags: HealthFlag[];
43
57
  suggestions: string[];
58
+ projects: ProjectHealth[]; // only projects with ≥1 flag, sorted open desc
59
+ noProject: { open: number }; // (no project) open count, for context
44
60
  }
45
61
 
46
62
  function daysAgo(iso: string): number {
@@ -53,6 +69,11 @@ export function healthReport(): HealthReport {
53
69
  const live = loadStore();
54
70
  const archive = loadArchive();
55
71
 
72
+ // reconcile registry first (lazy sync), persist iff changed
73
+ const reg = loadRegistry();
74
+ const { reg: synced, changed } = reconcileRegistry(reg, live.todos, archive.todos);
75
+ if (changed) saveRegistry(synced);
76
+
56
77
  const openTodos = live.todos.filter((t) => t.status === "open");
57
78
  const ipTodos = live.todos.filter((t) => t.status === "in_progress");
58
79
  const parkedTodos = live.todos.filter((t) => t.status === "parked");
@@ -93,5 +114,42 @@ export function healthReport(): HealthReport {
93
114
  if (parkedStale > 0) suggestions.push(`parked: ${parkedStale} parked > ${h.parkedStaleDays}d → restore or hard-prune`);
94
115
  if (actionable.length > h.activeMaxOpen) suggestions.push(`active: ${actionable.length} open+in_progress (max ${h.activeMaxOpen}) → close or park some before adding more`);
95
116
 
96
- return { active, parked, archive: arch, notesBytes, flags, suggestions };
117
+ // per-project flags (v0.4.0)
118
+ const archivedDone = archive.todos.filter((t) => t.status === "done");
119
+ const projectNames = new Set<string>();
120
+ for (const t of live.todos) { const p = t.project.trim(); if (p) projectNames.add(p); }
121
+ for (const t of archivedDone) { const p = t.project.trim(); if (p) projectNames.add(p); }
122
+
123
+ const projectHealth: ProjectHealth[] = [];
124
+ for (const name of projectNames) {
125
+ const liveForName = live.todos.filter((t) => t.project.trim() === name);
126
+ const open = liveForName.filter((t) => t.status === "open").length;
127
+ const entry = getProjectEntry(synced, name);
128
+ const maxOpen = entry?.maxOpen ?? null;
129
+ const over = maxOpen !== null && open > maxOpen;
130
+ const large = open > h.perProjectDefaultMax;
131
+ const lastUpdated = liveForName.length ? liveForName.map((t) => t.updatedAt).sort().at(-1) ?? "" : "";
132
+ const stale = lastUpdated !== "" && daysAgo(lastUpdated) > h.activeStaleDays;
133
+ const totalForName = liveForName.length + archivedDone.filter((t) => t.project.trim() === name).length;
134
+ const typo = totalForName === 1 && [...projectNames].some((o) => o !== name && levenshtein(name, o) <= 2);
135
+ if (over || large || stale || typo) {
136
+ projectHealth.push({ name, open, maxOpen, over, typo, large, stale, lastUpdated });
137
+ }
138
+ }
139
+ projectHealth.sort((a, b) => b.open - a.open || a.name.localeCompare(b.name));
140
+
141
+ for (const p of projectHealth) {
142
+ if (p.over) { flags.push("PROJECT_OVER"); suggestions.push(`project '${p.name}' ${p.open} open (maxOpen ${p.maxOpen}) → close/park some, or raise maxOpen`); }
143
+ if (p.large) { flags.push("PROJECT_LARGE"); suggestions.push(`project '${p.name}' ${p.open} open (per-project default max ${h.perProjectDefaultMax}) → over budget`); }
144
+ if (p.stale) { flags.push("PROJECT_STALE"); suggestions.push(`project '${p.name}' untouched > ${h.activeStaleDays}d → park or close`); }
145
+ if (p.typo) {
146
+ flags.push("PROJECT_TYPO");
147
+ const sib = [...projectNames].find((o) => o !== p.name && levenshtein(p.name, o) <= 2);
148
+ suggestions.push(`project '${p.name}' has 1 todo — possible typo of '${sib}'? → todo project rename ${p.name} ${sib}`);
149
+ }
150
+ }
151
+
152
+ const noProject = { open: live.todos.filter((t) => t.project.trim() === "" && t.status === "open").length };
153
+
154
+ return { active, parked, archive: arch, notesBytes, flags, suggestions, projects: projectHealth, noProject };
97
155
  }
@@ -0,0 +1,22 @@
1
+ // Tiny Levenshtein edit-distance helper for project-typo nearest-sibling
2
+ // detection. Kept dependency-free and allocation-light (two rolling rows).
3
+
4
+ export function levenshtein(a: string, b: string): number {
5
+ const m = a.length;
6
+ const n = b.length;
7
+ if (m === 0) return n;
8
+ if (n === 0) return m;
9
+ let prev = new Array<number>(n + 1);
10
+ let curr = new Array<number>(n + 1);
11
+ for (let j = 0; j <= n; j++) prev[j] = j;
12
+ for (let i = 1; i <= m; i++) {
13
+ curr[0] = i;
14
+ const ca = a.charCodeAt(i - 1);
15
+ for (let j = 1; j <= n; j++) {
16
+ const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
17
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
18
+ }
19
+ [prev, curr] = [curr, prev];
20
+ }
21
+ return prev[n];
22
+ }