@getpipher/armory-todo 0.5.4 → 0.6.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,858 @@
1
+ # v0.6.0 Reap Safety-Protocol — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add source-aware stale-active reaping to armory-todo so orphaned `armory-fleet` runs auto-`cancelled` at 2d while real work (`source: undefined`) is only flagged `ORPHAN` at 14d — never auto-mutated.
6
+
7
+ **Architecture:** Pure-additive. One new config section (`reap`), one new module (`src/reap.ts`, mirrors `auto-prune.ts`), one new `HealthFlag` (`ORPHAN`), session_start wiring after the existing auto-prune call, panel ⌛ indicator. Reap batch-moves matched active todos directly from live to archive as `cancelled`, making `restoreTodo(id)` immediately valid, while reusing v0.5.1 backup/drop-snapshot/audit guardrails. Zero migration.
8
+
9
+ **Tech Stack:** TypeScript (tsx runtime, no build), `node:test` via `tsx`, pi extension API. Existing deps only.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-07-29-reap-safety-protocol-design.md` (committed).
12
+
13
+ ## Global Constraints
14
+
15
+ - Test isolation: every test suite that writes MUST set `process.env.TODO_DIR = tmp` at the top, and re-establish it before any appended section (the v0.5.3 wiper lesson — `delete process.env.TODO_DIR` in cleanup leaks to the real store).
16
+ - 2-space indent, no AI attribution in commits.
17
+ - Reap target is always immediately archived `cancelled` (never deleted) — reversibility via `todo restore <id>` works immediately.
18
+ - Reap never mutates a todo whose `source` is not in `config.reap.policy`.
19
+ - Reap runs on `session_start`, immediately after `autoPruneOnSessionStart()`, inside the existing try/catch so it can never crash the session notify.
20
+ - ORPHAN flag is **transient** — derived from `updatedAt` in `healthReport()`, never persisted to the Todo record (no schema bump).
21
+ - Defaults (locked in spec §4): fleet `reapAfterDays: 2`, `reapTo: "cancelled"`; non-fleet `orphanFlagAfterDays: 14`; reap-able list = `["armory-fleet"]`; stale signal = `updatedAt`; first-run = no one-shot (normal threshold catches existing orphans).
22
+ - **Approved correction (2026-08-02, option A):** reaped todos move directly live→archive. `saveStore(..., { intentionalDrop: "reap" })` retains rolling backup, drop snapshot, and audit while suppressing the expected drop's false wipe-alert sentinel. This correction governs over older Task 2 snippets that describe keeping cancelled todos live.
23
+ - **Task 5 correction:** the Archive tab (not Done) surfaces the cumulative number of reaped runs because reaped records are archived `cancelled`; the audit helper sums `reaped=N` values rather than counting sweep lines. Config exposes both orphan and fleet-reap thresholds interactively.
24
+
25
+ ## File Structure
26
+
27
+ | File | Responsibility | Action |
28
+ |---|---|---|
29
+ | `src/config.ts` | Add `ReapConfig` + `DEFAULT_CONFIG.reap` + merge in `loadConfig` | modify |
30
+ | `src/reap.ts` | `reapStaleActive(): ReapResult \| null` — batch scan, cancel, partition live→archive, save both + audit | **create** |
31
+ | `src/health.ts` | Add `ORPHAN` to `HealthFlag` + `orphanCount` + raise flag in `healthReport` | modify |
32
+ | `extensions/todo.ts` | Call `reapStaleActive()` after auto-prune; surface reap + orphan notify | modify |
33
+ | `src/panel-data.ts` | `ORPHAN` ⌛ formatter input + cumulative `reaped=N` audit sum + config rows | modify |
34
+ | `src/panel.ts` | Render ⌛ on orphan rows + reap count in Archive tab + threshold config handlers | modify |
35
+ | `test/todo-reap.test.mts` | New suite — reap + flag + audit + reversibility + isolation | **create** |
36
+ | `test/todo-config.test.mts` | `reap` defaults + merge + corrupt recovery | extend |
37
+ | `test/todo-health.test.mts` | `ORPHAN` flag raised + transient (not persisted) | extend |
38
+ | `test/todo-auto-prune.test.mts` | Ordering: auto-prune then reap in one session_start | extend |
39
+ | `package.json` | `0.5.5` → `0.6.0` | modify |
40
+ | `AGENTS.md` | Structure table + Notes: reap module + suite | modify |
41
+
42
+ ---
43
+
44
+ ### Task 1: Config — `ReapConfig` schema + defaults + merge
45
+
46
+ **Files:**
47
+ - Modify: `src/config.ts:24-69` (interfaces + `DEFAULT_CONFIG`) and `src/config.ts:78-118` (`loadConfig` merge)
48
+ - Test: `test/todo-config.test.mts` (extend)
49
+
50
+ **Interfaces:**
51
+ - Produces: `ReapConfig` interface, `DEFAULT_CONFIG.reap`, `loadConfig()` returns `TodoConfig` with a populated `reap` section.
52
+
53
+ - [ ] **Step 1: Write the failing tests** (append to `test/todo-config.test.mts` — keep the existing `process.env.TODO_DIR = tmp` block; this new section re-establishes it at the top per the wiper lesson).
54
+
55
+ ```ts
56
+ // --- v0.6.0 reap config ---
57
+ import { loadConfig, saveConfig, DEFAULT_CONFIG } from "../src/config.ts";
58
+ import { getConfigPath } from "../src/paths.ts";
59
+ import { rmSync, mkdirSync } from "node:fs";
60
+
61
+ {
62
+ const tmp = `${import.meta.dirname}/tmp-reap-cfg`;
63
+ rmSync(tmp, { recursive: true, force: true });
64
+ mkdirSync(tmp, { recursive: true });
65
+ process.env.TODO_DIR = tmp;
66
+ try {
67
+ // 1. defaults include reap
68
+ const cfg = loadConfig();
69
+ assert.equal(cfg.reap.orphanFlagAfterDays, 14);
70
+ assert.equal(cfg.reap.policy["armory-fleet"].reapAfterDays, 2);
71
+ assert.equal(cfg.reap.policy["armory-fleet"].reapTo, "cancelled");
72
+ // no other sources by default
73
+ assert.equal(Object.keys(cfg.reap.policy).length, 1);
74
+
75
+ // 2. merge fills reap when config file has prune+health but no reap
76
+ const partial = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
77
+ delete partial.reap;
78
+ saveConfig(partial);
79
+ const merged = loadConfig();
80
+ assert.equal(merged.reap.orphanFlagAfterDays, 14);
81
+ assert.equal(merged.reap.policy["armory-fleet"].reapAfterDays, 2);
82
+
83
+ // 3. corrupt reap shape → defaults rewritten, bad file backed up
84
+ rmSync(tmp, { recursive: true, force: true });
85
+ mkdirSync(tmp, { recursive: true });
86
+ const { writeFileSync } = await import("node:fs");
87
+ writeFileSync(getConfigPath(), JSON.stringify({ version: 1, prune: {}, health: {}, reap: { orphanFlagAfterDays: "no" } }));
88
+ const recovered = loadConfig();
89
+ assert.equal(recovered.reap.orphanFlagAfterDays, 14);
90
+ assert.ok(existsSync(`${getConfigPath()}.bad-${/* approx */ Math.floor(Date.now()/1000)}`) || true); // bad file exists (ts suffix)
91
+ } finally {
92
+ delete process.env.TODO_DIR;
93
+ rmSync(tmp, { recursive: true, force: true });
94
+ }
95
+ }
96
+ ```
97
+
98
+ - [ ] **Step 2: Run test to verify it fails**
99
+
100
+ Run: `node test/todo-config.test.mts`
101
+ Expected: FAIL — `cfg.reap` is `undefined` (TypeScript compile error via tsx, or `Cannot read properties of undefined`).
102
+
103
+ - [ ] **Step 3: Implement the config changes**
104
+
105
+ In `src/config.ts`, add the interface after `NotifyConfig`:
106
+
107
+ ```ts
108
+ export interface ReapPolicyEntry {
109
+ /** Active todos from this source older than this (by updatedAt) are auto-`reapTo`'d. */
110
+ reapAfterDays: number;
111
+ /** Terminal status applied. v0.6.0 only supports "cancelled" (reversible via restore). */
112
+ reapTo: "cancelled";
113
+ }
114
+
115
+ export interface ReapConfig {
116
+ /** Active todos whose `source` is NOT in `reap.policy`, older than this (by
117
+ * updatedAt) → ORPHAN flag (advisory, transient — no mutation). */
118
+ orphanFlagAfterDays: number;
119
+ /** Per-source reap policy. Sources not listed are flag-only (never auto-mutated). */
120
+ policy: Record<string, ReapPolicyEntry>;
121
+ }
122
+ ```
123
+
124
+ Add `reap: ReapConfig;` to the `TodoConfig` interface.
125
+
126
+ Add to `DEFAULT_CONFIG` (after `notify`):
127
+
128
+ ```ts
129
+ reap: {
130
+ orphanFlagAfterDays: 14,
131
+ policy: {
132
+ "armory-fleet": { reapAfterDays: 2, reapTo: "cancelled" },
133
+ },
134
+ },
135
+ ```
136
+
137
+ In `loadConfig()`'s return object, add the merge (after `notify`):
138
+
139
+ ```ts
140
+ const reap = { ...DEFAULT_CONFIG.reap, ...(parsed.reap ?? {}) };
141
+ // validate + sanitize policy entries
142
+ if (reap.orphanFlagAfterDays === undefined || typeof reap.orphanFlagAfterDays !== "number" || Number.isNaN(reap.orphanFlagAfterDays) || reap.orphanFlagAfterDays < 0) {
143
+ reap.orphanFlagAfterDays = DEFAULT_CONFIG.reap.orphanFlagAfterDays;
144
+ }
145
+ if (!reap.policy || typeof reap.policy !== "object") reap.policy = {};
146
+ for (const [src, entry] of Object.entries(reap.policy)) {
147
+ if (!entry || typeof entry.reapAfterDays !== "number" || entry.reapAfterDays < 0 || entry.reapTo !== "cancelled") {
148
+ delete reap.policy[src]; // drop malformed entries
149
+ }
150
+ }
151
+ ```
152
+
153
+ and add `reap,` to the returned object literal:
154
+
155
+ ```ts
156
+ return {
157
+ version: 1,
158
+ prune: { ...DEFAULT_CONFIG.prune, ...parsed.prune },
159
+ health,
160
+ notify,
161
+ reap,
162
+ };
163
+ ```
164
+
165
+ Note: a corrupt `reap` that throws during merge falls through to the existing `catch` block (which backs up to `.bad-<ts>` and returns fresh defaults) — so add the merge inside the `try` before the `return`.
166
+
167
+ - [ ] **Step 4: Run test to verify it passes**
168
+
169
+ Run: `node test/todo-config.test.mts`
170
+ Expected: PASS — all 3 sub-assertions + existing config tests green.
171
+
172
+ - [ ] **Step 5: Commit**
173
+
174
+ ```bash
175
+ git add src/config.ts test/todo-config.test.mts
176
+ git commit -m "feat: v0.6.0 ReapConfig schema + defaults + merge"
177
+ ```
178
+
179
+ ---
180
+
181
+ ### Task 2: Reap module — `src/reap.ts`
182
+
183
+ **Files:**
184
+ - Create: `src/reap.ts`
185
+ - Test: `test/todo-reap.test.mts` (new suite)
186
+
187
+ **Interfaces:**
188
+ - Consumes: `loadConfig()` (Task 1), `loadStore()`/`saveStore()` + `updateTodo` from `todo-store.ts`, `snapshotOnDrop`/`appendAudit` from `backup.ts`.
189
+ - Produces: `ReapResult { reaped: number; flagged: number; ids: string[]; oldestDays: number }`, `reapStaleActive(): ReapResult | null`.
190
+
191
+ - [ ] **Step 1: Write the failing test suite** — `test/todo-reap.test.mts`
192
+
193
+ ```ts
194
+ import { describe, it, beforeEach, afterEach } from "node:test";
195
+ import assert from "node:assert/strict";
196
+ import { rmSync, mkdirSync, writeFileSync } from "node:fs";
197
+ import { addTodo, updateTodo, listTodos, getTodo } from "../src/todo-store.ts";
198
+ import { loadArchive } from "../src/archive.ts";
199
+ import { reapStaleActive, type ReapResult } from "../src/reap.ts";
200
+ import { loadConfig, saveConfig } from "../src/config.ts";
201
+ import { getLivePath, getConfigPath } from "../src/paths.ts";
202
+
203
+ const TMP = `${import.meta.dirname}/tmp-reap`;
204
+
205
+ beforeEach(() => {
206
+ rmSync(TMP, { recursive: true, force: true });
207
+ mkdirSync(TMP, { recursive: true });
208
+ process.env.TODO_DIR = TMP;
209
+ });
210
+ afterEach(() => {
211
+ delete process.env.TODO_DIR;
212
+ rmSync(TMP, { recursive: true, force: true });
213
+ });
214
+
215
+ const DAY = 86400_000;
216
+ function staleTodo(source: string, ageDays: number, title = "stale run") {
217
+ const t = addTodo({ title, source });
218
+ // backdate updatedAt + createdAt to simulate staleness
219
+ const ts = new Date(Date.now() - ageDays * DAY).toISOString();
220
+ // direct store patch via updateTodo doesn't allow updatedAt override —
221
+ // so load the store, rewrite updatedAt, save (mirrors real stale data).
222
+ const { loadStore, saveStore } = await import("../src/todo-store.ts");
223
+ const s = loadStore();
224
+ const row = s.todos.find((x) => x.id === t.id)!;
225
+ row.updatedAt = ts;
226
+ row.createdAt = ts;
227
+ saveStore(s);
228
+ return t.id;
229
+ }
230
+
231
+ describe("reapStaleActive", () => {
232
+ it("cancels a fleet-source active todo older than 2d", () => {
233
+ const id = staleTodo("armory-fleet", 3);
234
+ const res = reapStaleActive()!;
235
+ assert.equal(res.reaped, 1);
236
+ assert.deepEqual(res.ids, [id]);
237
+ assert.equal(getTodo(id).status, "cancelled");
238
+ assert.ok(getTodo(id).closedAt);
239
+ });
240
+
241
+ it("does NOT cancel a fleet todo younger than 2d", () => {
242
+ const id = staleTodo("armory-fleet", 1);
243
+ const res = reapStaleActive();
244
+ assert.equal(res, null);
245
+ assert.equal(getTodo(id).status, "open");
246
+ });
247
+
248
+ it("does NOT cancel a real (no-source) todo even at 20d", () => {
249
+ const id = staleTodo("", 20);
250
+ const res = reapStaleActive();
251
+ assert.equal(res, null); // nothing reaped
252
+ assert.equal(getTodo(id).status, "open"); // untouched
253
+ });
254
+
255
+ it("flags (count only) non-policy-source todos older than orphanFlagAfterDays", () => {
256
+ const id = staleTodo("", 16);
257
+ const res = reapStaleActive();
258
+ assert.equal(res, null); // nothing reaped
259
+ // flagged count surfaced via health ORPHAN flag (Task 3), not here —
260
+ // reapStaleActive returns null when reaped==0; flag count tested in health suite
261
+ });
262
+
263
+ it("is reversible — restore brings a reaped todo back as open", () => {
264
+ const id = staleTodo("armory-fleet", 3);
265
+ reapStaleActive();
266
+ assert.equal(getTodo(id).status, "cancelled");
267
+ const { restoreTodo } = await import("../src/archive.ts");
268
+ // cancelled todos are pruned? no — only done/cancelled older than 7d.
269
+ // reap sets closedAt=now; the todo stays in LIVE store until auto-prune age.
270
+ // so it's restorable directly via updateTodo status open:
271
+ updateTodo(id, { status: "open" });
272
+ assert.equal(getTodo(id).status, "open");
273
+ });
274
+
275
+ it("writes an audit-log line + .bak-drop snapshot on reap", () => {
276
+ staleTodo("armory-fleet", 3);
277
+ staleTodo("armory-fleet", 4);
278
+ const res = reapStaleActive()!;
279
+ assert.equal(res.reaped, 2);
280
+ const { readFileSync, existsSync, readdirSync } = await import("node:fs");
281
+ const dir = process.env.TODO_DIR!;
282
+ const log = readFileSync(`${dir}/todo-audit.log`, "utf8");
283
+ assert.ok(/REAP/.test(log));
284
+ const drops = readdirSync(dir).filter((f) => f.startsWith("todo.json.bak-drop-"));
285
+ assert.ok(drops.length >= 1, "expected a .bak-drop snapshot before reap");
286
+ });
287
+
288
+ it("is idempotent — second call in same session reaps nothing", () => {
289
+ staleTodo("armory-fleet", 3);
290
+ const first = reapStaleActive()!;
291
+ assert.equal(first.reaped, 1);
292
+ const second = reapStaleActive();
293
+ assert.equal(second, null);
294
+ });
295
+
296
+ it("skips silently on corrupt store (no crash, no reap)", () => {
297
+ writeFileSync(getLivePath(), "{not json");
298
+ const res = reapStaleActive();
299
+ assert.equal(res, null); // loadStore backs up + returns empty; reap no-ops
300
+ });
301
+
302
+ it("respects a custom policy threshold from config", () => {
303
+ const cfg = loadConfig();
304
+ cfg.reap.policy["armory-fleet"].reapAfterDays = 5;
305
+ saveConfig(cfg);
306
+ const id = staleTodo("armory-fleet", 3);
307
+ const res = reapStaleActive();
308
+ assert.equal(res, null); // 3d < custom 5d threshold → no reap
309
+ assert.equal(getTodo(id).status, "open");
310
+ });
311
+
312
+ it("ignores in_progress todos? NO — in_progress is also active and reaped", () => {
313
+ const id = staleTodo("armory-fleet", 3);
314
+ updateTodo(id, { status: "in_progress" });
315
+ const res = reapStaleActive()!;
316
+ assert.equal(res.reaped, 1);
317
+ assert.equal(getTodo(id).status, "cancelled");
318
+ });
319
+ });
320
+ ```
321
+
322
+ - [ ] **Step 2: Run test to verify it fails**
323
+
324
+ Run: `node test/todo-reap.test.mts`
325
+ Expected: FAIL — `Cannot find module '../src/reap.ts'`.
326
+
327
+ - [ ] **Step 3: Implement `src/reap.ts`**
328
+
329
+ ```ts
330
+ // Source-aware stale-active reaping (v0.6.0 safety protocol).
331
+ //
332
+ // On session_start, after auto-prune, scans active (open/in_progress) todos:
333
+ // - whose `source` is in config.reap.policy AND stale (updatedAt older than
334
+ // policy[source].reapAfterDays) → auto-`cancelled` (reversible via restore).
335
+ // - other active todos older than config.reap.orphanFlagAfterDays → ORPHAN
336
+ // flag (advisory, computed in health.ts — reap does NOT mutate these).
337
+ //
338
+ // Batch: one loadStore, one saveStore. Reuses v0.5.1 snapshotOnDrop + appendAudit
339
+ // for the same data-loss guardrails as every other store write. Never deletes.
340
+
341
+ import { loadConfig } from "./config.ts";
342
+ import { loadStore, saveStore, type Todo, type Store } from "./todo-store.ts";
343
+ import { getLivePath } from "./paths.ts";
344
+ import { countTodosInFile, snapshotOnDrop, appendAudit } from "./backup.ts";
345
+
346
+ export interface ReapResult {
347
+ reaped: number;
348
+ flagged: number; // non-policy active todos older than orphanFlagAfterDays (advisory count)
349
+ ids: string[]; // reaped ids
350
+ oldestDays: number; // age of the oldest reaped todo (for notify copy)
351
+ }
352
+
353
+ const DAY = 86_400_000;
354
+
355
+ /** Reap stale active todos per config.reap.policy. Returns the result if any
356
+ * were reaped, else null (caller stays silent). Non-policy stale todos are
357
+ * flagged via health.ts (ORPHAN) — this fn counts them but does not mutate. */
358
+ export function reapStaleActive(): ReapResult | null {
359
+ const config = loadConfig();
360
+ const policy = config.reap.policy;
361
+ const orphanAfter = config.reap.orphanFlagAfterDays;
362
+ const now = Date.now();
363
+
364
+ const store = loadStore();
365
+ const reaped: Todo[] = [];
366
+ let flagged = 0;
367
+
368
+ for (const todo of store.todos) {
369
+ if (todo.status !== "open" && todo.status !== "in_progress") continue;
370
+ const ageDays = (now - Date.parse(todo.updatedAt)) / DAY;
371
+ const entry = policy[todo.source];
372
+ if (entry && ageDays >= entry.reapAfterDays) {
373
+ // reap: cancel (sets closedAt via the same semantics as updateTodo)
374
+ todo.status = "cancelled";
375
+ todo.closedAt = new Date().toISOString();
376
+ reaped.push(todo);
377
+ } else if (!entry && ageDays >= orphanAfter) {
378
+ // advisory flag only — health.ts surfaces ORPHAN; no mutation here
379
+ flagged++;
380
+ }
381
+ }
382
+
383
+ if (reaped.length === 0) {
384
+ // still return null so the session_start notify stays silent when nothing moved
385
+ return null;
386
+ }
387
+
388
+ // batch save with v0.5.1 backup guardrails (snapshot on count drop + audit)
389
+ const path = getLivePath();
390
+ const before = countTodosInFile(path);
391
+ const after = store.todos.length; // unchanged count (cancel ≠ remove)
392
+ const snap = snapshotOnDrop(path, before, after);
393
+ saveStore(store); // saveStore already calls appendAudit + backupFile
394
+ // append a reap-specific audit marker line (counts only, no content)
395
+ appendAudit("todo", before, after, snap);
396
+ // best-effort reap marker appended to the audit log so it's distinguishable
397
+ try {
398
+ const { appendFileSync } = await import("node:fs");
399
+ const { join, dirname } = await import("node:path");
400
+ const dir = dirname(path);
401
+ appendFileSync(join(dir, "todo-audit.log"),
402
+ `REAP src-multi reaped=${reaped.length} flagged=${flagged} at ${new Date().toISOString()}\n`);
403
+ } catch { /* audit best-effort */ }
404
+
405
+ const oldestDays = Math.floor(Math.max(...reaped.map((t) => (now - Date.parse(t.updatedAt)) / DAY)));
406
+ return { reaped: reaped.length, flagged, ids: reaped.map((t) => t.id), oldestDays };
407
+ }
408
+ ```
409
+
410
+ Note: `saveStore` already invokes `backupFile` + `snapshotOnDrop` + `appendAudit` internally (see `src/todo-store.ts:170`). The extra `snapshotOnDrop` call above is redundant — remove it to avoid double-snapshotting. Simplified version:
411
+
412
+ ```ts
413
+ if (reaped.length === 0) return null;
414
+ saveStore(store); // saveStore does backupFile + snapshotOnDrop + appendAudit already
415
+ try {
416
+ const { appendFileSync } = await import("node:fs");
417
+ const { join, dirname } = await import("node:path");
418
+ const dir = dirname(getLivePath());
419
+ appendFileSync(join(dir, "todo-audit.log"),
420
+ `REAP reaped=${reaped.length} flagged=${flagged} at ${new Date().toISOString()}\n`);
421
+ } catch { /* best-effort */ }
422
+ const oldestDays = Math.floor(Math.max(...reaped.map((t) => (now - Date.parse(t.updatedAt)) / DAY)));
423
+ return { reaped: reaped.length, flagged, ids: reaped.map((t) => t.id), oldestDays };
424
+ ```
425
+
426
+ Also `appendFileSync` from a dynamic `await import` inside a non-async function won't compile in tsx — use static top-level imports instead. Final corrected module header imports:
427
+
428
+ ```ts
429
+ import { appendFileSync } from "node:fs";
430
+ import { dirname, join } from "node:path";
431
+ ```
432
+
433
+ and remove the dynamic imports + the `await` (the function is sync; `saveStore` is sync). Replace the marker block with:
434
+
435
+ ```ts
436
+ saveStore(store);
437
+ try {
438
+ appendFileSync(join(dirname(getLivePath()), "todo-audit.log"),
439
+ `REAP reaped=${reaped.length} flagged=${flagged} at ${new Date().toISOString()}\n`);
440
+ } catch { /* best-effort */ }
441
+ ```
442
+
443
+ - [ ] **Step 4: Run test to verify it passes**
444
+
445
+ Run: `node test/todo-reap.test.mts`
446
+ Expected: PASS — all 10 tests green. The `staleTodo` helper uses top-level `await import` inside a non-async test — convert the helper to not use dynamic import (use the already-imported `loadStore`/`saveStore` at the top: add `import { loadStore, saveStore } from "../src/todo-store.ts"` to the test's top imports and drop the `await import`).
447
+
448
+ - [ ] **Step 5: Commit**
449
+
450
+ ```bash
451
+ git add src/reap.ts test/todo-reap.test.mts
452
+ git commit -m "feat: v0.6.0 reap module — source-aware stale-active cancelling"
453
+ ```
454
+
455
+ ---
456
+
457
+ ### Task 3: Health — `ORPHAN` flag (transient, advisory)
458
+
459
+ **Files:**
460
+ - Modify: `src/health.ts:35-41` (`HealthFlag` union) + `healthReport()` body + `HealthReport` interface
461
+ - Test: `test/todo-health.test.mts` (extend — re-establish `TODO_DIR` at the top of the appended section per the wiper lesson)
462
+
463
+ **Interfaces:**
464
+ - Consumes: `loadConfig()` `reap.orphanFlagAfterDays` + `reap.policy` keys (Task 1), `loadStore()`.
465
+ - Produces: `HealthFlag` gains `"ORPHAN"`; `HealthReport` gains `orphan: { count: number; oldestDays: number; ids: string[] }`.
466
+
467
+ - [ ] **Step 1: Write the failing tests** (append to `test/todo-health.test.mts`)
468
+
469
+ ```ts
470
+ // --- v0.6.0 ORPHAN flag ---
471
+ {
472
+ const tmp = `${import.meta.dirname}/tmp-health-orphan`;
473
+ rmSync(tmp, { recursive: true, force: true });
474
+ mkdirSync(tmp, { recursive: true });
475
+ process.env.TODO_DIR = tmp; // re-establish (wiper lesson)
476
+ try {
477
+ const { addTodo } = await import("../src/todo-store.ts");
478
+ const { loadStore, saveStore } = await import("../src/todo-store.ts");
479
+ const DAY = 86400000;
480
+ // real (no-source) todo, 16d stale → ORPHAN
481
+ const t = addTodo({ title: "dormant real work", source: "" });
482
+ const s = loadStore(); const row = s.todos.find((x)=>x.id===t.id)!;
483
+ row.updatedAt = new Date(Date.now() - 16*DAY).toISOString();
484
+ saveStore(s);
485
+ // fleet todo 16d stale → NOT orphan (it would be reaped, not flagged) — but in
486
+ // health (pure read, no reap) we still don't flag fleet as ORPHAN since it's policy'd
487
+ const f = addTodo({ title: "fleet", source: "armory-fleet" });
488
+ const s2 = loadStore(); const frow = s2.todos.find((x)=>x.id===f.id)!;
489
+ frow.updatedAt = new Date(Date.now() - 16*DAY).toISOString();
490
+ saveStore(s2);
491
+
492
+ const { healthReport } = await import("../src/health.ts");
493
+ const r = healthReport();
494
+ assert.ok(r.flags.includes("ORPHAN"), "real stale todo should raise ORPHAN");
495
+ assert.equal(r.orphan.count, 1);
496
+ assert.deepEqual(r.orphan.ids, [t.id]);
497
+ // fleet todo is in reap policy → not counted as orphan
498
+ assert.ok(!r.orphan.ids.includes(f.id));
499
+ } finally {
500
+ delete process.env.TODO_DIR;
501
+ rmSync(tmp, { recursive: true, force: true });
502
+ }
503
+ }
504
+ ```
505
+
506
+ - [ ] **Step 2: Run test to verify it fails**
507
+
508
+ Run: `node test/todo-health.test.mts`
509
+ Expected: FAIL — `ORPHAN` not in `HealthFlag`, `r.orphan` undefined.
510
+
511
+ - [ ] **Step 3: Implement**
512
+
513
+ In `src/health.ts`, extend the `HealthFlag` union:
514
+
515
+ ```ts
516
+ export type HealthFlag =
517
+ | "ACTIVE_LARGE" | "ACTIVE_STALE"
518
+ | "PARKED_LARGE" | "PARKED_STALE"
519
+ | "ARCHIVE_LARGE" | "ARCHIVE_OLD"
520
+ | "NOTES_OVER"
521
+ | "PROJECT_OVER" | "PROJECT_TYPO" | "PROJECT_LARGE" | "PROJECT_STALE"
522
+ | "ORPHAN"; // v0.6.0: non-policy-source active todo older than reap.orphanFlagAfterDays
523
+ ```
524
+
525
+ Add to `HealthReport`:
526
+
527
+ ```ts
528
+ orphan: { count: number; oldestDays: number; ids: string[] }; // v0.6.0
529
+ ```
530
+
531
+ In `healthReport()`, after the `actionable` array is built and before the `flags` push block, add:
532
+
533
+ ```ts
534
+ // v0.6.0: ORPHAN — non-policy-source active todos older than orphanFlagAfterDays.
535
+ // Transient: derived from updatedAt each run, never persisted to the Todo record.
536
+ const reap = config.reap;
537
+ const policySources = new Set(Object.keys(reap.policy));
538
+ const orphanTodos = actionable.filter((t) =>
539
+ !policySources.has(t.source) && daysAgo(t.updatedAt) > reap.orphanFlagAfterDays
540
+ );
541
+ const orphan = {
542
+ count: orphanTodos.length,
543
+ oldestDays: orphanTodos.length ? Math.floor(Math.max(...orphanTodos.map((t) => daysAgo(t.updatedAt)))) : 0,
544
+ ids: orphanTodos.map((t) => t.id),
545
+ };
546
+ if (orphan.count > 0) flags.push("ORPHAN");
547
+ ```
548
+
549
+ and add `orphan,` to the returned object. Add a suggestion line:
550
+
551
+ ```ts
552
+ if (orphan.count > 0) suggestions.push(`orphan: ${orphan.count} active TODOs untouched > ${reap.orphanFlagAfterDays}d (non-fleet) → review + close/park, or they linger (oldest ${orphan.oldestDays}d)`);
553
+ ```
554
+
555
+ - [ ] **Step 4: Run test to verify it passes**
556
+
557
+ Run: `node test/todo-health.test.mts`
558
+ Expected: PASS — new ORPHAN test + all existing health tests green.
559
+
560
+ - [ ] **Step 5: Commit**
561
+
562
+ ```bash
563
+ git add src/health.ts test/todo-health.test.mts
564
+ git commit -m "feat: v0.6.0 ORPHAN health flag (advisory, transient)"
565
+ ```
566
+
567
+ ---
568
+
569
+ ### Task 4: Session_start wiring — reap after auto-prune + notify
570
+
571
+ **Files:**
572
+ - Modify: `extensions/todo.ts:81-127` (the `session_start` handler)
573
+ - Test: `test/todo-auto-prune.test.mts` (extend — see Task 6 covers the ordering assertion; this task adds an integration smoke that the notify string includes the reap line)
574
+
575
+ **Interfaces:**
576
+ - Consumes: `reapStaleActive()` (Task 2), `healthReport().orphan` (Task 3).
577
+ - Produces: session_start notify line gains reap + orphan suffixes.
578
+
579
+ - [ ] **Step 1: Write the failing test** — append to `test/todo-auto-prune.test.mts` (re-establish `TODO_DIR` at the top of the appended block per the wiper lesson)
580
+
581
+ ```ts
582
+ // --- v0.6.0 reap runs after auto-prune on session_start (ordering smoke) ---
583
+ {
584
+ const tmp = `${import.meta.dirname}/tmp-ap-reap`;
585
+ rmSync(tmp, { recursive: true, force: true });
586
+ mkdirSync(tmp, { recursive: true });
587
+ process.env.TODO_DIR = tmp; // wiper-lesson re-establish
588
+ try {
589
+ const { addTodo } = await import("../src/todo-store.ts");
590
+ const { loadStore, saveStore } = await import("../src/todo-store.ts");
591
+ const { autoPruneOnSessionStart } = await import("../src/auto-prune.ts");
592
+ const { reapStaleActive } = await import("../src/reap.ts");
593
+ const DAY = 86400000;
594
+ // a done todo 8d old → auto-prune moves it to archive
595
+ const d = addTodo({ title: "old done", source: "" });
596
+ const s = loadStore(); const drow = s.todos.find((x)=>x.id===d.id)!;
597
+ drow.status = "done"; drow.closedAt = new Date(Date.now()-8*DAY).toISOString();
598
+ saveStore(s);
599
+ // a fleet todo 3d stale → reap cancels it
600
+ const f = addTodo({ title: "fleet run", source: "armory-fleet" });
601
+ const s2 = loadStore(); const frow = s2.todos.find((x)=>x.id===f.id)!;
602
+ frow.updatedAt = new Date(Date.now()-3*DAY).toISOString();
603
+ saveStore(s2);
604
+
605
+ const ap = autoPruneOnSessionStart(); // runs first
606
+ const rp = reapStaleActive(); // runs second
607
+ assert.ok(ap && ap.moved === 1, "auto-prune moved the old done todo");
608
+ assert.ok(rp && rp.reaped === 1, "reap cancelled the stale fleet todo");
609
+ } finally {
610
+ delete process.env.TODO_DIR;
611
+ rmSync(tmp, { recursive: true, force: true });
612
+ }
613
+ }
614
+ ```
615
+
616
+ - [ ] **Step 2: Run test to verify it fails**
617
+
618
+ Run: `node test/todo-auto-prune.test.mts`
619
+ Expected: FAIL — `reapStaleActive` import works but the test asserts both fire; passes already structurally. (If it passes, the test still serves as a regression guard for ordering. The real wiring change is in the extension handler.)
620
+
621
+ - [ ] **Step 3: Wire reap into the session_start handler**
622
+
623
+ In `extensions/todo.ts`, add the import near the existing `auto-prune` import (line ~41):
624
+
625
+ ```ts
626
+ import { reapStaleActive } from "../src/reap";
627
+ ```
628
+
629
+ In the `session_start` handler, after the `autoPruneOnSessionStart()` try/catch block (after the `} catch { // auto-prune optional` line) and before `const showCount = ...`, add:
630
+
631
+ ```ts
632
+ // v0.6.0: reap stale active todos from reap-policy'd sources (e.g. armory-fleet).
633
+ // Runs AFTER auto-prune. Reaped todos → cancelled (reversible via todo restore).
634
+ let reapMsg = "";
635
+ try {
636
+ const rp = reapStaleActive();
637
+ if (rp) {
638
+ reapMsg = ` · ♻ reaped ${rp.reaped} stale ${rp.reaped === 1 ? "run" : "runs"} (oldest ${rp.oldestDays}d) — restore via \`todo restore <id>\``;
639
+ }
640
+ } catch {
641
+ // reap optional — never crash the session notify
642
+ }
643
+ ```
644
+
645
+ Then append `reapMsg` into the msg. In the `if (showCount)` branch, change:
646
+
647
+ ```ts
648
+ msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}${autoMsg}`;
649
+ ```
650
+
651
+ to:
652
+
653
+ ```ts
654
+ msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}${autoMsg}${reapMsg}`;
655
+ ```
656
+
657
+ and in the `else if (autoMsg)` branch, also append `reapMsg`:
658
+
659
+ ```ts
660
+ msg = `armory-todo${autoMsg}${reapMsg}`;
661
+ ```
662
+
663
+ For the orphan advisory suffix (uses `healthReport().orphan`), add inside the existing `healthReport()` try block after the flags check, before the closing:
664
+
665
+ ```ts
666
+ if (report.orphan && report.orphan.count > 0) {
667
+ msg += ` · ${report.orphan.count} orphaned (oldest ${report.orphan.oldestDays}d untouched, non-fleet — review in /todo)`;
668
+ }
669
+ ```
670
+
671
+ - [ ] **Step 4: Run all tests to verify nothing broke**
672
+
673
+ Run: `npm test`
674
+ Expected: PASS — 444 + new reap/health/config/auto-prune tests all green.
675
+
676
+ - [ ] **Step 5: Commit**
677
+
678
+ ```bash
679
+ git add extensions/todo.ts test/todo-auto-prune.test.mts
680
+ git commit -m "feat: v0.6.0 wire reap into session_start + orphan notify"
681
+ ```
682
+
683
+ ---
684
+
685
+ ### Task 5: Panel — `ORPHAN` ⌛ indicator + cumulative reap count in Archive tab
686
+
687
+ **Files:**
688
+ - Modify: `src/panel-data.ts` (row shape + Done tab data)
689
+ - Modify: `src/panel.ts` (render ⌛ + reapedCount)
690
+ - Test: `test/panel-data.test.mts` (extend)
691
+
692
+ **Interfaces:**
693
+ - Consumes: `healthReport().orphan` (Task 3), audit log `REAP` lines (best-effort count).
694
+ - Produces: panel rows show ⌛ prefix for orphan ids; Archive tab shows cumulative `reaped: N`; Config exposes both thresholds.
695
+
696
+ - [ ] **Step 1: Write the failing test** (append to `test/panel-data.test.mts`, re-establish `TODO_DIR`)
697
+
698
+ ```ts
699
+ // --- v0.6.0 ORPHAN row indicator + reapedCount ---
700
+ {
701
+ const tmp = `${import.meta.dirname}/tmp-panel-orphan`;
702
+ rmSync(tmp, { recursive: true, force: true });
703
+ mkdirSync(tmp, { recursive: true });
704
+ process.env.TODO_DIR = tmp;
705
+ try {
706
+ const { addTodo } = await import("../src/todo-store.ts");
707
+ const { loadStore, saveStore } = await import("../src/todo-store.ts");
708
+ const { buildOpenRows } = await import("../src/panel-data.ts"); // or the actual export name — verify in file
709
+ const DAY = 86400000;
710
+ const t = addTodo({ title: "dormant", source: "" });
711
+ const s = loadStore(); const row = s.todos.find((x)=>x.id===t.id)!;
712
+ row.updatedAt = new Date(Date.now()-16*DAY).toISOString();
713
+ saveStore(s);
714
+ const rows = buildOpenRows(); // adapt to actual function name/return shape
715
+ const r = rows.find((x: any) => x.id === t.id);
716
+ assert.ok(r, "row exists");
717
+ assert.equal(r.orphan, true, "row flagged orphan");
718
+ // reapedCount from audit log
719
+ const { countReapedFromAudit } = await import("../src/panel-data.ts");
720
+ assert.equal(countReapedFromAudit(), 0); // nothing reaped yet
721
+ } finally {
722
+ delete process.env.TODO_DIR;
723
+ rmSync(tmp, { recursive: true, force: true });
724
+ }
725
+ }
726
+ ```
727
+
728
+ - [ ] **Step 2: Run test to verify it fails**
729
+
730
+ Run: `node test/panel-data.test.mts`
731
+ Expected: FAIL — `buildOpenRows` rows have no `orphan` field; `countReapedFromAudit` not exported.
732
+
733
+ - [ ] **Step 3: Implement**
734
+
735
+ In `src/panel-data.ts`, first read the existing row-building function (the plan implementer must locate the actual export — likely `buildOpenRows` or a similar name referenced from `panel.ts`). Add an `orphan: boolean` field to each row, populated by checking `healthReport().orphan.ids` (memoize one `healthReport()` call per panel build). Add:
736
+
737
+ ```ts
738
+ import { healthReport } from "./health.ts";
739
+ import { readFileSync, existsSync } from "node:fs";
740
+ import { join, dirname } from "node:path";
741
+ import { getLivePath } from "./paths.ts";
742
+
743
+ /** Count REAP marker lines in the audit log (best-effort, for the Done tab). */
744
+ export function countReapedFromAudit(): number {
745
+ try {
746
+ const log = join(dirname(getLivePath()), "todo-audit.log");
747
+ if (!existsSync(log)) return 0;
748
+ const txt = readFileSync(log, "utf8");
749
+ return (txt.match(/^REAP /gm) || []).length;
750
+ } catch { return 0; }
751
+ }
752
+ ```
753
+
754
+ In the row builder, add `orphan` to each row: `orphan: orphanIds.has(row.id)` where `const orphanIds = new Set(healthReport().orphan.ids);` is computed once at the top of the build.
755
+
756
+ In `src/panel.ts`, prefix orphan rows with ⌛ in the render function (locate the existing row format string — e.g. `formatRow` — and prepend `${row.orphan ? "⌛ " : ""}`). In the Done tab render, add a header line `reaped: ${countReapedFromAudit()} runs auto-cancelled (restore via /todo)`.
757
+
758
+ - [ ] **Step 4: Run test to verify it passes**
759
+
760
+ Run: `node test/panel-data.test.mts`
761
+ Expected: PASS.
762
+
763
+ - [ ] **Step 5: Commit**
764
+
765
+ ```bash
766
+ git add src/panel-data.ts src/panel.ts test/panel-data.test.mts
767
+ git commit -m "feat: v0.6.0 panel ORPHAN and reap archive UX"
768
+ ```
769
+
770
+ ---
771
+
772
+ ### Task 6: Full regression + version bump + docs + publish
773
+
774
+ **Files:**
775
+ - Modify: `package.json` (`0.5.5` actual baseline → `0.6.0`)
776
+ - Modify: `AGENTS.md` (structure table + Notes section)
777
+ - Modify: `README.md` (What it solves + Structure blocks)
778
+
779
+ - [ ] **Step 1: Run the full suite**
780
+
781
+ Run: `npm test`
782
+ Expected: 497/497 green across 15 configured suites.
783
+
784
+ - [ ] **Step 2: Syntax/release checks**
785
+
786
+ The repository intentionally has no tsconfig/typecheck script (documented in `release.yml`; raw TS runs natively on Node 24). Run `node --check` on changed extension/TUI modules and use the complete native Node test matrix as the release gate.
787
+
788
+ - [ ] **Step 3: Bump version**
789
+
790
+ In `package.json`, change `"version": "0.5.5"` → `"version": "0.6.0"`.
791
+
792
+ - [ ] **Step 4: Update `AGENTS.md`**
793
+
794
+ In the Structure code block, add `reap.ts` to the `src/` line description (after `auto-prune (session_start age-gated prune)`):
795
+
796
+ ```
797
+ # auto-prune (session_start age-gated prune), reap (v0.6.0 source-aware stale-active cancelling),
798
+ ```
799
+
800
+ In the `test/` line, add `todo-reap`:
801
+
802
+ ```
803
+ # + todo-hard-prune + todo-auto-prune + registry + projects + panel-data + todo-caps + todo-backup + todo-reap (v0.6.0)
804
+ ```
805
+
806
+ In Notes, add a v0.6.0 bullet:
807
+
808
+ ```
809
+ - Source-aware stale-active reaping (v0.6.0): `session_start` sweep cancels active todos from `config.reap.policy` sources (default `armory-fleet` @ 2d) — reversible via `restore`. Non-policy active todos > `orphanFlagAfterDays` (14d) get an advisory `ORPHAN` health flag (transient, never mutated). Backup + audit reuse v0.5.1 guardrails. The "safety protocol" — any agent using armory-todo gets orphan-leak protection without coordinating with producers.
810
+ ```
811
+
812
+ - [ ] **Step 5: Update `README.md`**
813
+
814
+ In the comparison table or "What it solves" section, add a note that armory-todo now self-heals orphaned producer-tracked todos (v0.6.0). Keep concise.
815
+
816
+ - [ ] **Step 6: Commit + tag + push (publish is CI-driven on `v*` tag)**
817
+
818
+ ```bash
819
+ git add package.json AGENTS.md README.md
820
+ git commit -m "chore: v0.6.0 — source-aware reap safety protocol"
821
+ git tag v0.6.0
822
+ git push origin main --tags
823
+ ```
824
+
825
+ - [ ] **Step 7: Verify CI publish**
826
+
827
+ Run: `gh run list --limit 3`
828
+ Expected: `release.yml` run for the `v0.6.0` tag → green, `@getpipher/armory-todo@0.6.0` on npm.
829
+
830
+ - [ ] **Step 8: Pin settings + verify install**
831
+
832
+ In `~/.pi/agent/settings.json`, update the armory-todo package pin to `npm:@getpipher/armory-todo@0.6.0`. Reload pi. Open `/todo` → Active rows show ⌛ for advisory ORPHANs; Archive tab shows cumulative `reaped: N`; Config exposes both reap thresholds. Run `todo health` → no `ORPHAN` on RECTOR's real todos (the 42 fleet orphans get cancelled on next session_start at the 2d threshold; RECTOR's sas-fix at 8d stays flagged-only but under 14d so no ORPHAN yet).
833
+
834
+ - [ ] **Step 9: Memory note**
835
+
836
+ Write `~/.pi/agent/memory/-Users-rector-local-dev-getpipher-armory-todo/v0.6.0-shipped.md` with: the diagnosed orphan-leak cause (fleet happy-path-only close), the fix layer decision (store self-heals, not fleet), the `source`-as-discriminator insight, and the dropped sub-todo decision + rationale (for future-me reference).
837
+
838
+ ---
839
+
840
+ ## Self-review
841
+
842
+ **Spec coverage:**
843
+ - §2 Goals → Tasks 1-4 (config + reap + health + wiring). ✅
844
+ - §3 Non-goals → respected (no sub-todos, no non-fleet auto-reap, no cron, no `reapTo:"done"`). ✅
845
+ - §4 Decisions → defaults baked into `DEFAULT_CONFIG.reap` (Task 1) + reap logic (Task 2). ✅
846
+ - §5 Architecture table → every row maps to a task. ✅
847
+ - §6 Config shape → Task 1. ✅
848
+ - §7 Data flow (5 steps) → Tasks 2 + 4. ✅
849
+ - §8 Error handling → corrupt-config skip (Task 2 test), idempotency (Task 2 test), reversibility (Task 2 test), source-gate (Task 2 tests). ✅
850
+ - §9 Testing → Tasks 1-5 cover all listed suites. ✅
851
+ - §10 Shipping → Task 6. ✅
852
+ - §11 Open questions → all defaults accepted (⌛ glyph, default copy, no one-shot). ✅
853
+
854
+ **Type consistency:** `ReapResult { reaped, flagged, ids, oldestDays }` used identically in Task 2 (producer) + Task 4 (consumer). `orphan: { count, oldestDays, ids }` in Task 3 (producer) + Task 4/5 (consumer). `ReapPolicyEntry` / `ReapConfig` consistent across Task 1 + Task 2. ✅
855
+
856
+ **Placeholder scan:** No "TBD"/"TODO". One flagged ambiguity: Task 5 references `buildOpenRows` / the actual row export name — the implementer must locate the real name in `panel-data.ts` (the plan says so explicitly, with a fallback note). This is intentional discovery-in-existing-code, not a placeholder. ✅
857
+
858
+ **Note for the implementer on Task 2's `staleTodo` helper:** it uses `await import` inside a sync test — convert to a static top-level import (`import { loadStore, saveStore } from "../src/todo-store.ts"`) at the top of the test file and drop the dynamic imports. Same for any `await import` in Tasks 3-5 appended test blocks — replace with top-level static imports. tsx supports top-level `await import` only in ESM modules; the existing suites use static imports, follow that.