@getpipher/armory-todo 0.2.0 → 0.3.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,1586 @@
1
+ # Workstream B — title + notes schema split (v0.3.0) Implementation Plan
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:** Split the single `text` field into `title` (≤120 chars, injected) + `notes` (any length, not injected), with a v2→v3 migration, a new `get` action, and a hard title cap — so the 1.8KB ZeroClaw blob stops being injected verbatim every turn.
6
+
7
+ **Architecture:** Schema break in the pure store layer (`src/todo-store.ts`) — `Todo` gains `title`+`notes`, drops `text`; `Store.version` → 3. `loadStore` runs a v2→v3 migration (curated for 2 known ids + first-line fallback) and persists once. The extension tool gains a `get` action and rewrites `add`/`update` to use `title`/`notes`; `list` searches title+notes and shows a `•` notes-indicator. Auto-injection renders `title` only. The panel list shows `title`; a new read-only detail view shows `notes`; inline Edit edits `title` only. Health gains a notes-bytes diagnostic.
8
+
9
+ **Tech Stack:** TypeScript (raw `.ts`, no build step, run via tsx at pi runtime), node:test-style custom harness (ok/eq with temp `TODO_DIR`), typebox for tool schemas, `@earendil-works/pi-tui` for the panel. Zero runtime deps (node:fs only).
10
+
11
+ ## Global Constraints
12
+
13
+ - **Zero runtime deps** (node:fs only). 2-space indent. No TODO/FIXME. No AI attribution in commits.
14
+ - **`TITLE_MAX = 120`** chars — hard reject at `add`/`update` (not a soft truncate). Defined in `src/todo-store.ts` (normalizeTitle) and `src/migrate.ts` (splitTextFallback) — keep both in sync (commented).
15
+ - **Store version: 3.** v2→v3 migration runs in `loadStore` when `parsed.version === 2`; persists once via `saveStore`. The v1→v2 *file-move* migration (`migrateIfNeeded`, guarded to default `TODO_DIR`) stays as-is. The v2→v3 *schema* migration needs **no env guard** (only touches the live path, temp in tests).
16
+ - **Curated migration** for ids `td-mrt3zp9fcnug3p` + `td-mrt4e1qi9td6jz` (hand-written title+notes, verbatim from the spec §6.3). Fallback for any other v2 todo: first line → title (capped 120, word-boundary, full original first line preserved into notes if truncated), remainder → notes.
17
+ - **No `text` field on `Todo`** after Task 1. The `list`/archive `text` *filter param* stays (means "search query", matches title OR notes).
18
+ - **No append-only `log`** (YAGNI). **No notes caps** (Workstream C). **No in-panel multi-line notes edit** (pi-tui nested-UI blocker — tracked as known deferred issue).
19
+ - **Tests:** baseline 151 across 7 suites → target ~175+. New suite `test/todo-title-notes.test.mts`. Extend the 6 existing suites. Run via `npm test` (loops `node test/<suite>.test.mts`). Syntax-check extension/panel with `node --check`.
20
+ - **Branch:** `feat/title-notes-split` off `main`. Commits: `feat(scope): ...` per task. PR → `--merge --delete-branch`. No GitLab mirror (getpipher). Tag `v0.3.0` after RECTOR QA.
21
+
22
+ **Spec:** `docs/superpowers/specs/2026-07-21-title-notes-split-design.md`
23
+
24
+ ---
25
+
26
+ ## File Structure
27
+
28
+ | File | Responsibility | Task |
29
+ |---|---|---|
30
+ | `src/migrate.ts` | Add `splitTextFallback` (Task 1); add `CURATED_V2_TO_V3` + `migrateV2ToV3` (Task 2). Keep `migrateIfNeeded`. | 1, 2 |
31
+ | `src/todo-store.ts` | Schema break: `Todo`+`title`+`notes` (drop `text`), `Store.version:3`, `TITLE_MAX`+`normalizeTitle`, `addTodo`/`updateTodo`/`getTodo`, `listTodos` filter (title\|notes), `renderOpenBlock` (title+dot), `loadStore` v2→v3 wiring. | 1, 2 |
32
+ | `src/archive.ts` | `ArchiveStore.version:3`; `loadArchive` runs v2→v3 via `migrateV2ToV3`; `listArchived` text filter → title\|notes. | 3 |
33
+ | `extensions/todo.ts` | `ACTIONS`+`get`; input schema (`title`,`notes`,`text`→search); `fmt` (title+dot); add/update/get/complete/delete/park/restore/clear/prune output → title; prompt guidelines rewrite; `/todo add <title>`. | 4 |
34
+ | `src/panel-data.ts` | `todoToItem` → title+dot, delete truncation; `actionsForTodo` "Edit text"→"Edit title". | 5 |
35
+ | `src/panel.ts` | Detail view (read-only title+notes); Edit = title only. | 5 |
36
+ | `src/health.ts` | `notesBytes` in report (total/max/avg, active+parked). | 6 |
37
+ | `test/todo-title-notes.test.mts` | NEW — cap, add/update/get, fallback, list filter, renderOpenBlock. | 1 |
38
+ | `test/todo-store.test.mts` | Update `text`→`title` calls; add reference cap cases. | 1 |
39
+ | `test/todo-migrate.test.mts` | v2→v3 curated + fallback + persist-once + works under TODO_DIR. | 2 |
40
+ | `test/todo-archive.test.mts` | listArchived title+dot; v2 archive → v3 on load. | 3 |
41
+ | `test/panel-data.test.mts` | todoToItem title+dot; Todo literals → title/notes. | 5 |
42
+ | `test/todo-health.test.mts` | notesBytes line; active+parked only. | 6 |
43
+ | `test/todo-config.test.mts`, `test/todo-hard-prune.test.mts` | Update `addTodo({text})`→`{title}` call sites + Todo literals. | 1 |
44
+ | `README.md`, `AGENTS.md`, `package.json` | Schema docs, known issues, version 0.3.0. | 7 |
45
+
46
+ ---
47
+
48
+ ## Task 1: Store schema break + core CRUD + inline v2→v3 derivation
49
+
50
+ **Files:**
51
+ - Modify: `src/todo-store.ts` (full rewrite of `Todo`/`Store`/`AddInput`/`UpdateInput`/`addTodo`/`updateTodo`/`listTodos`/`renderOpenBlock`/`loadStore`/`emptyStore`; add `getTodo`/`normalizeTitle`/`TITLE_MAX`)
52
+ - Modify: `src/migrate.ts` (add `splitTextFallback` + `truncateWordBoundary`)
53
+ - Create: `test/todo-title-notes.test.mts`
54
+ - Modify: `test/todo-store.test.mts`, `test/todo-archive.test.mts`, `test/todo-config.test.mts`, `test/todo-hard-prune.test.mts`, `test/todo-health.test.mts`, `test/panel-data.test.mts` (update `addTodo({text})`→`{title}` + Todo literals `text`→`title`/`notes`)
55
+
56
+ **Interfaces:**
57
+ - Consumes: `splitTextFallback(text: string): { title: string; notes: string }` from `src/migrate.ts` (new in this task).
58
+ - Produces: `Todo { title, notes }`, `Store { version: 3 }`, `AddInput { title, notes? }`, `UpdateInput { title?, notes? }`, `getTodo(id)`, `TITLE_MAX = 120`. Later tasks rely on these exact names.
59
+
60
+ - [ ] **Step 1: Add `splitTextFallback` to `src/migrate.ts`**
61
+
62
+ Append after the existing `migrateIfNeeded` function (keep `migrateIfNeeded` unchanged):
63
+
64
+ ```ts
65
+ // v2 → v3 schema migration helpers. splitTextFallback is used by loadStore's
66
+ // inline derivation (Task 1) and by migrateV2ToV3 (Task 2, with the curated
67
+ // map). TITLE_MAX here must match the constant in todo-store.ts.
68
+ const TITLE_MAX = 120;
69
+
70
+ /** Truncate at the last word boundary ≤ TITLE_MAX (hard cut if none). No "…"
71
+ * suffix — the cap is a hard rule, not a display truncation. */
72
+ function truncateWordBoundary(s: string): string {
73
+ if (s.length <= TITLE_MAX) return s;
74
+ const slice = s.slice(0, TITLE_MAX);
75
+ const sp = slice.lastIndexOf(" ");
76
+ return sp > 0 ? slice.slice(0, sp) : slice;
77
+ }
78
+
79
+ /** Derive { title, notes } from a v2 `text` string (the fallback for any v2
80
+ * todo not in the curated map). Deterministic + idempotent. */
81
+ export function splitTextFallback(text: string): { title: string; notes: string } {
82
+ const raw = (text ?? "").trim();
83
+ if (!raw) return { title: "(untitled)", notes: "" };
84
+ const nl = raw.indexOf("\n");
85
+ if (nl < 0) {
86
+ if (raw.length <= TITLE_MAX) return { title: raw, notes: "" };
87
+ return { title: truncateWordBoundary(raw), notes: raw };
88
+ }
89
+ const firstLine = raw.slice(0, nl).trim();
90
+ const rest = raw.slice(nl + 1).trim();
91
+ if (firstLine.length <= TITLE_MAX) return { title: firstLine, notes: rest };
92
+ return { title: truncateWordBoundary(firstLine), notes: `${firstLine}\n${rest}` };
93
+ }
94
+ ```
95
+
96
+ - [ ] **Step 2: Verify it parses**
97
+
98
+ Run: `node --check src/migrate.ts`
99
+ Expected: no output (success).
100
+
101
+ - [ ] **Step 3: Rewrite the schema + core CRUD in `src/todo-store.ts`**
102
+
103
+ Replace the `Todo` interface:
104
+
105
+ ```ts
106
+ export interface Todo {
107
+ id: string;
108
+ title: string; // ≤120 chars, non-empty, trimmed
109
+ notes: string; // any length, may be ""
110
+ project: string;
111
+ tags: string[];
112
+ priority: Priority;
113
+ status: Status;
114
+ source: string;
115
+ createdAt: string;
116
+ updatedAt: string;
117
+ closedAt: string | null;
118
+ }
119
+ ```
120
+
121
+ Replace `Store` + `emptyStore`:
122
+
123
+ ```ts
124
+ export interface Store {
125
+ version: 3;
126
+ updatedAt: string;
127
+ todos: Todo[];
128
+ }
129
+
130
+ function emptyStore(): Store {
131
+ return { version: 3, updatedAt: now(), todos: [] };
132
+ }
133
+ ```
134
+
135
+ Replace `AddInput` + `UpdateInput`:
136
+
137
+ ```ts
138
+ export interface AddInput {
139
+ title: string;
140
+ notes?: string;
141
+ project?: string;
142
+ tags?: string[];
143
+ priority?: Priority;
144
+ source?: string;
145
+ }
146
+
147
+ export interface UpdateInput {
148
+ title?: string;
149
+ notes?: string;
150
+ project?: string;
151
+ tags?: string[];
152
+ priority?: Priority;
153
+ status?: Status;
154
+ }
155
+ ```
156
+
157
+ Add `TITLE_MAX` + `normalizeTitle` near the other helpers (after `genId`):
158
+
159
+ ```ts
160
+ const TITLE_MAX = 120; // must match the constant in migrate.ts
161
+
162
+ function normalizeTitle(raw: string): string {
163
+ const t = raw.trim();
164
+ if (!t) throw new TodoError("title is required");
165
+ if (t.length > TITLE_MAX) {
166
+ throw new TodoError(`title must be ≤${TITLE_MAX} chars (got ${t.length}); move detail into notes`);
167
+ }
168
+ return t;
169
+ }
170
+ ```
171
+
172
+ Update the `import` from `./migrate.ts` to also pull `splitTextFallback`:
173
+
174
+ ```ts
175
+ import { migrateIfNeeded, splitTextFallback } from "./migrate.ts";
176
+ ```
177
+
178
+ Replace `loadStore`'s version-handling block. The current block is:
179
+
180
+ ```ts
181
+ if (parsed.version !== 2) {
182
+ // v1 → v2: accept it (the migration moved the file), just bump the version in memory.
183
+ // The data shape is otherwise identical; parked status is new but old todos won't have it.
184
+ parsed.version = 2;
185
+ }
186
+ return parsed;
187
+ ```
188
+
189
+ Replace with:
190
+
191
+ ```ts
192
+ if (parsed.version === 2) {
193
+ // v2 → v3: derive title/notes from each todo's text (inline fallback).
194
+ // Task 2 replaces this with migrateV2ToV3 (curated map + persist-once).
195
+ parsed = {
196
+ version: 3,
197
+ updatedAt: parsed.updatedAt,
198
+ todos: parsed.todos.map((t: any) => {
199
+ const { title, notes } = splitTextFallback(t.text ?? "");
200
+ const { text: _drop, ...rest } = t;
201
+ return { ...rest, title, notes } as Todo;
202
+ }),
203
+ };
204
+ } else if (parsed.version !== 3) {
205
+ throw new Error("invalid store shape");
206
+ }
207
+ return parsed;
208
+ ```
209
+
210
+ Replace `addTodo`:
211
+
212
+ ```ts
213
+ export function addTodo(input: AddInput): Todo {
214
+ const title = normalizeTitle(input.title);
215
+ if (input.priority) assertPriority(input.priority);
216
+ const notes = (input.notes ?? "").trim();
217
+ const store = loadStore();
218
+ const todo: Todo = {
219
+ id: genId(),
220
+ title,
221
+ notes,
222
+ project: (input.project ?? "").trim(),
223
+ tags: (input.tags ?? []).map((t) => t.trim()).filter(Boolean),
224
+ priority: input.priority ?? "med",
225
+ status: "open",
226
+ source: (input.source ?? "").trim(),
227
+ createdAt: now(),
228
+ updatedAt: now(),
229
+ closedAt: null,
230
+ };
231
+ store.todos.push(todo);
232
+ saveStore(store);
233
+ return todo;
234
+ }
235
+ ```
236
+
237
+ Replace the `text`-handling block inside `updateTodo`:
238
+
239
+ ```ts
240
+ if (patch.title !== undefined) todo.title = normalizeTitle(patch.title);
241
+ if (patch.notes !== undefined) todo.notes = patch.notes.trim();
242
+ ```
243
+
244
+ (Remove the old `if (patch.text !== undefined) { ... }` block.)
245
+
246
+ Add `getTodo` after `updateTodo`:
247
+
248
+ ```ts
249
+ export function getTodo(id: string): Todo {
250
+ const store = loadStore();
251
+ return findOrFail(store, id);
252
+ }
253
+ ```
254
+
255
+ Replace the `text` filter inside `listTodos`:
256
+
257
+ ```ts
258
+ if (filter.text) {
259
+ const q = filter.text.toLowerCase();
260
+ out = out.filter((t) => t.title.toLowerCase().includes(q) || t.notes.toLowerCase().includes(q));
261
+ }
262
+ ```
263
+
264
+ Replace `renderOpenBlock`:
265
+
266
+ ```ts
267
+ export function renderOpenBlock(max = 15): string {
268
+ const todos = listTodos(); // actionable set, sorted
269
+ if (todos.length === 0) return "## Open TODOs\n(none — no pending cross-session TODOs)\n";
270
+ const shown = todos.slice(0, max);
271
+ const lines = shown.map((t) => {
272
+ const tag = t.project ? ` (${t.project})` : "";
273
+ const pin = t.status === "in_progress" ? " ⏵" : "";
274
+ const dot = t.notes.trim() ? " •" : "";
275
+ return `- [${t.id}] (${t.priority})${pin}${dot} ${t.title}${tag}`;
276
+ });
277
+ const overflow = todos.length > max ? `\n- … +${todos.length - max} more (use \`todo list\`)` : "";
278
+ return `## Open TODOs (${todos.length})\n${lines.join("\n")}${overflow}\n`;
279
+ }
280
+ ```
281
+
282
+ - [ ] **Step 4: Verify it parses**
283
+
284
+ Run: `node --check src/todo-store.ts`
285
+ Expected: no output (success).
286
+
287
+ - [ ] **Step 5: Write the failing tests in `test/todo-title-notes.test.mts`**
288
+
289
+ ```ts
290
+ // Suite for the title + notes schema split (Workstream B).
291
+ // Run: node test/todo-title-notes.test.mts
292
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
293
+ import { tmpdir } from "node:os";
294
+ import { join } from "node:path";
295
+
296
+ const tmp = mkdtempSync(join(tmpdir(), "armory-tn-"));
297
+ process.env.TODO_DIR = tmp;
298
+
299
+ let passed = 0;
300
+ let failed = 0;
301
+ function ok(name: string, cond: boolean, extra = ""): void {
302
+ if (cond) { passed++; } else { failed++; console.error(` ✗ ${name} ${extra}`); }
303
+ }
304
+ function eq<T>(name: string, got: T, want: T): void {
305
+ ok(name, got === want, `(got ${JSON.stringify(got)} want ${JSON.stringify(want)})`);
306
+ }
307
+
308
+ const { addTodo, updateTodo, getTodo, listTodos, renderOpenBlock, loadStore } =
309
+ await import("../src/todo-store.ts");
310
+ const { splitTextFallback } = await import("../src/migrate.ts");
311
+
312
+ // --- add: title only, notes defaults to "" ---
313
+ const t1 = addTodo({ title: "Write SPEC-2", project: "pi", priority: "high", source: "test" });
314
+ eq("add title set", t1.title, "Write SPEC-2");
315
+ eq("add notes defaults empty", t1.notes, "");
316
+ eq("add status open", t1.status, "open");
317
+
318
+ // --- add: title + notes ---
319
+ const t2 = addTodo({ title: "Ship v0.3.0", notes: "Migration first, then panel, then health." });
320
+ eq("add notes set", t2.notes, "Migration first, then panel, then health.");
321
+
322
+ // --- add: trims title before length check ---
323
+ const t3 = addTodo({ title: " trimmed title " });
324
+ eq("add trims title", t3.title, "trimmed title");
325
+
326
+ // --- add: rejects empty title ---
327
+ let threw = false;
328
+ try { addTodo({ title: " " } as any); } catch { threw = true; }
329
+ ok("add rejects blank title", threw);
330
+
331
+ // --- add: rejects title > 120 ---
332
+ threw = false;
333
+ try { addTodo({ title: "x".repeat(121) }); } catch { threw = true; }
334
+ ok("add rejects 121-char title", threw);
335
+ const ok120 = addTodo({ title: "y".repeat(120) });
336
+ eq("add accepts exactly 120 chars", ok120.title.length, 120);
337
+
338
+ // --- update: title + notes ---
339
+ updateTodo(t1.id, { title: "Write SPEC-2 + SPEC-3", notes: "Block Tuesday for it." });
340
+ const t1b = getTodo(t1.id);
341
+ eq("update title", t1b.title, "Write SPEC-2 + SPEC-3");
342
+ eq("update notes", t1b.notes, "Block Tuesday for it.");
343
+
344
+ // --- update: notes="" clears ---
345
+ updateTodo(t1.id, { notes: "" });
346
+ eq("update notes empty clears", getTodo(t1.id).notes, "");
347
+
348
+ // --- update: rejects title > 120 ---
349
+ threw = false;
350
+ try { updateTodo(t1.id, { title: "z".repeat(121) }); } catch { threw = true; }
351
+ ok("update rejects 121-char title", threw);
352
+
353
+ // --- get: missing id throws ---
354
+ threw = false;
355
+ try { getTodo("td-nonexistent"); } catch { threw = true; }
356
+ ok("get missing id throws", threw);
357
+
358
+ // --- list: text filter matches title OR notes ---
359
+ addTodo({ title: "unrelated title", notes: "special-token-xyz" });
360
+ addTodo({ title: "findme-abc title", notes: "" });
361
+ const byNotes = listTodos({ text: "special-token-xyz" });
362
+ ok("list text filter matches notes", byNotes.some((t) => t.notes.includes("special-token-xyz")));
363
+ const byTitle = listTodos({ text: "findme-abc" });
364
+ ok("list text filter matches title", byTitle.some((t) => t.title.includes("findme-abc")));
365
+
366
+ // --- renderOpenBlock: title only, never notes; dot when notes present ---
367
+ const block = renderOpenBlock();
368
+ ok("renderOpenBlock includes a title", block.includes("findme-abc"));
369
+ ok("renderOpenBlock never includes notes content", !block.includes("special-token-xyz"));
370
+ ok("renderOpenBlock has dot for notes-bearing todo", block.includes("•"));
371
+
372
+ // --- v2→v3 inline derivation on load (fallback, no curated map yet) ---
373
+ {
374
+ const dir2 = mkdtempSync(join(tmpdir(), "armory-tn-v2-"));
375
+ const file = join(dir2, "todo.json");
376
+ writeFileSync(file, JSON.stringify({
377
+ version: 2,
378
+ updatedAt: "2026-07-20T10:00:00Z",
379
+ todos: [{
380
+ id: "td-v2-1", text: "First line is the title\nbody detail here",
381
+ project: "", tags: [], priority: "med", status: "open", source: "",
382
+ createdAt: "2026-07-20T10:00:00Z", updatedAt: "2026-07-20T10:00:00Z", closedAt: null,
383
+ }],
384
+ }), "utf8");
385
+ process.env.TODO_DIR = dir2;
386
+ const store = loadStore();
387
+ eq("v2→v3 inline: version 3", store.version, 3);
388
+ eq("v2→v3 inline: title from first line", store.todos[0]!.title, "First line is the title");
389
+ eq("v2→v3 inline: notes from remainder", store.todos[0]!.notes, "body detail here");
390
+ ok("v2→v3 inline: no text field on todo", !("text" in store.todos[0]!));
391
+ process.env.TODO_DIR = tmp;
392
+ rmSync(dir2, { recursive: true, force: true });
393
+ }
394
+
395
+ // --- splitTextFallback unit cases ---
396
+ const s1 = splitTextFallback("one liner");
397
+ eq("split: single line ≤120 → title=whole, notes=''", s1.title, "one liner");
398
+ eq("split: single line notes empty", s1.notes, "");
399
+ const s2 = splitTextFallback("first\nsecond\nthird");
400
+ eq("split: multiline title=first line", s2.title, "first");
401
+ eq("split: multiline notes=rest joined", s2.notes, "second\nthird");
402
+ const long = "w".repeat(200);
403
+ const s3 = splitTextFallback(long);
404
+ ok("split: overlong single-line title ≤120", s3.title.length <= 120);
405
+ eq("split: overlong single-line notes=full original", s3.notes, long);
406
+ const s4 = splitTextFallback("first line is way too long " + "x".repeat(200) + "\nrest");
407
+ ok("split: overlong first-line title ≤120", s4.title.length <= 120);
408
+ ok("split: overlong first-line notes starts with full first line", s4.notes.startsWith("first line is way too long "));
409
+ ok("split: overlong first-line notes includes rest", s4.notes.endsWith("rest"));
410
+
411
+ console.log(`\n${passed} passed, ${failed} failed`);
412
+ if (failed > 0) process.exit(1);
413
+ ```
414
+
415
+ - [ ] **Step 6: Run the new suite to verify it fails**
416
+
417
+ Run: `node test/todo-title-notes.test.mts`
418
+ Expected: FAIL (the import + behaviors are new; some assertions may pass if the impl from Step 3 is in place — but run to confirm at least the suite executes). If the impl from Step 3 is already applied, this should PASS. If run before Step 3, FAIL with import/type errors.
419
+
420
+ - [ ] **Step 7: Update `test/todo-store.test.mts` — replace all `addTodo({ text: ... })` with `addTodo({ title: ... })`**
421
+
422
+ Every `addTodo({ text: "X", ... })` call becomes `addTodo({ title: "X", ... })`. Every assertion reading `.text` becomes `.title`. Specific replacements (search for `text:` in addTodo calls and `.text` in assertions):
423
+
424
+ - `addTodo({ text: "decouple AGENTS.md", ... })` → `addTodo({ title: "decouple AGENTS.md", ... })`
425
+ - `addTodo({ text: "research browser-use", ... })` → `addTodo({ title: "research browser-use", ... })`
426
+ - `addTodo({ text: "low prio task", ... })` → `addTodo({ title: "low prio task", ... })`
427
+ - `addTodo({ text: "sip thing", ... })` → `addTodo({ title: "sip thing", ... })`
428
+ - Any `t.text`/`order[i].text` assertion → `t.title`/`order[i].title`.
429
+
430
+ Also add two reference cap cases at the end (before the final console.log):
431
+
432
+ ```ts
433
+ // --- title cap (reference; full cases in todo-title-notes.test.mts) ---
434
+ let capThrew = false;
435
+ try { addTodo({ title: "a".repeat(121) }); } catch { capThrew = true; }
436
+ ok("add rejects >120 title (reference)", capThrew);
437
+ const capOk = addTodo({ title: "b".repeat(120) });
438
+ eq("add accepts exactly 120 (reference)", capOk.title.length, 120);
439
+ ```
440
+
441
+ - [ ] **Step 8: Update the remaining test suites' `addTodo` calls + Todo literals**
442
+
443
+ For each of `test/todo-archive.test.mts`, `test/todo-config.test.mts`, `test/todo-hard-prune.test.mts`, `test/todo-health.test.mts`, `test/panel-data.test.mts`:
444
+
445
+ - Replace every `addTodo({ text: "X", ... })` with `addTodo({ title: "X", ... })`.
446
+ - Replace every Todo object literal that has `text:` (e.g. in panel-data tests constructing a `Todo` directly) with `title:` + `notes:` (add `notes: ""` if the literal had no notes). Example panel-data literal:
447
+ ```ts
448
+ // before
449
+ { id: "td-1", text: "some todo", project: "pi", tags: [], priority: "med", status: "open", source: "", createdAt: "x", updatedAt: "x", closedAt: null }
450
+ // after
451
+ { id: "td-1", title: "some todo", notes: "", project: "pi", tags: [], priority: "med", status: "open", source: "", createdAt: "x", updatedAt: "x", closedAt: null }
452
+ ```
453
+ - Replace assertions reading `.text` → `.title` (e.g. `t.text` in archive/hard-prune tests).
454
+ - For `test/panel-data.test.mts`: the existing `todoToItem` assertions check the label contains the text — update to check `title`. (The truncation behavior changes in Task 5; for now just ensure the label contains `title`.)
455
+
456
+ Run each to confirm:
457
+ ```bash
458
+ node test/todo-archive.test.mts
459
+ node test/todo-config.test.mts
460
+ node test/todo-hard-prune.test.mts
461
+ node test/todo-health.test.mts
462
+ node test/panel-data.test.mts
463
+ ```
464
+ Expected: all PASS.
465
+
466
+ - [ ] **Step 9: Run the full suite**
467
+
468
+ Run: `npm test`
469
+ Expected: all 8 suites PASS (baseline 151 + new todo-title-notes cases; the count rises). If any suite fails, fix the missed call site before committing.
470
+
471
+ - [ ] **Step 10: Commit**
472
+
473
+ ```bash
474
+ git add src/todo-store.ts src/migrate.ts test/todo-title-notes.test.mts test/todo-store.test.mts test/todo-archive.test.mts test/todo-config.test.mts test/todo-hard-prune.test.mts test/todo-health.test.mts test/panel-data.test.mts
475
+ git commit -m "feat(store): title + notes schema split, v3 store, getTodo, title cap
476
+
477
+ Todo gains title (≤120, required) + notes (any length); text field removed.
478
+ Store.version → 3. addTodo/updateTodo/getTodo use the new fields; listTodos
479
+ text filter matches title|notes; renderOpenBlock injects title only (+ •
480
+ when notes present). loadStore derives title/notes inline from v2 text
481
+ (fallback); the curated map + persist-once land in Task 2. TITLE_MAX=120
482
+ hard-rejects at the write boundary.
483
+
484
+ New suite todo-title-notes.test.mts; all existing addTodo({text}) call sites
485
+ updated to {title}. Baseline 151 → grows by the new suite."
486
+ ```
487
+
488
+ ---
489
+
490
+ ## Task 2: v2→v3 migration module — curated map + persist-once
491
+
492
+ **Files:**
493
+ - Modify: `src/migrate.ts` (add `CURATED_V2_TO_V3` + `migrateV2ToV3`)
494
+ - Modify: `src/todo-store.ts` (replace the inline derivation in `loadStore` with `migrateV2ToV3` + `saveStore`)
495
+ - Modify: `test/todo-migrate.test.mts` (add v2→v3 cases)
496
+
497
+ **Interfaces:**
498
+ - Consumes: `Todo`, `Store` from `src/todo-store.ts`; `splitTextFallback` (Task 1).
499
+ - Produces: `migrateV2ToV3(store): Store` exported from `src/migrate.ts`. `loadStore` now persists the v3 store on first v2 load.
500
+
501
+ - [ ] **Step 1: Add `CURATED_V2_TO_V3` + `migrateV2ToV3` to `src/migrate.ts`**
502
+
503
+ Append after `splitTextFallback`:
504
+
505
+ ```ts
506
+ // Hand-curated title + notes for the 2 todos known at v2→v3 migration time
507
+ // (the only survivors of the v0.2.0 incident). Any other v2 todo uses
508
+ // splitTextFallback. Curated notes are reformatted for clarity, not a
509
+ // mechanical split.
510
+ const CURATED_V2_TO_V3: Record<string, { title: string; notes: string }> = {
511
+ "td-mrt3zp9fcnug3p": {
512
+ title: "ZeroClaw×Solana bounty — Phase 4-5: demo video (score bottleneck, unstarted)",
513
+ notes: `superteam.fun/earn/listing/zeroclaw · Superteam Brasil · 5,000 USDG pool / 1st=1,800 · winner Aug 21 2026 · TARGET #1.
514
+
515
+ PHASE 0-2 DONE ✅. PHASE 3 (RESEARCH+SPEC+PLAN + impl alerts+custody+docs) DONE ✅ — slices A-F+H, 45 tests, committed 8fd7483→80614c8, PUSHED, PR #76 retitled "Palinurus — depin-attest + depin-rewards", 17 commits.
516
+
517
+ claim_tx (G) DEFERRED — Helium hotspots are cNFTs → claim needs distribute_compression_rewards_v0 + DAS get_asset_proof (merkle proof), multi-session; PDAs verified, design in README.
518
+
519
+ Decision (score-max): ship alerts core complete, pivot to DEMO track.
520
+
521
+ NEXT (★ Phase 4-5, the score bottleneck — submission REQUIRES a demo video, currently unstarted):
522
+ (1) ASYNC: RECTOR's free Relay Community key → real Helium fixtures + live smoke test;
523
+ (2) Phase 4: wiring SVG (docs/wiring-diagram.svg, dark-mode, NOT ASCII) + marketing site (palinurus.rectorspace.com, Next.js+Tailwind+shadcn) + demo recording guide;
524
+ (3) Phase 5: record demo ≤3min (real ZeroClaw+Telegram, terminal+phone) → ElevenLabs voiceover → ffmpeg → submit on Superteam Earn + engage #solana-bounty Discord.
525
+
526
+ Test totals: 184 (71 palinurus-core + 68 depin-attest + 45 depin-rewards), all clippy+wasm clean.
527
+ HANDOFF: ~/Documents/secret/strategy/zeroclaw-solana/session-handoff-2026-07-21.md
528
+ Docs: {RESEARCH-3,SPEC-3,PLAN-3}-depin-rewards.md (SPEC-3 §4 + PLAN-3 G corrected for cNFT)
529
+ Cwd: ~/local-dev/RECTOR-LABS/zeroclaw-plugins/plugins/depin-rewards
530
+ PR: https://github.com/zeroclaw-labs/zeroclaw-plugins/pull/76`,
531
+ },
532
+ "td-mrt4e1qi9td6jz": {
533
+ title: "armory-todo v0.2.0 — Workstream A shipped (lifecycle boxes + prune + health + TUI)",
534
+ notes: `ALL 3 SPECS DONE ✅. SPEC-1 (store: parked+prune+archive+restore, 12 tasks), SPEC-2 (health+hard-prune, 6 tasks), SPEC-3 (interactive /todo TUI panel, 4 tasks). 147/147 tests across 7 suites. 24 commits on feat/spec-1-lifecycle-boxes, PR #3 retitled to full v0.2.0 scope. Auto-publish CI (release.yml, org NPM_TOKEN).
535
+
536
+ INCIDENT (SPEC-1 Task 9): migration bug destroyed real 52KB/47-todo store (35 done + ~10 open lost, no backup). FIXED (c034509): migration guarded to only run when TODO_DIR is default. RECOVERED: 2 todos.
537
+
538
+ Shipped: merge PR #3 → tag v0.2.0 → CI auto-publish → npm:@getpipher/armory-todo@0.2.0.
539
+ Out of scope: B (title+notes split), C (preventive caps+project registry).`,
540
+ },
541
+ };
542
+
543
+ /** A v2 todo (has `text`, no `title`/`notes`). */
544
+ export interface V2Todo {
545
+ id: string;
546
+ text: string;
547
+ project: string;
548
+ tags: string[];
549
+ priority: string;
550
+ status: string;
551
+ source: string;
552
+ createdAt: string;
553
+ updatedAt: string;
554
+ closedAt: string | null;
555
+ }
556
+
557
+ /** v2 store shape (input to migrateV2ToV3). */
558
+ export interface V2Store {
559
+ version: 2;
560
+ updatedAt: string;
561
+ todos: V2Todo[];
562
+ }
563
+
564
+ /** Transform a v2 store into a v3 store: each todo gains title + notes
565
+ * (curated for the 2 known ids, splitTextFallback for the rest), drops text.
566
+ * Pure — does not touch disk. Deterministic + idempotent on v2 input. */
567
+ export function migrateV2ToV3(store: V2Store): { version: 3; updatedAt: string; todos: any[] } {
568
+ const todos = store.todos.map((t) => {
569
+ const curated = CURATED_V2_TO_V3[t.id];
570
+ if (curated) {
571
+ const { text: _drop, ...rest } = t;
572
+ return { ...rest, title: curated.title, notes: curated.notes };
573
+ }
574
+ const { title, notes } = splitTextFallback(t.text ?? "");
575
+ const { text: _drop, ...rest } = t;
576
+ return { ...rest, title, notes };
577
+ });
578
+ return { version: 3, updatedAt: store.updatedAt, todos };
579
+ }
580
+ ```
581
+
582
+ - [ ] **Step 2: Verify it parses**
583
+
584
+ Run: `node --check src/migrate.ts`
585
+ Expected: no output (success).
586
+
587
+ - [ ] **Step 3: Replace the inline derivation in `loadStore` with `migrateV2ToV3` + persist-once**
588
+
589
+ In `src/todo-store.ts`, update the import:
590
+
591
+ ```ts
592
+ import { migrateIfNeeded, migrateV2ToV3 } from "./migrate.ts";
593
+ ```
594
+
595
+ Replace the `if (parsed.version === 2) { ... } else if (parsed.version !== 3) { ... }` block in `loadStore` with:
596
+
597
+ ```ts
598
+ if (parsed.version === 2) {
599
+ // v2 → v3: curated map + fallback, persist once so migration runs a single time.
600
+ const migrated = migrateV2ToV3(parsed as any) as unknown as Store;
601
+ saveStore(migrated);
602
+ return migrated;
603
+ }
604
+ if (parsed.version !== 3) {
605
+ throw new Error("invalid store shape");
606
+ }
607
+ return parsed;
608
+ ```
609
+
610
+ Remove the now-unused `splitTextFallback` import from `src/todo-store.ts` (it moved into `migrateV2ToV3`):
611
+
612
+ ```ts
613
+ import { migrateIfNeeded, migrateV2ToV3 } from "./migrate.ts";
614
+ ```
615
+
616
+ - [ ] **Step 4: Verify it parses**
617
+
618
+ Run: `node --check src/todo-store.ts`
619
+ Expected: no output (success).
620
+
621
+ - [ ] **Step 5: Write the failing tests — append to `test/todo-migrate.test.mts`**
622
+
623
+ Add before the final `console.log`:
624
+
625
+ ```ts
626
+ // --- v2 → v3 migration (migrateV2ToV3) ---
627
+ const { migrateV2ToV3 } = await import("../src/migrate.ts");
628
+
629
+ // Case 4: curated — the 2 known ids get hand-written title + notes
630
+ {
631
+ const v2 = {
632
+ version: 2 as const,
633
+ updatedAt: "2026-07-20T17:51:46.838Z",
634
+ todos: [
635
+ { id: "td-mrt3zp9fcnug3p", text: "old junk-drawer blob", project: "bug-bounty", tags: [], priority: "critical", status: "open", source: "", createdAt: "x", updatedAt: "x", closedAt: null },
636
+ { id: "td-mrt4e1qi9td6jz", text: "old armory blob", project: "getpipher", tags: ["a"], priority: "high", status: "done", source: "", createdAt: "x", updatedAt: "x", closedAt: "x" },
637
+ ],
638
+ };
639
+ const v3 = migrateV2ToV3(v2);
640
+ ok("v2→v3: version 3", v3.version === 3);
641
+ ok("v2→v3: curated ZeroClaw title", v3.todos[0]!.title === "ZeroClaw×Solana bounty — Phase 4-5: demo video (score bottleneck, unstarted)");
642
+ ok("v2→v3: curated ZeroClaw notes start with listing", v3.todos[0]!.notes.startsWith("superteam.fun/earn/listing/zeroclaw"));
643
+ ok("v2→v3: curated armory title", v3.todos[1]!.title === "armory-todo v0.2.0 — Workstream A shipped (lifecycle boxes + prune + health + TUI)");
644
+ ok("v2→v3: curated armory notes mention incident", v3.todos[1]!.notes.includes("INCIDENT"));
645
+ ok("v2→v3: no text field on curated todos", !("text" in v3.todos[0]!) && !("text" in v3.todos[1]!));
646
+ ok("v2→v3: curated preserves project", v3.todos[0]!.project === "bug-bounty" && v3.todos[1]!.project === "getpipher");
647
+ }
648
+
649
+ // Case 5: fallback — single-line text
650
+ {
651
+ const v3 = migrateV2ToV3({ version: 2, updatedAt: "x", todos: [{ id: "td-a", text: "just a title", project: "", tags: [], priority: "med", status: "open", source: "", createdAt: "x", updatedAt: "x", closedAt: null }] });
652
+ ok("v2→v3 fallback single-line: title=whole", v3.todos[0]!.title === "just a title");
653
+ ok("v2→v3 fallback single-line: notes empty", v3.todos[0]!.notes === "");
654
+ }
655
+
656
+ // Case 6: fallback — multi-line text
657
+ {
658
+ const v3 = migrateV2ToV3({ version: 2, updatedAt: "x", todos: [{ id: "td-b", text: "the title\nline two\nline three", project: "", tags: [], priority: "med", status: "open", source: "", createdAt: "x", updatedAt: "x", closedAt: null }] });
659
+ ok("v2→v3 fallback multi-line: title=first line", v3.todos[0]!.title === "the title");
660
+ ok("v2→v3 fallback multi-line: notes=rest", v3.todos[0]!.notes === "line two\nline three");
661
+ }
662
+
663
+ // Case 7: fallback — first line > 120 (truncated title, full first line preserved in notes)
664
+ {
665
+ const longFirst = "w".repeat(200);
666
+ const v3 = migrateV2ToV3({ version: 2, updatedAt: "x", todos: [{ id: "td-c", text: `${longFirst}\nrest of it`, project: "", tags: [], priority: "med", status: "open", source: "", createdAt: "x", updatedAt: "x", closedAt: null }] });
667
+ ok("v2→v3 fallback overlong: title ≤120", v3.todos[0]!.title.length <= 120);
668
+ ok("v2→v3 fallback overlong: notes starts with full original first line", v3.todos[0]!.notes.startsWith(longFirst));
669
+ ok("v2→v3 fallback overlong: notes ends with rest", v3.todos[0]!.notes.endsWith("rest of it"));
670
+ }
671
+
672
+ // Case 8: persist-once — loadStore migrates a v2 file to v3 on disk
673
+ {
674
+ const dir = mkdtempSync(join(tmpdir(), "armory-mig-v3-"));
675
+ const file = join(dir, "todo.json");
676
+ writeFileSync(file, JSON.stringify({ version: 2, updatedAt: "2026-07-20T10:00:00Z", todos: [{ id: "td-p", text: "persist me\nbody", project: "", tags: [], priority: "med", status: "open", source: "", createdAt: "x", updatedAt: "x", closedAt: null }] }), "utf8");
677
+ process.env.TODO_DIR = dir;
678
+ const { loadStore } = await import("../src/todo-store.ts");
679
+ const store = loadStore();
680
+ ok("persist-once: in-memory version 3", store.version === 3);
681
+ ok("persist-once: title derived", store.todos[0]!.title === "persist me");
682
+ // re-read the file on disk → must now be version 3
683
+ const onDisk = JSON.parse(readFileSync(file, "utf8"));
684
+ ok("persist-once: disk version 3", onDisk.version === 3);
685
+ ok("persist-once: disk no text field", !("text" in onDisk.todos[0]));
686
+ ok("persist-once: second load is a no-op (version already 3)", loadStore().version === 3);
687
+ process.env.TODO_DIR = tmp;
688
+ rmSync(dir, { recursive: true, force: true });
689
+ }
690
+
691
+ // Case 9: v2→v3 works under TODO_DIR override (no env guard needed)
692
+ {
693
+ const dir = mkdtempSync(join(tmpdir(), "armory-mig-env-"));
694
+ process.env.TODO_DIR = dir;
695
+ const file = join(dir, "todo.json");
696
+ writeFileSync(file, JSON.stringify({ version: 2, updatedAt: "x", todos: [{ id: "td-e", text: "env override ok", project: "", tags: [], priority: "med", status: "open", source: "", createdAt: "x", updatedAt: "x", closedAt: null }] }), "utf8");
697
+ const { loadStore } = await import("../src/todo-store.ts");
698
+ const store = loadStore();
699
+ ok("v2→v3 under TODO_DIR override: migrates", store.version === 3 && store.todos[0]!.title === "env override ok");
700
+ process.env.TODO_DIR = tmp;
701
+ rmSync(dir, { recursive: true, force: true });
702
+ }
703
+ ```
704
+
705
+ Note: in Case 4, the curated armory todo's project is `"getpipher"` — fix the assertion `v3.todos[1]!.project === "getpither"` to `"getpipher"` (the v2 input uses `"getpipher"`). Make sure the assertion matches the input.
706
+
707
+ - [ ] **Step 6: Run the migrate suite**
708
+
709
+ Run: `node test/todo-migrate.test.mts`
710
+ Expected: PASS (all cases including the new 4–9).
711
+
712
+ - [ ] **Step 7: Run the full suite**
713
+
714
+ Run: `npm test`
715
+ Expected: all 8 suites PASS.
716
+
717
+ - [ ] **Step 8: Commit**
718
+
719
+ ```bash
720
+ git add src/migrate.ts src/todo-store.ts test/todo-migrate.test.mts
721
+ git commit -m "feat(migrate): v2→v3 curated map + persist-once
722
+
723
+ migrateV2ToV3 in migrate.ts: hand-curated title+notes for the 2 known ids
724
+ (td-mrt3zp9fcnug3p ZeroClaw, td-mrt4e1qi9td6jz armory v0.2.0); splitTextFallback
725
+ for any other v2 todo. loadStore now calls migrateV2ToV3 + saveStore on a v2
726
+ store so the migration runs exactly once (disk becomes v3). No env guard
727
+ needed (only touches the live path, temp in tests) — the v1→v2 file-move
728
+ guard stays as-is. Extends todo-migrate.test.mts with curated + fallback +
729
+ persist-once + TODO_DIR-override cases."
730
+ ```
731
+
732
+ ---
733
+
734
+ ## Task 3: Archive v3 + listArchived filter
735
+
736
+ **Files:**
737
+ - Modify: `src/archive.ts` (`ArchiveStore.version: 3`; `loadArchive` runs `migrateV2ToV3` on a v2 archive; `listArchived` text filter → title|notes)
738
+ - Modify: `test/todo-archive.test.mts` (listArchived title+dot is extension-level — here test the filter + v2 archive migration)
739
+
740
+ **Interfaces:**
741
+ - Consumes: `migrateV2ToV3` from `src/migrate.ts`; `Todo` from `src/todo-store.ts`.
742
+ - Produces: `ArchiveStore { version: 3 }`; `loadArchive` migrates v2 archives on load.
743
+
744
+ - [ ] **Step 1: Update `src/archive.ts`**
745
+
746
+ Change `ArchiveStore` version:
747
+
748
+ ```ts
749
+ export interface ArchiveStore {
750
+ version: 3;
751
+ updatedAt: string;
752
+ todos: Todo[];
753
+ }
754
+ ```
755
+
756
+ Change `emptyArchive`:
757
+
758
+ ```ts
759
+ function emptyArchive(): ArchiveStore {
760
+ return { version: 3, updatedAt: now(), todos: [] };
761
+ }
762
+ ```
763
+
764
+ Add the import of `migrateV2ToV3`:
765
+
766
+ ```ts
767
+ import { migrateV2ToV3 } from "./migrate.ts";
768
+ ```
769
+
770
+ In `loadArchive`, after `const parsed = JSON.parse(raw) as ArchiveStore;` and the shape check, replace the `return parsed;` (the happy path) with version-aware logic. The current happy-path tail is:
771
+
772
+ ```ts
773
+ return parsed;
774
+ } catch {
775
+ ```
776
+
777
+ Replace with:
778
+
779
+ ```ts
780
+ if (parsed.version === 2) {
781
+ const migrated = migrateV2ToV3(parsed as any) as unknown as ArchiveStore;
782
+ saveArchive(migrated);
783
+ return migrated;
784
+ }
785
+ if (parsed.version !== 3) {
786
+ throw new Error("invalid archive shape");
787
+ }
788
+ return parsed;
789
+ } catch {
790
+ ```
791
+
792
+ Change the `listArchived` text filter:
793
+
794
+ ```ts
795
+ if (filter.text) {
796
+ const q = filter.text.toLowerCase();
797
+ out = out.filter((t) => t.title.toLowerCase().includes(q) || t.notes.toLowerCase().includes(q));
798
+ }
799
+ ```
800
+
801
+ - [ ] **Step 2: Verify it parses**
802
+
803
+ Run: `node --check src/archive.ts`
804
+ Expected: no output (success).
805
+
806
+ - [ ] **Step 3: Write the failing tests — append to `test/todo-archive.test.mts`**
807
+
808
+ Add before the final `console.log`:
809
+
810
+ ```ts
811
+ // --- v2 archive → v3 on load (symmetric with live store) ---
812
+ {
813
+ const dir = mkdtempSync(join(tmpdir(), "armory-arc-v3-"));
814
+ process.env.TODO_DIR = dir;
815
+ const arcFile = join(dir, "todo-archive.json");
816
+ writeFileSync(arcFile, JSON.stringify({
817
+ version: 2,
818
+ updatedAt: "x",
819
+ todos: [{ id: "td-arc-1", text: "done thing\nwith detail", project: "pi", tags: [], priority: "med", status: "done", source: "", createdAt: "x", updatedAt: "x", closedAt: "2026-07-01T00:00:00Z" }],
820
+ }), "utf8");
821
+ const { loadArchive, listArchived } = await import("../src/archive.ts");
822
+ const arc = loadArchive();
823
+ ok("archive v2→v3: version 3", arc.version === 3);
824
+ ok("archive v2→v3: title derived", arc.todos[0]!.title === "done thing");
825
+ ok("archive v2→v3: notes derived", arc.todos[0]!.notes === "with detail");
826
+ ok("archive v2→v3: no text field", !("text" in arc.todos[0]!));
827
+ // persisted to disk
828
+ const onDisk = JSON.parse(readFileSync(arcFile, "utf8"));
829
+ ok("archive v2→v3: disk version 3", onDisk.version === 3);
830
+ // listArchived text filter matches title OR notes
831
+ const byTitle = listArchived({ text: "done thing", limit: 50 });
832
+ ok("archive listArchived text matches title", byTitle.items.some((t) => t.title === "done thing"));
833
+ const byNotes = listArchived({ text: "with detail", limit: 50 });
834
+ ok("archive listArchived text matches notes", byNotes.items.some((t) => t.notes === "with detail"));
835
+ process.env.TODO_DIR = tmp;
836
+ rmSync(dir, { recursive: true, force: true });
837
+ }
838
+ ```
839
+
840
+ Ensure `readFileSync` is imported at the top of the file (add to the existing `node:fs` import if missing).
841
+
842
+ - [ ] **Step 4: Run the archive suite**
843
+
844
+ Run: `node test/todo-archive.test.mts`
845
+ Expected: PASS.
846
+
847
+ - [ ] **Step 5: Run the full suite**
848
+
849
+ Run: `npm test`
850
+ Expected: all 8 suites PASS.
851
+
852
+ - [ ] **Step 6: Commit**
853
+
854
+ ```bash
855
+ git add src/archive.ts test/todo-archive.test.mts
856
+ git commit -m "feat(archive): v3 archive + listArchived title|notes filter
857
+
858
+ ArchiveStore.version → 3; loadArchive migrates a v2 archive file to v3 on
859
+ load (curated + fallback, persist-once) — symmetric with the live store.
860
+ listArchived text filter now matches title OR notes. The archive file does
861
+ not exist today (the v0.2.0 incident left none), but the guard is defensive
862
+ for restored/future archives."
863
+ ```
864
+
865
+ ---
866
+
867
+ ## Task 4: Extension tool surface — `get` action, title/notes params, prompt guidelines
868
+
869
+ **Files:**
870
+ - Modify: `extensions/todo.ts` (ACTIONS + `get`; input schema; `fmt`; add/update/complete/delete/park/restore/clear/prune output → title; `get` handler; prompt guidelines; `/todo add <title>`)
871
+
872
+ **Interfaces:**
873
+ - Consumes: `getTodo` from `src/todo-store.ts` (Task 1); `title`/`notes` on `Todo`.
874
+ - Produces: the model-callable `todo` tool with the new surface. Manual-gate (no unit test — verified by `node --check` + RECTOR QA).
875
+
876
+ - [ ] **Step 1: Update `ACTIONS` + imports**
877
+
878
+ Add `getTodo` to the import from `../src/todo-store`:
879
+
880
+ ```ts
881
+ import {
882
+ addTodo,
883
+ completeTodo,
884
+ deleteTodo,
885
+ clearTodos,
886
+ getTodo,
887
+ listTodos,
888
+ renderOpenBlock,
889
+ updateTodo,
890
+ parkTodo,
891
+ getStorePath,
892
+ } from "../src/todo-store";
893
+ ```
894
+
895
+ Change `ACTIONS`:
896
+
897
+ ```ts
898
+ const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
899
+ ```
900
+
901
+ - [ ] **Step 2: Replace `fmt`**
902
+
903
+ ```ts
904
+ function fmt(t: ReturnType<typeof listTodos>[number]): string {
905
+ const tag = t.project ? ` (${t.project})` : "";
906
+ const pins = t.tags.length ? ` #${t.tags.join(" #")}` : "";
907
+ const dot = t.notes.trim() ? " •" : "";
908
+ return `- [${t.id}] (${t.priority}/${t.status})${dot} ${t.title}${tag}${pins}`;
909
+ }
910
+ ```
911
+
912
+ - [ ] **Step 3: Add a `fmtFull` for the `get` action**
913
+
914
+ After `fmt`:
915
+
916
+ ```ts
917
+ function fmtFull(t: ReturnType<typeof getTodo>): string {
918
+ const tag = t.project ? ` (${t.project})` : "";
919
+ const tags = t.tags.length ? ` #${t.tags.join(" #")}` : "";
920
+ return [
921
+ `${t.id} [${t.priority}/${t.status}] ${t.title}${tag}${tags}`,
922
+ `created: ${t.createdAt}`,
923
+ `updated: ${t.updatedAt}`,
924
+ `closed: ${t.closedAt ?? "(open)"}`,
925
+ `source: ${t.source || "(none)"}`,
926
+ "",
927
+ "notes:",
928
+ t.notes || "(empty)",
929
+ ].join("\n");
930
+ }
931
+ ```
932
+
933
+ - [ ] **Step 4: Update the input schema — add `title` + `notes`, rewrite `text`**
934
+
935
+ Replace the `text` parameter line:
936
+
937
+ ```ts
938
+ text: Type.Optional(Type.String({ description: "Search query (list only) — substring match on title OR notes. Not used by add/update." })),
939
+ ```
940
+
941
+ Add `title` + `notes` (place them right after `id`):
942
+
943
+ ```ts
944
+ title: Type.Optional(Type.String({ description: "Todo title (add required; update optional). ≤120 chars; put detail in notes." })),
945
+ notes: Type.Optional(Type.String({ description: "Todo notes/body (add/update optional; long-form, not injected). Pass \"\" on update to clear." })),
946
+ ```
947
+
948
+ - [ ] **Step 5: Rewrite the `add` case**
949
+
950
+ ```ts
951
+ case "add": {
952
+ if (!params.title) {
953
+ return { content: [{ type: "text" as const, text: "Error: `title` is required for add." }] };
954
+ }
955
+ const t = addTodo({
956
+ title: params.title,
957
+ notes: params.notes,
958
+ project: params.project,
959
+ tags: params.tags,
960
+ priority: params.priority as any,
961
+ source: params.source as any,
962
+ });
963
+ return { content: [{ type: "text" as const, text: `Added ${t.id}: ${t.title}` }] };
964
+ }
965
+ ```
966
+
967
+ - [ ] **Step 6: Rewrite the `update` case**
968
+
969
+ ```ts
970
+ case "update": {
971
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for update." }] };
972
+ const t = updateTodo(params.id, {
973
+ title: params.title,
974
+ notes: params.notes,
975
+ project: params.project,
976
+ tags: params.tags,
977
+ priority: params.priority as any,
978
+ status: params.status as any,
979
+ });
980
+ return { content: [{ type: "text" as const, text: `Updated ${t.id}: ${t.title} [${t.status}]` }] };
981
+ }
982
+ ```
983
+
984
+ - [ ] **Step 7: Add the `get` case (after `update`)**
985
+
986
+ ```ts
987
+ case "get": {
988
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for get." }] };
989
+ const t = getTodo(params.id);
990
+ return { content: [{ type: "text" as const, text: fmtFull(t) }] };
991
+ }
992
+ ```
993
+
994
+ - [ ] **Step 8: Update the output lines for complete/delete/park/restore to use `title`**
995
+
996
+ ```ts
997
+ case "complete": {
998
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for complete." }] };
999
+ const t = completeTodo(params.id);
1000
+ return { content: [{ type: "text" as const, text: `Completed ${t.id}: ${t.title}` }] };
1001
+ }
1002
+ case "delete": {
1003
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for delete." }] };
1004
+ const t = deleteTodo(params.id);
1005
+ return { content: [{ type: "text" as const, text: `Cancelled ${t.id}: ${t.title}` }] };
1006
+ }
1007
+ case "park": {
1008
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for park." }] };
1009
+ const t = parkTodo(params.id);
1010
+ return { content: [{ type: "text" as const, text: `Parked ${t.id}: ${t.title}` }] };
1011
+ }
1012
+ ```
1013
+
1014
+ And the `restore` case:
1015
+
1016
+ ```ts
1017
+ const t = restoreTodo(params.id);
1018
+ return { content: [{ type: "text" as const, text: `Restored ${t.id}: ${t.title} [open]` }] };
1019
+ ```
1020
+
1021
+ - [ ] **Step 9: Rewrite `promptGuidelines`**
1022
+
1023
+ Replace the whole `promptGuidelines: [...]` array with:
1024
+
1025
+ ```ts
1026
+ promptGuidelines: [
1027
+ "Use todo (action:'add', title, notes?, project?, tags?, priority?, source?) when the user says 'put this in our TODO'. title is ≤120 chars (one-line summary); put long detail in notes.",
1028
+ "Use todo (action:'get', id) to read a todo's full notes before acting on it (the • marker in lists means notes exist).",
1029
+ "Use todo (action:'update', id, title?, notes?, project?, tags?, priority?, status?) to edit; notes=\"\" clears.",
1030
+ "Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending' (text filter searches title+notes).",
1031
+ "Use todo (action:'complete', id) to mark a TODO done; (action:'delete', id) to cancel it.",
1032
+ "Use todo (action:'park', id) to defer a TODO (not injected, recoverable); (action:'update', id, status:'open') to un-park.",
1033
+ "Use todo (action:'prune') to move done/cancelled todos to the archive (reversible); (action:'prune', all:true) to prune all regardless of age.",
1034
+ "Use todo (action:'restore', id) to bring an archived TODO back as open.",
1035
+ "Use todo (action:'list', archived:true) to query the archive — bare call returns a summary; add a filter (project/text/since) for specific items.",
1036
+ "Use todo (action:'health') to check bloat across all boxes — returns counts + flags + suggestions. Run this when the user asks about hygiene/bloat or before any hard-prune.",
1037
+ "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.",
1038
+ ],
1039
+ ```
1040
+
1041
+ - [ ] **Step 10: Update the `/todo add` slash subcommand**
1042
+
1043
+ Replace the `if (sub === "add") { ... }` block:
1044
+
1045
+ ```ts
1046
+ if (sub === "add") {
1047
+ const title = rest.join(" ").trim();
1048
+ if (!title) { if (ctx.hasUI) ctx.ui.notify("usage: /todo add <title> (notes via the todo tool)", "warning"); return; }
1049
+ const t = addTodo({ title, source: "slash" });
1050
+ if (ctx.hasUI) ctx.ui.notify(`Added ${t.id}: ${t.title}`, "info");
1051
+ return;
1052
+ }
1053
+ ```
1054
+
1055
+ - [ ] **Step 11: Update the slash command description**
1056
+
1057
+ Replace the `description:` string:
1058
+
1059
+ ```ts
1060
+ description:
1061
+ "Global cross-session TODO list. " +
1062
+ "/todo · /todo all · /todo add <title> · /todo done <id> · /todo rm <id> · " +
1063
+ "/todo park <id> · /todo restore <id> · /todo prune [--all|--hard --box <b> --older-than <d>] · " +
1064
+ "/todo archive [project:X|text:Y] · /todo health · /todo clean · /todo path",
1065
+ ```
1066
+
1067
+ - [ ] **Step 12: Update remaining slash output lines that used `t.text`**
1068
+
1069
+ In the slash handler, the `park` and `restore` notify lines use `t.text`:
1070
+
1071
+ ```ts
1072
+ if (sub === "park") {
1073
+ const id = rest[0];
1074
+ if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo park <id>", "warning"); return; }
1075
+ const t = parkTodo(id);
1076
+ if (ctx.hasUI) ctx.ui.notify(`Parked ${t.id}: ${t.title}`, "info");
1077
+ return;
1078
+ }
1079
+ if (sub === "restore") {
1080
+ const id = rest[0];
1081
+ if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo restore <id>", "warning"); return; }
1082
+ const t = restoreTodo(id);
1083
+ if (ctx.hasUI) ctx.ui.notify(`Restored ${t.id}: ${t.title}`, "info");
1084
+ return;
1085
+ }
1086
+ ```
1087
+
1088
+ - [ ] **Step 13: Verify it parses**
1089
+
1090
+ Run: `node --check extensions/todo.ts`
1091
+ Expected: no output (success).
1092
+
1093
+ - [ ] **Step 14: Run the full suite (no new tests — extension is manual-gate)**
1094
+
1095
+ Run: `npm test`
1096
+ Expected: all 8 suites PASS (extension isn't loaded by the suites).
1097
+
1098
+ - [ ] **Step 15: Commit**
1099
+
1100
+ ```bash
1101
+ git add extensions/todo.ts
1102
+ git commit -m "feat(ext): get action + title/notes params + prompt guidelines
1103
+
1104
+ ACTIONS gains get (returns a todo's full record incl notes via fmtFull).
1105
+ add requires title (+ optional notes); update gains title+notes, drops text.
1106
+ list.text searches title+notes; fmt shows title + • when notes present.
1107
+ complete/delete/park/restore/clear output lines use title. Prompt guidelines
1108
+ rewritten to teach title (≤120) + notes + get. /todo add <title> (notes via
1109
+ the tool). Extension is manual-gate (node --check + RECTOR QA)."
1110
+ ```
1111
+
1112
+ ---
1113
+
1114
+ ## Task 5: Panel — title list, read-only notes detail view, title-only Edit
1115
+
1116
+ **Files:**
1117
+ - Modify: `src/panel-data.ts` (`todoToItem` → title+dot, delete truncation; `actionsForTodo` "Edit text"→"Edit title")
1118
+ - Modify: `src/panel.ts` (detail view rendering notes read-only; Edit writes `title`; detail-view Back)
1119
+ - Modify: `test/panel-data.test.mts` (todoToItem title+dot assertions)
1120
+
1121
+ **Interfaces:**
1122
+ - Consumes: `Todo.title`/`Todo.notes` from Task 1.
1123
+ - Produces: `todoToItem` label = `[id] (prio)⏵ (project) • title` (dot when notes present). Panel detail view + title-only Edit.
1124
+
1125
+ - [ ] **Step 1: Rewrite `todoToItem` in `src/panel-data.ts`**
1126
+
1127
+ ```ts
1128
+ /** Format a todo as a SelectList item: "[id] (prio)⏵ (project) • title".
1129
+ * title is already ≤120 chars (enforced at write time), so no truncation is
1130
+ * needed. The • marker shows when notes is non-empty (signals "open the
1131
+ * detail view / use `todo get` for context"). */
1132
+ export function todoToItem(t: Todo): SelectItem {
1133
+ const pin = t.status === "in_progress" ? " ⏵" : "";
1134
+ const proj = t.project ? ` (${t.project})` : "";
1135
+ const dot = t.notes.trim() ? " •" : "";
1136
+ return {
1137
+ value: t.id,
1138
+ label: `[${t.id}] (${t.priority})${pin}${proj}${dot} ${t.title}`,
1139
+ };
1140
+ }
1141
+ ```
1142
+
1143
+ - [ ] **Step 2: Rename "Edit text" → "Edit title" in `actionsForTodo`**
1144
+
1145
+ ```ts
1146
+ actions.push({ label: "Edit title", action: "edit" });
1147
+ ```
1148
+
1149
+ - [ ] **Step 3: Update `test/panel-data.test.mts` — `todoToItem` assertions**
1150
+
1151
+ Replace any existing `todoToItem` assertions that checked the label contains `text` / truncation behavior:
1152
+
1153
+ ```ts
1154
+ // --- todoToItem: title + dot ---
1155
+ const { todoToItem, actionsForTodo } = await import("../src/panel-data.ts");
1156
+
1157
+ const noNotes: Todo = { id: "td-1", title: "Simple title", notes: "", project: "pi", tags: [], priority: "med", status: "open", source: "", createdAt: "x", updatedAt: "x", closedAt: null };
1158
+ const itemNoNotes = todoToItem(noNotes);
1159
+ ok("todoToItem: label has title", itemNoNotes.label.includes("Simple title"));
1160
+ ok("todoToItem: no • when notes empty", !itemNoNotes.label.includes("•"));
1161
+
1162
+ const withNotes: Todo = { id: "td-2", title: "Has detail", notes: "lots of context", project: "pi", tags: [], priority: "high", status: "in_progress", source: "", createdAt: "x", updatedAt: "x", closedAt: null };
1163
+ const itemWithNotes = todoToItem(withNotes);
1164
+ ok("todoToItem: label has • when notes present", itemWithNotes.label.includes("•"));
1165
+ ok("todoToItem: label has title not notes content", itemWithNotes.label.includes("Has detail") && !itemWithNotes.label.includes("lots of context"));
1166
+ ok("todoToItem: in_progress pin present", itemWithNotes.label.includes("⏵"));
1167
+
1168
+ // --- actionsForTodo: "Edit title" (renamed from "Edit text") ---
1169
+ const acts = actionsForTodo(noNotes);
1170
+ ok("actionsForTodo: has 'Edit title'", acts.some((a) => a.label === "Edit title" && a.action === "edit"));
1171
+ ok("actionsForTodo: no 'Edit text' (renamed)", !acts.some((a) => a.label === "Edit text"));
1172
+ ```
1173
+
1174
+ Ensure `Todo` is imported at the top:
1175
+
1176
+ ```ts
1177
+ import type { Todo } from "../src/todo-store.ts";
1178
+ ```
1179
+
1180
+ - [ ] **Step 4: Run the panel-data suite**
1181
+
1182
+ Run: `node test/panel-data.test.mts`
1183
+ Expected: PASS.
1184
+
1185
+ - [ ] **Step 5: Add the detail view + title-only Edit to `src/panel.ts`**
1186
+
1187
+ Add a `detailMode` + `detailId` state near the other mode flags in the `TodoPanel` class:
1188
+
1189
+ ```ts
1190
+ private detailMode = false;
1191
+ private detailId = "";
1192
+ ```
1193
+
1194
+ Add a `viewDetail(id)` method that loads the todo and switches to detail mode. Place it after `openActionSubmenu`:
1195
+
1196
+ ```ts
1197
+ private viewDetail(id: string): void {
1198
+ const all = listTodos({ status: "all", limit: 200 });
1199
+ const t = all.find((x) => x.id === id);
1200
+ if (!t) { this.onNotify("Todo not found.", "info"); return; }
1201
+ this.detailId = id;
1202
+ this.actionMode = false;
1203
+ this.actionList = null;
1204
+ this.editMode = false;
1205
+ this.renderShell();
1206
+ }
1207
+ ```
1208
+
1209
+ In `renderShell`, add a branch for detail mode. Insert this `else if` before the `this.currentBox === "config"` branch (and after the edit/action branches):
1210
+
1211
+ ```ts
1212
+ } else if (this.detailMode) {
1213
+ const all = listTodos({ status: "all", limit: 200 });
1214
+ const t = all.find((x) => x.id === this.detailId);
1215
+ if (!t) { this.detailMode = false; this.renderShell(); return; }
1216
+ const proj = t.project || "no project";
1217
+ const tags = t.tags.length ? t.tags.join(" ") : "(none)";
1218
+ this.addChild(new Text(this.theme.fg("accent", ` ${t.title}`), 0, 0));
1219
+ this.addChild(new Text(this.theme.fg("muted", ` (${t.priority}/${t.status}) · ${proj} · #${tags}`), 0, 0));
1220
+ this.addChild(new Spacer(1));
1221
+ this.addChild(new Text(this.theme.fg("dim", " notes:"), 0, 0));
1222
+ this.addChild(new Text(` ${t.notes || "(empty)"}`, 0, 0));
1223
+ this.addChild(new Spacer(1));
1224
+ this.addChild(new Text(this.theme.fg("dim", " notes: read-only · todo update <id> notes=… to edit"), 0, 0));
1225
+ } else if (this.currentBox === "config") {
1226
+ ```
1227
+
1228
+ Add a "View detail" action to the action submenu. In `openActionSubmenu`, prepend a View action to `acts`:
1229
+
1230
+ ```ts
1231
+ const acts = [{ label: "View detail", action: "view" }, ...actionsForTodo(todo)];
1232
+ ```
1233
+
1234
+ In `executeAction`, add the `view` case (at the top of the switch):
1235
+
1236
+ ```ts
1237
+ case "view": this.viewDetail(id); return;
1238
+ ```
1239
+
1240
+ Change the `edit` case to edit `title` instead of `text`:
1241
+
1242
+ ```ts
1243
+ case "edit": {
1244
+ const all = listTodos({ status: "all", limit: 200 });
1245
+ const t = all.find((x) => x.id === id);
1246
+ this.editId = id;
1247
+ this.editInput = new Input();
1248
+ this.editInput.setValue(t?.title ?? "");
1249
+ this.editInput.onSubmit = (value) => {
1250
+ if (value.trim()) {
1251
+ try { updateTodo(id, { title: value.trim() }); this.onNotify(`Edited ${id}`); }
1252
+ catch (err) { this.onNotify(`Error: ${(err as Error).message}`, "error"); }
1253
+ }
1254
+ this.exitEditMode();
1255
+ };
1256
+ this.editInput.onEscape = () => this.exitEditMode();
1257
+ this.actionMode = false;
1258
+ this.actionList = null;
1259
+ this.editMode = true;
1260
+ this.renderShell();
1261
+ break;
1262
+ }
1263
+ ```
1264
+
1265
+ In `handleInput`, add a branch for detail mode (Esc/Back returns to list). Insert after the edit-mode branch:
1266
+
1267
+ ```ts
1268
+ if (this.detailMode) {
1269
+ if (matchesKey(data, "escape") || matchesKey(data, "esc") || matchesKey(data, "enter") || matchesKey(data, "return")) {
1270
+ this.detailMode = false;
1271
+ this.detailId = "";
1272
+ this.refreshList();
1273
+ this.renderShell();
1274
+ return;
1275
+ }
1276
+ this.invalidate();
1277
+ return;
1278
+ }
1279
+ ```
1280
+
1281
+ Update the footer hint to mention the detail view. Replace the existing footer Text:
1282
+
1283
+ ```ts
1284
+ this.addChild(new Text(this.theme.fg("dim", " ↑↓ navigate • enter select/action • tab switch box • esc done"), 0, 0));
1285
+ ```
1286
+
1287
+ (no change needed — "enter select/action" covers it; the detail view's own hints are inline.)
1288
+
1289
+ - [ ] **Step 6: Verify both files parse**
1290
+
1291
+ Run: `node --check src/panel.ts && node --check src/panel-data.ts`
1292
+ Expected: no output (success).
1293
+
1294
+ - [ ] **Step 7: Run the full suite**
1295
+
1296
+ Run: `npm test`
1297
+ Expected: all 8 suites PASS (panel.ts is manual-gate; panel-data tests cover the pure helpers).
1298
+
1299
+ - [ ] **Step 8: Commit**
1300
+
1301
+ ```bash
1302
+ git add src/panel-data.ts src/panel.ts test/panel-data.test.mts
1303
+ git commit -m "feat(panel): title list + read-only notes detail view + title-only Edit
1304
+
1305
+ todoToItem shows title + • (when notes present); the ~80-char truncation hack
1306
+ is deleted (title is already ≤120 by the cap). New detail view (View detail
1307
+ action / enter on a row): renders title + full notes read-only with a footer
1308
+ hint 'notes: read-only · todo update <id> notes=… to edit'. Inline Edit now
1309
+ edits title only (single-line Input — same constraint as v0.2.0); notes
1310
+ editing via the todo tool. Known deferred issue: no in-panel multi-line notes
1311
+ edit (pi-tui nested-UI blocker). 'Edit text' action renamed to 'Edit title'."
1312
+ ```
1313
+
1314
+ ---
1315
+
1316
+ ## Task 6: Health notes-bytes diagnostic
1317
+
1318
+ **Files:**
1319
+ - Modify: `src/health.ts` (add `notesBytes` to `HealthReport`; compute across active+parked)
1320
+ - Modify: `extensions/todo.ts` (health output line + `/todo health` slash line)
1321
+ - Modify: `test/todo-health.test.mts` (notesBytes assertions)
1322
+
1323
+ **Interfaces:**
1324
+ - Consumes: `Todo.notes` from Task 1.
1325
+ - Produces: `HealthReport.notesBytes: { total: number; max: number; avg: number }`.
1326
+
1327
+ - [ ] **Step 1: Add `notesBytes` to `HealthReport` in `src/health.ts`**
1328
+
1329
+ Add the interface:
1330
+
1331
+ ```ts
1332
+ export interface NotesBytes {
1333
+ total: number;
1334
+ max: number;
1335
+ avg: number;
1336
+ }
1337
+ ```
1338
+
1339
+ Add to `HealthReport`:
1340
+
1341
+ ```ts
1342
+ export interface HealthReport {
1343
+ active: ActiveHealth;
1344
+ parked: ParkedHealth;
1345
+ archive: ArchiveHealth;
1346
+ notesBytes: NotesBytes;
1347
+ flags: HealthFlag[];
1348
+ suggestions: string[];
1349
+ }
1350
+ ```
1351
+
1352
+ In `healthReport`, compute notesBytes across active + parked (not archived). After the `archiveOld` computation, add:
1353
+
1354
+ ```ts
1355
+ const apTodos = [...openTodos, ...ipTodos, ...parkedTodos];
1356
+ const notesSizes = apTodos.map((t) => Buffer.byteLength(t.notes, "utf8"));
1357
+ const notesBytes: NotesBytes = {
1358
+ total: notesSizes.reduce((a, b) => a + b, 0),
1359
+ max: notesSizes.length ? Math.max(...notesSizes) : 0,
1360
+ avg: notesSizes.length ? Math.round(notesSizes.reduce((a, b) => a + b, 0) / notesSizes.length) : 0,
1361
+ };
1362
+ ```
1363
+
1364
+ Include `notesBytes` in the returned object:
1365
+
1366
+ ```ts
1367
+ return { active, parked, archive: arch, notesBytes, flags, suggestions };
1368
+ ```
1369
+
1370
+ - [ ] **Step 2: Verify it parses**
1371
+
1372
+ Run: `node --check src/health.ts`
1373
+ Expected: no output (success).
1374
+
1375
+ - [ ] **Step 3: Write the failing tests — append to `test/todo-health.test.mts`**
1376
+
1377
+ Add before the final `console.log`:
1378
+
1379
+ ```ts
1380
+ // --- notesBytes: total/max/avg across active+parked (archived excluded) ---
1381
+ {
1382
+ const dir = mkdtempSync(join(tmpdir(), "armory-health-notes-"));
1383
+ process.env.TODO_DIR = dir;
1384
+ const { addTodo, completeTodo, parkTodo } = await import("../src/todo-store.ts");
1385
+ const { healthReport } = await import("../src/health.ts");
1386
+ addTodo({ title: "a", notes: "short" }); // 5 bytes
1387
+ addTodo({ title: "b", notes: "x".repeat(100) }); // 100 bytes
1388
+ const parked = addTodo({ title: "c", notes: "y".repeat(40) }); // 40 bytes
1389
+ parkTodo(parked.id);
1390
+ const done = addTodo({ title: "d", notes: "z".repeat(999) }); // archived-excluded
1391
+ completeTodo(done.id);
1392
+ // prune to move `done` into the archive so it's excluded from the active+parked set
1393
+ const { pruneTodos } = await import("../src/archive.ts");
1394
+ pruneTodos({ all: true });
1395
+ const r = healthReport();
1396
+ ok("notesBytes: total = 5+100+40", r.notesBytes.total === 145);
1397
+ ok("notesBytes: max = 100", r.notesBytes.max === 100);
1398
+ ok("notesBytes: avg = round(145/3) = 48", r.notesBytes.avg === 48);
1399
+ ok("notesBytes: excludes archived (999 not in total)", r.notesBytes.total < 999);
1400
+ process.env.TODO_DIR = tmp;
1401
+ rmSync(dir, { recursive: true, force: true });
1402
+ }
1403
+
1404
+ // --- notesBytes: empty store → zeros ---
1405
+ {
1406
+ const dir = mkdtempSync(join(tmpdir(), "armory-health-empty-"));
1407
+ process.env.TODO_DIR = dir;
1408
+ const { healthReport } = await import("../src/health.ts");
1409
+ const r = healthReport();
1410
+ eq("notesBytes empty: total 0", r.notesBytes.total, 0);
1411
+ eq("notesBytes empty: max 0", r.notesBytes.max, 0);
1412
+ eq("notesBytes empty: avg 0", r.notesBytes.avg, 0);
1413
+ process.env.TODO_DIR = tmp;
1414
+ rmSync(dir, { recursive: true, force: true });
1415
+ }
1416
+ ```
1417
+
1418
+ Ensure `eq` is defined in the file (it is — todo-health uses ok/eq like the others). Ensure `mkdtempSync`, `rmSync`, `tmpdir`, `join` are imported.
1419
+
1420
+ - [ ] **Step 4: Run the health suite**
1421
+
1422
+ Run: `node test/todo-health.test.mts`
1423
+ Expected: PASS.
1424
+
1425
+ - [ ] **Step 5: Add the notesBytes line to the extension `health` output + `/todo health` slash output**
1426
+
1427
+ In `extensions/todo.ts`, the tool `health` case — add a line after the `archive:` line:
1428
+
1429
+ ```ts
1430
+ `archive: ${report.archive.count} (${report.archive.older_180d} old)`,
1431
+ `notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
1432
+ ```
1433
+
1434
+ In the `/todo health` slash handler, add the same line after the `archive:` line:
1435
+
1436
+ ```ts
1437
+ ` archive: ${report.archive.count} (${report.archive.older_180d} old)`,
1438
+ ` notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
1439
+ ```
1440
+
1441
+ - [ ] **Step 6: Verify it parses**
1442
+
1443
+ Run: `node --check extensions/todo.ts`
1444
+ Expected: no output (success).
1445
+
1446
+ - [ ] **Step 7: Run the full suite**
1447
+
1448
+ Run: `npm test`
1449
+ Expected: all 8 suites PASS.
1450
+
1451
+ - [ ] **Step 8: Commit**
1452
+
1453
+ ```bash
1454
+ git add src/health.ts extensions/todo.ts test/todo-health.test.mts
1455
+ git commit -m "feat(health): notes-bytes diagnostic (total/max/avg, active+parked)
1456
+
1457
+ HealthReport gains notesBytes { total, max, avg } computed across active +
1458
+ parked (archived excluded — sealed history). Read-only diagnostic: shows
1459
+ latent notes bloat that isn't injected. No caps enforced in B (Workstream C).
1460
+ Extension + /todo health output a 'notes: NB total · max NB · avg NB' line."
1461
+ ```
1462
+
1463
+ ---
1464
+
1465
+ ## Task 7: Docs + version bump + ship prep
1466
+
1467
+ **Files:**
1468
+ - Modify: `README.md` (schema section: title+notes; known issues; test count)
1469
+ - Modify: `AGENTS.md` (modules list reflects v3; known deferred issues)
1470
+ - Modify: `package.json` (`version: 0.3.0`)
1471
+ - Modify: `docs/superpowers/specs/2026-07-21-title-notes-split-design.md` (status → shipped, after RECTOR QA)
1472
+
1473
+ **Interfaces:** none (docs + version).
1474
+
1475
+ - [ ] **Step 1: Bump `package.json` version**
1476
+
1477
+ ```bash
1478
+ # edit package.json: "version": "0.2.0" → "0.3.0"
1479
+ ```
1480
+
1481
+ - [ ] **Step 2: Update `README.md`**
1482
+
1483
+ - In the structure/feature section, replace references to the `text` field with `title` + `notes`.
1484
+ - Update the test count line ("151/151 across 7 suites" → the new count from `npm test`; 8 suites now).
1485
+ - Add a "Known issues" bullet: "No in-panel multi-line notes editing (pi-tui nested-UI blocker); notes are model-managed via the `todo` tool."
1486
+ - Add a schema note: "`title` ≤120 chars (hard-capped, injected); `notes` any length (not injected)."
1487
+
1488
+ - [ ] **Step 3: Update `AGENTS.md`**
1489
+
1490
+ - In the "Notes" section, update the version references (v0.2.0 → v0.3.0) and add the title/notes split to the lifecycle description.
1491
+ - Add the known deferred issue (in-panel notes edit) to the "Open follow issue" line alongside Workstream C.
1492
+ - Update the test count if cited.
1493
+
1494
+ - [ ] **Step 4: Run the full suite + syntax checks one final time**
1495
+
1496
+ Run:
1497
+ ```bash
1498
+ npm test
1499
+ node --check extensions/todo.ts
1500
+ node --check src/panel.ts
1501
+ node --check src/health.ts
1502
+ node --check src/archive.ts
1503
+ node --check src/todo-store.ts
1504
+ node --check src/migrate.ts
1505
+ node --check src/panel-data.ts
1506
+ ```
1507
+ Expected: all suites PASS; all `--check` silent.
1508
+
1509
+ - [ ] **Step 5: Commit**
1510
+
1511
+ ```bash
1512
+ git add package.json README.md AGENTS.md
1513
+ git commit -m "docs(v0.3.0): title+notes schema, known issues, version bump
1514
+
1515
+ README + AGENTS updated for the title (≤120, injected) + notes (any length,
1516
+ not injected) split, the new get action, and the known deferred issue
1517
+ (no in-panel multi-line notes edit). package.json → 0.3.0."
1518
+ ```
1519
+
1520
+ - [ ] **Step 6: Push + open PR**
1521
+
1522
+ ```bash
1523
+ git push -u origin feat/title-notes-split
1524
+ gh pr create --base main --head feat/title-notes-split \
1525
+ --title "Workstream B — title + notes schema split (v0.3.0)" \
1526
+ --body "Bumps store to v3. Splits the single text field into title (≤120, injected) + notes (any length, not injected). New get action. Hard title cap. v2→v3 migration (curated for the 2 known ids + first-line fallback, persist-once). Panel detail view for notes; title-only Edit. Health notes-bytes diagnostic. Closes the v0.2.0 'no title field' gap. See docs/superpowers/specs/2026-07-21-title-notes-split-design.md."
1527
+ ```
1528
+
1529
+ - [ ] **Step 7: RECTOR QA gate (manual — do NOT merge until RECTOR signs off)**
1530
+
1531
+ Local install + restart pi, then verify in a real session:
1532
+ 1. v2→v3 migration runs on first load → the 2 known todos get curated title+notes; `~/.pi/agent/todo/todo.json` is version 3 on disk.
1533
+ 2. `## Open TODOs` injection shows the ZeroClaw *title* (+ `•`), not the 1.8KB blob.
1534
+ 3. `/todo` panel: list rows show titles + `•`; View detail shows notes read-only; Edit edits title; `•` appears iff notes non-empty.
1535
+ 4. `todo add` with `title`+`notes` works; `todo get <id>` returns notes; `todo update <id> notes=…` edits notes; `todo update <id> title=…` edits title.
1536
+ 5. Title >120 rejects with the actionable error.
1537
+ 6. `/todo health` shows the `notes:` bytes line.
1538
+
1539
+ - [ ] **Step 8: After QA sign-off — merge + tag**
1540
+
1541
+ ```bash
1542
+ gh pr merge --merge --delete-branch
1543
+ git checkout main && git pull
1544
+ git tag v0.3.0
1545
+ git push origin v0.3.0 # triggers release.yml → npm auto-publish
1546
+ ```
1547
+
1548
+ Verify publish:
1549
+ ```bash
1550
+ npm view @getpipher/armory-todo version # → 0.3.0
1551
+ ```
1552
+
1553
+ - [ ] **Step 9: Mark the spec shipped**
1554
+
1555
+ In `docs/superpowers/specs/2026-07-21-title-notes-split-design.md`, change the status line:
1556
+
1557
+ ```
1558
+ **Status:** Shipped (v0.3.0, PR #<N>, <date>)
1559
+ ```
1560
+
1561
+ Commit + push:
1562
+ ```bash
1563
+ git add docs/superpowers/specs/2026-07-21-title-notes-split-design.md
1564
+ git commit -m "docs(spec): Workstream B shipped (v0.3.0)"
1565
+ git push
1566
+ ```
1567
+
1568
+ ---
1569
+
1570
+ ## Self-Review (run after writing the plan)
1571
+
1572
+ **Spec coverage:**
1573
+ - §5 schema → Task 1 ✓
1574
+ - §6 migration (curated + fallback + persist-once + no env guard) → Task 2 ✓
1575
+ - §7 tool surface (get, add/update title+notes, list.text title|notes, prompt guidelines, slash) → Task 4 ✓
1576
+ - §8 injection (title only + •) → Task 1 (renderOpenBlock) ✓
1577
+ - §9 panel (list title+•, detail view, title-only Edit, known deferred issue) → Task 5 ✓
1578
+ - §10 health notes-bytes → Task 6 ✓
1579
+ - §11 archive v3 + listArchived filter → Task 3 ✓
1580
+ - §12 tests → Tasks 1–6 each carry their own test steps ✓
1581
+ - §13 branch + ship → Task 7 ✓
1582
+ - §14 incident lesson (v2→v3 no env guard; v1→v2 guard preserved) → Task 2 notes + Global Constraints ✓
1583
+
1584
+ **Placeholder scan:** none — every step has exact code or exact commands.
1585
+
1586
+ **Type consistency:** `getTodo`, `TITLE_MAX`, `splitTextFallback`, `migrateV2ToV3`, `notesBytes`, `fmtFull`, `viewDetail`, `detailMode` — names match across tasks. `Todo`/`Store`/`AddInput`/`UpdateInput` shapes are consistent. `ArchiveStore.version: 3` matches `Store.version: 3`. (Fixed: Task 2 Case 4 project assertion uses `"getpipher"` matching the v2 input.)