@getpipher/armory-todo 0.1.0 → 0.2.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,1691 @@
1
+ # SPEC-1: Store Layer — Lifecycle Boxes + Prune + Archive + Restore
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:** Turn armory-todo's flat open/done list into a lifecycle-box store (active / parked / archive) so the agent context sees only the working set, finished work moves to a sealed archive, and deferred work has a `parked` home — all reversible, nothing deleted by default.
6
+
7
+ **Architecture:** Split the single `src/todo-store.ts` into focused modules: `paths.ts` (TODO_DIR resolution), `config.ts` (prune/health config), `migrate.ts` (v1→v2 folder migration), `archive.ts` (archive store + prune + restore), with `todo-store.ts` remaining the public API surface that re-exports + owns the live store CRUD + `parked` status + extended `list`. The extension (`extensions/todo.ts`) gains `park`/`prune`/`restore` tool actions + typed slash subcommands.
8
+
9
+ **Tech Stack:** TypeScript, Node.js (`node:fs`, `node:os`, `node:path`), zero runtime deps. Tests: plain `node test/*.test.mts` with `await import` (matches existing harness). pi extension API (`@earendil-works/pi-coding-agent`), typebox, `@earendil-works/pi-ai` (peer deps).
10
+
11
+ **Design doc:** `docs/superpowers/specs/2026-07-20-lifecycle-boxes-prune-design.md` (§4 storage, §5 data model, §6 transitions, §7 tool API, §12 edge cases, §13 testing).
12
+
13
+ ## Global Constraints
14
+
15
+ - **Zero runtime dependencies** — `node:fs`/`node:os`/`node:path` only. No `better-sqlite3`, no `lodash`, nothing. (Matches v0.1.0's "no dependencies" stance, design §9 of the original SPEC.)
16
+ - **File perms `0600`** on every JSON file written (`todo.json`, `todo-archive.json`, `todo.config.json`).
17
+ - **Atomic writes** — write to `<path>.tmp` + `renameSync` to the final path. Never write partial JSON.
18
+ - **Corrupt-file recovery** — on parse failure, back up to `<path>.bad-<ts>` and start fresh. Never crash the session.
19
+ - **2-space indent**, meaningful names, comments only for complex logic. No TODO/FIXME in delivered code.
20
+ - **Backwards-compatible** — existing `add`/`update`/`complete`/`delete`/`list` behavior preserved. `clear` is deprecated but kept (removed in a later release).
21
+ - **Store version bumped to 2.** v1 stores are migrated on first load.
22
+ - **`Status` enum widened to `"open" | "in_progress" | "parked" | "done" | "cancelled"`.**
23
+ - **Auto-injection logic unchanged** — `renderOpenBlock` still injects `open` + `in_progress` only (parked is auto-excluded by the existing filter).
24
+ - Tests run via `node test/todo-store.test.mts` (and new `test/todo-archive.test.mts`, `test/todo-config.test.mts`, `test/todo-migrate.test.mts`).
25
+
26
+ ---
27
+
28
+ ## File Structure
29
+
30
+ | File | Responsibility | Status |
31
+ |---|---|---|
32
+ | `src/paths.ts` | Resolve `TODO_DIR` → concrete file paths (`todo.json`, `todo-archive.json`, `todo.config.json`). Single source of truth for path logic. | Create |
33
+ | `src/config.ts` | `TodoConfig` type, `DEFAULT_CONFIG`, `loadConfig`, `saveConfig`. Owns `todo.config.json`. | Create |
34
+ | `src/migrate.ts` | `migrateIfNeeded({ todoDir, legacyPath })` — one-time v1 single-file → v2 folder move. Pure function, testable without touching real home. | Create |
35
+ | `src/archive.ts` | `ArchiveStore` type, `loadArchive`, `saveArchive`, `pruneTodos`, `restoreTodo`, `archiveSummary`, `listArchived`. Owns the archive box + cross-file moves. | Create |
36
+ | `src/todo-store.ts` | Public API surface. `Todo`/`Store` types, `loadStore`/`saveStore` (v2), `add`/`update`/`complete`/`delete`/`park`/`list` (extended with archived/filters/pagination/summary), `renderOpenBlock`, `clearTodos` (deprecated). Re-exports config/archive/migrate entry points used by the extension. | Modify |
37
+ | `extensions/todo.ts` | Add `park`/`prune`/`restore` tool actions; extend `list` params (`archived`, `since`, `before`, `text`, `limit`, `page`); add typed slash subcommands (`park`, `restore`, `prune`, `prune --all`, `archive`, `archive <filter>`). | Modify |
38
+ | `test/todo-store.test.mts` | Extend existing tests: `parked` round-trip, `parked` excluded from `renderOpenBlock`, extended `list` filters/pagination/summary. | Modify |
39
+ | `test/todo-config.test.mts` | Config defaults, load, save, corrupt-file recovery, missing-file → defaults. | Create |
40
+ | `test/todo-migrate.test.mts` | v1 single-file → v2 folder migration; idempotency; migration failure → backup. | Create |
41
+ | `test/todo-archive.test.mts` | Archive load/save, `prune` age-based + `--all`, `restore`, `listArchived` filters + pagination, `archiveSummary`, `restore` of non-archived id errors. | Create |
42
+
43
+ ---
44
+
45
+ ## Task 1: Paths module — TODO_DIR resolution
46
+
47
+ **Files:**
48
+ - Create: `src/paths.ts`
49
+ - Test: `test/todo-store.test.mts` (modify the env-setup block at top)
50
+
51
+ **Interfaces:**
52
+ - Produces: `getTodoDir(): string`, `getLivePath(): string`, `getArchivePath(): string`, `getConfigPath(): string`, `getLegacyPath(): string` (the old `~/.pi/agent/todo.json`).
53
+
54
+ - [ ] **Step 1: Write the failing test**
55
+
56
+ Add to the top of `test/todo-store.test.mts`, replacing the existing `process.env.TODO_STORE_PATH = ...` line:
57
+
58
+ ```ts
59
+ import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, statSync } from "node:fs";
60
+ import { tmpdir } from "node:os";
61
+ import { join } from "node:path";
62
+
63
+ const tmp = mkdtempSync(join(tmpdir(), "armory-todo-"));
64
+ process.env.TODO_DIR = tmp;
65
+
66
+ const { getTodoDir, getLivePath, getArchivePath, getConfigPath, getLegacyPath } =
67
+ await import("../src/paths.ts");
68
+
69
+ // --- paths resolve under TODO_DIR ---
70
+ eq("getTodoDir is TODO_DIR", getTodoDir(), tmp);
71
+ eq("live path under TODO_DIR", getLivePath(), join(tmp, "todo.json"));
72
+ eq("archive path under TODO_DIR", getArchivePath(), join(tmp, "todo-archive.json"));
73
+ eq("config path under TODO_DIR", getConfigPath(), join(tmp, "todo.config.json"));
74
+ ok("legacy path is the real ~/.pi/agent/todo.json", getLegacyPath().endsWith(join(".pi", "agent", "todo.json")));
75
+ ```
76
+
77
+ - [ ] **Step 2: Run test to verify it fails**
78
+
79
+ Run: `node test/todo-store.test.mts`
80
+ Expected: FAIL — `Cannot find module '../src/paths.ts'`
81
+
82
+ - [ ] **Step 3: Write minimal implementation**
83
+
84
+ Create `src/paths.ts`:
85
+
86
+ ```ts
87
+ // Path resolution for the armory-todo folder layout (v2).
88
+ //
89
+ // All store files live under TODO_DIR (default ~/.pi/agent/todo/):
90
+ // todo.json — live store (open, in_progress, parked)
91
+ // todo-archive.json — sealed history (done, cancelled)
92
+ // todo.config.json — prune ages + health thresholds
93
+ //
94
+ // The legacy v1 single file was ~/.pi/agent/todo.json; migrate.ts handles
95
+ // moving it into the folder on first load.
96
+
97
+ import { homedir } from "node:os";
98
+ import { join } from "node:path";
99
+
100
+ const DEFAULT_DIR = join(homedir(), ".pi", "agent", "todo");
101
+ const LEGACY_PATH = join(homedir(), ".pi", "agent", "todo.json");
102
+
103
+ export function getTodoDir(): string {
104
+ return process.env.TODO_DIR || DEFAULT_DIR;
105
+ }
106
+
107
+ export function getLivePath(): string {
108
+ return join(getTodoDir(), "todo.json");
109
+ }
110
+
111
+ export function getArchivePath(): string {
112
+ return join(getTodoDir(), "todo-archive.json");
113
+ }
114
+
115
+ export function getConfigPath(): string {
116
+ return join(getTodoDir(), "todo.config.json");
117
+ }
118
+
119
+ /** The pre-v2 single-file store location. Used by migrate.ts. */
120
+ export function getLegacyPath(): string {
121
+ return LEGACY_PATH;
122
+ }
123
+ ```
124
+
125
+ - [ ] **Step 4: Run test to verify it passes**
126
+
127
+ Run: `node test/todo-store.test.mts`
128
+ Expected: the 5 path assertions pass. (Other tests may still fail if they referenced the old `TODO_STORE_PATH` — we fix those in Task 9 when we rewire the store. For now, paths-only assertions must pass.)
129
+
130
+ - [ ] **Step 5: Commit**
131
+
132
+ ```bash
133
+ git add src/paths.ts test/todo-store.test.mts
134
+ git commit -m "feat(paths): TODO_DIR-based path resolution for v2 folder layout"
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Task 2: Config module — defaults, load, save
140
+
141
+ **Files:**
142
+ - Create: `src/config.ts`
143
+ - Create: `test/todo-config.test.mts`
144
+
145
+ **Interfaces:**
146
+ - Produces: `TodoConfig`, `DEFAULT_CONFIG`, `loadConfig(): TodoConfig`, `saveConfig(config: TodoConfig): void`.
147
+
148
+ - [ ] **Step 1: Write the failing test**
149
+
150
+ Create `test/todo-config.test.mts`:
151
+
152
+ ```ts
153
+ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs";
154
+ import { tmpdir } from "node:os";
155
+ import { join } from "node:path";
156
+
157
+ let passed = 0;
158
+ let failed = 0;
159
+ function ok(name: string, cond: boolean, extra = ""): void {
160
+ if (cond) { passed++; } else { failed++; console.error(` ✗ ${name} ${extra}`); }
161
+ }
162
+ function eq<T>(name: string, got: T, want: T): void {
163
+ ok(name, got === want, `(got ${JSON.stringify(got)} want ${JSON.stringify(want)})`);
164
+ }
165
+
166
+ const tmp = mkdtempSync(join(tmpdir(), "armory-config-"));
167
+ process.env.TODO_DIR = tmp;
168
+
169
+ const { DEFAULT_CONFIG, loadConfig, saveConfig } = await import("../src/config.ts");
170
+
171
+ // --- defaults ---
172
+ eq("default prune age 7", DEFAULT_CONFIG.prune.defaultAgeDays, 7);
173
+ eq("default hard age 180", DEFAULT_CONFIG.prune.hardAgeDays, 180);
174
+ eq("default prune statuses done+cancelled", DEFAULT_CONFIG.prune.statuses.length, 2);
175
+ eq("default activeMaxOpen 15", DEFAULT_CONFIG.health.activeMaxOpen, 15);
176
+ eq("default activeStaleDays 30", DEFAULT_CONFIG.health.activeStaleDays, 30);
177
+ eq("default parkedMax 10", DEFAULT_CONFIG.health.parkedMax, 10);
178
+ eq("default parkedStaleDays 60", DEFAULT_CONFIG.health.parkedStaleDays, 60);
179
+ eq("default archiveMax 200", DEFAULT_CONFIG.health.archiveMax, 200);
180
+ eq("default archiveOldDays 180", DEFAULT_CONFIG.health.archiveOldDays, 180);
181
+
182
+ // --- missing config → defaults written ---
183
+ const cfg = loadConfig();
184
+ eq("loadConfig returns defaults when missing", cfg.prune.defaultAgeDays, 7);
185
+ ok("config file created on first load", existsSync(join(tmp, "todo.config.json")));
186
+
187
+ // --- save + reload round-trip ---
188
+ const mutated = { ...cfg, prune: { ...cfg.prune, defaultAgeDays: 14 } };
189
+ saveConfig(mutated);
190
+ const reloaded = loadConfig();
191
+ eq("saved config reloads", reloaded.prune.defaultAgeDays, 14);
192
+
193
+ // --- config file is 0600 ---
194
+ const stat = readFileSync(join(tmp, "todo.config.json"));
195
+ // (mode checked via statSync below for consistency with store tests)
196
+ import { statSync } from "node:fs";
197
+ const mode = statSync(join(tmp, "todo.config.json")).mode & 0o777;
198
+ ok("config file mode 0600", mode === 0o600, `(mode ${mode.toString(8)})`);
199
+
200
+ // --- corrupt config → backup + fresh defaults ---
201
+ writeFileSync(join(tmp, "todo.config.json"), "{ not json", "utf8");
202
+ const recovered = loadConfig();
203
+ eq("corrupt config → defaults", recovered.prune.defaultAgeDays, 7);
204
+ ok("corrupt config backed up", existsSync(join(tmp, "todo.config.json" + ".bad-")) || recovered.prune.defaultAgeDays === 7);
205
+
206
+ rmSync(tmp, { recursive: true, force: true });
207
+ console.log(`\n${passed} passed, ${failed} failed`);
208
+ if (failed > 0) process.exit(1);
209
+ ```
210
+
211
+ - [ ] **Step 2: Run test to verify it fails**
212
+
213
+ Run: `node test/todo-config.test.mts`
214
+ Expected: FAIL — `Cannot find module '../src/config.ts'`
215
+
216
+ - [ ] **Step 3: Write minimal implementation**
217
+
218
+ Create `src/config.ts`:
219
+
220
+ ```ts
221
+ // Prune + health configuration for armory-todo.
222
+ //
223
+ // Stored at <TODO_DIR>/todo.config.json. Missing or corrupt → defaults are
224
+ // rewritten (the bad file is backed up to todo.config.json.bad-<ts>). All
225
+ // values are editable (later, via the SPEC-3 /todo Config panel).
226
+
227
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
228
+ import { dirname } from "node:path";
229
+ import { getConfigPath } from "./paths.ts";
230
+
231
+ export interface PruneConfig {
232
+ /** Closed todos older than this (by closedAt) are moved to archive on `prune`. */
233
+ defaultAgeDays: number;
234
+ /** Archive items older than this are flagged for hard-prune suggestion. */
235
+ hardAgeDays: number;
236
+ /** Which terminal statuses get pruned. */
237
+ statuses: ("done" | "cancelled")[];
238
+ }
239
+
240
+ export interface HealthConfig {
241
+ activeMaxOpen: number;
242
+ activeStaleDays: number;
243
+ parkedMax: number;
244
+ parkedStaleDays: number;
245
+ archiveMax: number;
246
+ archiveOldDays: number;
247
+ }
248
+
249
+ export interface TodoConfig {
250
+ version: 1;
251
+ prune: PruneConfig;
252
+ health: HealthConfig;
253
+ }
254
+
255
+ export const DEFAULT_CONFIG: TodoConfig = {
256
+ version: 1,
257
+ prune: {
258
+ defaultAgeDays: 7,
259
+ hardAgeDays: 180,
260
+ statuses: ["done", "cancelled"],
261
+ },
262
+ health: {
263
+ activeMaxOpen: 15,
264
+ activeStaleDays: 30,
265
+ parkedMax: 10,
266
+ parkedStaleDays: 60,
267
+ archiveMax: 200,
268
+ archiveOldDays: 180,
269
+ },
270
+ };
271
+
272
+ function now(): string {
273
+ return new Date().toISOString();
274
+ }
275
+
276
+ /** Deep clone of DEFAULT_CONFIG (so callers can't mutate the constant). */
277
+ function freshDefaults(): TodoConfig {
278
+ return JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as TodoConfig;
279
+ }
280
+
281
+ export function loadConfig(): TodoConfig {
282
+ const path = getConfigPath();
283
+ if (!existsSync(path)) {
284
+ const cfg = freshDefaults();
285
+ saveConfig(cfg);
286
+ return cfg;
287
+ }
288
+ try {
289
+ const raw = readFileSync(path, "utf8");
290
+ const parsed = JSON.parse(raw) as TodoConfig;
291
+ if (!parsed || typeof parsed !== "object" || !parsed.prune || !parsed.health) {
292
+ throw new Error("invalid config shape");
293
+ }
294
+ // Merge with defaults so new fields get filled in on upgrade.
295
+ return {
296
+ version: 1,
297
+ prune: { ...DEFAULT_CONFIG.prune, ...parsed.prune },
298
+ health: { ...DEFAULT_CONFIG.health, ...parsed.health },
299
+ };
300
+ } catch {
301
+ try {
302
+ renameSync(path, `${path}.bad-${Date.now()}`);
303
+ } catch {
304
+ // best-effort backup
305
+ }
306
+ const cfg = freshDefaults();
307
+ saveConfig(cfg);
308
+ return cfg;
309
+ }
310
+ }
311
+
312
+ /** Atomic, 0600 write. */
313
+ export function saveConfig(config: TodoConfig): void {
314
+ const path = getConfigPath();
315
+ const dir = dirname(path);
316
+ mkdirSync(dir, { recursive: true });
317
+ const tmp = `${path}.tmp`;
318
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
319
+ try {
320
+ chmodSync(tmp, 0o600);
321
+ } catch {
322
+ // some filesystems ignore mode bits
323
+ }
324
+ renameSync(tmp, path);
325
+ }
326
+ ```
327
+
328
+ - [ ] **Step 4: Run test to verify it passes**
329
+
330
+ Run: `node test/todo-config.test.mts`
331
+ Expected: PASS (all assertions)
332
+
333
+ - [ ] **Step 5: Commit**
334
+
335
+ ```bash
336
+ git add src/config.ts test/todo-config.test.mts
337
+ git commit -m "feat(config): todo.config.json with prune + health defaults"
338
+ ```
339
+
340
+ ---
341
+
342
+ ## Task 3: Migration — v1 single-file → v2 folder
343
+
344
+ **Files:**
345
+ - Create: `src/migrate.ts`
346
+ - Create: `test/todo-migrate.test.mts`
347
+
348
+ **Interfaces:**
349
+ - Produces: `migrateIfNeeded({ todoDir: string, legacyPath: string }): void` — if `todoDir/todo.json` doesn't exist but `legacyPath` does, create the folder + move the legacy file to `todoDir/todo.json`. Idempotent. On failure, restore the legacy file from a `.bak-<ts>` backup.
350
+
351
+ - [ ] **Step 1: Write the failing test**
352
+
353
+ Create `test/todo-migrate.test.mts`:
354
+
355
+ ```ts
356
+ import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync, mkdirSync, renameSync } from "node:fs";
357
+ import { tmpdir } from "node:os";
358
+ import { join } from "node:path";
359
+
360
+ let passed = 0;
361
+ let failed = 0;
362
+ function ok(name: string, cond: boolean, extra = ""): void {
363
+ if (cond) { passed++; } else { failed++; console.error(` ✗ ${name} ${extra}`); }
364
+ }
365
+
366
+ const { migrateIfNeeded } = await import("../src/migrate.ts");
367
+
368
+ // --- Case 1: legacy file exists, folder doesn't → migrate ---
369
+ {
370
+ const todoDir = mkdtempSync(join(tmpdir(), "armory-mig1-"));
371
+ const legacy = join(todoDir, "legacy-todo.json");
372
+ // legacy file with a v1 store
373
+ writeFileSync(legacy, JSON.stringify({ version: 1, updatedAt: "2026-06-23T10:00:00Z", todos: [{ id: "td-x", text: "old", project: "", tags: [], priority: "med", status: "done", source: "", createdAt: "2026-06-23T10:00:00Z", updatedAt: "2026-06-23T10:00:00Z", closedAt: "2026-06-23T10:00:00Z" }] }, null, 2), "utf8");
374
+ // the "folder" is a sibling dir that doesn't exist yet
375
+ const targetDir = join(todoDir, "todo");
376
+ migrateIfNeeded({ todoDir: targetDir, legacyPath: legacy });
377
+ ok("case1: todo.json moved into folder", existsSync(join(targetDir, "todo.json")));
378
+ ok("case1: legacy file removed", !existsSync(legacy));
379
+ const moved = JSON.parse(readFileSync(join(targetDir, "todo.json"), "utf8"));
380
+ ok("case1: moved content preserved", moved.todos.length === 1 && moved.todos[0].id === "td-x");
381
+ rmSync(todoDir, { recursive: true, force: true });
382
+ }
383
+
384
+ // --- Case 2: folder already exists → no-op (idempotent) ---
385
+ {
386
+ const todoDir = mkdtempSync(join(tmpdir(), "armory-mig2-"));
387
+ const legacy = join(todoDir, "legacy-todo.json");
388
+ writeFileSync(legacy, '{"version":1,"todos":[]}', "utf8");
389
+ const targetDir = join(todoDir, "todo");
390
+ mkdirSync(targetDir, { recursive: true });
391
+ writeFileSync(join(targetDir, "todo.json"), '{"version":2,"todos":[]}', "utf8");
392
+ migrateIfNeeded({ todoDir: targetDir, legacyPath: legacy });
393
+ ok("case2: legacy file untouched (no-op)", existsSync(legacy));
394
+ ok("case2: existing todo.json untouched", JSON.parse(readFileSync(join(targetDir, "todo.json"), "utf8")).version === 2);
395
+ rmSync(todoDir, { recursive: true, force: true });
396
+ }
397
+
398
+ // --- Case 3: neither exists → no-op (fresh install) ---
399
+ {
400
+ const todoDir = mkdtempSync(join(tmpdir(), "armory-mig3-"));
401
+ const legacy = join(todoDir, "nope.json"); // doesn't exist
402
+ const targetDir = join(todoDir, "todo");
403
+ migrateIfNeeded({ todoDir: targetDir, legacyPath: legacy });
404
+ ok("case3: no todo.json created (no legacy to move)", !existsSync(join(targetDir, "todo.json")));
405
+ rmSync(todoDir, { recursive: true, force: true });
406
+ }
407
+
408
+ console.log(`\n${passed} passed, ${failed} failed`);
409
+ if (failed > 0) process.exit(1);
410
+ ```
411
+
412
+ - [ ] **Step 2: Run test to verify it fails**
413
+
414
+ Run: `node test/todo-migrate.test.mts`
415
+ Expected: FAIL — `Cannot find module '../src/migrate.ts'`
416
+
417
+ - [ ] **Step 3: Write minimal implementation**
418
+
419
+ Create `src/migrate.ts`:
420
+
421
+ ```ts
422
+ // One-time v1 → v2 migration: move the legacy single-file store
423
+ // (~/.pi/agent/todo.json) into the v2 folder layout (~/.pi/agent/todo/todo.json).
424
+ //
425
+ // Pure + testable: takes explicit paths rather than reading env, so tests can
426
+ // point at temp dirs without touching the real home directory.
427
+ //
428
+ // Idempotent: if the target todo.json already exists, do nothing (the user
429
+ // already migrated, or started fresh on v2).
430
+
431
+ import { copyFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
432
+ import { join } from "node:path";
433
+
434
+ export interface MigrateInput {
435
+ /** The v2 folder (e.g. ~/.pi/agent/todo/). */
436
+ todoDir: string;
437
+ /** The pre-v2 single file (e.g. ~/.pi/agent/todo.json). */
438
+ legacyPath: string;
439
+ }
440
+
441
+ /**
442
+ * If `<todoDir>/todo.json` does not exist but `legacyPath` does, create the
443
+ * folder and move the legacy file in. Atomic-ish: the legacy file is copied
444
+ * first (as a .bak), then moved, so a mid-move failure leaves the legacy file
445
+ * intact. Safe to call on every load.
446
+ */
447
+ export function migrateIfNeeded(input: MigrateInput): void {
448
+ const target = join(input.todoDir, "todo.json");
449
+ if (existsSync(target)) return; // already v2
450
+ if (!existsSync(input.legacyPath)) return; // nothing to migrate
451
+
452
+ mkdirSync(input.todoDir, { recursive: true });
453
+ // Copy-then-rename so the legacy file survives a crash between copy + rename.
454
+ const backup = `${input.legacyPath}.migrate-bak-${Date.now()}`;
455
+ copyFileSync(input.legacyPath, backup);
456
+ try {
457
+ renameSync(input.legacyPath, target);
458
+ // success → remove the backup
459
+ try { unlinkSync(backup); } catch { /* best-effort */ }
460
+ } catch {
461
+ // rename failed → restore from backup (legacy file may have been moved
462
+ // on some filesystems; copy it back to be safe)
463
+ try { copyFileSync(backup, input.legacyPath); } catch { /* best-effort */ }
464
+ throw new Error(`migration failed: could not move ${input.legacyPath} → ${target}`);
465
+ }
466
+ }
467
+ ```
468
+
469
+ - [ ] **Step 4: Run test to verify it passes**
470
+
471
+ Run: `node test/todo-migrate.test.mts`
472
+ Expected: PASS (all 3 cases)
473
+
474
+ - [ ] **Step 5: Commit**
475
+
476
+ ```bash
477
+ git add src/migrate.ts test/todo-migrate.test.mts
478
+ git commit -m "feat(migrate): v1 single-file → v2 folder layout migration"
479
+ ```
480
+
481
+ ---
482
+
483
+ ## Task 4: `parked` status + `parkTodo`
484
+
485
+ **Files:**
486
+ - Modify: `src/todo-store.ts` (widen Status enum, add `parkTodo`)
487
+ - Modify: `test/todo-store.test.mts` (parked round-trip + renderOpenBlock exclusion)
488
+
489
+ **Interfaces:**
490
+ - Produces: `Status` now includes `"parked"`. `parkTodo(id: string): Todo` — sets status to `parked`, clears `closedAt`.
491
+
492
+ - [ ] **Step 1: Write the failing test**
493
+
494
+ Append to `test/todo-store.test.mts` (after the `fresh import` line, which we'll update in Task 9 to import from the rewired store; for now add the import of `parkTodo`):
495
+
496
+ ```ts
497
+ // At the top, extend the fresh-import line to include parkTodo + listTodos:
498
+ const { addTodo, listTodos, updateTodo, completeTodo, deleteTodo, clearTodos, renderOpenBlock, loadStore, parkTodo } =
499
+ await import("../src/todo-store.ts");
500
+ ```
501
+
502
+ Then append these assertions near the end of the test file (before the `rmSync(tmp, ...)` cleanup):
503
+
504
+ ```ts
505
+ // --- parked status: round-trip + excluded from renderOpenBlock ---
506
+ const p1 = addTodo({ text: "maybe someday task", project: "pi", priority: "low" });
507
+ const parked = parkTodo(p1.id);
508
+ eq("park sets status parked", parked.status, "parked");
509
+ eq("park clears closedAt", parked.closedAt, null);
510
+ // parked is NOT in the default actionable list
511
+ eq("parked excluded from default list", listTodos().some((t) => t.id === p1.id), false);
512
+ // parked IS visible with status=all
513
+ eq("parked in status=all", listTodos({ status: "all" }).some((t) => t.id === p1.id), true);
514
+ // parked is NOT in the injected Open TODOs block
515
+ const blockAfterPark = renderOpenBlock();
516
+ ok("parked not in renderOpenBlock", !blockAfterPark.includes("maybe someday task"));
517
+ // parked → back to open via update
518
+ updateTodo(p1.id, { status: "open" });
519
+ eq("parked → open re-includes in default list", listTodos().some((t) => t.id === p1.id), true);
520
+ ```
521
+
522
+ - [ ] **Step 2: Run test to verify it fails**
523
+
524
+ Run: `node test/todo-store.test.mts`
525
+ Expected: FAIL — `parkTodo is not a function` (and `parked` not yet a valid status)
526
+
527
+ - [ ] **Step 3: Write minimal implementation**
528
+
529
+ In `src/todo-store.ts`, make these exact edits:
530
+
531
+ 1. Widen the `Status` type and `STATUSES` array:
532
+
533
+ ```ts
534
+ export type Status = "open" | "in_progress" | "parked" | "done" | "cancelled";
535
+ ```
536
+ ```ts
537
+ const STATUSES: Status[] = ["open", "in_progress", "parked", "done", "cancelled"];
538
+ ```
539
+
540
+ 2. Add `parkTodo` after `completeTodo`:
541
+
542
+ ```ts
543
+ export function parkTodo(id: string): Todo {
544
+ return updateTodo(id, { status: "parked" });
545
+ }
546
+ ```
547
+
548
+ (The existing `updateTodo` already handles `closedAt` clearing: `if (!nowDone) todo.closedAt = null;` — and `parked` is not `done`/`cancelled`, so `closedAt` is cleared. The `assertStatus` call in `updateTodo` will accept `"parked"` once it's in `STATUS_SET`, which it is because `STATUSES` now includes it.)
549
+
550
+ - [ ] **Step 4: Run test to verify it passes**
551
+
552
+ Run: `node test/todo-store.test.mts`
553
+ Expected: PASS — parked assertions pass. (Note: the test file still sets `process.env.TODO_STORE_PATH` at the top from the old harness; the store still reads `STORE_PATH` until Task 9 rewires it to `TODO_DIR`. The parked tests will work because they go through `addTodo`/`parkTodo`/`listTodos` which use whatever path the store currently reads. **Do not remove the old `TODO_STORE_PATH` line yet — Task 9 handles the full rewire.**)
554
+
555
+ - [ ] **Step 5: Commit**
556
+
557
+ ```bash
558
+ git add src/todo-store.ts test/todo-store.test.mts
559
+ git commit -m "feat(store): parked status + parkTodo (deferred/someday box)"
560
+ ```
561
+
562
+ ---
563
+
564
+ ## Task 5: Archive store — load + save
565
+
566
+ **Files:**
567
+ - Create: `src/archive.ts` (ArchiveStore type, loadArchive, saveArchive)
568
+ - Create: `test/todo-archive.test.mts` (first slice — load/save only; prune/restore come in Tasks 6–7)
569
+
570
+ **Interfaces:**
571
+ - Produces: `ArchiveStore` (`{ version: 2, updatedAt, todos: Todo[] }`), `loadArchive(): ArchiveStore`, `saveArchive(store: ArchiveStore): void`. Missing archive → empty store (no file created until first save).
572
+
573
+ - [ ] **Step 1: Write the failing test**
574
+
575
+ Create `test/todo-archive.test.mts`:
576
+
577
+ ```ts
578
+ import { mkdtempSync, rmSync, existsSync, readFileSync, statSync } from "node:fs";
579
+ import { tmpdir } from "node:os";
580
+ import { join } from "node:path";
581
+
582
+ let passed = 0;
583
+ let failed = 0;
584
+ function ok(name: string, cond: boolean, extra = ""): void {
585
+ if (cond) { passed++; } else { failed++; console.error(` ✗ ${name} ${extra}`); }
586
+ }
587
+ function eq<T>(name: string, got: T, want: T): void {
588
+ ok(name, got === want, `(got ${JSON.stringify(got)} want ${JSON.stringify(want)})`);
589
+ }
590
+
591
+ const tmp = mkdtempSync(join(tmpdir(), "armory-archive-"));
592
+ process.env.TODO_DIR = tmp;
593
+ // Pre-Task-9 rewire: the live store still reads TODO_STORE_PATH. Point it at the
594
+ // same temp file the v2 folder will use (<tmp>/todo.json) so prune/restore don't
595
+ // touch the real ~/.pi/agent/todo.json. Task 9 drops this once loadStore reads
596
+ // getLivePath() under TODO_DIR.
597
+ process.env.TODO_STORE_PATH = join(tmp, "todo.json");
598
+
599
+ const { loadArchive, saveArchive } = await import("../src/archive.ts");
600
+ import type { Todo } from "../src/todo-store.ts";
601
+
602
+ // --- missing archive → empty store, no file created ---
603
+ const empty = loadArchive();
604
+ eq("missing archive → 0 todos", empty.todos.length, 0);
605
+ eq("archive version 2", empty.version, 2);
606
+ ok("archive file not created on bare load", !existsSync(join(tmp, "todo-archive.json")));
607
+
608
+ // --- save + reload round-trip ---
609
+ const sample: Todo = { id: "td-arch1", text: "finished thing", project: "pi", tags: [], priority: "med", status: "done", source: "test", createdAt: "2026-07-01T00:00:00Z", updatedAt: "2026-07-01T00:00:00Z", closedAt: "2026-07-02T00:00:00Z" };
610
+ saveArchive({ version: 2, updatedAt: "2026-07-02T00:00:00Z", todos: [sample] });
611
+ ok("archive file created on save", existsSync(join(tmp, "todo-archive.json")));
612
+ const reloaded = loadArchive();
613
+ eq("archive reload count", reloaded.todos.length, 1);
614
+ eq("archive reload id", reloaded.todos[0]!.id, "td-arch1");
615
+
616
+ // --- 0600 perms + atomic (no .tmp leftover) ---
617
+ const mode = statSync(join(tmp, "todo-archive.json")).mode & 0o777;
618
+ ok("archive file mode 0600", mode === 0o600, `(mode ${mode.toString(8)})`);
619
+ ok("no archive .tmp leftover", !existsSync(join(tmp, "todo-archive.json.tmp")));
620
+
621
+ rmSync(tmp, { recursive: true, force: true });
622
+ console.log(`\n${passed} passed, ${failed} failed`);
623
+ if (failed > 0) process.exit(1);
624
+ ```
625
+
626
+ - [ ] **Step 2: Run test to verify it fails**
627
+
628
+ Run: `node test/todo-archive.test.mts`
629
+ Expected: FAIL — `Cannot find module '../src/archive.ts'`
630
+
631
+ - [ ] **Step 3: Write minimal implementation**
632
+
633
+ Create `src/archive.ts`:
634
+
635
+ ```ts
636
+ // Sealed history store for armory-todo — holds done/cancelled todos moved
637
+ // here by `prune`. Recoverable via `restore`; permanently deletable only via
638
+ // `prune --hard` (SPEC-2). Never auto-injected into the system prompt.
639
+ //
640
+ // File: <TODO_DIR>/todo-archive.json (0600, atomic write). Missing on disk
641
+ // → empty store returned; the file is created on first save, not first load.
642
+
643
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
644
+ import { dirname } from "node:path";
645
+ import { getArchivePath } from "./paths.ts";
646
+ import type { Todo } from "./todo-store.ts";
647
+
648
+ export interface ArchiveStore {
649
+ version: 2;
650
+ updatedAt: string;
651
+ todos: Todo[];
652
+ }
653
+
654
+ function now(): string {
655
+ return new Date().toISOString();
656
+ }
657
+
658
+ function emptyArchive(): ArchiveStore {
659
+ return { version: 2, updatedAt: now(), todos: [] };
660
+ }
661
+
662
+ /** Load the archive. Missing file → empty store (no file created). */
663
+ export function loadArchive(): ArchiveStore {
664
+ const path = getArchivePath();
665
+ if (!existsSync(path)) return emptyArchive();
666
+ try {
667
+ const raw = readFileSync(path, "utf8");
668
+ const parsed = JSON.parse(raw) as ArchiveStore;
669
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.todos)) {
670
+ throw new Error("invalid archive shape");
671
+ }
672
+ return parsed;
673
+ } catch {
674
+ try {
675
+ renameSync(path, `${path}.bad-${Date.now()}`);
676
+ } catch {
677
+ // best-effort backup
678
+ }
679
+ return emptyArchive();
680
+ }
681
+ }
682
+
683
+ /** Atomic, 0600 write. */
684
+ export function saveArchive(store: ArchiveStore): void {
685
+ store.updatedAt = now();
686
+ const path = getArchivePath();
687
+ const dir = dirname(path);
688
+ mkdirSync(dir, { recursive: true });
689
+ const tmp = `${path}.tmp`;
690
+ writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
691
+ try {
692
+ chmodSync(tmp, 0o600);
693
+ } catch {
694
+ // some filesystems ignore mode bits
695
+ }
696
+ renameSync(tmp, path);
697
+ }
698
+ ```
699
+
700
+ - [ ] **Step 4: Run test to verify it passes**
701
+
702
+ Run: `node test/todo-archive.test.mts`
703
+ Expected: PASS
704
+
705
+ - [ ] **Step 5: Commit**
706
+
707
+ ```bash
708
+ git add src/archive.ts test/todo-archive.test.mts
709
+ git commit -m "feat(archive): sealed history store (load + save, 0600 atomic)"
710
+ ```
711
+
712
+ ---
713
+
714
+ ## Task 6: `prune` — age-based move to archive
715
+
716
+ **Files:**
717
+ - Modify: `src/archive.ts` (add `pruneTodos`)
718
+ - Modify: `test/todo-archive.test.mts` (prune tests)
719
+
720
+ **Interfaces:**
721
+ - Produces: `pruneTodos(opts: { ageDays?: number; all?: boolean; statuses?: ("done" | "cancelled")[] }): { moved: number; ids: string[] }` — reads the live store, moves qualifying `done`/`cancelled` todos to the archive, saves both. `ageDays` defaults to `config.prune.defaultAgeDays`; `all: true` ignores age. `statuses` defaults to `config.prune.statuses`.
722
+
723
+ **Consumes:** `loadStore`/`saveStore` from `src/todo-store.ts` (Task 9 rewires these to the folder; for now they still read `STORE_PATH`, so `pruneTodos` must call into the live-store functions). `loadConfig` from `src/config.ts`.
724
+
725
+ - [ ] **Step 1: Write the failing test**
726
+
727
+ Append to `test/todo-archive.test.mts` (before the cleanup `rmSync`):
728
+
729
+ ```ts
730
+ // --- prune: age-based move to archive ---
731
+ const { pruneTodos, loadArchive: reloadArch } = await import("../src/archive.ts");
732
+ const { addTodo, completeTodo, loadStore, saveStore } = await import("../src/todo-store.ts");
733
+ import { writeFileSync } from "node:fs";
734
+
735
+ // Set up a live store with: one old-done (prunable), one fresh-done (not prunable), one open (never pruned)
736
+ const livePath = process.env.TODO_STORE_PATH!;
737
+ // Build a live store directly on disk so we control closedAt timestamps
738
+ const oldDate = new Date(Date.now() - 30 * 86400_000).toISOString(); // 30 days ago
739
+ const freshDate = new Date().toISOString();
740
+ writeFileSync(livePath, JSON.stringify({
741
+ version: 1, updatedAt: freshDate,
742
+ todos: [
743
+ { id: "td-old-done", text: "old done", project: "", tags: [], priority: "med", status: "done", source: "", createdAt: oldDate, updatedAt: oldDate, closedAt: oldDate },
744
+ { id: "td-fresh-done", text: "fresh done", project: "", tags: [], priority: "med", status: "done", source: "", createdAt: freshDate, updatedAt: freshDate, closedAt: freshDate },
745
+ { id: "td-open", text: "still open", project: "", tags: [], priority: "med", status: "open", source: "", createdAt: freshDate, updatedAt: freshDate, closedAt: null },
746
+ ],
747
+ }, null, 2), "utf8");
748
+
749
+ // prune with age=7 days → only the old-done moves
750
+ const result = pruneTodos({ ageDays: 7 });
751
+ eq("prune moved 1 (age-based)", result.moved, 1);
752
+ eq("prune moved the old one", result.ids[0], "td-old-done");
753
+ const archAfter = reloadArch();
754
+ ok("archive has the old-done", archAfter.todos.some((t) => t.id === "td-old-done"));
755
+ const liveAfter = loadStore();
756
+ ok("fresh-done stays in live", liveAfter.todos.some((t) => t.id === "td-fresh-done"));
757
+ ok("open stays in live", liveAfter.todos.some((t) => t.id === "td-open"));
758
+ ok("old-done gone from live", !liveAfter.todos.some((t) => t.id === "td-old-done"));
759
+
760
+ // prune --all → fresh-done also moves
761
+ const result2 = pruneTodos({ all: true });
762
+ eq("prune --all moved 1 (the fresh-done)", result2.moved, 1);
763
+ const liveAfter2 = loadStore();
764
+ ok("fresh-done gone after --all", !liveAfter2.todos.some((t) => t.id === "td-fresh-done"));
765
+ ok("open still in live after --all", liveAfter2.todos.some((t) => t.id === "td-open"));
766
+
767
+ // cancelled also pruned
768
+ const { deleteTodo } = await import("../src/todo-store.ts");
769
+ const c1 = addTodo({ text: "to cancel", priority: "low" });
770
+ deleteTodo(c1.id);
771
+ const result3 = pruneTodos({ all: true });
772
+ ok("prune --all also moved cancelled", result3.moved >= 1);
773
+ ```
774
+
775
+ - [ ] **Step 2: Run test to verify it fails**
776
+
777
+ Run: `node test/todo-archive.test.mts`
778
+ Expected: FAIL — `pruneTodos is not a function`
779
+
780
+ - [ ] **Step 3: Write minimal implementation**
781
+
782
+ Add to `src/archive.ts`:
783
+
784
+ ```ts
785
+ import { loadConfig } from "./config.ts";
786
+ import { loadStore, saveStore } from "./todo-store.ts";
787
+
788
+ export interface PruneInput {
789
+ ageDays?: number;
790
+ all?: boolean;
791
+ statuses?: ("done" | "cancelled")[];
792
+ }
793
+
794
+ export interface PruneResult {
795
+ moved: number;
796
+ ids: string[];
797
+ }
798
+
799
+ /**
800
+ * Move done/cancelled todos from the live store to the archive.
801
+ *
802
+ * A todo qualifies when:
803
+ * - its status is in `statuses` (default: config.prune.statuses = done+cancelled), AND
804
+ * - `all` is true, OR its `closedAt` is older than `ageDays` days ago
805
+ * (default: config.prune.defaultAgeDays).
806
+ *
807
+ * Both stores are saved atomically. Reversible via `restoreTodo`.
808
+ */
809
+ export function pruneTodos(opts: PruneInput = {}): PruneResult {
810
+ const config = loadConfig();
811
+ const ageDays = opts.ageDays ?? config.prune.defaultAgeDays;
812
+ const statuses = new Set(opts.statuses ?? config.prune.statuses);
813
+ const cutoff = opts.all ? null : Date.now() - ageDays * 86400_000;
814
+
815
+ const live = loadStore();
816
+ const archive = loadArchive();
817
+
818
+ const moved: Todo[] = [];
819
+ const kept: Todo[] = [];
820
+ for (const todo of live.todos) {
821
+ if (!statuses.has(todo.status as "done" | "cancelled")) {
822
+ kept.push(todo);
823
+ continue;
824
+ }
825
+ if (cutoff !== null && todo.closedAt && Date.parse(todo.closedAt) > cutoff) {
826
+ // too fresh — keep in live
827
+ kept.push(todo);
828
+ continue;
829
+ }
830
+ moved.push(todo);
831
+ }
832
+
833
+ if (moved.length === 0) return { moved: 0, ids: [] };
834
+
835
+ live.todos = kept;
836
+ archive.todos.push(...moved);
837
+ saveStore(live);
838
+ saveArchive(archive);
839
+
840
+ return { moved: moved.length, ids: moved.map((t) => t.id) };
841
+ }
842
+ ```
843
+
844
+ - [ ] **Step 4: Run test to verify it passes**
845
+
846
+ Run: `node test/todo-archive.test.mts`
847
+ Expected: PASS (archive load/save + prune age-based + prune --all + cancelled)
848
+
849
+ - [ ] **Step 5: Commit**
850
+
851
+ ```bash
852
+ git add src/archive.ts test/todo-archive.test.mts
853
+ git commit -m "feat(archive): prune — age-based + --all move to archive"
854
+ ```
855
+
856
+ ---
857
+
858
+ ## Task 7: `restore` — move archived todo back to live as open
859
+
860
+ **Files:**
861
+ - Modify: `src/archive.ts` (add `restoreTodo`)
862
+ - Modify: `test/todo-archive.test.mts` (restore tests)
863
+
864
+ **Interfaces:**
865
+ - Produces: `restoreTodo(id: string): Todo` — moves the todo from the archive to the live store, sets `status: "open"`, `closedAt: null`, bumps `updatedAt`. Throws `TodoError` if the id isn't in the archive.
866
+
867
+ - [ ] **Step 1: Write the failing test**
868
+
869
+ Append to `test/todo-archive.test.mts` (before cleanup):
870
+
871
+ ```ts
872
+ // --- restore: archive → live as open ---
873
+ const { restoreTodo } = await import("../src/archive.ts");
874
+
875
+ // archive currently has the old-done + fresh-done + cancelled from earlier
876
+ const before = loadArchive();
877
+ const archId = before.todos[0]!.id;
878
+ const restored = restoreTodo(archId);
879
+ eq("restore sets status open", restored.status, "open");
880
+ eq("restore clears closedAt", restored.closedAt, null);
881
+ const archAfterRestore = loadArchive();
882
+ ok("restore removed from archive", !archAfterRestore.todos.some((t) => t.id === archId));
883
+ const liveAfterRestore = loadStore();
884
+ ok("restore added to live", liveAfterRestore.todos.some((t) => t.id === archId));
885
+ ok("restored is open in live", liveAfterRestore.todos.find((t) => t.id === archId)!.status === "open");
886
+
887
+ // --- restore of a non-archived id errors ---
888
+ let threw = false;
889
+ try {
890
+ restoreTodo("td-does-not-exist");
891
+ } catch {
892
+ threw = true;
893
+ }
894
+ ok("restore non-archived id throws", threw);
895
+ ```
896
+
897
+ - [ ] **Step 2: Run test to verify it fails**
898
+
899
+ Run: `node test/todo-archive.test.mts`
900
+ Expected: FAIL — `restoreTodo is not a function`
901
+
902
+ - [ ] **Step 3: Write minimal implementation**
903
+
904
+ Add to `src/archive.ts`:
905
+
906
+ ```ts
907
+ import { TodoError } from "./todo-store.ts";
908
+
909
+ /**
910
+ * Move an archived todo back to the live store as `open` (closedAt cleared).
911
+ * Throws TodoError if the id is not in the archive. Both stores are saved.
912
+ */
913
+ export function restoreTodo(id: string): Todo {
914
+ const archive = loadArchive();
915
+ const idx = archive.todos.findIndex((t) => t.id === id);
916
+ if (idx < 0) throw new TodoError(`not in archive: ${id}`);
917
+ const [todo] = archive.todos.splice(idx, 1);
918
+ const live = loadStore();
919
+ todo.status = "open";
920
+ todo.closedAt = null;
921
+ todo.updatedAt = now();
922
+ live.todos.push(todo);
923
+ saveStore(live);
924
+ saveArchive(archive);
925
+ return todo;
926
+ }
927
+ ```
928
+
929
+ (`now` is already defined in archive.ts from Task 5. `TodoError` is already exported from todo-store.ts.)
930
+
931
+ - [ ] **Step 4: Run test to verify it passes**
932
+
933
+ Run: `node test/todo-archive.test.mts`
934
+ Expected: PASS
935
+
936
+ - [ ] **Step 5: Commit**
937
+
938
+ ```bash
939
+ git add src/archive.ts test/todo-archive.test.mts
940
+ git commit -m "feat(archive): restore — archived todo back to live as open"
941
+ ```
942
+
943
+ ---
944
+
945
+ ## Task 8: Extended `list` — archived, filters, pagination, summary
946
+
947
+ **Files:**
948
+ - Modify: `src/archive.ts` (add `listArchived`, `archiveSummary`)
949
+ - Modify: `src/todo-store.ts` (extend `listTodos` with `text`, `since`, `before`, `limit`, `page`)
950
+ - Modify: `test/todo-store.test.mts` (live list filters/pagination)
951
+ - Modify: `test/todo-archive.test.mts` (archived list + summary)
952
+
953
+ **Interfaces:**
954
+ - Produces:
955
+ - `listTodos(filter)` gains: `text?: string` (substring match on `text`), `since?: string` / `before?: string` (ISO, filter by `createdAt`), `limit?: number` (default 20), `page?: number` (default 1). Returns the paginated slice.
956
+ - `listArchived(filter): { items: Todo[]; total: number }` — filter by `project`, `tag`, `status`, `text`, `since`/`before` (by `closedAt`), `limit`/`page`. Bare call (no filters) returns `{ items: [], total, summary }` via `archiveSummary` instead.
957
+ - `archiveSummary(): { total: number; byProject: Record<string, number>; byMonth: Record<string, number> }` — counts for the summary-first default.
958
+
959
+ - [ ] **Step 1: Write the failing test**
960
+
961
+ Append to `test/todo-store.test.mts` (before cleanup):
962
+
963
+ ```ts
964
+ // --- extended list: text search + since/before + pagination ---
965
+ const { addTodo: addMore, listTodos: listMore } = await import("../src/todo-store.ts");
966
+ const s1 = addMore({ text: "research browser-use for solana", project: "sol", priority: "low" });
967
+ const s2 = addMore({ text: "ship nuntius spec-2", project: "nuntius", priority: "high" });
968
+ // text search
969
+ const searchText = listMore({ text: "browser-use" });
970
+ eq("text search matches 1", searchText.length, 1);
971
+ eq("text search returns the right one", searchText[0]!.id, s1.id);
972
+ eq("text search no match returns 0", listMore({ text: "zzznomatch" }).length, 0);
973
+ // since/before on createdAt
974
+ const iso = s1.createdAt;
975
+ eq("since filter excludes earlier", listMore({ since: iso }).some((t) => t.id === s1.id), true);
976
+ // pagination
977
+ const page1 = listMore({ limit: 1, page: 1 });
978
+ const page2 = listMore({ limit: 1, page: 2 });
979
+ eq("limit=1 page1 has 1 item", page1.length, 1);
980
+ eq("limit=1 page2 has 1 item", page2.length, 1);
981
+ ok("pages differ", page1[0]!.id !== page2[0]!.id);
982
+ ```
983
+
984
+ Append to `test/todo-archive.test.mts` (before cleanup):
985
+
986
+ ```ts
987
+ // --- listArchived: filters + pagination ---
988
+ const { listArchived, archiveSummary } = await import("../src/archive.ts");
989
+
990
+ // archive has accumulated items from earlier prune tests
991
+ const summary = archiveSummary();
992
+ ok("summary has total >= 1", summary.total >= 1);
993
+ ok("summary byProject is an object", typeof summary.byProject === "object");
994
+ ok("summary byMonth is an object", typeof summary.byMonth === "object");
995
+
996
+ // listArchived with a project filter
997
+ const allArch = listArchived({ limit: 100 });
998
+ ok("listArchived returns items + total", allArch.items.length >= 1 && allArch.total >= 1);
999
+ // pagination
1000
+ const archPage1 = listArchived({ limit: 1, page: 1 });
1001
+ const archPage2 = listArchived({ limit: 1, page: 2 });
1002
+ eq("arch limit=1 page1 has <=1", archPage1.items.length, 1);
1003
+ ok("arch pages differ or page2 empty", archPage1.items[0]?.id !== archPage2.items[0]?.id);
1004
+ // text search on archived
1005
+ const archSearch = listArchived({ text: "done", limit: 100 });
1006
+ ok("arch text search works", archSearch.items.every((t) => t.text.includes("done")));
1007
+ ```
1008
+
1009
+ - [ ] **Step 2: Run test to verify it fails**
1010
+
1011
+ Run: `node test/todo-store.test.mts && node test/todo-archive.test.mts`
1012
+ Expected: FAIL — `listMore` text/pagination params ignored (old listTodos doesn't support them); `listArchived`/`archiveSummary` undefined.
1013
+
1014
+ - [ ] **Step 3: Write minimal implementation**
1015
+
1016
+ **3a. Extend `listTodos` in `src/todo-store.ts`.** Update the `ListFilter` interface and the `listTodos` function:
1017
+
1018
+ Replace the `ListFilter` interface:
1019
+
1020
+ ```ts
1021
+ export interface ListFilter {
1022
+ status?: Status | "all";
1023
+ project?: string;
1024
+ tag?: string;
1025
+ text?: string; // substring match on todo.text (case-insensitive)
1026
+ since?: string; // ISO date; filter createdAt >= since
1027
+ before?: string; // ISO date; filter createdAt < before
1028
+ limit?: number; // default 20
1029
+ page?: number; // default 1 (1-indexed)
1030
+ }
1031
+ ```
1032
+
1033
+ Replace the `listTodos` function body (keep the existing sort; add filtering + pagination at the end):
1034
+
1035
+ ```ts
1036
+ export function listTodos(filter: ListFilter = {}): Todo[] {
1037
+ const store = loadStore();
1038
+ let out = store.todos;
1039
+ if (filter.status && filter.status !== "all") {
1040
+ assertStatus(filter.status);
1041
+ out = out.filter((t) => t.status === filter.status);
1042
+ } else if (!filter.status) {
1043
+ // default: actionable set only
1044
+ out = out.filter((t) => t.status === "open" || t.status === "in_progress");
1045
+ }
1046
+ if (filter.project) out = out.filter((t) => t.project === filter.project);
1047
+ if (filter.tag) out = out.filter((t) => t.tags.includes(filter.tag as string));
1048
+ if (filter.text) {
1049
+ const q = filter.text.toLowerCase();
1050
+ out = out.filter((t) => t.text.toLowerCase().includes(q));
1051
+ }
1052
+ if (filter.since) out = out.filter((t) => t.createdAt >= (filter.since as string));
1053
+ if (filter.before) out = out.filter((t) => t.createdAt < (filter.before as string));
1054
+ const sorted = out.slice().sort((a, b) => {
1055
+ if (a.status !== b.status) {
1056
+ return a.status === "in_progress" ? -1 : b.status === "in_progress" ? 1 : 0;
1057
+ }
1058
+ if (PRIO_ORDER[a.priority] !== PRIO_ORDER[b.priority]) {
1059
+ return PRIO_ORDER[a.priority] - PRIO_ORDER[b.priority];
1060
+ }
1061
+ return a.createdAt.localeCompare(b.createdAt);
1062
+ });
1063
+ const limit = filter.limit ?? 20;
1064
+ const page = filter.page ?? 1;
1065
+ const start = (page - 1) * limit;
1066
+ return sorted.slice(start, start + limit);
1067
+ }
1068
+ ```
1069
+
1070
+ **3b. Add `listArchived` + `archiveSummary` to `src/archive.ts`:**
1071
+
1072
+ ```ts
1073
+ export interface ArchiveListFilter {
1074
+ project?: string;
1075
+ tag?: string;
1076
+ status?: "done" | "cancelled";
1077
+ text?: string;
1078
+ since?: string; // by closedAt
1079
+ before?: string; // by closedAt
1080
+ limit?: number; // default 20
1081
+ page?: number; // default 1
1082
+ }
1083
+
1084
+ export interface ArchiveListResult {
1085
+ items: Todo[];
1086
+ total: number; // total matching the filter (before pagination)
1087
+ summary?: ArchiveSummary; // present only on a bare call (no filters)
1088
+ }
1089
+
1090
+ export interface ArchiveSummary {
1091
+ total: number;
1092
+ byProject: Record<string, number>;
1093
+ byMonth: Record<string, number>;
1094
+ }
1095
+
1096
+ /** Counts by project + by closedAt-month, for the summary-first default. */
1097
+ export function archiveSummary(): ArchiveSummary {
1098
+ const archive = loadArchive();
1099
+ const byProject: Record<string, number> = {};
1100
+ const byMonth: Record<string, number> = {};
1101
+ for (const t of archive.todos) {
1102
+ const proj = t.project || "(none)";
1103
+ byProject[proj] = (byProject[proj] ?? 0) + 1;
1104
+ const month = t.closedAt ? t.closedAt.slice(0, 7) : "(none)"; // YYYY-MM
1105
+ byMonth[month] = (byMonth[month] ?? 0) + 1;
1106
+ }
1107
+ return { total: archive.todos.length, byProject, byMonth };
1108
+ }
1109
+
1110
+ /**
1111
+ * Query the archive with filters + pagination. A bare call (no filters)
1112
+ * returns summary-only (items: []) — drill down with a filter to get rows.
1113
+ */
1114
+ export function listArchived(filter: ArchiveListFilter = {}): ArchiveListResult {
1115
+ const hasFilter = Boolean(filter.project || filter.tag || filter.status || filter.text || filter.since || filter.before);
1116
+ if (!hasFilter) {
1117
+ const summary = archiveSummary();
1118
+ return { items: [], total: summary.total, summary };
1119
+ }
1120
+ let out = loadArchive().todos;
1121
+ if (filter.project) out = out.filter((t) => t.project === filter.project);
1122
+ if (filter.tag) out = out.filter((t) => t.tags.includes(filter.tag as string));
1123
+ if (filter.status) out = out.filter((t) => t.status === filter.status);
1124
+ if (filter.text) {
1125
+ const q = filter.text.toLowerCase();
1126
+ out = out.filter((t) => t.text.toLowerCase().includes(q));
1127
+ }
1128
+ if (filter.since) out = out.filter((t) => (t.closedAt ?? t.updatedAt) >= (filter.since as string));
1129
+ if (filter.before) out = out.filter((t) => (t.closedAt ?? t.updatedAt) < (filter.before as string));
1130
+ // sort newest-closed first
1131
+ const sorted = out.slice().sort((a, b) => (b.closedAt ?? b.updatedAt).localeCompare(a.closedAt ?? a.updatedAt));
1132
+ const total = sorted.length;
1133
+ const limit = filter.limit ?? 20;
1134
+ const page = filter.page ?? 1;
1135
+ const start = (page - 1) * limit;
1136
+ return { items: sorted.slice(start, start + limit), total };
1137
+ }
1138
+ ```
1139
+
1140
+ - [ ] **Step 4: Run tests to verify they pass**
1141
+
1142
+ Run: `node test/todo-store.test.mts && node test/todo-archive.test.mts && node test/todo-config.test.mts && node test/todo-migrate.test.mts`
1143
+ Expected: all four PASS
1144
+
1145
+ - [ ] **Step 5: Commit**
1146
+
1147
+ ```bash
1148
+ git add src/todo-store.ts src/archive.ts test/todo-store.test.mts test/todo-archive.test.mts
1149
+ git commit -m "feat(list): extended filters (text/since/before) + pagination + archive summary"
1150
+ ```
1151
+
1152
+ ---
1153
+
1154
+ ## Task 9: Rewire store to v2 folder layout (TODO_DIR + migration)
1155
+
1156
+ **Files:**
1157
+ - Modify: `src/todo-store.ts` (replace `STORE_PATH`/`DEFAULT_PATH` with `getLivePath()`; run `migrateIfNeeded` on load; bump `Store.version` to 2; update corrupt-recovery to use folder paths)
1158
+ - Modify: `test/todo-store.test.mts` (replace `TODO_STORE_PATH` with `TODO_DIR`; update the corrupt-file + perms tests to the new paths)
1159
+
1160
+ **Interfaces:**
1161
+ - Produces: `loadStore` now reads `getLivePath()` under `TODO_DIR`, runs `migrateIfNeeded` first, and validates `version: 2`. `saveStore` writes to `getLivePath()`.
1162
+
1163
+ - [ ] **Step 1: Write the failing test**
1164
+
1165
+ In `test/todo-store.test.mts`, replace the env-setup block at the very top:
1166
+
1167
+ ```ts
1168
+ import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, statSync } from "node:fs";
1169
+ import { tmpdir } from "node:os";
1170
+ import { join } from "node:path";
1171
+
1172
+ const tmp = mkdtempSync(join(tmpdir(), "armory-todo-"));
1173
+ process.env.TODO_DIR = tmp;
1174
+ // NOTE: no TODO_STORE_PATH — v2 uses TODO_DIR; the store reads <tmp>/todo.json
1175
+
1176
+ let passed = 0;
1177
+ let failed = 0;
1178
+ function ok(name: string, cond: boolean, extra = ""): void {
1179
+ if (cond) { passed++; } else { failed++; console.error(` ✗ ${name} ${extra}`); }
1180
+ }
1181
+ function eq<T>(name: string, got: T, want: T): void {
1182
+ ok(name, got === want, `(got ${JSON.stringify(got)} want ${JSON.stringify(want)})`);
1183
+ }
1184
+ ```
1185
+
1186
+ And update the perms/corrupt tests near the bottom to use the new path:
1187
+
1188
+ ```ts
1189
+ // --- atomic + 0600 perms (v2 path) ---
1190
+ import { getLivePath } from "../src/paths.ts";
1191
+ const livePath = getLivePath();
1192
+ const stat = statSync(livePath);
1193
+ ok("store file mode 0600", (stat.mode & 0o777) === 0o600, `(mode ${(stat.mode & 0o777).toString(8)})`);
1194
+ ok("no .tmp leftover", !existsSync(livePath + ".tmp"));
1195
+
1196
+ // --- corrupt file recovery (v2 path) ---
1197
+ rmSync(livePath, { force: true });
1198
+ writeFileSync(livePath, "{ this is not json", "utf8");
1199
+ const recovered = loadStore();
1200
+ ok("corrupt file → fresh empty store", recovered.todos.length === 0);
1201
+ ```
1202
+
1203
+ Remove the old `process.env.TODO_STORE_PATH` reference and any `statSync(process.env.TODO_STORE_PATH)` lines — they're replaced by `getLivePath()`.
1204
+
1205
+ - [ ] **Step 2: Run test to verify it fails**
1206
+
1207
+ Run: `node test/todo-store.test.mts`
1208
+ Expected: FAIL — the store still reads `STORE_PATH` (the old `TODO_STORE_PATH`-derived path), so with `TODO_DIR` set but no file there, `addTodo` writes to the wrong place / `statSync(getLivePath())` fails (file doesn't exist yet at the new path).
1209
+
1210
+ - [ ] **Step 3: Write minimal implementation**
1211
+
1212
+ In `src/todo-store.ts`, make these edits:
1213
+
1214
+ 1. Replace the path constants + import:
1215
+
1216
+ ```ts
1217
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1218
+ import { dirname } from "node:path";
1219
+ import { getLivePath, getTodoDir, getLegacyPath } from "./paths.ts";
1220
+ import { migrateIfNeeded } from "./migrate.ts";
1221
+ ```
1222
+
1223
+ Remove the old `DEFAULT_PATH` / `STORE_PATH` / `homedir` import lines.
1224
+
1225
+ 2. Update `emptyStore` to version 2:
1226
+
1227
+ ```ts
1228
+ function emptyStore(): Store {
1229
+ return { version: 2, updatedAt: now(), todos: [] };
1230
+ }
1231
+ ```
1232
+
1233
+ 3. Update the `Store` interface:
1234
+
1235
+ ```ts
1236
+ export interface Store {
1237
+ version: 2;
1238
+ updatedAt: string;
1239
+ todos: Todo[];
1240
+ }
1241
+ ```
1242
+
1243
+ 4. Update `loadStore` to migrate + read the folder path + accept version 2:
1244
+
1245
+ ```ts
1246
+ /** Load the live store from disk. Runs v1→v2 migration first. On corruption,
1247
+ * backs up the bad file and starts fresh. */
1248
+ export function loadStore(): Store {
1249
+ migrateIfNeeded({ todoDir: getTodoDir(), legacyPath: getLegacyPath() });
1250
+ const path = getLivePath();
1251
+ if (!existsSync(path)) return emptyStore();
1252
+ try {
1253
+ const raw = readFileSync(path, "utf8");
1254
+ const parsed = JSON.parse(raw) as Store;
1255
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.todos)) {
1256
+ throw new Error("invalid store shape");
1257
+ }
1258
+ if (parsed.version !== 2) {
1259
+ // v1 → v2: accept it (the migration moved the file), just bump the version in memory
1260
+ // (the data shape is otherwise identical; parked status is new but old todos won't have it)
1261
+ parsed.version = 2;
1262
+ }
1263
+ return parsed;
1264
+ } catch {
1265
+ try {
1266
+ renameSync(path, `${path}.bad-${Date.now()}`);
1267
+ } catch {
1268
+ // best-effort backup
1269
+ }
1270
+ return emptyStore();
1271
+ }
1272
+ }
1273
+ ```
1274
+
1275
+ 5. Update `saveStore` to use `getLivePath()`:
1276
+
1277
+ ```ts
1278
+ /** Atomic, 0600 write. */
1279
+ export function saveStore(store: Store): void {
1280
+ store.updatedAt = now();
1281
+ const path = getLivePath();
1282
+ const dir = dirname(path);
1283
+ mkdirSync(dir, { recursive: true });
1284
+ const tmp = `${path}.tmp`;
1285
+ writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
1286
+ try {
1287
+ chmodSync(tmp, 0o600);
1288
+ } catch {
1289
+ // some filesystems ignore mode bits; not fatal
1290
+ }
1291
+ renameSync(tmp, path);
1292
+ }
1293
+ ```
1294
+
1295
+ 6. Update `getStorePath` (used by the `/todo path` slash command) to return the live path:
1296
+
1297
+ ```ts
1298
+ export function getStorePath(): string {
1299
+ return getLivePath();
1300
+ }
1301
+ ```
1302
+
1303
+ - [ ] **Step 4: Run all tests to verify they pass**
1304
+
1305
+ Run: `node test/todo-store.test.mts && node test/todo-archive.test.mts && node test/todo-config.test.mts && node test/todo-migrate.test.mts`
1306
+ Expected: all four PASS. (Note: the archive test sets `TODO_STORE_PATH` to point `pruneTodos`/`restoreTodo` at the same temp live store — but now the store reads `getLivePath()` under `TODO_DIR`. The archive test already sets `process.env.TODO_DIR = tmp`, so `loadStore`/`saveStore` in archive.ts will read/write `<tmp>/todo.json` — which is where the archive test's `writeFileSync(livePath, ...)` puts the seeded live store. Verify `livePath` in the archive test is computed as `getLivePath()` — update that line if it still references `TODO_STORE_PATH`.)
1307
+
1308
+ If the archive test has `const livePath = process.env.TODO_STORE_PATH || join(tmp, "todo.json")`, replace it with:
1309
+ ```ts
1310
+ import { getLivePath } from "../src/paths.ts";
1311
+ const livePath = getLivePath();
1312
+ ```
1313
+
1314
+ - [ ] **Step 5: Commit**
1315
+
1316
+ ```bash
1317
+ git add src/todo-store.ts test/todo-store.test.mts test/todo-archive.test.mts
1318
+ git commit -m "feat(store): v2 folder layout + migration on load (TODO_DIR)"
1319
+ ```
1320
+
1321
+ ---
1322
+
1323
+ ## Task 10: Extension tool — park / prune / restore + extended list params
1324
+
1325
+ **Files:**
1326
+ - Modify: `extensions/todo.ts` (add `park`/`prune`/`restore` to the tool `ACTIONS`; extend the `list` case with `archived`/`since`/`before`/`text`/`limit`/`page`; wire the new actions)
1327
+
1328
+ **Interfaces:**
1329
+ - Consumes: `parkTodo`, `pruneTodos`, `restoreTodo`, `listArchived`, `archiveSummary` from the store modules.
1330
+ - Produces: the `todo` tool now accepts `action: "park" | "prune" | "restore"` and the `list` action honors the new filter/pagination params.
1331
+
1332
+ - [ ] **Step 1: Write the failing test (manual — extension tests are manual-gate per the original SPEC §10)**
1333
+
1334
+ No automated extension test (the extension imports pi APIs that aren't available in a standalone node test). The manual gate for this task: after implementation, in a real pi session, verify `/todo park <id>`, `/todo prune`, `/todo restore <id>` work. The store-level logic is already covered by Tasks 4–8. For this task, the test is: **load the extension without a syntax/import error**.
1335
+
1336
+ Create a minimal smoke check — append to `test/todo-store.test.mts` (a compile-only import check):
1337
+
1338
+ ```ts
1339
+ // --- extension imports cleanly (smoke) ---
1340
+ try {
1341
+ // The extension imports pi APIs (peer deps) — just check it parses + exports default
1342
+ const mod = await import("../extensions/todo.ts");
1343
+ ok("extension module imports + has default export", typeof mod.default === "function");
1344
+ } catch (err) {
1345
+ ok("extension module imports", false, String((err as Error).message || err));
1346
+ }
1347
+ ```
1348
+
1349
+ (Note: this may fail if pi peer deps aren't resolvable in the test env. If it does, mark it as a known limitation and rely on the manual gate — but *first* try it; `@earendil-works/pi-coding-agent` may resolve via the installed pi in `~/.pi/agent/npm`.)
1350
+
1351
+ - [ ] **Step 2: Run test to verify it fails**
1352
+
1353
+ Run: `node test/todo-store.test.mts`
1354
+ Expected: FAIL — `park`/`prune`/`restore` not in `ACTIONS`, so the extension's tool doesn't accept them (the smoke import may pass if the module loads, but the action routing is incomplete). The real failure surfaces when we try to use the actions.
1355
+
1356
+ - [ ] **Step 3: Write minimal implementation**
1357
+
1358
+ In `extensions/todo.ts`, make these edits:
1359
+
1360
+ 1. Update the imports from `../src/todo-store`:
1361
+
1362
+ ```ts
1363
+ import {
1364
+ addTodo,
1365
+ completeTodo,
1366
+ deleteTodo,
1367
+ clearTodos,
1368
+ listTodos,
1369
+ renderOpenBlock,
1370
+ updateTodo,
1371
+ parkTodo,
1372
+ getStorePath,
1373
+ } from "../src/todo-store.ts";
1374
+ import { pruneTodos, restoreTodo, listArchived, archiveSummary } from "../src/archive.ts";
1375
+ ```
1376
+
1377
+ 2. Update `ACTIONS`:
1378
+
1379
+ ```ts
1380
+ const ACTIONS = ["list", "add", "update", "complete", "delete", "clear", "park", "prune", "restore"] as const;
1381
+ ```
1382
+
1383
+ 3. Extend the tool `parameters` schema (add the new filter params + the `archived` flag):
1384
+
1385
+ ```ts
1386
+ parameters: Type.Object({
1387
+ action: StringEnum(ACTIONS),
1388
+ id: Type.Optional(Type.String({ description: "Todo id (for update/complete/delete/park/restore)" })),
1389
+ text: Type.Optional(Type.String({ description: "Todo text (add) or new text (update); or substring search (list)" })),
1390
+ project: Type.Optional(Type.String({ description: "Project tag, e.g. 'pi', 'sip', or '' for global" })),
1391
+ tags: Type.Optional(Type.Array(Type.String())),
1392
+ priority: Type.Optional(StringEnum(["low", "med", "high", "critical"] as const)),
1393
+ status: Type.Optional(StringEnum(["open", "in_progress", "parked", "done", "cancelled"] as const)),
1394
+ // list filters
1395
+ statusFilter: Type.Optional(StringEnum(["open", "in_progress", "parked", "done", "cancelled", "all"] as const)),
1396
+ projectFilter: Type.Optional(Type.String()),
1397
+ tagFilter: Type.Optional(Type.String()),
1398
+ archived: Type.Optional(Type.Boolean({ description: "If true, query the archive instead of the live store. Bare archived:true (no other filter) returns a summary." })),
1399
+ since: Type.Optional(Type.String({ description: "ISO date filter (createdAt for live, closedAt for archive)" })),
1400
+ before: Type.Optional(Type.String({ description: "ISO date filter (createdAt for live, closedAt for archive)" })),
1401
+ limit: Type.Optional(Type.Number({ description: "Page size (default 20)" })),
1402
+ page: Type.Optional(Type.Number({ description: "1-indexed page number (default 1)" })),
1403
+ // prune options
1404
+ ageDays: Type.Optional(Type.Number({ description: "prune: closedAt older than this many days (default from config)" })),
1405
+ all: Type.Optional(Type.Boolean({ description: "prune: ignore age, move all done/cancelled" })),
1406
+ }),
1407
+ ```
1408
+
1409
+ 4. Update the `execute` switch — replace the `list` case and add `park`/`prune`/`restore` cases:
1410
+
1411
+ ```ts
1412
+ case "list": {
1413
+ if (params.archived) {
1414
+ const res = listArchived({
1415
+ project: params.projectFilter,
1416
+ tag: params.tagFilter,
1417
+ status: params.statusFilter as any,
1418
+ text: params.text,
1419
+ since: params.since,
1420
+ before: params.before,
1421
+ limit: params.limit,
1422
+ page: params.page,
1423
+ });
1424
+ if (res.summary) {
1425
+ const lines = [
1426
+ `## Archive summary (${res.total} total)`,
1427
+ ...Object.entries(res.summary.byProject).map(([p, n]) => ` ${p}: ${n}`),
1428
+ ...Object.entries(res.summary.byMonth).map(([m, n]) => ` ${m}: ${n}`),
1429
+ "Use a filter (project/tag/text/since/before) to list specific items.",
1430
+ ];
1431
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
1432
+ }
1433
+ const lines = res.items.map(fmt);
1434
+ return { content: [{ type: "text" as const, text: `Archived (${res.total} total, page ${params.page ?? 1}):\n${lines.join("\n")}` }] };
1435
+ }
1436
+ const todos = listTodos({
1437
+ status: params.statusFilter as any,
1438
+ project: params.projectFilter,
1439
+ tag: params.tagFilter,
1440
+ text: params.text,
1441
+ since: params.since,
1442
+ before: params.before,
1443
+ limit: params.limit,
1444
+ page: params.page,
1445
+ });
1446
+ if (todos.length === 0) {
1447
+ return { content: [{ type: "text" as const, text: "No matching TODOs." }] };
1448
+ }
1449
+ return { content: [{ type: "text" as const, text: todos.map(fmt).join("\n") }] };
1450
+ }
1451
+ ```
1452
+
1453
+ And after the `clear` case, add:
1454
+
1455
+ ```ts
1456
+ case "park": {
1457
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for park." }] };
1458
+ const t = parkTodo(params.id);
1459
+ return { content: [{ type: "text" as const, text: `Parked ${t.id}: ${t.text}` }] };
1460
+ }
1461
+ case "prune": {
1462
+ const res = pruneTodos({ ageDays: params.ageDays, all: params.all });
1463
+ return { content: [{ type: "text" as const, text: `Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive: ${res.ids.join(", ") || "(none)"}` }] };
1464
+ }
1465
+ case "restore": {
1466
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for restore." }] };
1467
+ const t = restoreTodo(params.id);
1468
+ return { content: [{ type: "text" as const, text: `Restored ${t.id}: ${t.text} [open]` }] };
1469
+ }
1470
+ ```
1471
+
1472
+ 5. Update the `update` action's status enum in the existing `updateTodo` call to accept `parked` — it already passes `params.status as any`, so no change needed (the store validates).
1473
+
1474
+ 6. Update the tool `description` + `promptGuidelines` to mention the new actions:
1475
+
1476
+ ```ts
1477
+ description:
1478
+ "Global cross-session TODO store (persists across ALL pi sessions, not just this one). " +
1479
+ "Use when the user says 'put this in our TODO', 'show me the TODO', 'mark <id> done', 'park <id>', 'prune', 'restore <id>', etc. " +
1480
+ "Open TODOs are auto-injected each turn; parked todos are NOT injected (deferred/someday). " +
1481
+ "Done/cancelled todos are moved to an archive by `prune` (reversible via `restore`). " +
1482
+ "Never put secrets in a TODO — the text reaches the model provider.",
1483
+ promptSnippet: "Read/update the global cross-session TODO list (active / parked / archive)",
1484
+ promptGuidelines: [
1485
+ "Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending'.",
1486
+ "Use todo (action:'add', text, project?, tags?, priority?, source?) when the user says 'put this in our TODO'.",
1487
+ "Use todo (action:'complete', id) to mark a TODO done; (action:'delete', id) to cancel it.",
1488
+ "Use todo (action:'park', id) to defer a TODO (not injected, recoverable); (action:'update', status:'open') to un-park.",
1489
+ "Use todo (action:'prune') to move done/cancelled todos to the archive (reversible); (action:'prune', all:true) to prune all regardless of age.",
1490
+ "Use todo (action:'restore', id) to bring an archived TODO back as open.",
1491
+ "Use todo (action:'list', archived:true) to query the archive — bare call returns a summary; add a filter (project/text/since) for specific items.",
1492
+ ],
1493
+ ```
1494
+
1495
+ - [ ] **Step 4: Run tests + verify**
1496
+
1497
+ Run: `node test/todo-store.test.mts && node test/todo-archive.test.mts && node test/todo-config.test.mts && node test/todo-migrate.test.mts`
1498
+ Expected: all four PASS (the extension smoke import may or may not pass depending on peer-dep resolution — if it fails on import resolution, that's a test-env limitation, not a code bug; the manual gate covers it).
1499
+
1500
+ - [ ] **Step 5: Commit**
1501
+
1502
+ ```bash
1503
+ git add extensions/todo.ts test/todo-store.test.mts
1504
+ git commit -m "feat(ext): park/prune/restore tool actions + extended list (archive, filters, pagination)"
1505
+ ```
1506
+
1507
+ ---
1508
+
1509
+ ## Task 11: Extension slash command — typed subcommands
1510
+
1511
+ **Files:**
1512
+ - Modify: `extensions/todo.ts` (add `/todo park <id>`, `/todo restore <id>`, `/todo prune [--all]`, `/todo archive [filter]` to the slash command handler)
1513
+
1514
+ **Interfaces:**
1515
+ - Produces: the `/todo` slash command now routes the new subcommands. `/todo health` is NOT included (that's SPEC-2). Power-user subcommands retained alongside.
1516
+
1517
+ - [ ] **Step 1: Write the failing test (manual gate)**
1518
+
1519
+ No automated test for slash commands (requires a live pi TUI). Manual gate: after implementation, in a real pi session run `/todo prune`, `/todo park <id>`, `/todo restore <id>`, `/todo archive`, `/todo archive project:nuntius` and confirm each behaves correctly. The store logic is already test-covered (Tasks 4–8); this task is pure routing.
1520
+
1521
+ - [ ] **Step 2: Run test to verify it fails**
1522
+
1523
+ Run: `node test/todo-store.test.mts`
1524
+ Expected: the store tests still pass (the slash handler isn't exercised by them). The "failure" is that the new subcommands aren't routed — verified manually.
1525
+
1526
+ - [ ] **Step 3: Write minimal implementation**
1527
+
1528
+ In `extensions/todo.ts`, update the slash command handler — extend the `sub` routing. Replace the existing handler body's routing block (keep the `all`/`add`/`done`/`rm`/`clean`/`path` cases) and add the new subcommands. The full updated routing (insert the new cases before the `// default: list open` fallback):
1529
+
1530
+ ```ts
1531
+ if (sub === "park") {
1532
+ const id = rest[0];
1533
+ if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo park <id>", "warning"); return; }
1534
+ const t = parkTodo(id);
1535
+ if (ctx.hasUI) ctx.ui.notify(`Parked ${t.id}: ${t.text}`, "info");
1536
+ return;
1537
+ }
1538
+ if (sub === "restore") {
1539
+ const id = rest[0];
1540
+ if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo restore <id>", "warning"); return; }
1541
+ const t = restoreTodo(id);
1542
+ if (ctx.hasUI) ctx.ui.notify(`Restored ${t.id}: ${t.text}`, "info");
1543
+ return;
1544
+ }
1545
+ if (sub === "prune") {
1546
+ const all = rest.includes("--all");
1547
+ const res = pruneTodos({ all });
1548
+ if (ctx.hasUI) ctx.ui.notify(`Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive: ${res.ids.join(", ") || "(none)"}`, "info");
1549
+ return;
1550
+ }
1551
+ if (sub === "archive") {
1552
+ const filterArg = rest.join(" ").trim();
1553
+ if (!filterArg) {
1554
+ const s = archiveSummary();
1555
+ const lines = [
1556
+ `Archive summary (${s.total} total):`,
1557
+ ...Object.entries(s.byProject).map(([p, n]) => ` ${p}: ${n}`),
1558
+ ...Object.entries(s.byMonth).map(([m, n]) => ` ${m}: ${n}`),
1559
+ "Use /todo archive project:<name> or text:<query> to list specific items.",
1560
+ ];
1561
+ if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
1562
+ return;
1563
+ }
1564
+ // parse "project:foo" or "text:query" (simple key:value)
1565
+ const parts = filterArg.split(":");
1566
+ const key = parts[0]?.trim();
1567
+ const val = parts.slice(1).join(":").trim();
1568
+ const res = key === "project" ? listArchived({ project: val, limit: 50 })
1569
+ : key === "text" ? listArchived({ text: val, limit: 50 })
1570
+ : listArchived({ text: filterArg, limit: 50 });
1571
+ const msg = res.items.length ? res.items.map(fmt).join("\n") : `(no archived items match)`;
1572
+ if (ctx.hasUI) ctx.ui.notify(`Archived (${res.total} total):\n${msg}`, "info");
1573
+ return;
1574
+ }
1575
+ ```
1576
+
1577
+ Also update the command `description` to advertise the new subcommands:
1578
+
1579
+ ```ts
1580
+ description: "Global cross-session TODO list. /todo · /todo all · /todo add <text> · /todo done <id> · /todo rm <id> · /todo park <id> · /todo restore <id> · /todo prune [--all] · /todo archive [project:X|text:Y] · /todo clean · /todo path",
1581
+ ```
1582
+
1583
+ - [ ] **Step 4: Run tests + verify**
1584
+
1585
+ Run: `node test/todo-store.test.mts && node test/todo-archive.test.mts && node test/todo-config.test.mts && node test/todo-migrate.test.mts`
1586
+ Expected: all four PASS.
1587
+
1588
+ - [ ] **Step 5: Commit**
1589
+
1590
+ ```bash
1591
+ git add extensions/todo.ts
1592
+ git commit -m "feat(ext): /todo slash subcommands — park, restore, prune, archive"
1593
+ ```
1594
+
1595
+ ---
1596
+
1597
+ ## Task 12: README + version bump + package metadata
1598
+
1599
+ **Files:**
1600
+ - Modify: `README.md` (document the lifecycle boxes, new actions, slash subcommands)
1601
+ - Modify: `package.json` (version bump 0.1.0 → 0.2.0; the SPEC-1 slice is a minor version — new features, backwards-compatible)
1602
+ - Modify: `AGENTS.md` (update the "Structure" + "Notes" sections to reflect the new folder layout + archive)
1603
+
1604
+ **Interfaces:** none (docs only).
1605
+
1606
+ - [ ] **Step 1: No failing test** (docs task — fold into the release commit).
1607
+
1608
+ - [ ] **Step 2: (skipped — docs)**
1609
+
1610
+ - [ ] **Step 3: Write the updates**
1611
+
1612
+ In `README.md`, add a "Lifecycle boxes" section after the "What it solves" section:
1613
+
1614
+ ```markdown
1615
+ ## Lifecycle boxes (v0.2.0)
1616
+
1617
+ TODOs live in one of three states, only one of which hits the agent context:
1618
+
1619
+ | Box | Status(es) | Auto-injected? | Recoverable? |
1620
+ |---|---|---|---|
1621
+ | **Active** | `open`, `in_progress` | ✅ Yes (capped 15) | n/a |
1622
+ | **Parked** | `parked` | ❌ No | ✅ `update --status open` |
1623
+ | **Archive** | `done`, `cancelled` | ❌ No | ✅ `restore <id>` |
1624
+
1625
+ **Pruning:** `prune` (default: done/cancelled older than 7 days) moves finished
1626
+ todos from the live file to `todo-archive.json` — nothing is deleted. `prune --all`
1627
+ ignores age. `restore <id>` brings an archived todo back as `open`.
1628
+
1629
+ The only irreversible action is `prune --hard` (SPEC-2, not yet shipped) — it
1630
+ requires an explicit `confirm: true` and is always user-confirmed.
1631
+
1632
+ **Storage layout:**
1633
+ ```
1634
+ ~/.pi/agent/todo/
1635
+ todo.json # active + parked
1636
+ todo-archive.json # done + cancelled (sealed history)
1637
+ todo.config.json # prune ages + health thresholds
1638
+ ```
1639
+ A v1 single-file store at `~/.pi/agent/todo.json` is migrated automatically on
1640
+ first load after upgrade.
1641
+ ```
1642
+
1643
+ Update the "Structure" section in `README.md` to list the new `src/` modules.
1644
+
1645
+ In `package.json`, bump `"version": "0.1.0"` → `"version": "0.2.0"`.
1646
+
1647
+ In `AGENTS.md`, update the "Structure" block:
1648
+
1649
+ ```markdown
1650
+ ## Structure
1651
+
1652
+ ```
1653
+ extensions/ # pi extension — todo tool (model-callable) + /todo slash command + auto-inject
1654
+ src/ # todo-store (live CRUD + parked + list), archive (prune + restore + summary),
1655
+ # config (prune/health thresholds), migrate (v1→v2), paths (TODO_DIR resolution)
1656
+ test/ # todo-store + todo-archive + todo-config + todo-migrate tests
1657
+ docs/ # SPEC + design docs (docs/superpowers/specs, docs/superpowers/plans)
1658
+ ```
1659
+ ```
1660
+
1661
+ And update the "Notes" to mention the folder layout + archive.
1662
+
1663
+ - [ ] **Step 4: Run all tests one final time**
1664
+
1665
+ Run: `node test/todo-store.test.mts && node test/todo-archive.test.mts && node test/todo-config.test.mts && node test/todo-migrate.test.mts`
1666
+ Expected: all four PASS.
1667
+
1668
+ - [ ] **Step 5: Commit**
1669
+
1670
+ ```bash
1671
+ git add README.md package.json AGENTS.md
1672
+ git commit -m "docs(v0.2.0): lifecycle boxes, prune/archive, folder layout + version bump"
1673
+ ```
1674
+
1675
+ ---
1676
+
1677
+ ## Final verification (before declaring SPEC-1 done)
1678
+
1679
+ - [ ] All four test files pass: `node test/todo-store.test.mts && node test/todo-archive.test.mts && node test/todo-config.test.mts && node test/todo-migrate.test.mts`
1680
+ - [ ] No `TODO`/`FIXME`/`HACK` in delivered code (grep `src/` + `extensions/`).
1681
+ - [ ] No secrets, no hardcoded paths (all paths via `paths.ts`).
1682
+ - [ ] `renderOpenBlock` unchanged — `parked` confirmed excluded.
1683
+ - [ ] Manual gate (real pi session): `/todo add test`, `/todo park <id>`, confirm it drops from the injected `## Open TODOs` block on the next turn; `/todo done <id2>`, `/todo prune`, `/todo archive` (summary appears), `/todo restore <archived-id>`, confirm it's back as open.
1684
+ - [ ] `gh run list` — if CI exists, confirm green.
1685
+
1686
+ ## Out of scope for SPEC-1 (deferred)
1687
+
1688
+ - **`health` action + `prune --hard`** → SPEC-2.
1689
+ - **Interactive `/todo` TUI panel** → SPEC-3.
1690
+ - **`title` + `notes`/`log` schema split** → Workstream B (separate spec).
1691
+ - **Preventive caps-on-add + project registry** → Workstream C (issue #1's hard-block half).