@getpipher/armory-todo 0.3.0 → 0.3.1

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,893 @@
1
+ # Workstream v0.3.1 — auto-prune + unified Done view 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. (Pi has no core subagent tool → execute sequentially inline, checkpoint per task.)
4
+
5
+ **Goal:** Make `done`/`cancelled` todos auto-archive on `session_start` (age-gated, silent when clean, rich notify) and give finished work a single home — a unified `Done` listing (`todo list status:done` + `/todo done` + a new `Done` panel tab) spanning live + archive — without changing the injection contract (only `open`+`in_progress` injected).
6
+
7
+ **Architecture:** A pure `autoPruneOnSessionStart()` in a new `src/auto-prune.ts` wraps `pruneTodos` (age-gated, never `--all`); the extension's `session_start` handler calls it + notifies. `pruneTodos` gains a rich `items` result (id+status+title+ageDays) used by both auto + manual prune. A new `listDoneUnified()` in `archive.ts` merges live-done + archived-done (excludes `cancelled`, sorted newest-closed first, location-tagged) — consumed by the `todo list status:done` tool, the `/todo done` slash, and a new `Done` box tab in the panel. The Archive tab + `--all`/`--hard` are unchanged.
8
+
9
+ **Tech Stack:** TypeScript (raw `.ts`, tsx at pi runtime), node:test-style custom harness (temp `TODO_DIR`), typebox schemas, `@earendil-works/pi-tui` 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.
14
+ - **Injection contract UNCHANGED:** `renderOpenBlock` + `listTodos` default filter (`open`+`in_progress`) are not touched. Auto-prune moves done→archive (both non-injected) → zero injection impact.
15
+ - **Auto-prune is age-gated, never `--all`:** stale = `closedAt` older than `config.prune.defaultAgeDays` (default 7). Fresh done (<7d) stays in live. `--all` remains manual-only.
16
+ - **`cancelled` excluded from the Done view** (Done = finished work; cancelled = abandoned → Archive tab only).
17
+ - **Reversible, no gate** for auto-prune (it's `prune`, not `--hard`). The `--hard` gate stays untouched.
18
+ - **Reuses `config.prune.defaultAgeDays`** — no new config key.
19
+ - **Tests:** baseline 220 across 8 suites → target ~240+. New `test/todo-auto-prune.test.mts`. Extend `todo-archive` + `panel-data`. Run via `npm test`. Syntax-check extension/panel with `node --check`.
20
+ - **Branch:** `feat/auto-prune-done-view` off `main`. Commits: `feat(scope): ...` per task. PR → `--merge --delete-branch`. No GitLab mirror. Tag `v0.3.1` after RECTOR QA → CI auto-publishes npm + creates GitHub Release.
21
+ - **Rich result always-on** (resolves spec §12): `pruneTodos` always returns `items` (no `detail` flag). Callers that only need `{moved, ids}` ignore `items`. Cost is trivial (ageDays per moved todo).
22
+
23
+ **Spec:** `docs/superpowers/specs/2026-07-21-auto-prune-done-view-design.md`
24
+
25
+ ---
26
+
27
+ ## File Structure
28
+
29
+ | File | Responsibility | Task |
30
+ |---|---|---|
31
+ | `src/archive.ts` | `pruneTodos` returns rich `items` (id+status+title+ageDays); new `listDoneUnified()` + `DoneItem` type | 1, 2 |
32
+ | `src/auto-prune.ts` | NEW — `autoPruneOnSessionStart(): PruneDetail \| null` (wraps `pruneTodos`, age-gated) | 4 |
33
+ | `extensions/todo.ts` | `session_start` auto-prune + rich notify; `todo list status:done` → unified; manual `prune` rich output; `/todo done` slash; prompt guidelines | 3, 4 |
34
+ | `src/panel-data.ts` | `todoDoneItem` (location-tagged label) + `actionsForDoneTodo` | 5 |
35
+ | `src/panel.ts` | `BOXES` + `done` tab; `refreshList` Done branch; done-row action submenu (View detail + Restore-from-archive) | 5 |
36
+ | `test/todo-auto-prune.test.mts` | NEW — autoPruneOnSessionStart: stale moves, fresh stays, no-op clean, idempotent, cancelled, rich items | 4 |
37
+ | `test/todo-archive.test.mts` | pruneTodos rich items+ageDays; listDoneUnified (merge, exclude cancelled, sort, location, filters) | 1, 2 |
38
+ | `test/panel-data.test.mts` | todoDoneItem label + actionsForDoneTodo | 5 |
39
+ | `README.md`, `AGENTS.md`, `package.json` | auto-prune + Done tab + /todo done docs; version 0.3.1 | 6 |
40
+
41
+ ---
42
+
43
+ ## Task 1: Rich prune result (`pruneTodos` returns `items` + `ageDays`)
44
+
45
+ **Files:**
46
+ - Modify: `src/archive.ts` (`PruneResult` gains `items`; `pruneTodos` populates it from moved todos' `closedAt`)
47
+ - Modify: `test/todo-archive.test.mts` (rich-result assertions)
48
+
49
+ **Interfaces:**
50
+ - Produces: `PruneResult { moved: number; ids: string[]; items: { id: string; status: "done"|"cancelled"; title: string; ageDays: number }[] }`. Later tasks (3, 4) format `items` into the rich output/notify.
51
+
52
+ - [ ] **Step 1: Write the failing test — append to `test/todo-archive.test.mts`**
53
+
54
+ ```ts
55
+ // --- pruneTodos rich result (items + ageDays) ---
56
+ {
57
+ const dir = mkdtempSync(join(tmpdir(), "armory-prune-rich-"));
58
+ process.env.TODO_DIR = dir;
59
+ const { pruneTodos, loadArchive } = await import("../src/archive.ts");
60
+ const { addTodo, completeTodo, deleteTodo } = await import("../src/todo-store.ts");
61
+ const old = addTodo({ title: "old done", notes: "x" }); completeTodo(old.id);
62
+ const fresh = addTodo({ title: "fresh done", notes: "y" }); completeTodo(fresh.id);
63
+ const cancelled = addTodo({ title: "cancelled old", notes: "z" }); deleteTodo(cancelled.id);
64
+ // backdate the closed todos: old → 30d, cancelled → 30d, fresh → now
65
+ const live = JSON.parse(readFileSync(join(dir, "todo.json"), "utf8"));
66
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 86400_000).toISOString();
67
+ for (const t of live.todos) {
68
+ if (t.id === old.id || t.id === cancelled.id) { t.closedAt = thirtyDaysAgo; t.updatedAt = thirtyDaysAgo; }
69
+ }
70
+ writeFileSync(join(dir, "todo.json"), JSON.stringify(live, null, 2), "utf8");
71
+ const res = pruneTodos({ ageDays: 7 });
72
+ ok("rich: moved 2 (old done + old cancelled; fresh stays)", res.moved === 2);
73
+ ok("rich: items length matches moved", res.items.length === res.moved);
74
+ ok("rich: items have title", res.items.every((i) => typeof i.title === "string" && i.title.length > 0));
75
+ ok("rich: items have status done|cancelled", res.items.every((i) => i.status === "done" || i.status === "cancelled"));
76
+ ok("rich: old done ageDays ~30", res.items.find((i) => i.id === old.id)!.ageDays >= 29 && res.items.find((i) => i.id === old.id)!.ageDays <= 31);
77
+ ok("rich: ids still present (back-compat)", res.ids.length === res.moved);
78
+ // fresh done (<7d) stays in live
79
+ process.env.TODO_DIR = dir;
80
+ const { loadStore } = await import("../src/todo-store.ts");
81
+ ok("rich: fresh done stays in live", loadStore().todos.some((t) => t.id === fresh.id));
82
+ delete process.env.TODO_DIR;
83
+ rmSync(dir, { recursive: true, force: true });
84
+ }
85
+ ```
86
+
87
+ Ensure `readFileSync`, `writeFileSync` are imported at the top of `test/todo-archive.test.mts` (they are, from prior work).
88
+
89
+ - [ ] **Step 2: Run it to verify it fails**
90
+
91
+ Run: `node test/todo-archive.test.mts`
92
+ Expected: FAIL — `res.items` is undefined (pruneTodos doesn't return items yet).
93
+
94
+ - [ ] **Step 3: Implement — extend `PruneResult` + populate `items` in `src/archive.ts`**
95
+
96
+ Change the `PruneResult` interface:
97
+
98
+ ```ts
99
+ export interface PruneItem {
100
+ id: string;
101
+ status: "done" | "cancelled";
102
+ title: string;
103
+ ageDays: number;
104
+ }
105
+
106
+ export interface PruneResult {
107
+ moved: number;
108
+ ids: string[];
109
+ items: PruneItem[];
110
+ }
111
+ ```
112
+
113
+ In `pruneTodos`, after building `moved: Todo[]`, compute items. Replace the final `return { moved: moved.length, ids: moved.map((t) => t.id) };` (and the early `return { moved: 0, ids: [] }`) — the early return becomes `return { moved: 0, ids: [], items: [] };` and the success return:
114
+
115
+ ```ts
116
+ const items: PruneItem[] = moved.map((t) => ({
117
+ id: t.id,
118
+ status: t.status as "done" | "cancelled",
119
+ title: t.title,
120
+ ageDays: t.closedAt ? Math.floor((Date.now() - Date.parse(t.closedAt)) / 86400_000) : 0,
121
+ }));
122
+ return { moved: moved.length, ids: moved.map((t) => t.id), items };
123
+ ```
124
+
125
+ - [ ] **Step 4: Run the test to verify it passes**
126
+
127
+ Run: `node test/todo-archive.test.mts`
128
+ Expected: PASS (all prior cases + the new rich-result case).
129
+
130
+ - [ ] **Step 5: Run the full suite**
131
+
132
+ Run: `npm test`
133
+ Expected: all 8 suites PASS.
134
+
135
+ - [ ] **Step 6: Commit**
136
+
137
+ ```bash
138
+ git add src/archive.ts test/todo-archive.test.mts
139
+ git commit -m "feat(prune): rich result — pruneTodos returns items (id+status+title+ageDays)
140
+
141
+ PruneResult gains items[] (always populated; callers that only need
142
+ {moved, ids} ignore it — resolves spec §12 toward always-rich). ageDays
143
+ computed from closedAt. Used next by the extension's prune output + the
144
+ auto-prune notify. Back-compat: moved + ids unchanged."
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Task 2: `listDoneUnified` — unified Done listing (store layer)
150
+
151
+ **Files:**
152
+ - Modify: `src/archive.ts` (new `DoneItem` interface + `listDoneUnified(filter)`)
153
+ - Modify: `test/todo-archive.test.mts` (unified-listing assertions)
154
+
155
+ **Interfaces:**
156
+ - Produces: `DoneItem extends Todo { location: "live"|"archive"; archivedAt: string|null }`; `listDoneUnified(filter): DoneItem[]`. Consumed by Task 3 (tool) + Task 5 (panel).
157
+
158
+ - [ ] **Step 1: Write the failing test — append to `test/todo-archive.test.mts`**
159
+
160
+ ```ts
161
+ // --- listDoneUnified: live done + archived done, excludes cancelled, sorted newest-closed first ---
162
+ {
163
+ const dir = mkdtempSync(join(tmpdir(), "armory-done-unified-"));
164
+ process.env.TODO_DIR = dir;
165
+ const { listDoneUnified, saveArchive } = await import("../src/archive.ts");
166
+ const { addTodo, completeTodo, deleteTodo, loadStore } = await import("../src/todo-store.ts");
167
+ // live done: recent (today) + older
168
+ const liveRecent = addTodo({ title: "live recent done", notes: "lr" }); completeTodo(liveRecent.id);
169
+ const liveOld = addTodo({ title: "live older done", notes: "lo" }); completeTodo(liveOld.id);
170
+ // backdate liveOld closedAt to 10d ago
171
+ const st = loadStore();
172
+ const t = st.todos.find((x) => x.id === liveOld.id)!;
173
+ const tenAgo = new Date(Date.now() - 10 * 86400_000).toISOString();
174
+ t.closedAt = tenAgo; t.updatedAt = tenAgo;
175
+ const { saveStore } = await import("../src/todo-store.ts");
176
+ saveStore(st);
177
+ // archived done + archived cancelled
178
+ const archDone: any = { id: "td-arch-d", title: "archived done old", notes: "", project: "pi", tags: [], priority: "med", status: "done", source: "", createdAt: "x", updatedAt: "x", closedAt: new Date(Date.now() - 40 * 86400_000).toISOString() };
179
+ const archCancelled: any = { id: "td-arch-c", title: "archived cancelled", notes: "", project: "", tags: [], priority: "med", status: "cancelled", source: "", createdAt: "x", updatedAt: "x", closedAt: new Date(Date.now() - 40 * 86400_000).toISOString() };
180
+ saveArchive({ version: 3, updatedAt: "x", todos: [archDone, archCancelled] });
181
+ // live cancelled (should be EXCLUDED from Done view)
182
+ const liveCancelled = addTodo({ title: "live cancelled", notes: "" }); deleteTodo(liveCancelled.id);
183
+
184
+ const all = listDoneUnified({});
185
+ ok("unified: 3 done (live recent + live older + archived done)", all.length === 3);
186
+ ok("unified: excludes cancelled (live + archived)", !all.some((d) => d.status === "cancelled"));
187
+ ok("unified: live done tagged location live", all.filter((d) => d.location === "live").length === 2);
188
+ ok("unified: archived done tagged location archive", all.filter((d) => d.location === "archive").length === 1);
189
+ // sorted newest-closed first: liveRecent (today) > liveOld (10d) > archDone (40d)
190
+ eq("unified: sorted newest-closed first", all[0]!.id, liveRecent.id);
191
+ eq("unified: oldest last", all[2]!.id, "td-arch-d");
192
+ // text filter matches title OR notes
193
+ const byNotes = listDoneUnified({ text: "lr" });
194
+ ok("unified: text filter matches notes", byNotes.length === 1 && byNotes[0]!.id === liveRecent.id);
195
+ // project filter
196
+ const byProj = listDoneUnified({ project: "pi" });
197
+ ok("unified: project filter", byProj.every((d) => d.project === "pi"));
198
+ delete process.env.TODO_DIR;
199
+ rmSync(dir, { recursive: true, force: true });
200
+ }
201
+ ```
202
+
203
+ - [ ] **Step 2: Run it to verify it fails**
204
+
205
+ Run: `node test/todo-archive.test.mts`
206
+ Expected: FAIL — `listDoneUnified` is not exported.
207
+
208
+ - [ ] **Step 3: Implement `listDoneUnified` in `src/archive.ts`**
209
+
210
+ Add the interface + function (after `listArchived`):
211
+
212
+ ```ts
213
+ export interface DoneItem extends Todo {
214
+ location: "live" | "archive";
215
+ archivedAt: string | null;
216
+ }
217
+
218
+ export interface DoneFilter {
219
+ text?: string; // title OR notes substring (case-insensitive)
220
+ project?: string;
221
+ since?: string; // closedAt >= since
222
+ before?: string; // closedAt < before
223
+ limit?: number; // default 50
224
+ page?: number; // default 1
225
+ }
226
+
227
+ /** Unified done todos across the live store + the archive. Excludes cancelled
228
+ * (Done = finished work). Sorted newest-closed first. */
229
+ export function listDoneUnified(filter: DoneFilter = {}): DoneItem[] {
230
+ const live = loadStore().todos.filter((t) => t.status === "done");
231
+ const arch = loadArchive().todos.filter((t) => t.status === "done");
232
+ const items: DoneItem[] = [
233
+ ...live.map((t) => ({ ...t, location: "live" as const, archivedAt: null })),
234
+ ...arch.map((t) => ({ ...t, location: "archive" as const, archivedAt: t.closedAt })),
235
+ ];
236
+ let out = items;
237
+ if (filter.text) {
238
+ const q = filter.text.toLowerCase();
239
+ out = out.filter((t) => t.title.toLowerCase().includes(q) || t.notes.toLowerCase().includes(q));
240
+ }
241
+ if (filter.project) out = out.filter((t) => t.project === filter.project);
242
+ if (filter.since) out = out.filter((t) => (t.closedAt ?? t.updatedAt) >= (filter.since as string));
243
+ if (filter.before) out = out.filter((t) => (t.closedAt ?? t.updatedAt) < (filter.before as string));
244
+ const sorted = out.slice().sort((a, b) => (b.closedAt ?? b.updatedAt).localeCompare(a.closedAt ?? a.updatedAt));
245
+ const limit = filter.limit ?? 50;
246
+ const page = filter.page ?? 1;
247
+ const start = (page - 1) * limit;
248
+ return sorted.slice(start, start + limit);
249
+ }
250
+ ```
251
+
252
+ - [ ] **Step 4: Run the test to verify it passes**
253
+
254
+ Run: `node test/todo-archive.test.mts`
255
+ Expected: PASS.
256
+
257
+ - [ ] **Step 5: Run the full suite**
258
+
259
+ Run: `npm test`
260
+ Expected: all 8 suites PASS.
261
+
262
+ - [ ] **Step 6: Commit**
263
+
264
+ ```bash
265
+ git add src/archive.ts test/todo-archive.test.mts
266
+ git commit -m "feat(done): listDoneUnified — unified done listing across live + archive
267
+
268
+ DoneItem extends Todo with location (live|archive) + archivedAt.
269
+ listDoneUnified merges live done + archived done (EXCLUDES cancelled —
270
+ Done = finished work), sorted newest-closed first, filterable by
271
+ text (title|notes) / project / since / before / limit / page. Consumed
272
+ next by the todo list status:done tool, /todo done slash, and the
273
+ panel Done tab."
274
+ ```
275
+
276
+ ---
277
+
278
+ ## Task 3: Extension tool surface — `todo list status:done` unified + rich prune output + `/todo done`
279
+
280
+ **Files:**
281
+ - Modify: `extensions/todo.ts`
282
+
283
+ **Interfaces:**
284
+ - Consumes: `listDoneUnified`, `PruneItem` from `src/archive.ts` (Tasks 1, 2).
285
+ - Produces: the updated `todo` tool + `/todo` slash. Manual-gate (node --check + RECTOR QA).
286
+
287
+ - [ ] **Step 1: Import `listDoneUnified` + a `fmtDone` helper**
288
+
289
+ At the top, add to the `../src/archive` import:
290
+
291
+ ```ts
292
+ import { pruneTodos, restoreTodo, listArchived, archiveSummary, listDoneUnified } from "../src/archive";
293
+ ```
294
+
295
+ Add a `fmtDone` helper near `fmt`/`fmtFull`:
296
+
297
+ ```ts
298
+ function fmtDone(d: ReturnType<typeof listDoneUnified>[number]): string {
299
+ const tag = d.project ? ` (${d.project})` : "";
300
+ const loc = d.location === "archive" && d.archivedAt
301
+ ? ` [archived ${d.archivedAt.slice(0, 10)}]`
302
+ : (() => {
303
+ const days = d.closedAt ? Math.floor((Date.now() - Date.parse(d.closedAt)) / 86400_000) : 0;
304
+ return ` [live ${days}d]`;
305
+ })();
306
+ return `- [${d.id}] (done)${tag} ${d.title}${loc}`;
307
+ }
308
+ ```
309
+
310
+ - [ ] **Step 2: Route `todo list status:done` to the unified listing**
311
+
312
+ In the `list` case, **before** the existing `listTodos` branch, add a done-routing branch:
313
+
314
+ ```ts
315
+ case "list": {
316
+ if (params.status === "done" && !params.archived) {
317
+ const items = listDoneUnified({
318
+ text: params.text,
319
+ project: params.projectFilter,
320
+ since: params.since,
321
+ before: params.before,
322
+ limit: params.limit,
323
+ page: params.page,
324
+ });
325
+ if (items.length === 0) {
326
+ return { content: [{ type: "text" as const, text: "No done TODOs (live or archive)." }] };
327
+ }
328
+ return { content: [{ type: "text" as const, text: `Done (${items.length}):\n${items.map(fmtDone).join("\n")}` }] };
329
+ }
330
+ if (params.archived) {
331
+ // ... existing archive branch unchanged
332
+ ```
333
+
334
+ (Insert the done-routing `if` right after `case "list": {` and before `if (params.archived) {`. Keep the existing archive + live branches untouched for other statuses.)
335
+
336
+ - [ ] **Step 3: Rich output for the manual `prune` tool case**
337
+
338
+ Replace the non-hard `prune` return (the `return { content: ... Pruned N todo... }` line) with a rich format:
339
+
340
+ ```ts
341
+ const res = pruneTodos({ ageDays: params.ageDays, all: params.all });
342
+ if (res.moved === 0) {
343
+ return { content: [{ type: "text" as const, text: "Nothing to prune (no stale done/cancelled)." }] };
344
+ }
345
+ const lines = res.items.map((i) => ` [${i.id}] ${i.status} ${i.title} (was ${i.ageDays}d old)`);
346
+ return { content: [{ type: "text" as const, text: `Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive:\n${lines.join("\n")}\nUndo any with: todo restore <id>` }] };
347
+ ```
348
+
349
+ - [ ] **Step 4: `/todo done` slash subcommand + rich `/todo prune` slash output**
350
+
351
+ In the slash `handler`, add a `done` subcommand (after the `archive` subcommand block):
352
+
353
+ ```ts
354
+ if (sub === "done") {
355
+ const { listDoneUnified } = await import("../src/archive.ts");
356
+ const items = listDoneUnified({ text: rest.join(" ").trim() || undefined, limit: 100 });
357
+ const msg = items.length ? `Done (${items.length}):\n${items.map(fmtDone).join("\n")}` : "(no done TODOs)";
358
+ if (ctx.hasUI) ctx.ui.notify(msg, "info");
359
+ return;
360
+ }
361
+ ```
362
+
363
+ And replace the slash `prune` (non-hard) notify with the rich format (mirror Step 3):
364
+
365
+ ```ts
366
+ const all = rest.includes("--all");
367
+ const res = pruneTodos({ all });
368
+ if (ctx.hasUI) {
369
+ const msg = res.moved === 0
370
+ ? "Nothing to prune."
371
+ : `Pruned ${res.moved} to archive:\n${res.items.map((i) => ` [${i.id}] ${i.title} (${i.ageDays}d)`).join("\n")}\nUndo: todo restore <id>`;
372
+ ctx.ui.notify(msg, "info");
373
+ }
374
+ return;
375
+ ```
376
+
377
+ Add `done` to the slash `description` string (`/todo done`).
378
+
379
+ - [ ] **Step 5: Update `promptGuidelines`** — add auto-prune awareness + `/todo done`
380
+
381
+ Add two guidelines (keep the rest):
382
+
383
+ ```ts
384
+ "Done/cancelled todos older than the prune age (default 7d) auto-archive on session start — you'll see a notify; they're reversible via todo restore <id>. Use /todo done or todo list status:'done' to see all finished work (live + archived).",
385
+ "Use todo (action:'prune') only for an explicit user prune (e.g. prune --all to move fresh done too); routine pruning is automatic.",
386
+ ```
387
+
388
+ - [ ] **Step 6: Verify it parses**
389
+
390
+ Run: `node --check extensions/todo.ts`
391
+ Expected: no output (success).
392
+
393
+ - [ ] **Step 7: Run the full suite**
394
+
395
+ Run: `npm test`
396
+ Expected: all 8 suites PASS (extension isn't loaded by suites).
397
+
398
+ - [ ] **Step 8: Commit**
399
+
400
+ ```bash
401
+ git add extensions/todo.ts
402
+ git commit -m "feat(ext): todo list status:done unified + /todo done + rich prune output
403
+
404
+ todo list status:'done' now returns the unified done set (live + archive,
405
+ location-tagged) via listDoneUnified. Manual prune (tool + /todo prune slash)
406
+ emits a rich result (id+status+title+ageDays + restore hint). New /todo done
407
+ slash. Prompt guidelines teach the auto-prune-on-session-start behavior +
408
+ /todo done. Extension is manual-gate (node --check + RECTOR QA)."
409
+ ```
410
+
411
+ ---
412
+
413
+ ## Task 4: Auto-prune on `session_start` (`src/auto-prune.ts` + extension wiring)
414
+
415
+ **Files:**
416
+ - Create: `src/auto-prune.ts` (`autoPruneOnSessionStart(): PruneResult | null`)
417
+ - Modify: `extensions/todo.ts` (`session_start` handler calls it + rich notify)
418
+ - Create: `test/todo-auto-prune.test.mts`
419
+
420
+ **Interfaces:**
421
+ - Consumes: `pruneTodos` (Task 1), `loadConfig`.
422
+ - Produces: `autoPruneOnSessionStart()` — returns the `PruneResult` if anything moved, else `null`.
423
+
424
+ - [ ] **Step 1: Write the failing tests — `test/todo-auto-prune.test.mts`**
425
+
426
+ ```ts
427
+ // Auto-prune on session_start — the deterministic age-gated prune.
428
+ // Run: node test/todo-auto-prune.test.mts
429
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs";
430
+ import { tmpdir } from "node:os";
431
+ import { join } from "node:path";
432
+
433
+ let passed = 0;
434
+ let failed = 0;
435
+ function ok(name: string, cond: boolean, extra = ""): void {
436
+ if (cond) { passed++; } else { failed++; console.error(` ✗ ${name} ${extra}`); }
437
+ }
438
+ function eq<T>(name: string, got: T, want: T): void {
439
+ ok(name, got === want, `(got ${JSON.stringify(got)} want ${JSON.stringify(want)})`);
440
+ }
441
+
442
+ const { autoPruneOnSessionStart } = await import("../src/auto-prune.ts");
443
+
444
+ function seed(dir: string) {
445
+ process.env.TODO_DIR = dir;
446
+ return dir;
447
+ }
448
+
449
+ // Case 1: stale done (>7d) + stale cancelled (>7d) → auto-pruned; fresh done + open untouched
450
+ {
451
+ const dir = seed(mkdtempSync(join(tmpdir(), "armory-ap1-")));
452
+ const { addTodo, completeTodo, deleteTodo, loadStore } = await import("../src/todo-store.ts");
453
+ const { loadArchive } = await import("../src/archive.ts");
454
+ const staleDone = addTodo({ title: "stale done", notes: "" }); completeTodo(staleDone.id);
455
+ const staleCancelled = addTodo({ title: "stale cancelled", notes: "" }); deleteTodo(staleCancelled.id);
456
+ const freshDone = addTodo({ title: "fresh done", notes: "" }); completeTodo(freshDone.id);
457
+ const open = addTodo({ title: "open", notes: "" });
458
+ // backdate stale to 30d ago
459
+ const st = loadStore();
460
+ const thirtyAgo = new Date(Date.now() - 30 * 86400_000).toISOString();
461
+ for (const t of st.todos) {
462
+ if (t.id === staleDone.id || t.id === staleCancelled.id) { t.closedAt = thirtyAgo; t.updatedAt = thirtyAgo; }
463
+ }
464
+ const { saveStore } = await import("../src/todo-store.ts");
465
+ saveStore(st);
466
+ const res = autoPruneOnSessionStart();
467
+ ok("ap: returns result when something moved", res !== null);
468
+ eq("ap: moved 2", res!.moved, 2);
469
+ ok("ap: rich items present", res!.items.length === 2);
470
+ ok("ap: stale done moved to archive", loadArchive().todos.some((t) => t.id === staleDone.id));
471
+ ok("ap: stale cancelled moved to archive", loadArchive().todos.some((t) => t.id === staleCancelled.id));
472
+ const live = loadStore();
473
+ ok("ap: fresh done stays in live", live.todos.some((t) => t.id === freshDone.id));
474
+ ok("ap: open untouched", live.todos.some((t) => t.id === open.id));
475
+ delete process.env.TODO_DIR;
476
+ rmSync(dir, { recursive: true, force: true });
477
+ }
478
+
479
+ // Case 2: nothing stale → null, no-op
480
+ {
481
+ const dir = seed(mkdtempSync(join(tmpdir(), "armory-ap2-")));
482
+ const { addTodo, completeTodo, loadStore } = await import("../src/todo-store.ts");
483
+ addTodo({ title: "fresh done today", notes: "" }); // will complete below
484
+ const st = loadStore();
485
+ const t = st.todos[0]!; t.status = "done"; t.closedAt = new Date().toISOString();
486
+ const { saveStore } = await import("../src/todo-store.ts");
487
+ saveStore(st);
488
+ const res = autoPruneOnSessionStart();
489
+ eq("ap: null when nothing stale", res, null);
490
+ ok("ap: live unchanged when no-op", loadStore().todos.length === 1);
491
+ delete process.env.TODO_DIR;
492
+ rmSync(dir, { recursive: true, force: true });
493
+ }
494
+
495
+ // Case 3: idempotent — second call is a no-op (already pruned)
496
+ {
497
+ const dir = seed(mkdtempSync(join(tmpdir(), "armory-ap3-")));
498
+ const { addTodo, completeTodo, loadStore, saveStore } = await import("../src/todo-store.ts");
499
+ const d = addTodo({ title: "stale", notes: "" }); completeTodo(d.id);
500
+ const st = loadStore();
501
+ const t = st.todos.find((x) => x.id === d.id)!;
502
+ t.closedAt = new Date(Date.now() - 30 * 86400_000).toISOString();
503
+ saveStore(st);
504
+ const first = autoPruneOnSessionStart();
505
+ eq("ap: first moved 1", first!.moved, 1);
506
+ const second = autoPruneOnSessionStart();
507
+ eq("ap: second is null (idempotent)", second, null);
508
+ delete process.env.TODO_DIR;
509
+ rmSync(dir, { recursive: true, force: true });
510
+ }
511
+
512
+ // Case 4: respects config defaultAgeDays (set to 1d; a 3d-old done prunes)
513
+ {
514
+ const dir = seed(mkdtempSync(join(tmpdir(), "armory-ap4-")));
515
+ const { addTodo, completeTodo, loadStore, saveStore } = await import("../src/todo-store.ts");
516
+ const { loadConfig, saveConfig } = await import("../src/config.ts");
517
+ const cfg = loadConfig(); cfg.prune.defaultAgeDays = 1; saveConfig(cfg);
518
+ const d = addTodo({ title: "3d done", notes: "" }); completeTodo(d.id);
519
+ const st = loadStore();
520
+ const t = st.todos.find((x) => x.id === d.id)!;
521
+ t.closedAt = new Date(Date.now() - 3 * 86400_000).toISOString();
522
+ saveStore(st);
523
+ const res = autoPruneOnSessionStart();
524
+ eq("ap: 3d-old prunes when ageDays=1", res!.moved, 1);
525
+ delete process.env.TODO_DIR;
526
+ rmSync(dir, { recursive: true, force: true });
527
+ }
528
+
529
+ console.log(`\n${passed} passed, ${failed} failed`);
530
+ if (failed > 0) process.exit(1);
531
+ ```
532
+
533
+ - [ ] **Step 2: Run it to verify it fails**
534
+
535
+ Run: `node test/todo-auto-prune.test.mts`
536
+ Expected: FAIL — `autoPruneOnSessionStart` not exported.
537
+
538
+ - [ ] **Step 3: Implement `src/auto-prune.ts`**
539
+
540
+ ```ts
541
+ // Auto-prune on session_start — the deterministic age-gated prune that runs
542
+ // when the extension loads. Wraps pruneTodos with the config default age; never
543
+ // --all (fresh done <defaultAgeDays stays). Returns the rich PruneResult if
544
+ // anything moved, else null (caller stays silent). Reversible via restore.
545
+
546
+ import { pruneTodos, type PruneResult } from "./archive.ts";
547
+ import { loadConfig } from "./config.ts";
548
+
549
+ /** Prune stale done/cancelled (older than config.prune.defaultAgeDays) on
550
+ * session start. Returns the PruneResult if anything moved, else null. */
551
+ export function autoPruneOnSessionStart(): PruneResult | null {
552
+ const config = loadConfig();
553
+ const res = pruneTodos({ ageDays: config.prune.defaultAgeDays });
554
+ return res.moved > 0 ? res : null;
555
+ }
556
+ ```
557
+
558
+ - [ ] **Step 4: Run the test to verify it passes**
559
+
560
+ Run: `node test/todo-auto-prune.test.mts`
561
+ Expected: PASS.
562
+
563
+ - [ ] **Step 5: Wire into the extension `session_start` handler**
564
+
565
+ In `extensions/todo.ts`, add the import + rewrite the `session_start` body. Import:
566
+
567
+ ```ts
568
+ import { autoPruneOnSessionStart } from "../src/auto-prune";
569
+ ```
570
+
571
+ Replace the `session_start` handler body:
572
+
573
+ ```ts
574
+ pi.on("session_start", async (_event, ctx) => {
575
+ try {
576
+ let autoMsg = "";
577
+ try {
578
+ const ap = autoPruneOnSessionStart();
579
+ if (ap) {
580
+ const lines = ap.items.map((i) => ` [${i.id}] ${i.status} ${i.title}`);
581
+ autoMsg = ` · auto-pruned ${ap.moved} stale done (>${loadConfig().prune.defaultAgeDays}d):\n${lines.join("\n")}\nUndo any with: todo restore <id>`;
582
+ }
583
+ } catch {
584
+ // auto-prune optional — don't crash the session notify
585
+ }
586
+ const open = listTodos();
587
+ let msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}${autoMsg}`;
588
+ try {
589
+ const report = healthReport();
590
+ if (report.flags.length > 0) {
591
+ msg += `${autoMsg ? "\n" : " "}` + `⚠ ${report.flags.length} bloat signal${report.flags.length === 1 ? "" : "s"} (run /todo health)`;
592
+ }
593
+ } catch {
594
+ // health optional
595
+ }
596
+ if (ctx.hasUI) ctx.ui.notify(msg, "info");
597
+ } catch {
598
+ // store unavailable — never crash the session
599
+ }
600
+ });
601
+ ```
602
+
603
+ Add `loadConfig` to the imports from `../src/config` (if not already imported — it's used by the panel; the extension may not import it directly. Check + add):
604
+
605
+ ```ts
606
+ import { loadConfig } from "../src/config";
607
+ ```
608
+
609
+ - [ ] **Step 6: Verify it parses + add to the test loop**
610
+
611
+ Run:
612
+ ```bash
613
+ node --check extensions/todo.ts
614
+ node --check src/auto-prune.ts
615
+ ```
616
+ Expected: no output (success).
617
+
618
+ Add `todo-auto-prune` to the `npm test` loop in `package.json` (before `panel-data`):
619
+
620
+ ```text
621
+ for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune panel-data; do node test/$t.test.mts || exit 1; done
622
+ ```
623
+
624
+ - [ ] **Step 7: Run the full suite**
625
+
626
+ Run: `npm test`
627
+ Expected: all 9 suites PASS.
628
+
629
+ - [ ] **Step 8: Commit**
630
+
631
+ ```bash
632
+ git add src/auto-prune.ts extensions/todo.ts package.json test/todo-auto-prune.test.mts
633
+ git commit -m "feat(auto-prune): age-gated prune on session_start + rich notify
634
+
635
+ New src/auto-prune.ts: autoPruneOnSessionStart() wraps pruneTodos with the
636
+ config default age (never --all; fresh done stays). Returns the rich
637
+ PruneResult if anything moved, else null. The extension session_start
638
+ handler calls it + emits a rich notify (ids+status+title + restore hint)
639
+ when something was pruned, silent otherwise. Injection contract unchanged
640
+ (only open+in_progress injected). New todo-auto-prune.test.mts (4 cases:
641
+ stale moves + fresh stays, no-op when clean, idempotent, respects config
642
+ age). npm test loop gains todo-auto-prune (8 -> 9 suites)."
643
+ ```
644
+
645
+ ---
646
+
647
+ ## Task 5: Panel `Done` box tab
648
+
649
+ **Files:**
650
+ - Modify: `src/panel-data.ts` (`todoDoneItem` + `actionsForDoneTodo`)
651
+ - Modify: `src/panel.ts` (`BOXES` + `done` tab; `refreshList` Done branch; done-row action submenu)
652
+ - Modify: `test/panel-data.test.mts` (`todoDoneItem` + `actionsForDoneTodo`)
653
+
654
+ **Interfaces:**
655
+ - Consumes: `listDoneUnified`, `DoneItem` (Task 2).
656
+ - Produces: `todoDoneItem(d: DoneItem): SelectItem`; `actionsForDoneTodo(d: DoneItem): {label, action}[]`.
657
+
658
+ - [ ] **Step 1: Write the failing tests — append to `test/panel-data.test.mts`**
659
+
660
+ ```ts
661
+ // --- todoDoneItem: location-tagged label ---
662
+ const { todoDoneItem, actionsForDoneTodo } = await import("../src/panel-data.ts");
663
+ import type { DoneItem } from "../src/archive.ts";
664
+
665
+ const liveDone: DoneItem = { id: "td-d1", title: "finished today", notes: "", project: "pi", tags: [], priority: "med", status: "done", source: "", createdAt: "x", updatedAt: "x", closedAt: new Date().toISOString(), location: "live", archivedAt: null };
666
+ const archDone: DoneItem = { id: "td-d2", title: "old finished", notes: "", project: "", tags: [], priority: "low", status: "done", source: "", createdAt: "x", updatedAt: "x", closedAt: "2026-07-10T00:00:00Z", location: "archive", archivedAt: "2026-07-10T00:00:00Z" };
667
+
668
+ const li = todoDoneItem(liveDone);
669
+ ok("doneItem: label has title", li.label.includes("finished today"));
670
+ ok("doneItem: live tagged [live Nd]", /\[live \d+d\]/.test(li.label));
671
+
672
+ const ai = todoDoneItem(archDone);
673
+ ok("doneItem: archive tagged [archived YYYY-MM-DD]", ai.label.includes("[archived 2026-07-10]"));
674
+
675
+ // --- actionsForDoneTodo ---
676
+ ok("done actions: View detail (live)", actionsForDoneTodo(liveDone).some((a) => a.action === "view"));
677
+ ok("done actions: no Restore for live", !actionsForDoneTodo(liveDone).some((a) => a.action === "restore"));
678
+ ok("done actions: Restore for archived", actionsForDoneTodo(archDone).some((a) => a.action === "restore"));
679
+ ok("done actions: no Delete for done", !actionsForDoneTodo(liveDone).some((a) => a.action === "delete"));
680
+ ```
681
+
682
+ - [ ] **Step 2: Run it to verify it fails**
683
+
684
+ Run: `node test/panel-data.test.mts`
685
+ Expected: FAIL — `todoDoneItem`/`actionsForDoneTodo` not exported.
686
+
687
+ - [ ] **Step 3: Implement in `src/panel-data.ts`**
688
+
689
+ ```ts
690
+ import type { DoneItem } from "./archive.ts";
691
+
692
+ /** Format a done todo (live or archived) as a SelectList item with a
693
+ * location tag: "[live Nd]" or "[archived YYYY-MM-DD]". */
694
+ export function todoDoneItem(d: DoneItem): SelectItem {
695
+ const proj = d.project ? ` (${d.project})` : "";
696
+ const loc = d.location === "archive" && d.archivedAt
697
+ ? ` [archived ${d.archivedAt.slice(0, 10)}]`
698
+ : ` [live ${d.closedAt ? Math.floor((Date.now() - Date.parse(d.closedAt)) / 86400_000) : 0}d]`;
699
+ return { value: d.id, label: `[${d.id}] (done)${proj}${loc} ${d.title}` };
700
+ }
701
+
702
+ /** Actions for a done todo: View detail always; Restore only if archived. */
703
+ export function actionsForDoneTodo(d: DoneItem): { label: string; action: string }[] {
704
+ const acts: { label: string; action: string }[] = [{ label: "View detail", action: "view" }];
705
+ if (d.location === "archive") acts.push({ label: "Restore (from archive)", action: "restore" });
706
+ return acts;
707
+ }
708
+ ```
709
+
710
+ - [ ] **Step 4: Run the test to verify it passes**
711
+
712
+ Run: `node test/panel-data.test.mts`
713
+ Expected: PASS.
714
+
715
+ - [ ] **Step 5: Wire the `Done` tab into `src/panel.ts`**
716
+
717
+ Update `BOXES`:
718
+
719
+ ```ts
720
+ const BOXES: Box[] = ["active", "parked", "done", "archive", "config"];
721
+ ```
722
+
723
+ Update the `Box` type: `export type Box = "active" | "parked" | "done" | "archive" | "config";`
724
+
725
+ Add imports: `listDoneUnified` from `./archive.ts`, `todoDoneItem, actionsForDoneTodo` from `./panel-data.ts`, `restoreTodo` (already imported).
726
+
727
+ In `refreshList`, add a `done` branch (after the `parked` branch, before the `archive` branch):
728
+
729
+ ```ts
730
+ } else if (this.currentBox === "done") {
731
+ const items = listDoneUnified({ text: filter || undefined, limit: 50 });
732
+ this.setSelectItems(items.map(todoDoneItem));
733
+ } else if (this.currentBox === "archive") {
734
+ ```
735
+
736
+ In `onItemSelect`, the done-box rows have plain ids (no `project:`/`month:`/`total` prefix) → they fall through to `openActionSubmenu(item.value)`. But the action set differs for done. Add a done-aware path: in `openActionSubmenu`, detect the done box and use `actionsForDoneTodo`. Simplest: pass the current box to `openActionSubmenu` and branch. Change `openActionSubmenu(id: string)` to look up the todo via `listDoneUnified` when `this.currentBox === "done"`, and build the action list with `actionsForDoneTodo`:
737
+
738
+ ```ts
739
+ private openActionSubmenu(id: string): void {
740
+ let acts: { label: string; action: string }[];
741
+ let todoExists = true;
742
+ if (this.currentBox === "done") {
743
+ const d = listDoneUnified({}).find((x) => x.id === id);
744
+ if (!d) { this.onNotify("Done todo not found.", "info"); return; }
745
+ acts = actionsForDoneTodo(d);
746
+ } else {
747
+ const all = listTodos({ status: "all", limit: 200 });
748
+ const todo = all.find((t) => t.id === id);
749
+ if (!todo) { this.onNotify("Todo not found in the live store (archive restore: use the archive box).", "info"); return; }
750
+ acts = [{ label: "View detail", action: "view" }, ...actionsForTodo(todo)];
751
+ }
752
+ const items: SelectItem[] = acts.map((a) => ({ value: a.action, label: a.label }));
753
+ this.actionList = new SelectList(items, 8, { /* same opts as before */ });
754
+ this.actionList.onSelect = (a) => this.executeAction(id, a.value);
755
+ this.actionList.onCancel = () => { this.actionMode = false; this.actionList = null; this.renderShell(); };
756
+ this.actionMode = true;
757
+ this.renderShell();
758
+ }
759
+ ```
760
+
761
+ (Keep the existing `SelectList` options verbatim — only the `acts` source changes.)
762
+
763
+ `executeAction` already handles `view` (Task 5 of v0.3.0) + `restore` (calls `restoreTodo(id)`). The done-box `restore` reuses the existing `case "restore": restoreTodo(id); ...`. No new case needed — confirm `executeAction`'s `restore` case exists + works for done-box rows. (It does from v0.3.0.) After a restore from the done box, `refreshList()` re-pulls `listDoneUnified` (the restored todo is now `open` in live → no longer in the done set) and the list updates. Good.
764
+
765
+ - [ ] **Step 6: Verify both files parse**
766
+
767
+ Run:
768
+ ```bash
769
+ node --check src/panel.ts
770
+ node --check src/panel-data.ts
771
+ ```
772
+ Expected: no output (success).
773
+
774
+ - [ ] **Step 7: Run the full suite**
775
+
776
+ Run: `npm test`
777
+ Expected: all 9 suites PASS.
778
+
779
+ - [ ] **Step 8: Commit**
780
+
781
+ ```bash
782
+ git add src/panel-data.ts src/panel.ts test/panel-data.test.mts
783
+ git commit -m "feat(panel): Done box tab — unified done view (live + archived)
784
+
785
+ New 5th box tab 'Done' shows status:done todos unified across live + archive
786
+ (excludes cancelled), location-tagged ([live Nd] / [archived YYYY-MM-DD]),
787
+ sorted newest-closed first, filterable. todoDoneItem + actionsForDoneTodo
788
+ helpers (View detail always; Restore only if archived; no Delete for done).
789
+ Archive tab unchanged (sealed vault: done + cancelled). BOXES -> 5 tabs."
790
+ ```
791
+
792
+ ---
793
+
794
+ ## Task 6: Docs + version bump + ship
795
+
796
+ **Files:**
797
+ - Modify: `package.json` (`version: 0.3.1`)
798
+ - Modify: `README.md` (auto-prune, Done tab, `/todo done`, test count)
799
+ - Modify: `AGENTS.md` (modules + suites + features)
800
+
801
+ - [ ] **Step 1: Bump version**
802
+
803
+ ```bash
804
+ sed -i '' 's/"version": "0.3.0"/"version": "0.3.1"/' package.json
805
+ ```
806
+
807
+ - [ ] **Step 2: Update README**
808
+
809
+ - Lifecycle section: add a note that done/cancelled auto-archive on session_start after `defaultAgeDays` (reversible).
810
+ - Slash list: add `/todo done`.
811
+ - Panel section: add the `Done` tab to the box-tabs line + the location tags + done-row actions.
812
+ - Test count line: `npm test (220/220 across 8 suites)` → the new count (run `npm test` to get it; 9 suites now).
813
+ - (Full README revamp is a separate tracked TODO `td-mru4r65krntwz0` — after v0.3.1 ships; here just add the v0.3.1 facts.)
814
+
815
+ - [ ] **Step 3: Update AGENTS.md**
816
+
817
+ - Structure: `test/` 8 → 9 suites (add `todo-auto-prune`); `src/` add `auto-prune (session_start age-gated prune)`.
818
+ - Common Commands: add `node test/todo-auto-prune.test.mts` + update the total count.
819
+ - Notes: add the auto-prune + unified Done view bullets.
820
+
821
+ - [ ] **Step 4: Run the full suite + syntax checks**
822
+
823
+ Run:
824
+ ```bash
825
+ npm test
826
+ for f in src/*.ts extensions/todo.ts; do node --check "$f" || echo "FAIL $f"; done
827
+ ```
828
+ Expected: all 9 suites PASS; all `--check` silent.
829
+
830
+ - [ ] **Step 5: Commit**
831
+
832
+ ```bash
833
+ git add package.json README.md AGENTS.md
834
+ git commit -m "docs(v0.3.1): auto-prune + Done tab + /todo done, version bump"
835
+ ```
836
+
837
+ - [ ] **Step 6: Push + open PR**
838
+
839
+ ```bash
840
+ git push -u origin feat/auto-prune-done-view
841
+ gh pr create --base main --head feat/auto-prune-done-view \
842
+ --title "v0.3.1 — auto-prune on session_start + unified Done view" \
843
+ --body-file /tmp/v031-pr-body.md # write the body to a file first (backticks break inline heredoc)
844
+ ```
845
+
846
+ (PR body: summary of auto-prune + rich result + unified Done + Done tab + injection unchanged; tests 9 suites; QA gate note. Write to `/tmp/v031-pr-body.md` then `--body-file`.)
847
+
848
+ - [ ] **Step 7: RECTOR QA gate (manual — do NOT merge until sign-off)**
849
+
850
+ Local install + restart pi, verify:
851
+ 1. **Auto-prune:** seed a done todo >7d old in the live store, restart pi → startup notify shows `auto-pruned 1 stale done (>7d): ... Undo with: todo restore <id>`; the todo is in the archive; a fresh done (<7d) stays in live.
852
+ 2. **`/todo done`** → unified list (live + archived done, location-tagged).
853
+ 3. **`todo list status:'done'`** (tool) → same unified list.
854
+ 4. **`/todo` panel** → `Done` tab shows unified done; View detail works; Restore-from-archive works (archived done → back to live as open); Archive tab unchanged.
855
+ 5. **Manual `/todo prune`** → rich output (id+title+age + restore hint).
856
+ 6. **Injection unchanged** → `## Open TODOs` still shows only open+in_progress (title + `•`).
857
+ 7. **`/todo health`** → unchanged (+notes-bytes line from v0.3.0).
858
+
859
+ - [ ] **Step 8: After QA sign-off — merge + tag**
860
+
861
+ ```bash
862
+ gh pr merge <N> --merge --delete-branch
863
+ git checkout main && git pull
864
+ git tag -am "v0.3.1 — auto-prune + unified Done view" v0.3.1
865
+ git push origin v0.3.1 # triggers release.yml → npm publish + GitHub Release (auto)
866
+ npm view @getpipher/armory-todo version # verify 0.3.1
867
+ ```
868
+
869
+ - [ ] **Step 9: Post-ship**
870
+
871
+ - Mark the spec `shipped` in `docs/superpowers/specs/2026-07-21-auto-prune-done-view-design.md`.
872
+ - Record a `v0.3.1-shipped.md` memory (gotchas: auto-prune notify on every /reload repeats — acceptable; the open `detail` flag question resolved → always-rich).
873
+ - Flip RECTOR's settings back to `npm:@getpipher/armory-todo@0.3.1` (remove local-path if used for QA).
874
+ - **Then action the parked README-revamp TODO `td-mru4r65krntwz0`** (start with the hero section).
875
+
876
+ ---
877
+
878
+ ## Self-Review (run after writing the plan)
879
+
880
+ **Spec coverage:**
881
+ - §5 auto-prune (session_start, age-gated, never --all, silent-when-clean, idempotent, rich notify) → Task 4 ✅
882
+ - §6 rich prune result (items + ageDays, auto + manual) → Task 1 (store) + Task 3 (ext output) ✅
883
+ - §7 unified Done listing (listDoneUnified, list status:done unified, /todo done) → Task 2 (store) + Task 3 (tool/slash) ✅
884
+ - §8 panel Done tab (5 tabs, location tags, View detail + Restore-from-archive, Archive unchanged) → Task 5 ✅
885
+ - §9 injection contract unchanged → Global Constraints + Task 4 notes (no renderOpenBlock change) ✅
886
+ - §10 tests (new todo-auto-prune + extend archive/panel-data) → Tasks 1, 2, 4, 5 ✅
887
+ - §11 branch + ship → Task 6 ✅
888
+
889
+ **Placeholder scan:** none — every step has exact code or commands.
890
+
891
+ **Type consistency:** `PruneResult.items: PruneItem[]` (Task 1) ← consumed by Task 3 + 4. `DoneItem` + `listDoneUnified` (Task 2) ← consumed by Task 3 + 5. `autoPruneOnSessionStart(): PruneResult | null` (Task 4) ← consumed by the extension. `todoDoneItem` + `actionsForDoneTodo` (Task 5) ← consumed by panel. Names match across tasks.
892
+
893
+ **One note:** Task 5 Step 5's `openActionSubmenu` rewrite reuses the existing `SelectList` options — at implementation, copy the option object verbatim from the current `openActionSubmenu` (don't retype it; the plan elides it with a comment for brevity).