@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,1537 @@
1
+ # Project-Scope Management Implementation Plan (v0.4.0)
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Ship armory-todo v0.4.0 (Workstream C / Feature A) — a project registry, a `projects` overview, per-project `health` flags, and interactive project rename/merge + `maxOpen` editing in the `/todo` panel. Advisory-only; no enforcement (that's v0.5.0).
6
+
7
+ **Architecture:** New sibling `projects.json` registry file (own schema v1, lazy-synced on read, no store/config migration). Two new pure modules (`src/registry.ts`, `src/projects.ts`) extend the existing `src/` family. `src/health.ts` gains 4 per-project flags + one new config field (`perProjectDefaultMax=8`, forward-compatible merge, no `TodoConfig` version bump). `extensions/todo.ts` adds two tool actions (`projects`, `project_rename`), one thin slash (`/todo projects`), and a 6th panel tab (`projects`). `src/panel.ts` gains a `Projects` tab with an action submenu (Rename / Set maxOpen / Filter active to project) using the existing inline-`Input` idiom. `src/todo-store.ts` is unchanged (no registry writes on add/update — lazy sync).
8
+
9
+ **Tech Stack:** TypeScript (raw `.ts`, run via tsx — no build step), `node:fs` only (zero runtime deps), `node:test`-style ad-hoc test harness (`ok`/`eq` + `mkdtempSync`/`TODO_DIR`, matching the 9 existing suites), `@earendil-works/pi-tui` (panel), `typebox` + `@earendil-works/pi-ai` (tool schema).
10
+
11
+ ## Global Constraints
12
+
13
+ - **Branch:** `feat/project-scope-management` off `main` (already created; spec committed at `8a59baa`).
14
+ - **No runtime deps** — `node:fs`/`node:path`/`node:os` only. 2-space indent. No TODO/FIXME. No AI attribution.
15
+ - **Atomic 0600 writes** — every new file write uses the tmp+rename+chmod pattern (see `saveConfig`/`saveArchive`).
16
+ - **Test idiom** — match `test/todo-config.test.mts`: `mkdtempSync` + `process.env.TODO_DIR = tmp`, `ok`/`eq` helpers, dynamic `await import("../src/…")`, `rmSync` cleanup, `console.log(\`${passed} passed, ${failed} failed\`)`, `process.exit(1)` on fail. Each suite is standalone (run via `node test/<name>.test.mts`).
17
+ - **No store migration** — `Store.version: 3` and `TodoConfig.version: 1` stay unchanged. `addTodo`/`updateTodo` are unchanged (no registry writes).
18
+ - **Injection unchanged** — `renderOpenBlock` is NOT modified in v0.4.0.
19
+ - **Commits** — one per task, `feat(scope): …`. PR → `--merge --delete-branch`. RECTOR QA gate → tag `v0.4.0` → CI auto-publishes npm + GitHub Release.
20
+ - **Spec** — `docs/superpowers/specs/2026-07-21-project-scope-management-design.md` (committed). This plan is the execution breakdown of that spec.
21
+ - **Carried gotchas** — `ctx.ui.notify(msg, "error")` already prefixes `Error:` (pass bare message). Panel mode flags: set the entering flag `true` AND clear others (`renderShell` branch order: editMode → actionMode → detailMode → config → projects → list). `SelectList` has no public items setter — replace the instance. `git tag` needs `-am` in non-TTY. Edit-tool flakiness on template literals → Python `str.replace` fallback.
22
+
23
+ ---
24
+
25
+ ## File Structure
26
+
27
+ **Create:**
28
+ - `src/registry.ts` — `ProjectRegistry` load/save/reconcile/setMaxOpen/rename. Pure, pi-independent.
29
+ - `src/levenshtein.ts` — tiny edit-distance helper (used by `projects.ts` + `health.ts` for typo nearest-sibling).
30
+ - `src/projects.ts` — `projectsOverview()` pure read (reconciles registry first).
31
+ - `test/registry.test.mts` — registry suite.
32
+ - `test/projects.test.mts` — overview suite.
33
+
34
+ **Modify:**
35
+ - `src/paths.ts` — add `getRegistryPath()`.
36
+ - `src/config.ts` — add `HealthConfig.perProjectDefaultMax` (default 8) + `DEFAULT_CONFIG` + merge assertion.
37
+ - `src/health.ts` — 4 new flags + `ProjectHealth[]` + `noProject` + actionable suggestions; reconcile-first.
38
+ - `src/panel-data.ts` — `projectOverviewToItems`, `actionsForProject`, `noProjectSummaryItem`.
39
+ - `src/panel.ts` — `Box` gains `projects` (6 tabs); `refreshList` + `onItemSelect` + `openActionSubmenu` + `executeAction` + `renderShell` branch for the projects box; inline `Input` for Rename + Set maxOpen.
40
+ - `extensions/todo.ts` — `ACTIONS` gains `projects` + `project_rename`; tool `execute` switch + parameter schema (`oldName`, `newName`); `/todo projects` slash subcommand; health text output gains `projects:` section.
41
+ - `test/todo-config.test.mts` — assert `perProjectDefaultMax` default + merge.
42
+ - `test/todo-health.test.mts` — 4 per-project flags + `projects[]` + `noProject`.
43
+ - `test/panel-data.test.mts` — project-row + action-submenu helpers.
44
+ - `README.md` — v0.4.0 section + Known issues (no enforcement until v0.5.0).
45
+ - `AGENTS.md` (repo) — v0.4.0 row in the version table.
46
+ - `package.json` — version `0.4.0` (final task, before tag).
47
+
48
+ ---
49
+
50
+ ## Task 1: `src/levenshtein.ts` + `src/registry.ts` + registry tests
51
+
52
+ **Files:**
53
+ - Create: `src/levenshtein.ts`
54
+ - Create: `src/registry.ts`
55
+ - Create: `test/registry.test.mts`
56
+
57
+ **Interfaces:**
58
+ - Produces: `levenshtein(a: string, b: string): number` (≤ 2 used for typo sibling).
59
+ - Produces: `ProjectEntry`, `ProjectRegistry`, `getRegistryPath()`, `loadRegistry()`, `saveRegistry(reg)`, `reconcileRegistry(reg, liveTodos, archivedTodos)`, `getProjectEntry(reg, name)`, `setProjectMaxOpen(reg, name, max)`, `renameProject(oldName, newName)`.
60
+ - Consumes: `getTodoDir()` from `src/paths.ts` (Task 2 adds `getRegistryPath`, but Task 1 can use `join(getTodoDir(), "projects.json")` directly — keep `getRegistryPath` in `paths.ts` for Task 2 to centralize).
61
+
62
+ > **Note:** To keep Task 1 self-contained, `src/registry.ts` imports `getTodoDir` from `./paths.ts` and computes the path inline via `join(getTodoDir(), "projects.json")`. Task 2 extracts `getRegistryPath()` into `paths.ts` and swaps the inline call — a tiny refactor that keeps each task independently testable.
63
+
64
+ - [ ] **Step 1: Write `src/levenshtein.ts`**
65
+
66
+ ```ts
67
+ // Tiny Levenshtein edit-distance helper for project-typo nearest-sibling
68
+ // detection. Kept dependency-free and allocation-light (two rolling rows).
69
+
70
+ export function levenshtein(a: string, b: string): number {
71
+ const m = a.length;
72
+ const n = b.length;
73
+ if (m === 0) return n;
74
+ if (n === 0) return m;
75
+ let prev = new Array<number>(n + 1);
76
+ let curr = new Array<number>(n + 1);
77
+ for (let j = 0; j <= n; j++) prev[j] = j;
78
+ for (let i = 1; i <= m; i++) {
79
+ curr[0] = i;
80
+ const ca = a.charCodeAt(i - 1);
81
+ for (let j = 1; j <= n; j++) {
82
+ const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
83
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
84
+ }
85
+ [prev, curr] = [curr, prev];
86
+ }
87
+ return prev[n];
88
+ }
89
+ ```
90
+
91
+ - [ ] **Step 2: Write `src/registry.ts`**
92
+
93
+ ```ts
94
+ // Project registry for armory-todo — a sibling file to todo.json holding the
95
+ // canonical list of known projects + their per-project advisory cap slot
96
+ // (`maxOpen`). Advisory in v0.4.0 (drives a health flag); enforcement
97
+ // (block-on-add) graduates in v0.5.0.
98
+ //
99
+ // File: <TODO_DIR>/projects.json (0600, atomic write). Lazy-synced on read:
100
+ // `reconcileRegistry` appends any unknown project strings (live + archived)
101
+ // with maxOpen:null. `loadRegistry` is side-effect-free (missing → empty,
102
+ // no file created); seeding happens on the first reconcile call.
103
+ // No env guard — projects.json always lives under TODO_DIR (temp dir in tests).
104
+
105
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
106
+ import { dirname, join } from "node:path";
107
+ import { getTodoDir } from "./paths.ts";
108
+ import { loadStore, saveStore, TodoError, type Todo } from "./todo-store.ts";
109
+ import { loadArchive, saveArchive } from "./archive.ts";
110
+
111
+ export interface ProjectEntry {
112
+ name: string;
113
+ maxOpen: number | null; // null = no advisory cap for this project
114
+ createdAt: string;
115
+ updatedAt: string;
116
+ }
117
+
118
+ export interface ProjectRegistry {
119
+ version: 1;
120
+ updatedAt: string;
121
+ projects: ProjectEntry[];
122
+ }
123
+
124
+ function now(): string { return new Date().toISOString(); }
125
+
126
+ function emptyRegistry(): ProjectRegistry {
127
+ return { version: 1, updatedAt: now(), projects: [] };
128
+ }
129
+
130
+ export function getRegistryPath(): string {
131
+ return join(getTodoDir(), "projects.json");
132
+ }
133
+
134
+ export function loadRegistry(): ProjectRegistry {
135
+ const path = getRegistryPath();
136
+ if (!existsSync(path)) return emptyRegistry();
137
+ try {
138
+ const raw = readFileSync(path, "utf8");
139
+ const parsed = JSON.parse(raw) as ProjectRegistry;
140
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.projects)) {
141
+ throw new Error("invalid registry shape");
142
+ }
143
+ if (parsed.version !== 1) throw new Error("invalid registry shape");
144
+ return parsed;
145
+ } catch {
146
+ try {
147
+ renameSync(path, `${path}.bad-${Date.now()}`);
148
+ } catch {
149
+ // best-effort backup
150
+ }
151
+ return emptyRegistry();
152
+ }
153
+ }
154
+
155
+ export function saveRegistry(reg: ProjectRegistry): void {
156
+ reg.updatedAt = now();
157
+ const path = getRegistryPath();
158
+ const dir = dirname(path);
159
+ mkdirSync(dir, { recursive: true });
160
+ const tmp = `${path}.tmp`;
161
+ writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
162
+ try { chmodSync(tmp, 0o600); } catch { /* fs may ignore mode bits */ }
163
+ renameSync(tmp, path);
164
+ }
165
+
166
+ /**
167
+ * Lazy sync: append any unknown non-empty project strings (from live + archived
168
+ * todos) as new entries with maxOpen:null. Returns { reg, changed }. Caller
169
+ * persists iff changed. Idempotent (a second call with no new names → changed=false).
170
+ */
171
+ export function reconcileRegistry(
172
+ reg: ProjectRegistry,
173
+ liveTodos: Todo[],
174
+ archivedTodos: Todo[],
175
+ ): { reg: ProjectRegistry; changed: boolean } {
176
+ const known = new Set(reg.projects.map((p) => p.name));
177
+ const names = new Set<string>();
178
+ for (const t of liveTodos) { const p = t.project.trim(); if (p) names.add(p); }
179
+ for (const t of archivedTodos) { const p = t.project.trim(); if (p) names.add(p); }
180
+ let changed = false;
181
+ for (const name of names) {
182
+ if (!known.has(name)) {
183
+ reg.projects.push({ name, maxOpen: null, createdAt: now(), updatedAt: now() });
184
+ changed = true;
185
+ }
186
+ }
187
+ if (changed) reg.updatedAt = now();
188
+ return { reg, changed };
189
+ }
190
+
191
+ export function getProjectEntry(reg: ProjectRegistry, name: string): ProjectEntry | undefined {
192
+ return reg.projects.find((p) => p.name === name);
193
+ }
194
+
195
+ /**
196
+ * Set a project's maxOpen slot. `max = null` clears. Creates the entry if the
197
+ * name is unknown (with createdAt/updatedAt = now). Throws if name is "" (the
198
+ * (no project) group can't be capped). Mutates `reg` in place + returns the entry.
199
+ */
200
+ export function setProjectMaxOpen(reg: ProjectRegistry, name: string, max: number | null): ProjectEntry {
201
+ const trimmed = name.trim();
202
+ if (!trimmed) throw new TodoError("cannot set maxOpen on the (no project) group");
203
+ if (max !== null && (!Number.isFinite(max) || max < 0)) {
204
+ throw new TodoError(`maxOpen must be a non-negative number or null (got ${String(max)})`);
205
+ }
206
+ let entry = getProjectEntry(reg, trimmed);
207
+ if (!entry) {
208
+ entry = { name: trimmed, maxOpen: null, createdAt: now(), updatedAt: now() };
209
+ reg.projects.push(entry);
210
+ }
211
+ entry.maxOpen = max;
212
+ entry.updatedAt = now();
213
+ reg.updatedAt = now();
214
+ return entry;
215
+ }
216
+
217
+ export interface RenameResult {
218
+ liveRenamed: number;
219
+ archivedRenamed: number;
220
+ merged: boolean;
221
+ newName: string;
222
+ }
223
+
224
+ /**
225
+ * Rename (or merge) a project: rewrite every live + archived todo whose
226
+ * `project === oldName` to `newName`, remove the `oldName` registry entry,
227
+ * and ensure the `newName` entry exists. Best-effort multi-file (no WAL):
228
+ * live → archive → registry, each saved atomically with backup-on-corrupt.
229
+ * Throws if `oldName` is not in the registry. Self-rename is a no-op success.
230
+ */
231
+ export function renameProject(oldName: string, newName: string): RenameResult {
232
+ const old = oldName.trim();
233
+ const next = newName.trim();
234
+ if (!old) throw new TodoError("oldName is required");
235
+ if (!next) throw new TodoError("newName is required");
236
+ if (old === next) return { liveRenamed: 0, archivedRenamed: 0, merged: false, newName: next };
237
+
238
+ const reg = loadRegistry();
239
+ const oldEntry = getProjectEntry(reg, old);
240
+ if (!oldEntry) throw new TodoError(`no project named '${old}' in the registry`);
241
+ const merged = getProjectEntry(reg, next) !== undefined;
242
+
243
+ // 1. live store
244
+ const live = loadStore();
245
+ let liveRenamed = 0;
246
+ for (const t of live.todos) {
247
+ if (t.project === old) { t.project = next; t.updatedAt = now(); liveRenamed++; }
248
+ }
249
+ if (liveRenamed > 0) saveStore(live);
250
+
251
+ // 2. archive
252
+ const archive = loadArchive();
253
+ let archivedRenamed = 0;
254
+ for (const t of archive.todos) {
255
+ if (t.project === old) { t.project = next; archivedRenamed++; }
256
+ }
257
+ if (archivedRenamed > 0) saveArchive(archive);
258
+
259
+ // 3. registry: remove old, ensure next exists
260
+ reg.projects = reg.projects.filter((p) => p.name !== old);
261
+ if (!getProjectEntry(reg, next)) {
262
+ reg.projects.push({ name: next, maxOpen: null, createdAt: now(), updatedAt: now() });
263
+ }
264
+ reg.updatedAt = now();
265
+ saveRegistry(reg);
266
+
267
+ return { liveRenamed, archivedRenamed, merged, newName: next };
268
+ }
269
+ ```
270
+
271
+ - [ ] **Step 3: Write `test/registry.test.mts`**
272
+
273
+ ```ts
274
+ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
275
+ import { tmpdir } from "node:os";
276
+ import { join } from "node:path";
277
+
278
+ let passed = 0, failed = 0;
279
+ function ok(name: string, cond: boolean, extra = ""): void { if (cond) { passed++; } else { failed++; console.error(` ✗ ${name} ${extra}`); } }
280
+ function eq<T>(name: string, got: T, want: T): void { ok(name, got === want, `(got ${JSON.stringify(got)} want ${JSON.stringify(want)})`); }
281
+
282
+ const tmp = mkdtempSync(join(tmpdir(), "armory-reg-"));
283
+ process.env.TODO_DIR = tmp;
284
+
285
+ const { loadRegistry, saveRegistry, reconcileRegistry, getProjectEntry, setProjectMaxOpen, renameProject } = await import("../src/registry.ts");
286
+ const { addTodo, listTodos, type Todo } = await import("../src/todo-store.ts");
287
+ const { saveArchive, type ArchiveStore } = await import("../src/archive.ts");
288
+
289
+ // --- loadRegistry: missing → empty, no file created ---
290
+ const r0 = loadRegistry();
291
+ eq("missing registry → empty", r0.projects.length, 0);
292
+ ok("missing registry → no file yet", !existsSync(join(tmp, "projects.json")));
293
+
294
+ // --- saveRegistry: atomic + 0600 + version 1 ---
295
+ saveRegistry({ version: 1, updatedAt: "x", projects: [{ name: "pi", maxOpen: 5, createdAt: "x", updatedAt: "x" }] });
296
+ const r1 = loadRegistry();
297
+ eq("saved registry reloads name", r1.projects[0]!.name, "pi");
298
+ eq("saved registry reloads maxOpen", r1.projects[0]!.maxOpen, 5);
299
+ eq("registry version 1", r1.version, 1);
300
+ const mode = statSync(join(tmp, "projects.json")).mode & 0o777;
301
+ ok("registry file mode 0600", mode === 0o600, `(mode ${mode.toString(8)})`);
302
+
303
+ // --- reconcileRegistry: appends unknown from live + archive, idempotent ---
304
+ const a = addTodo({ title: "T1", project: "getpipher" });
305
+ const arch0: ArchiveStore = { version: 3, updatedAt: "x", todos: [{ ...a, project: "bug-bounty", status: "done", closedAt: "x" }] };
306
+ saveArchive(arch0);
307
+ let reg = loadRegistry();
308
+ const res1 = reconcileRegistry(reg, listTodos({ status: "all", limit: 200 }), arch0.todos);
309
+ ok("reconcile changed (2 new)", res1.changed && res1.reg.projects.length === 2);
310
+ const res2 = reconcileRegistry(res1.reg, listTodos({ status: "all", limit: 200 }), arch0.todos);
311
+ ok("reconcile idempotent", !res2.changed);
312
+
313
+ // --- reconcile ignores empty-string project ---
314
+ addTodo({ title: "T2", project: "" });
315
+ const res3 = reconcileRegistry(res1.reg, listTodos({ status: "all", limit: 200 }), arch0.todos);
316
+ ok("reconcile no (no project) entry", !res3.reg.projects.some((p) => p.name === ""));
317
+
318
+ // --- getProjectEntry hit/miss ---
319
+ ok("getProjectEntry hit", getProjectEntry(res3.reg, "getpipher") !== undefined);
320
+ ok("getProjectEntry miss", getProjectEntry(res3.reg, "nope") === undefined);
321
+
322
+ // --- setProjectMaxOpen: create-if-unknown, set number, null clears ---
323
+ const e1 = setProjectMaxOpen(res3.reg, "getpipher", 8);
324
+ eq("setMaxOpen sets 8", e1.maxOpen, 8);
325
+ const e2 = setProjectMaxOpen(res3.reg, "brand-new", 3);
326
+ ok("setMaxOpen creates unknown", getProjectEntry(res3.reg, "brand-new") !== undefined && e2.maxOpen === 3);
327
+ const e3 = setProjectMaxOpen(res3.reg, "getpipher", null);
328
+ eq("setMaxOpen null clears", e3.maxOpen, null);
329
+ let threw = false;
330
+ try { setProjectMaxOpen(res3.reg, "", 5); } catch { threw = true; }
331
+ ok("setMaxOpen '' throws", threw);
332
+ let threw2 = false;
333
+ try { setProjectMaxOpen(res3.reg, "x", -1); } catch { threw2 = true; }
334
+ ok("setMaxOpen negative throws", threw2);
335
+
336
+ // --- renameProject: rewrites live + archive + registry; merge; self no-op ---
337
+ // (fresh isolated registry for rename tests)
338
+ rmSync(join(tmp, "projects.json"), { force: true });
339
+ addTodo({ title: "R1", project: "getpither" });
340
+ addTodo({ title: "R2", project: "getpither" });
341
+ const arch1: ArchiveStore = { version: 3, updatedAt: "x", todos: [{ ...a, project: "getpither", status: "done", closedAt: "x" }] };
342
+ saveArchive(arch1);
343
+ let regR = loadRegistry();
344
+ reconcileRegistry(regR, listTodos({ status: "all", limit: 200 }), loadArchiveAsync().todos);
345
+ saveRegistry(regR);
346
+ // merge: getpither → getpipher (getpipher already exists from earlier? no — fresh. so create)
347
+ const rr = renameProject("getpither", "getpipher");
348
+ eq("rename liveRenamed", rr.liveRenamed, 2);
349
+ eq("rename archivedRenamed", rr.archivedRenamed, 1);
350
+ ok("rename to-new → merged=false", !rr.merged);
351
+ ok("rename removed old entry", getProjectEntry(loadRegistry(), "getpither") === undefined);
352
+ ok("rename created new entry", getProjectEntry(loadRegistry(), "getpipher") !== undefined);
353
+
354
+ // self-rename no-op
355
+ const self = renameProject("getpipher", "getpipher");
356
+ eq("self-rename liveRenamed 0", self.liveRenamed, 0);
357
+ eq("self-rename merged false", self.merged, false);
358
+
359
+ // rename onto existing = merge
360
+ addTodo({ title: "R3", project: "alpha" });
361
+ addTodo({ title: "R4", project: "beta" });
362
+ let regM = loadRegistry();
363
+ reconcileRegistry(regM, listTodos({ status: "all", limit: 200 }), loadArchiveAsync().todos);
364
+ saveRegistry(regM);
365
+ const mr = renameProject("alpha", "beta");
366
+ ok("merge → merged=true", mr.merged);
367
+ ok("merge removed alpha entry", getProjectEntry(loadRegistry(), "alpha") === undefined);
368
+
369
+ // rename unknown old → throws
370
+ let threw3 = false;
371
+ try { renameProject("does-not-exist", "x"); } catch { threw3 = true; }
372
+ ok("rename unknown throws", threw3);
373
+
374
+ // --- corrupt registry → backup + fresh empty ---
375
+ writeFileSync(join(tmp, "projects.json"), "{ not json", "utf8");
376
+ const recovered = loadRegistry();
377
+ eq("corrupt registry → empty", recovered.projects.length, 0);
378
+
379
+ function loadArchiveAsync() { return (loadArchive as any)() as ArchiveStore; }
380
+
381
+ rmSync(tmp, { recursive: true, force: true });
382
+ console.log(`\n${passed} passed, ${failed} failed`);
383
+ if (failed > 0) process.exit(1);
384
+ ```
385
+
386
+ - [ ] **Step 4: Run registry tests — verify pass**
387
+
388
+ Run: `node test/registry.test.mts`
389
+ Expected: `22 passed, 0 failed` (adjust count to actual; the suite has ~22 assertions).
390
+
391
+ - [ ] **Step 5: Confirm baseline suites still green**
392
+
393
+ Run: `npm test`
394
+ Expected: all 10 suites green (255 prior + new registry suite).
395
+
396
+ - [ ] **Step 6: Commit**
397
+
398
+ ```bash
399
+ git add src/levenshtein.ts src/registry.ts test/registry.test.mts
400
+ git commit -m "feat(registry): project registry (projects.json) + levenshtein helper"
401
+ ```
402
+
403
+ ---
404
+
405
+ ## Task 2: `src/paths.ts` — extract `getRegistryPath()`
406
+
407
+ **Files:**
408
+ - Modify: `src/paths.ts` (add `getRegistryPath` after `getConfigPath`)
409
+ - Modify: `src/registry.ts` (replace inline `join(getTodoDir(), "projects.json")` with `getRegistryPath()` import)
410
+
411
+ **Interfaces:**
412
+ - Produces: `getRegistryPath(): string` (centralized; `registry.ts` consumes it).
413
+
414
+ - [ ] **Step 1: Add `getRegistryPath` to `src/paths.ts`**
415
+
416
+ Edit `src/paths.ts` — update the module doc comment's file list + add the function. Replace:
417
+
418
+ ```ts
419
+ export function getConfigPath(): string {
420
+ return join(getTodoDir(), "todo.config.json");
421
+ }
422
+ ```
423
+
424
+ with:
425
+
426
+ ```ts
427
+ export function getConfigPath(): string {
428
+ return join(getTodoDir(), "todo.config.json");
429
+ }
430
+
431
+ export function getRegistryPath(): string {
432
+ return join(getTodoDir(), "projects.json");
433
+ }
434
+ ```
435
+
436
+ And update the header file list comment to add the `projects.json` line.
437
+
438
+ - [ ] **Step 2: Swap `registry.ts` to use `getRegistryPath`**
439
+
440
+ In `src/registry.ts`, replace:
441
+
442
+ ```ts
443
+ import { dirname, join } from "node:path";
444
+ import { getTodoDir } from "./paths.ts";
445
+ ```
446
+
447
+ with:
448
+
449
+ ```ts
450
+ import { dirname } from "node:path";
451
+ import { getRegistryPath } from "./paths.ts";
452
+ ```
453
+
454
+ and replace the `getRegistryPath` function body:
455
+
456
+ ```ts
457
+ export function getRegistryPath(): string {
458
+ return join(getTodoDir(), "projects.json");
459
+ }
460
+ ```
461
+
462
+ with a re-export (so `registry.ts` still exports `getRegistryPath` for the extension/tests):
463
+
464
+ ```ts
465
+ export { getRegistryPath } from "./paths.ts";
466
+ ```
467
+
468
+ - [ ] **Step 3: Run registry tests + baseline — verify still green**
469
+
470
+ Run: `node test/registry.test.mts && npm test`
471
+ Expected: all green (behavior unchanged; refactor only).
472
+
473
+ - [ ] **Step 4: Commit**
474
+
475
+ ```bash
476
+ git add src/paths.ts src/registry.ts
477
+ git commit -m "refactor(paths): centralize getRegistryPath in paths.ts"
478
+ ```
479
+
480
+ ---
481
+
482
+ ## Task 3: `src/projects.ts` + overview tests
483
+
484
+ **Files:**
485
+ - Create: `src/projects.ts`
486
+ - Create: `test/projects.test.mts`
487
+
488
+ **Interfaces:**
489
+ - Consumes: `loadStore()` (`src/todo-store.ts`), `loadArchive()` (`src/archive.ts`), `loadRegistry()`/`reconcileRegistry()`/`saveRegistry()`/`getProjectEntry()` (`src/registry.ts`), `levenshtein()` (`src/levenshtein.ts`).
490
+ - Produces: `ProjectOverviewRow`, `ProjectsOverview`, `projectsOverview()`.
491
+
492
+ - [ ] **Step 1: Write `src/projects.ts`**
493
+
494
+ ```ts
495
+ // Per-project scope overview for armory-todo (Feature A). Pure read that
496
+ // reconciles the registry first (lazy sync), then aggregates counts across
497
+ // the live store + archived done. The `projects` action + panel Projects tab
498
+ // + the per-project health flags all consume this shape (or its derivatives).
499
+
500
+ import { loadStore, type Todo } from "./todo-store.ts";
501
+ import { loadArchive } from "./archive.ts";
502
+ import { loadRegistry, reconcileRegistry, saveRegistry, getProjectEntry } from "./registry.ts";
503
+ import { levenshtein } from "./levenshtein.ts";
504
+
505
+ export interface ProjectOverviewRow {
506
+ name: string;
507
+ open: number;
508
+ in_progress: number;
509
+ parked: number;
510
+ done: number; // live done + archived done
511
+ total: number; // open + in_progress + parked + done
512
+ maxOpen: number | null;
513
+ over: boolean; // open > maxOpen (only when maxOpen !== null)
514
+ typo: boolean; // total === 1 AND a near-sibling (levenshtein ≤ 2) exists
515
+ lastUpdated: string; // max updatedAt across the project's live todos (ISO), or "" if none
516
+ }
517
+
518
+ export interface ProjectsOverview {
519
+ rows: ProjectOverviewRow[]; // sorted: open desc → total desc → name asc
520
+ totalTodos: number; // sum of rows' total
521
+ noProject: { count: number; open: number }; // the (no project) bucket, not a row
522
+ }
523
+
524
+ function aggregate(todos: Todo[], archivedDone: Todo[]): Map<string, Todo[]> {
525
+ const by = new Map<string, Todo[]>();
526
+ for (const t of todos) {
527
+ const key = t.project.trim();
528
+ const list = by.get(key) ?? [];
529
+ list.push(t);
530
+ by.set(key, list);
531
+ }
532
+ for (const t of archivedDone) {
533
+ const key = t.project.trim();
534
+ if (!by.has(key)) by.set(key, []); // archived done still counts toward total/done
535
+ }
536
+ return by;
537
+ }
538
+
539
+ export function projectsOverview(): ProjectsOverview {
540
+ const live = loadStore();
541
+ const archive = loadArchive();
542
+ const archivedDone = archive.todos.filter((t) => t.status === "done");
543
+
544
+ // reconcile registry first (lazy sync), persist iff changed
545
+ const reg = loadRegistry();
546
+ const { reg: synced, changed } = reconcileRegistry(reg, live.todos, archive.todos);
547
+ if (changed) saveRegistry(synced);
548
+
549
+ const by = aggregate(live.todos, archivedDone);
550
+ const names = [...by.keys()].filter((n) => n !== "").sort();
551
+
552
+ let totalTodos = 0;
553
+ const rows: ProjectOverviewRow[] = [];
554
+ for (const name of names) {
555
+ const liveForName = live.todos.filter((t) => t.project.trim() === name);
556
+ const archivedDoneForName = archivedDone.filter((t) => t.project.trim() === name);
557
+ const open = liveForName.filter((t) => t.status === "open").length;
558
+ const in_progress = liveForName.filter((t) => t.status === "in_progress").length;
559
+ const parked = liveForName.filter((t) => t.status === "parked").length;
560
+ const done = liveForName.filter((t) => t.status === "done").length + archivedDoneForName.length;
561
+ const total = open + in_progress + parked + done;
562
+ totalTodos += total;
563
+ const entry = getProjectEntry(synced, name);
564
+ const maxOpen = entry?.maxOpen ?? null;
565
+ const over = maxOpen !== null && open > maxOpen;
566
+ const lastUpdated = liveForName.length
567
+ ? liveForName.map((t) => t.updatedAt).sort().at(-1) ?? ""
568
+ : "";
569
+ rows.push({ name, open, in_progress, parked, done, total, maxOpen, over, typo: false, lastUpdated });
570
+ }
571
+
572
+ // typo: total === 1 AND a near-sibling (levenshtein ≤ 2) among other names
573
+ for (const row of rows) {
574
+ if (row.total === 1) {
575
+ row.typo = names.some((other) => other !== row.name && levenshtein(row.name, other) <= 2);
576
+ }
577
+ }
578
+
579
+ // (no project) bucket
580
+ const noProjectLive = live.todos.filter((t) => t.project.trim() === "");
581
+ const noProjectArchivedDone = archivedDone.filter((t) => t.project.trim() === "");
582
+ const noProject = {
583
+ count: noProjectLive.length + noProjectArchivedDone.length,
584
+ open: noProjectLive.filter((t) => t.status === "open").length,
585
+ };
586
+
587
+ rows.sort((a, b) => b.open - a.open || b.total - a.total || a.name.localeCompare(b.name));
588
+ return { rows, totalTodos, noProject };
589
+ }
590
+ ```
591
+
592
+ - [ ] **Step 2: Write `test/projects.test.mts`**
593
+
594
+ ```ts
595
+ import { mkdtempSync, rmSync } from "node:fs";
596
+ import { tmpdir } from "node:os";
597
+ import { join } from "node:path";
598
+
599
+ let passed = 0, failed = 0;
600
+ function ok(name: string, cond: boolean, extra = ""): void { if (cond) { passed++; } else { failed++; console.error(` ✗ ${name} ${extra}`); } }
601
+ function eq<T>(name: string, got: T, want: T): void { ok(name, got === want, `(got ${JSON.stringify(got)} want ${JSON.stringify(want)})`); }
602
+
603
+ const tmp = mkdtempSync(join(tmpdir(), "armory-proj-"));
604
+ process.env.TODO_DIR = tmp;
605
+
606
+ const { projectsOverview } = await import("../src/projects.ts");
607
+ const { addTodo, updateTodo, completeTodo } = await import("../src/todo-store.ts");
608
+ const { saveArchive } = await import("../src/archive.ts");
609
+ const { setProjectMaxOpen, loadRegistry, saveRegistry } = await import("../src/registry.ts");
610
+
611
+ // --- empty store → empty overview ---
612
+ const o0 = projectsOverview();
613
+ eq("empty rows", o0.rows.length, 0);
614
+ eq("empty totalTodos", o0.totalTodos, 0);
615
+ eq("empty noProject count", o0.noProject.count, 0);
616
+
617
+ // --- counts across live + archived done ---
618
+ addTodo({ title: "a", project: "pi" });
619
+ addTodo({ title: "b", project: "pi" });
620
+ const ip = addTodo({ title: "c", project: "pi" });
621
+ updateTodo(ip.id, { status: "in_progress" });
622
+ const done1 = addTodo({ title: "d", project: "pi" });
623
+ completeTodo(done1.id); // live done
624
+ addTodo({ title: "e", project: "sip" }); // open, separate project
625
+ addTodo({ title: "f", project: "" }); // (no project)
626
+
627
+ // archived done for "pi"
628
+ saveArchive({ version: 3, updatedAt: "x", todos: [{ ...done1, project: "pi", status: "done", closedAt: "2026-07-01T00:00:00.000Z" }] });
629
+
630
+ const o1 = projectsOverview();
631
+ const pi = o1.rows.find((r) => r.name === "pi")!;
632
+ const sip = o1.rows.find((r) => r.name === "sip")!;
633
+ eq("pi open", pi.open, 2);
634
+ eq("pi in_progress", pi.in_progress, 1);
635
+ eq("pi parked", pi.parked, 0);
636
+ eq("pi done (live done + archived done)", pi.done, 1 + 1);
637
+ eq("pi total", pi.total, 5);
638
+ eq("sip open", sip.open, 1);
639
+ eq("noProject count", o1.noProject.count, 1);
640
+ eq("noProject open", o1.noProject.open, 1);
641
+
642
+ // --- sort: open desc → total desc → name asc ---
643
+ addTodo({ title: "g", project: "alpha" });
644
+ addTodo({ title: "h", project: "alpha" }); // alpha: 2 open
645
+ const o2 = projectsOverview();
646
+ eq("first row is pi (3 actionable open)", o2.rows[0]!.name, "pi");
647
+ ok("sort then total/name", o2.rows.length >= 3);
648
+
649
+ // --- maxOpen + over flag ---
650
+ let reg = loadRegistry();
651
+ setProjectMaxOpen(reg, "sip", 0); // maxOpen 0 → any open is over
652
+ saveRegistry(reg);
653
+ const o3 = projectsOverview();
654
+ const sip3 = o3.rows.find((r) => r.name === "sip")!;
655
+ eq("sip maxOpen 0", sip3.maxOpen, 0);
656
+ ok("sip over (1 > 0)", sip3.over);
657
+
658
+ // --- typo: 1-todo project with near-sibling ---
659
+ addTodo({ title: "z", project: "getpither" }); // 1 todo, near "getpipher"?
660
+ addTodo({ title: "y", project: "getpipher" }); // sibling
661
+ const o4 = projectsOverview();
662
+ const typo = o4.rows.find((r) => r.name === "getpither")!;
663
+ ok("getpither typo (near getpipher)", typo.typo);
664
+ const notTypo = o4.rows.find((r) => r.name === "getpipher")!;
665
+ ok("getpipher not typo (>1 todo)", !notTypo.typo);
666
+
667
+ // --- lastUpdated = max live updatedAt, "" if no live todos ---
668
+ const o5 = projectsOverview();
669
+ ok("pi lastUpdated non-empty", o5.rows.find((r) => r.name === "pi")!.lastUpdated.length > 0);
670
+
671
+ // --- registry seeded via reconcile (projectsOverview persists) ---
672
+ ok("registry has getpither", loadRegistry().projects.some((p) => p.name === "getpither"));
673
+
674
+ rmSync(tmp, { recursive: true, force: true });
675
+ console.log(`\n${passed} passed, ${failed} failed`);
676
+ if (failed > 0) process.exit(1);
677
+ ```
678
+
679
+ - [ ] **Step 3: Run projects tests — verify pass**
680
+
681
+ Run: `node test/projects.test.mts`
682
+ Expected: ~16 passed, 0 failed.
683
+
684
+ - [ ] **Step 4: Full suite green**
685
+
686
+ Run: `npm test`
687
+ Expected: 11 suites green.
688
+
689
+ - [ ] **Step 5: Commit**
690
+
691
+ ```bash
692
+ git add src/projects.ts test/projects.test.mts
693
+ git commit -m "feat(projects): per-project scope overview (counts + maxOpen + typo)"
694
+ ```
695
+
696
+ ---
697
+
698
+ ## Task 4: `src/config.ts` — add `perProjectDefaultMax`
699
+
700
+ **Files:**
701
+ - Modify: `src/config.ts` — `HealthConfig` + `DEFAULT_CONFIG.health` + (no version bump).
702
+ - Modify: `test/todo-config.test.mts` — assert default 8 + forward-compatible merge.
703
+
704
+ **Interfaces:**
705
+ - Produces: `HealthConfig.perProjectDefaultMax: number` (default 8).
706
+ - Consumes: nothing new.
707
+
708
+ - [ ] **Step 1: Write the failing test additions in `test/todo-config.test.mts`**
709
+
710
+ Add after the existing defaults block:
711
+
712
+ ```ts
713
+ eq("default perProjectDefaultMax 8", DEFAULT_CONFIG.health.perProjectDefaultMax, 8);
714
+
715
+ // forward-compatible merge: an old config without the field gets the default
716
+ saveConfig({ version: 1, prune: DEFAULT_CONFIG.prune, health: { ...DEFAULT_CONFIG.health, perProjectDefaultMax: undefined } } as any);
717
+ const merged = loadConfig();
718
+ eq("missing perProjectDefaultMax → default 8", merged.health.perProjectDefaultMax, 8);
719
+ ```
720
+
721
+ - [ ] **Step 2: Run — verify fails**
722
+
723
+ Run: `node test/todo-config.test.mts`
724
+ Expected: FAIL — `DEFAULT_CONFIG.health.perProjectDefaultMax` is `undefined` (type error / eq fails).
725
+
726
+ - [ ] **Step 3: Add the field to `src/config.ts`**
727
+
728
+ In `src/config.ts`, extend `HealthConfig`:
729
+
730
+ ```ts
731
+ export interface HealthConfig {
732
+ activeMaxOpen: number;
733
+ activeStaleDays: number;
734
+ parkedMax: number;
735
+ parkedStaleDays: number;
736
+ archiveMax: number;
737
+ archiveOldDays: number;
738
+ perProjectDefaultMax: number; // v0.4.0: per-project PROJECT_LARGE threshold (advisory)
739
+ }
740
+ ```
741
+
742
+ and `DEFAULT_CONFIG`:
743
+
744
+ ```ts
745
+ health: {
746
+ activeMaxOpen: 15,
747
+ activeStaleDays: 30,
748
+ parkedMax: 10,
749
+ parkedStaleDays: 60,
750
+ archiveMax: 200,
751
+ archiveOldDays: 180,
752
+ perProjectDefaultMax: 8,
753
+ },
754
+ ```
755
+
756
+ (The existing `loadConfig` merge `{ ...DEFAULT_CONFIG.health, ...parsed.health }` fills `perProjectDefaultMax` for old configs automatically — but `undefined` in the spread would *overwrite* the default. Handle it explicitly:)
757
+
758
+ In `loadConfig`'s merge, replace:
759
+
760
+ ```ts
761
+ return {
762
+ version: 1,
763
+ prune: { ...DEFAULT_CONFIG.prune, ...parsed.prune },
764
+ health: { ...DEFAULT_CONFIG.health, ...parsed.health },
765
+ };
766
+ ```
767
+
768
+ with:
769
+
770
+ ```ts
771
+ const health = { ...DEFAULT_CONFIG.health, ...parsed.health };
772
+ if (health.perProjectDefaultMax === undefined) health.perProjectDefaultMax = DEFAULT_CONFIG.health.perProjectDefaultMax;
773
+ return {
774
+ version: 1,
775
+ prune: { ...DEFAULT_CONFIG.prune, ...parsed.prune },
776
+ health,
777
+ };
778
+ ```
779
+
780
+ - [ ] **Step 4: Run config tests — verify pass**
781
+
782
+ Run: `node test/todo-config.test.mts`
783
+ Expected: 17 passed (15 prior + 2 new), 0 failed.
784
+
785
+ - [ ] **Step 5: Full suite green**
786
+
787
+ Run: `npm test`
788
+ Expected: all green.
789
+
790
+ - [ ] **Step 6: Commit**
791
+
792
+ ```bash
793
+ git add src/config.ts test/todo-config.test.mts
794
+ git commit -m "feat(config): perProjectDefaultMax health threshold (default 8)"
795
+ ```
796
+
797
+ ---
798
+
799
+ ## Task 5: `src/health.ts` — 4 per-project flags + reconcile-first
800
+
801
+ **Files:**
802
+ - Modify: `src/health.ts` — new flags + `ProjectHealth[]` + `noProject` + suggestions; reconcile-first.
803
+ - Modify: `test/todo-health.test.mts` — per-project flag coverage.
804
+
805
+ **Interfaces:**
806
+ - Consumes: `loadRegistry`/`reconcileRegistry`/`saveRegistry`/`getProjectEntry` (`src/registry.ts`), `levenshtein` (`src/levenshtein.ts`).
807
+ - Produces: `HealthFlag` += `PROJECT_OVER | PROJECT_TYPO | PROJECT_LARGE | PROJECT_STALE`; `HealthReport.projects: ProjectHealth[]`; `HealthReport.noProject: { open: number }`.
808
+
809
+ - [ ] **Step 1: Write failing test additions in `test/todo-health.test.mts`**
810
+
811
+ Append (after existing assertions, before `rmSync`):
812
+
813
+ ```ts
814
+ // --- per-project flags (v0.4.0) ---
815
+ const { setProjectMaxOpen, loadRegistry, saveRegistry } = await import("../src/registry.ts");
816
+ const { addTodo, updateTodo, completeTodo } = await import("../src/todo-store.ts");
817
+
818
+ // PROJECT_OVER: maxOpen set + exceeded
819
+ addTodo({ title: "p1", project: "over-proj" });
820
+ addTodo({ title: "p2", project: "over-proj" }); // 2 open
821
+ let reg = loadRegistry();
822
+ setProjectMaxOpen(reg, "over-proj", 1); // max 1 → 2 is over
823
+ saveRegistry(reg);
824
+
825
+ // PROJECT_LARGE: open > perProjectDefaultMax (8) with maxOpen null
826
+ for (let i = 0; i < 9; i++) addTodo({ title: `large-${i}`, project: "large-proj" });
827
+
828
+ // PROJECT_TYPO: 1 todo + near-sibling
829
+ addTodo({ title: "typo", project: "getpither" });
830
+ addTodo({ title: "sib", project: "getpipher" });
831
+
832
+ // PROJECT_STALE: lastUpdated > activeStaleDays (30)
833
+ const stale = addTodo({ title: "stale", project: "stale-proj" });
834
+ updateTodo(stale.id, {} as any); // touch updatedAt? — instead write an old updatedAt via store reload not needed; skip stale assertion if hard to forge
835
+
836
+ const report2 = healthReport();
837
+ const overFlag = report2.flags.includes("PROJECT_OVER");
838
+ const largeFlag = report2.flags.includes("PROJECT_LARGE");
839
+ const typoFlag = report2.flags.includes("PROJECT_TYPO");
840
+ ok("PROJECT_OVER flag", overFlag);
841
+ ok("PROJECT_LARGE flag (9 > 8)", largeFlag);
842
+ ok("PROJECT_TYPO flag", typoFlag);
843
+ ok("health.projects populated", report2.projects.length > 0);
844
+ ok("noProject reported", typeof report2.noProject.open === "number");
845
+ ok("suggestion mentions rename for typo", report2.suggestions.some((s) => s.includes("rename")));
846
+ ```
847
+
848
+ > **Note on PROJECT_STALE:** forging a `lastUpdated > 30d` without time mocking is fiddly. If a stale assertion is impractical in the existing harness (no clock mock), skip the stale *flag* assertion in tests and rely on the manual QA gate + code review for that flag. The stale *logic* is simple (`daysAgo(lastUpdated) > activeStaleDays`) and covered by inspection. Add a stale test only if a clock-injection helper already exists in the suite (it does not — check first).
849
+
850
+ - [ ] **Step 2: Run — verify fails**
851
+
852
+ Run: `node test/todo-health.test.mts`
853
+ Expected: FAIL — `report2.flags`/`report2.projects`/`report2.noProject` undefined.
854
+
855
+ - [ ] **Step 3: Implement per-project flags in `src/health.ts`**
856
+
857
+ Update `HealthFlag`:
858
+
859
+ ```ts
860
+ export type HealthFlag =
861
+ | "ACTIVE_LARGE" | "ACTIVE_STALE"
862
+ | "PARKED_LARGE" | "PARKED_STALE"
863
+ | "ARCHIVE_LARGE" | "ARCHIVE_OLD"
864
+ | "PROJECT_OVER" | "PROJECT_TYPO" | "PROJECT_LARGE" | "PROJECT_STALE";
865
+ ```
866
+
867
+ Add `ProjectHealth` + extend `HealthReport`:
868
+
869
+ ```ts
870
+ export interface ProjectHealth {
871
+ name: string;
872
+ open: number;
873
+ maxOpen: number | null;
874
+ over: boolean;
875
+ typo: boolean;
876
+ large: boolean;
877
+ stale: boolean;
878
+ lastUpdated: string;
879
+ }
880
+
881
+ export interface HealthReport {
882
+ active: ActiveHealth;
883
+ parked: ParkedHealth;
884
+ archive: ArchiveHealth;
885
+ notesBytes: NotesBytes;
886
+ flags: HealthFlag[];
887
+ suggestions: string[];
888
+ projects: ProjectHealth[]; // only projects with ≥1 flag, sorted open desc
889
+ noProject: { open: number }; // (no project) open count, for context
890
+ }
891
+ ```
892
+
893
+ Add imports at top of `src/health.ts`:
894
+
895
+ ```ts
896
+ import { loadRegistry, reconcileRegistry, saveRegistry, getProjectEntry } from "./registry.ts";
897
+ import { levenshtein } from "./levenshtein.ts";
898
+ ```
899
+
900
+ In `healthReport()`, **before** the existing diagnostics (so the registry is reconciled first), add:
901
+
902
+ ```ts
903
+ // reconcile registry first (lazy sync), persist iff changed
904
+ const reg = loadRegistry();
905
+ const { reg: synced, changed } = reconcileRegistry(reg, live.todos, archive.todos);
906
+ if (changed) saveRegistry(synced);
907
+ ```
908
+
909
+ After the existing `flags`/`suggestions` blocks (before `return`), add the per-project computation:
910
+
911
+ ```ts
912
+ // per-project flags
913
+ const archivedDone = archive.todos.filter((t) => t.status === "done");
914
+ const names = new Set<string>();
915
+ for (const t of live.todos) { const p = t.project.trim(); if (p) names.add(p); }
916
+ for (const t of archivedDone) { const p = t.project.trim(); if (p) names.add(p); }
917
+
918
+ const projectHealth: ProjectHealth[] = [];
919
+ for (const name of names) {
920
+ const liveForName = live.todos.filter((t) => t.project.trim() === name);
921
+ const open = liveForName.filter((t) => t.status === "open").length;
922
+ const entry = getProjectEntry(synced, name);
923
+ const maxOpen = entry?.maxOpen ?? null;
924
+ const over = maxOpen !== null && open > maxOpen;
925
+ const large = open > h.perProjectDefaultMax;
926
+ const lastUpdated = liveForName.length ? liveForName.map((t) => t.updatedAt).sort().at(-1) ?? "" : "";
927
+ const stale = lastUpdated !== "" && daysAgo(lastUpdated) > h.activeStaleDays;
928
+ const totalForName = liveForName.length + archivedDone.filter((t) => t.project.trim() === name).length;
929
+ const typo = totalForName === 1 && [...names].some((o) => o !== name && levenshtein(name, o) <= 2);
930
+ if (over || large || stale || typo) {
931
+ projectHealth.push({ name, open, maxOpen, over, typo, large, stale, lastUpdated });
932
+ }
933
+ }
934
+ projectHealth.sort((a, b) => b.open - a.open || a.name.localeCompare(b.name));
935
+
936
+ for (const p of projectHealth) {
937
+ if (p.over) { flags.push("PROJECT_OVER"); suggestions.push(`project '${p.name}' ${p.open} open (maxOpen ${p.maxOpen}) → close/park some, or raise maxOpen`); }
938
+ if (p.large) { flags.push("PROJECT_LARGE"); suggestions.push(`project '${p.name}' ${p.open} open (per-project default max ${h.perProjectDefaultMax}) → over budget`); }
939
+ if (p.stale) { flags.push("PROJECT_STALE"); suggestions.push(`project '${p.name}' untouched > ${h.activeStaleDays}d → park or close`); }
940
+ if (p.typo) {
941
+ flags.push("PROJECT_TYPO");
942
+ const sib = [...names].find((o) => o !== p.name && levenshtein(p.name, o) <= 2);
943
+ suggestions.push(`project '${p.name}' has 1 todo — possible typo of '${sib}'? → todo project rename ${p.name} ${sib}`);
944
+ }
945
+ }
946
+
947
+ const noProject = { open: live.todos.filter((t) => t.project.trim() === "" && t.status === "open").length };
948
+ ```
949
+
950
+ Update the `return` statement to include `projects: projectHealth, noProject`.
951
+
952
+ - [ ] **Step 4: Run health tests — verify pass**
953
+
954
+ Run: `node test/todo-health.test.mts`
955
+ Expected: prior count + ~7 new assertions, 0 failed.
956
+
957
+ - [ ] **Step 5: Full suite green**
958
+
959
+ Run: `npm test`
960
+ Expected: all green.
961
+
962
+ - [ ] **Step 6: Commit**
963
+
964
+ ```bash
965
+ git add src/health.ts test/todo-health.test.mts
966
+ git commit -m "feat(health): per-project flags (OVER/TYPO/LARGE/STALE) + reconcile-first"
967
+ ```
968
+
969
+ ---
970
+
971
+ ## Task 6: `src/panel-data.ts` — project row + action helpers + tests
972
+
973
+ **Files:**
974
+ - Modify: `src/panel-data.ts` — add `projectOverviewToItems`, `actionsForProject`, `noProjectSummaryItem`.
975
+ - Modify: `test/panel-data.test.mts` — project helper coverage.
976
+
977
+ **Interfaces:**
978
+ - Consumes: `ProjectsOverview`/`ProjectOverviewRow` (`src/projects.ts`).
979
+ - Produces: `projectOverviewToItems(overview): SelectItem[]`, `actionsForProject(): {label, action}[]`, `noProjectSummaryItem(overview): SelectItem`.
980
+
981
+ - [ ] **Step 1: Write failing tests in `test/panel-data.test.mts`**
982
+
983
+ Append:
984
+
985
+ ```ts
986
+ import { projectOverviewToItems, actionsForProject, noProjectSummaryItem } from "../src/panel-data.ts";
987
+ import type { ProjectsOverview } from "../src/projects.ts";
988
+
989
+ const overview: ProjectsOverview = {
990
+ rows: [
991
+ { name: "pi", open: 3, in_progress: 0, parked: 0, done: 1, total: 4, maxOpen: 2, over: true, typo: false, lastUpdated: "2026-07-21T00:00:00.000Z" },
992
+ { name: "getpither", open: 0, in_progress: 0, parked: 0, done: 0, total: 1, maxOpen: null, over: false, typo: true, lastUpdated: "" },
993
+ ],
994
+ totalTodos: 5,
995
+ noProject: { count: 2, open: 1 },
996
+ };
997
+
998
+ const items = projectOverviewToItems(overview);
999
+ ok("project item label has name", items[0]!.label.includes("pi"));
1000
+ ok("project item value is name", items[0]!.value === "pi");
1001
+ ok("OVER marker rendered", items[0]!.label.includes("OVER"));
1002
+ ok("typo marker rendered", items[1]!.label.includes("typo"));
1003
+
1004
+ const acts = actionsForProject();
1005
+ ok("actions include Rename", acts.some((a) => a.action === "rename"));
1006
+ ok("actions include Set maxOpen", acts.some((a) => a.action === "setmax"));
1007
+ ok("actions include Filter", acts.some((a) => a.action === "filter"));
1008
+
1009
+ const np = noProjectSummaryItem(overview);
1010
+ ok("no-project summary value is __noproject__", np.value === "__noproject__");
1011
+ ok("no-project summary label has count", np.label.includes("2"));
1012
+ ```
1013
+
1014
+ - [ ] **Step 2: Run — verify fails**
1015
+
1016
+ Run: `node test/panel-data.test.mts`
1017
+ Expected: FAIL — imports not found.
1018
+
1019
+ - [ ] **Step 3: Add helpers to `src/panel-data.ts`**
1020
+
1021
+ Append:
1022
+
1023
+ ```ts
1024
+ import type { ProjectsOverview, ProjectOverviewRow } from "./projects.ts";
1025
+
1026
+ /** Format the projects overview into SelectList items. Markers: OVER / typo. */
1027
+ export function projectOverviewToItems(o: ProjectsOverview): SelectItem[] {
1028
+ return o.rows.map((r) => {
1029
+ const cap = r.maxOpen !== null ? ` [max:${r.maxOpen}]` : "";
1030
+ const over = r.over ? " OVER" : "";
1031
+ const typo = r.typo ? " ?typo" : "";
1032
+ const last = r.lastUpdated ? ` · ${r.lastUpdated.slice(0, 10)}` : " · (no live)";
1033
+ return {
1034
+ value: r.name,
1035
+ label: `${r.name} ${r.open}/${r.in_progress}/${r.parked}/${r.done} (total ${r.total})${cap}${over}${typo}${last}`,
1036
+ };
1037
+ });
1038
+ }
1039
+
1040
+ /** Actions for a project row in the Projects tab. */
1041
+ export function actionsForProject(): { label: string; action: string }[] {
1042
+ return [
1043
+ { label: "Rename / merge", action: "rename" },
1044
+ { label: "Set maxOpen", action: "setmax" },
1045
+ { label: "Filter active to project", action: "filter" },
1046
+ ];
1047
+ }
1048
+
1049
+ /** The (no project) summary row — non-selectable (no submenu). */
1050
+ export function noProjectSummaryItem(o: ProjectsOverview): SelectItem {
1051
+ return { value: "__noproject__", label: `(no project): ${o.noProject.count} total · ${o.noProject.open} open` };
1052
+ }
1053
+ ```
1054
+
1055
+ - [ ] **Step 4: Run panel-data tests — verify pass**
1056
+
1057
+ Run: `node test/panel-data.test.mts`
1058
+ Expected: 31 prior + ~8 new, 0 failed.
1059
+
1060
+ - [ ] **Step 5: Full suite green**
1061
+
1062
+ Run: `npm test`
1063
+ Expected: all green.
1064
+
1065
+ - [ ] **Step 6: Commit**
1066
+
1067
+ ```bash
1068
+ git add src/panel-data.ts test/panel-data.test.mts
1069
+ git commit -m "feat(panel-data): project overview items + action submenu helpers"
1070
+ ```
1071
+
1072
+ ---
1073
+
1074
+ ## Task 7: `src/panel.ts` — 6th tab `projects` + action submenu (Rename/Set maxOpen/Filter)
1075
+
1076
+ **Files:**
1077
+ - Modify: `src/panel.ts` — `Box` type + `BOXES`, `refreshList`, `onItemSelect`, `openActionSubmenu`, `executeAction`, `renderShell` branch, inline-`Input` for Rename + Set maxOpen, a `projectFilterName` field for the Filter action.
1078
+ - Manual-gate only (no unit test — `panel.ts` is the one non-unit-tested component; covered by the autonomous tmux QA in Task 9).
1079
+
1080
+ **Interfaces:**
1081
+ - Consumes: `projectsOverview` (`src/projects.ts`), `renameProject`/`setProjectMaxOpen`/`loadRegistry` (`src/registry.ts`), `projectOverviewToItems`/`actionsForProject`/`noProjectSummaryItem` (`src/panel-data.ts`).
1082
+
1083
+ - [ ] **Step 1: Extend `Box` + `BOXES`**
1084
+
1085
+ In `src/panel.ts`, replace:
1086
+
1087
+ ```ts
1088
+ export type Box = "active" | "parked" | "done" | "archive" | "config";
1089
+ const BOXES: Box[] = ["active", "parked", "done", "archive", "config"];
1090
+ ```
1091
+
1092
+ with:
1093
+
1094
+ ```ts
1095
+ export type Box = "active" | "parked" | "done" | "archive" | "projects" | "config";
1096
+ const BOXES: Box[] = ["active", "parked", "done", "archive", "projects", "config"];
1097
+ ```
1098
+
1099
+ - [ ] **Step 2: Add imports + a `projectFilterName` field + edit-mode variants**
1100
+
1101
+ Add to the imports block:
1102
+
1103
+ ```ts
1104
+ import { projectsOverview } from "./projects.ts";
1105
+ import { renameProject, setProjectMaxOpen, loadRegistry, saveRegistry } from "./registry.ts";
1106
+ import { projectOverviewToItems, actionsForProject, noProjectSummaryItem } from "./panel-data.ts";
1107
+ ```
1108
+
1109
+ Add fields near the other private fields:
1110
+
1111
+ ```ts
1112
+ private projectFilterName = ""; // set by the "Filter active to project" action
1113
+ private projectEditKind: "rename" | "setmax" | null = null;
1114
+ private projectEditName = ""; // which project is being edited
1115
+ ```
1116
+
1117
+ - [ ] **Step 3: Extend `refreshList` with the `projects` branch**
1118
+
1119
+ In `refreshList()`, add before the closing brace (after the `archive` branch):
1120
+
1121
+ ```ts
1122
+ } else if (this.currentBox === "projects") {
1123
+ const overview = projectsOverview();
1124
+ const rows = projectOverviewToItems(overview);
1125
+ // prepend the (no project) summary as a non-actionable first row
1126
+ this.setSelectItems([noProjectSummaryItem(overview), ...rows]);
1127
+ }
1128
+ ```
1129
+
1130
+ - [ ] **Step 4: Extend `onItemSelect` to guard the `(no project)` row + route to the project submenu**
1131
+
1132
+ In `onItemSelect`, add at the top (before `this.openActionSubmenu(item.value)`):
1133
+
1134
+ ```ts
1135
+ if (this.currentBox === "projects" && item.value === "__noproject__") {
1136
+ // summary row — no submenu
1137
+ return;
1138
+ }
1139
+ if (this.currentBox === "projects") {
1140
+ this.openProjectSubmenu(item.value);
1141
+ return;
1142
+ }
1143
+ ```
1144
+
1145
+ - [ ] **Step 5: Add `openProjectSubmenu` + the rename/setmax inline-Input flow**
1146
+
1147
+ Add new methods (mirroring `openActionSubmenu`/`executeAction`):
1148
+
1149
+ ```ts
1150
+ private openProjectSubmenu(name: string): void {
1151
+ const acts = actionsForProject();
1152
+ const items: SelectItem[] = acts.map((a) => ({ value: a.action, label: a.label }));
1153
+ this.actionList = new SelectList(items, 8, {
1154
+ selectedPrefix: (s) => this.theme.fg("accent", s),
1155
+ selectedText: (s) => this.theme.fg("accent", s),
1156
+ description: (s) => this.theme.fg("muted", s),
1157
+ scrollInfo: (s) => this.theme.fg("dim", s),
1158
+ noMatch: (s) => this.theme.fg("warning", s),
1159
+ });
1160
+ this.actionList.onSelect = (a) => this.executeProjectAction(name, a.value);
1161
+ this.actionList.onCancel = () => { this.actionMode = false; this.actionList = null; this.renderShell(); };
1162
+ this.actionMode = true;
1163
+ this.renderShell();
1164
+ }
1165
+
1166
+ private async executeProjectAction(name: string, action: string): Promise<void> {
1167
+ try {
1168
+ if (action === "filter") {
1169
+ this.projectFilterName = name;
1170
+ this.currentBox = "active";
1171
+ this.filterInput.setValue(`(${name})`); // the active tab's filter is free-text; we set a project-scoped hint
1172
+ // NOTE: listTodos filter is exact project match — the panel's filterInput is a text search, not a project filter.
1173
+ // For an exact project scope, we store projectFilterName and refreshList honors it in the active branch.
1174
+ this.actionMode = false; this.actionList = null;
1175
+ this.refreshList();
1176
+ this.renderShell();
1177
+ return;
1178
+ }
1179
+ if (action === "rename" || action === "setmax") {
1180
+ this.projectEditKind = action;
1181
+ this.projectEditName = name;
1182
+ this.editInput = new Input();
1183
+ this.editInput.setValue(action === "rename" ? name : "");
1184
+ this.editInput.onSubmit = (value) => {
1185
+ try {
1186
+ if (this.projectEditKind === "rename") {
1187
+ const r = renameProject(this.projectEditName, value.trim());
1188
+ this.onNotify(`Renamed ${this.projectEditName} → ${r.newName} (${r.liveRenamed} live + ${r.archivedRenamed} archived${r.merged ? ", merged" : ""})`);
1189
+ } else if (this.projectEditKind === "setmax") {
1190
+ const v = value.trim().toLowerCase();
1191
+ const max = v === "clear" || v === "" ? null : Number(v);
1192
+ if (!Number.isFinite(max) && max !== null) throw new Error("maxOpen must be a number or 'clear'");
1193
+ const reg = loadRegistry();
1194
+ setProjectMaxOpen(reg, this.projectEditName, max);
1195
+ saveRegistry(reg);
1196
+ this.onNotify(`${this.projectEditName} maxOpen = ${max === null ? "cleared" : max}`);
1197
+ }
1198
+ } catch (err) { this.onNotify((err as Error).message, "error"); }
1199
+ this.exitProjectEdit();
1200
+ };
1201
+ this.editInput.onEscape = () => this.exitProjectEdit();
1202
+ this.actionMode = false; this.actionList = null;
1203
+ this.editMode = true;
1204
+ this.renderShell();
1205
+ return;
1206
+ }
1207
+ } catch (err) {
1208
+ this.onNotify((err as Error).message, "error");
1209
+ }
1210
+ this.actionMode = false; this.actionList = null;
1211
+ this.refreshList();
1212
+ this.renderShell();
1213
+ }
1214
+
1215
+ private exitProjectEdit(): void {
1216
+ this.editMode = false;
1217
+ this.editInput = null;
1218
+ this.projectEditKind = null;
1219
+ this.projectEditName = "";
1220
+ this.refreshList();
1221
+ this.renderShell();
1222
+ }
1223
+ ```
1224
+
1225
+ - [ ] **Step 6: Honor `projectFilterName` in the `active` refresh branch**
1226
+
1227
+ In `refreshList`, replace the `active` branch:
1228
+
1229
+ ```ts
1230
+ if (this.currentBox === "active") {
1231
+ const todos = listTodos({ text: filter || undefined, limit: 50 });
1232
+ this.setSelectItems(todos.map(todoToItem));
1233
+ }
1234
+ ```
1235
+
1236
+ with:
1237
+
1238
+ ```ts
1239
+ if (this.currentBox === "active") {
1240
+ const project = this.projectFilterName || undefined;
1241
+ const todos = listTodos({ project, text: filter || undefined, limit: 50 });
1242
+ this.setSelectItems(todos.map(todoToItem));
1243
+ if (this.projectFilterName) {
1244
+ // show a one-line scope hint by prepending a non-actionable summary row
1245
+ // (SelectList rows are SelectItem; reuse the no-project summary shape)
1246
+ }
1247
+ }
1248
+ ```
1249
+
1250
+ And clear `projectFilterName` in `switchBox` (so switching tabs resets the scope):
1251
+
1252
+ ```ts
1253
+ private switchBox(dir: 1 | -1): void {
1254
+ const idx = BOXES.indexOf(this.currentBox);
1255
+ const next = (idx + dir + BOXES.length) % BOXES.length;
1256
+ this.currentBox = BOXES[next]!;
1257
+ this.filterInput.setValue("");
1258
+ this.projectFilterName = ""; // reset project scope on tab switch
1259
+ this.actionMode = false;
1260
+ this.actionList = null;
1261
+ this.refreshList();
1262
+ this.renderShell();
1263
+ }
1264
+ ```
1265
+
1266
+ - [ ] **Step 7: Syntax-check the panel + extension**
1267
+
1268
+ Run: `node --check src/panel.ts && node --check extensions/todo.ts`
1269
+ Expected: no output (syntax OK). (tsx compiles on the fly; `node --check` validates syntax only.)
1270
+
1271
+ - [ ] **Step 8: Full unit suite green (panel.ts itself is manual-gate)**
1272
+
1273
+ Run: `npm test`
1274
+ Expected: all green.
1275
+
1276
+ - [ ] **Step 9: Commit**
1277
+
1278
+ ```bash
1279
+ git add src/panel.ts
1280
+ git commit -m "feat(panel): Projects tab (6th) + Rename/Set maxOpen/Filter actions"
1281
+ ```
1282
+
1283
+ ---
1284
+
1285
+ ## Task 8: `extensions/todo.ts` — tool actions `projects` + `project_rename`, `/todo projects` slash, health text
1286
+
1287
+ **Files:**
1288
+ - Modify: `extensions/todo.ts` — `ACTIONS`, parameter schema (`oldName`, `newName`), `execute` switch, slash `projects` subcommand, health text output.
1289
+
1290
+ **Interfaces:**
1291
+ - Consumes: `projectsOverview` (`src/projects.ts`), `renameProject` (`src/registry.ts`).
1292
+ - Produces: two new tool actions + one new slash sub + extended health text.
1293
+
1294
+ - [ ] **Step 1: Extend `ACTIONS` + imports**
1295
+
1296
+ In `extensions/todo.ts`, replace:
1297
+
1298
+ ```ts
1299
+ const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
1300
+ ```
1301
+
1302
+ with:
1303
+
1304
+ ```ts
1305
+ const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health", "projects", "project_rename"] as const;
1306
+ ```
1307
+
1308
+ Add imports near the existing `../src/...` imports:
1309
+
1310
+ ```ts
1311
+ import { projectsOverview } from "../src/projects";
1312
+ import { renameProject } from "../src/registry";
1313
+ ```
1314
+
1315
+ - [ ] **Step 2: Add `oldName` + `newName` to the parameter schema**
1316
+
1317
+ In the `parameters: Type.Object({...})` block, add (before the closing `}`):
1318
+
1319
+ ```ts
1320
+ // project actions (v0.4.0)
1321
+ oldName: Type.Optional(Type.String({ description: "project_rename: current project name" })),
1322
+ newName: Type.Optional(Type.String({ description: "project_rename: new project name (merge if it already exists)" })),
1323
+ ```
1324
+
1325
+ - [ ] **Step 3: Add the two tool action cases**
1326
+
1327
+ In the `switch (params.action)` block, before `default:`, add:
1328
+
1329
+ ```ts
1330
+ case "projects": {
1331
+ const o = projectsOverview();
1332
+ const rows = o.rows.map((r) => {
1333
+ const cap = r.maxOpen !== null ? ` [max:${r.maxOpen}]` : "";
1334
+ const over = r.over ? " OVER" : "";
1335
+ const typo = r.typo ? " ?typo" : "";
1336
+ return ` ${r.name} ${r.open}o/${r.in_progress}i/${r.parked}p/${r.done}d (total ${r.total})${cap}${over}${typo}`;
1337
+ });
1338
+ const np = `(no project): ${o.noProject.count} total · ${o.noProject.open} open`;
1339
+ const text = rows.length ? `Projects (${o.rows.length}):\n${rows.join("\n")}\n${np}` : `Projects: (none)\n${np}`;
1340
+ return { content: [{ type: "text" as const, text }] };
1341
+ }
1342
+ case "project_rename": {
1343
+ if (!params.oldName || !params.newName) {
1344
+ return { content: [{ type: "text" as const, text: "Error: `oldName` and `newName` are required for project_rename." }] };
1345
+ }
1346
+ const r = renameProject(params.oldName, params.newName);
1347
+ return { content: [{ type: "text" as const, text: `Renamed ${params.oldName} → ${r.newName}: ${r.liveRenamed} live + ${r.archivedRenamed} archived${r.merged ? " (merged)" : ""}` }] };
1348
+ }
1349
+ ```
1350
+
1351
+ - [ ] **Step 4: Extend the `health` action text with the `projects:` section**
1352
+
1353
+ In the `case "health"` block, replace the `const lines = [...]` with:
1354
+
1355
+ ```ts
1356
+ const projLines = report.projects.length
1357
+ ? [`projects:` , ...report.projects.map((p) => {
1358
+ const cap = p.maxOpen !== null ? ` [max:${p.maxOpen}]` : "";
1359
+ const flags = [p.over && "OVER", p.large && "LARGE", p.stale && "STALE", p.typo && "TYPO"].filter(Boolean).join(" ");
1360
+ return ` ${p.name} ${p.open} open${cap}${flags ? ` ${flags}` : ""}`;
1361
+ })]
1362
+ : [];
1363
+ const lines = [
1364
+ `## TODO Health Report`,
1365
+ `active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
1366
+ `parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
1367
+ `archive: ${report.archive.count} (${report.archive.older_180d} old)`,
1368
+ `notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
1369
+ `(no project): ${report.noProject.open} open`,
1370
+ report.flags.length ? `flags: ${report.flags.join(", ")}` : "flags: (none — healthy)",
1371
+ ...projLines,
1372
+ ...report.suggestions.map((s) => ` → ${s}`),
1373
+ ];
1374
+ ```
1375
+
1376
+ - [ ] **Step 5: Add the `/todo projects` slash subcommand**
1377
+
1378
+ In the slash handler, after the `if (sub === "path")` block, add:
1379
+
1380
+ ```ts
1381
+ if (sub === "projects") {
1382
+ const o = projectsOverview();
1383
+ const rows = o.rows.map((r) => {
1384
+ const cap = r.maxOpen !== null ? ` [max:${r.maxOpen}]` : "";
1385
+ const over = r.over ? " OVER" : "";
1386
+ const typo = r.typo ? " ?typo" : "";
1387
+ return ` ${r.name} ${r.open}o/${r.in_progress}i/${r.parked}p/${r.done}d (total ${r.total})${cap}${over}${typo}`;
1388
+ });
1389
+ const np = `(no project): ${o.noProject.count} total · ${o.noProject.open} open`;
1390
+ const msg = rows.length ? `Projects (${o.rows.length}):\n${rows.join("\n")}\n${np}` : `Projects: (none)\n${np}`;
1391
+ if (ctx.hasUI) ctx.ui.notify(msg, "info");
1392
+ return;
1393
+ }
1394
+ ```
1395
+
1396
+ Update the slash `description` string to include `/todo projects`:
1397
+
1398
+ ```ts
1399
+ "Global cross-session TODO list. " +
1400
+ "/todo / /todo all / /todo add <title> / /todo done <id> / /todo rm <id> / " +
1401
+ "/todo park <id> / /todo restore <id> / /todo prune [--all|--hard --box <b> --older-than <d>] / " +
1402
+ "/todo archive [project:X|text:Y] / /todo finished / /todo projects / /todo health / /todo clean / /todo path",
1403
+ ```
1404
+
1405
+ - [ ] **Step 6: Extend the slash `health` block to mirror the tool's projects section**
1406
+
1407
+ Apply the same `projLines` addition to the slash `if (sub === "health")` block (duplicate the construction; the slash builds its own `lines` array).
1408
+
1409
+ - [ ] **Step 7: Syntax-check**
1410
+
1411
+ Run: `node --check extensions/todo.ts`
1412
+ Expected: no output.
1413
+
1414
+ - [ ] **Step 8: Full suite green**
1415
+
1416
+ Run: `npm test`
1417
+ Expected: all green.
1418
+
1419
+ - [ ] **Step 9: Commit**
1420
+
1421
+ ```bash
1422
+ git add extensions/todo.ts
1423
+ git commit -m "feat(extension): projects + project_rename tool actions, /todo projects slash, health projects section"
1424
+ ```
1425
+
1426
+ ---
1427
+
1428
+ ## Task 9: README + AGENTS + version bump + autonomous QA gate + ship
1429
+
1430
+ **Files:**
1431
+ - Modify: `README.md` — v0.4.0 section + Known issues.
1432
+ - Modify: `AGENTS.md` (repo) — v0.4.0 row.
1433
+ - Modify: `package.json` — version `0.4.0`.
1434
+
1435
+ - [ ] **Step 1: README v0.4.0 section**
1436
+
1437
+ Add a `## v0.4.0 — Project-Scope Management` section after the v0.3.1 section, summarizing: project registry (`projects.json`), `projects` overview, per-project health flags (`PROJECT_OVER`/`PROJECT_TYPO`/`PROJECT_LARGE`/`PROJECT_STALE`), `maxOpen` advisory slot (enforcement in v0.5.0), interactive Rename/Set maxOpen/Filter in the `/todo` panel's 6th tab, `todo project_rename` tool action, `/todo projects` slash. Add to Known issues: "No caps enforcement yet (count/notes/injection) — lands in v0.5.0."
1438
+
1439
+ - [ ] **Step 2: Repo `AGENTS.md` version-table row**
1440
+
1441
+ Add a v0.4.0 row to the Common Commands / Notes table mirroring the v0.3.1 row, mentioning the new `projects.json` file in the structure block.
1442
+
1443
+ - [ ] **Step 3: Version bump**
1444
+
1445
+ In `package.json`, set `"version": "0.4.0"`.
1446
+
1447
+ - [ ] **Step 4: Final full suite**
1448
+
1449
+ Run: `npm test`
1450
+ Expected: all suites green (~300+ total).
1451
+
1452
+ - [ ] **Step 5: Commit**
1453
+
1454
+ ```bash
1455
+ git add README.md AGENTS.md package.json
1456
+ git commit -m "docs(v0.4.0): project-scope management + version bump"
1457
+ ```
1458
+
1459
+ - [ ] **Step 6: RECTOR QA gate (autonomous tmux harness, per v0.3.1 pattern)**
1460
+
1461
+ Manual/interactive gate. Using the tmux harness from the v0.3.1 session (isolated pi, temp `TODO_DIR`, `send-keys` + `capture-pane`), verify:
1462
+ 1. `/todo` opens the panel; 6 tabs present, `Projects` is the 5th.
1463
+ 2. Projects tab lists the overview rows + a `(no project)` summary row.
1464
+ 3. Selecting a project → action submenu shows Rename / Set maxOpen / Filter.
1465
+ 4. Rename (incl. merge onto an existing name) rewrites live + archive + registry; notify shows counts.
1466
+ 5. Set maxOpen (number + `clear`) persists; the row's `[max:N]` + `OVER` marker updates.
1467
+ 6. Filter active to project jumps to the active tab scoped to that project.
1468
+ 7. `/todo projects` slash prints the overview text.
1469
+ 8. `todo health` (tool + `/todo health` slash) shows the `projects:` section + per-project flags.
1470
+ 9. `todo project_rename` (tool) returns the rename result.
1471
+ 10. No `/todo project rename` slash exists (rename is panel-only).
1472
+ 11. Baseline regression: existing tabs (active/parked/done/archive/config) unchanged.
1473
+
1474
+ - [ ] **Step 7: Push + PR**
1475
+
1476
+ ```bash
1477
+ git push -u origin feat/project-scope-management
1478
+ gh pr create --title "v0.4.0: Project-Scope Management (Workstream C / Feature A)" --body "..." --base main
1479
+ ```
1480
+
1481
+ PR body: summary of the 13 locked decisions + a link to the spec + the QA-gate results. After RECTOR approval: `gh pr merge --merge --delete-branch`.
1482
+
1483
+ - [ ] **Step 8: Tag → CI auto-publish**
1484
+
1485
+ ```bash
1486
+ git checkout main && git pull
1487
+ git tag -am "v0.4.0 — project-scope management (registry + projects overview + per-project health + rename/merge)" v0.4.0
1488
+ git push origin v0.4.0
1489
+ ```
1490
+
1491
+ CI (`release.yml`) auto-publishes npm `@getpipher/armory-todo@0.4.0` + auto-creates the GitHub Release (the workflow step added in v0.3.0-post). Verify: `npm view @getpipher/armory-todo version` → `0.4.0`; GitHub Releases shows `v0.4.0`.
1492
+
1493
+ - [ ] **Step 9: Update `~/.pi/agent/settings.json` + record memory**
1494
+
1495
+ ```bash
1496
+ # pin the new version (force @0.4.0 to dodge the npm cache)
1497
+ pi install npm:@getpipher/armory-todo@0.4.0
1498
+ ```
1499
+
1500
+ Write `~/.pi/agent/memory/-Users-rector-local-dev-getpipher-armory-todo/v0.4.0-shipped.md` (gotchas + decisions), mirroring the v0.3.0/v0.3.1 memory files. Update the repo `AGENTS.md` test counts.
1501
+
1502
+ ---
1503
+
1504
+ ## Self-Review (run after writing, before handoff)
1505
+
1506
+ **1. Spec coverage** — every spec section maps to a task:
1507
+ - §3.1 `projects.json` → Task 1 (registry.ts). ✓
1508
+ - §3.2 `src/registry.ts` → Task 1. ✓
1509
+ - §3.3 `src/projects.ts` → Task 3. ✓
1510
+ - §3.4 `health.ts` per-project flags → Task 5. ✓
1511
+ - §3.5 `todo-store.ts` unchanged → no task (constraint). ✓
1512
+ - §3.6 tool actions + panel + slash → Tasks 7 + 8. ✓
1513
+ - §3.7 `config.ts` `perProjectDefaultMax` → Task 4. ✓
1514
+ - §3.8 injection unchanged → constraint. ✓
1515
+ - §4 data flow → Tasks 1/3/5/7/8. ✓
1516
+ - §5 edge cases → covered in registry/projects tests (Task 1/3). ✓
1517
+ - §6 testing → Tasks 1/3/4/5/6. ✓
1518
+ - §7 plan → this plan. ✓
1519
+ - §8 out of scope → enforced by *not* touching `renderOpenBlock`/`addTodo`. ✓
1520
+
1521
+ **2. Placeholder scan** — no TBD/TODO/“implement later”; every code step shows real code. The single explicit skip (PROJECT_STALE clock-mock) is flagged with rationale, not hidden.
1522
+
1523
+ **3. Type consistency** — `projectsOverview` (Task 3) consumed by panel (Task 7) + extension (Task 8); `renameProject` return `{ liveRenamed, archivedRenamed, merged, newName }` consistent across registry (Task 1) + extension (Task 8) + panel notify (Task 7); `HealthReport.projects`/`noProject` consistent across health (Task 5) + extension health text (Task 8); `actionsForProject()` returns `rename`/`setmax`/`filter` action ids consistent across panel-data (Task 6) + panel dispatch (Task 7).
1524
+
1525
+ ---
1526
+
1527
+ ## Execution Handoff
1528
+
1529
+ **Plan complete and saved to `docs/superpowers/plans/2026-07-21-project-scope-management.md`. Two execution options:**
1530
+
1531
+ **1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration.
1532
+
1533
+ **2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints.
1534
+
1535
+ **Which approach?**
1536
+
1537
+ > **Note:** pi has no native Task/sub-agent tool (see `~/.pi/agent/AGENTS.md` pi addendum). The in-house `pi-subagents` is a parked TODO (`td-mru4wn19qfj946`). So in pi, the realistic option is **Inline Execution** (executing-plans, batched with checkpoints) — "Subagent-Driven" would require installing the third-party `nicobailon/pi-subagents`, which RECTOR previously declined on trust-surface grounds. Confirm the host before choosing.