@getpipher/armory-todo 0.5.5 → 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,187 @@
1
+ # v0.6.0 — Source-aware stale-active reaping ("safety protocol")
2
+
3
+ **Date:** 2026-07-29
4
+ **Status:** Approved + implemented (release verification pending)
5
+ **Branch:** `feat/reap-safety-protocol` off `main`
6
+ **Predecessor:** v0.5.3 (wiper fix + data-loss defense complete), PR #11, shipped 2026-07-21
7
+ **Target ship:** v0.6.0, auto-published via `release.yml` on `v0.6.0` tag
8
+
9
+ ---
10
+
11
+ ## 1. Problem
12
+
13
+ The v0.5.3 session closed armory-todo as "fully complete." Eight days of heavy
14
+ dogfooding later, RECTOR flagged a new symptom: **"every day I see many todos in
15
+ `/todo`."** Investigation of the live store (`~/.pi/agent/todo/todo.json`, 229
16
+ todos, 688KB) found three mechanics producing the bloat:
17
+
18
+ 1. **`armory-fleet` auto-tracking (volume driver).** Every `subagent(...)` and
19
+ background fleet run auto-creates a tracked TODO (`source: "armory-fleet"`,
20
+ `tag: "fleet-run"`). 187 fleet todos created in 5 days — 82% of the live
21
+ store. By design, but high-volume under dogfooding.
22
+
23
+ 2. **Orphaned fleet runs (the real leak).** 42 fleet todos are
24
+ `open`/`in_progress` with no `updatedAt` movement in 3–7 days. These are
25
+ dead runs — background process killed, session closed, run abandoned —
26
+ where fleet only closed its tracked todo on the **happy path** (foreground
27
+ completion or `fleet_results` pull). Fleet never closes the todo when the
28
+ run dies. **Auto-prune (v0.3.1) only touches `done`/`cancelled`, so orphaned
29
+ active todos pile up indefinitely** — they never reach a terminal status.
30
+
31
+ 3. **Done-todos linger up to 7 days (working as designed).** 151 done todos in
32
+ the live store, all <3d old. Auto-prune archives them at the 7-day mark.
33
+ Correct behavior, but high volume makes the visible queue large.
34
+
35
+ The user's hypothesis ("agents don't mark done") was **wrong** — the data shows
36
+ 151 done vs 65 open; agents close todos fine. The real bug is **producer
37
+ discipline**: `armory-fleet` should close its todo on every terminal state, but
38
+ relying on every consumer to behave correctly is fragile. A new extension, a
39
+ sloppy agent, a crashed session — any can orphan a todo.
40
+
41
+ **The fix belongs in armory-todo, not fleet.** Defense-in-depth: the store
42
+ self-heals regardless of who wrote it. This is the "safety protocol" — any agent
43
+ or extension using armory-todo gets orphan-leak protection for free, without
44
+ coordinating with armory-todo's maintainers.
45
+
46
+ ## 2. Goals
47
+
48
+ - **Self-heal orphaned active todos** from known-prolific producers
49
+ (`armory-fleet`) without cross-package coordination.
50
+ - **Never auto-mutate real work.** Todos with no `source` (agent-managed,
51
+ real-work) are flagged-only — irreversible state changes stay human-driven.
52
+ - **Immediate reversibility.** Reaping sets status to `cancelled` and moves the
53
+ todo directly from the live store to the archive (never deleted) →
54
+ `todo restore <id>` works immediately. Same `.bak-drop-<ts>` snapshot +
55
+ audit-log guardrails as the v0.5.1 backup system — a bad threshold or runaway
56
+ reap is recoverable.
57
+ - **Config-driven, YAGNI-respecting.** The reap-able source list lives in
58
+ `todo.config.json`. Adding a new source later is a config edit, not a code
59
+ change. Ship with `armory-fleet` only; expand when there's a real second
60
+ producer.
61
+ - **Zero migration.** Pure additive — one new config section, one new module,
62
+ two new health flags. Existing stores load unchanged.
63
+
64
+ ## 3. Non-goals (deferred / rejected)
65
+
66
+ | Out of scope | Why |
67
+ |---|---|
68
+ | Auto-reap for non-fleet sources | Real work stays flag-only — irreversible actions stay human-driven |
69
+ | `reapTo: "done"` option | Only `cancelled` — semantically "abandoned run," not "completed work" |
70
+ | Per-project reap policy | `source` is the right discriminator (it identifies the producer), not `project` |
71
+ | Background reaper (cron) | `session_start` is enough — RECTOR boots pi daily |
72
+ | **Sub-todos** | Brainstormed and **rejected**. The diagnosed problem is orphan lifecycle, not hierarchy. Sub-todos add cascade semantics, break v0.5.0 caps counting, hit the pi-tui nested-UI blocker, and `notes`-as-checklist already covers 80% at zero schema cost. Revisit only if epic-level independent lifecycle tracking becomes a real pain. |
73
+ | Fixing `armory-fleet` itself | Filed as a separate observation — fleet should close its todo on run death. This spec makes armory-todo resilient *regardless* of whether fleet is ever fixed. |
74
+
75
+ ## 4. Decisions log (from brainstorm Q&A)
76
+
77
+ | # | Question | Decision |
78
+ |---|---|---|
79
+ | Q1 | Drop sub-todos? | **Yes** — not needed once orphans self-heal. Rejected for this release; see §3. |
80
+ | Q2 | Safety posture (surface / source-aware reap / universal reap) | **B — source-aware auto-reap.** Fleet-source active todos are auto-`cancelled` at 2d; real (`source: undefined`) todos are flag-only at 14d. The `source` field cleanly separates producers (57 fleet = `armory-fleet`, ~22 real = no source). |
81
+ | Q3 | Fleet reap threshold | **2d.** The 42 current orphans are 3–7d old; 2d catches the next wave before they pile up. Fleet runs that matter resolve in minutes, not days. |
82
+ | Q4 | Non-fleet orphan-flag threshold | **14d.** RECTOR's dormant `zeroclaw-solana` sas-fix is 8d and intentionally paused — 14d gives it headroom so it doesn't nag. |
83
+ | Q5 | Reap-able source list | **`["armory-fleet"]` only, for now.** Open list is YAGNI; config schema supports future additions without code change. |
84
+ | Q6 | Reap target status | **`cancelled`** (reversible via `restore`); never deleted. |
85
+ | Q7 | "Stale" signal | **`updatedAt`** — already on every todo, bumped on every `update`. No new `lastTouchedAt` field. |
86
+ | Q8 | Reap timing | **`session_start`**, immediately after `autoPruneOnSessionStart()`. One self-healing pass per boot. |
87
+ | Q9 | Audit/backup | Reuse v0.5.1 `snapshotOnDrop` + `appendAudit` — same guardrails, no new mechanism. |
88
+ | Q10 | Reaped todo destination (implementation challenge, approved 2026-08-02) | **A — immediate archive.** A live `cancelled` todo is hidden from every panel tab and `restoreTodo` only accepts archived ids. Therefore reaping atomically partitions live→archive so `todo restore <id>` works immediately. Intentional count drops retain backup/drop-snapshot/audit but suppress the false wipe-alert sentinel. |
89
+
90
+ ## 5. Architecture
91
+
92
+ | Layer | File | Change | New? |
93
+ |---|---|---|---|
94
+ | Config schema | `src/config.ts` | + `ReapConfig` interface + `DEFAULT_CONFIG.reap` + merge validation + corrupt recovery | extend |
95
+ | Reap logic | `src/reap.ts` | New module — `reapStaleActive(): ReapResult \| null` (mirrors `auto-prune.ts` shape) | **new** |
96
+ | Health flags | `src/health.ts` | + `ORPHAN` (flagged-not-reaped, real todos — advisory, transient) | extend |
97
+ | Audit/notify | `extensions/todo.ts` | `REAPED` is an **audit-log marker** (not a `HealthFlag` — reaped todos become archived `cancelled` and leave the active box, so health never sees them). Surfaced via the reap notify line + a cumulative run count in the Archive tab. | extend |
98
+ | Session_start wiring | `extensions/todo.ts` | Call `reapStaleActive()` after `autoPruneOnSessionStart()`; surface reap result in the existing notify block | extend |
99
+ | Backup/audit | `src/backup.ts` | Reuse `snapshotOnDrop` + `appendAudit` (box `"todo"`, counts-only) — no new mechanism | reuse |
100
+ | Store/archive | `src/todo-store.ts` + `src/archive.ts` | Reap batch-partitions matched todos from live into archive as `cancelled`; `saveStore(..., { intentionalDrop: "reap" })` keeps backup/drop-snapshot/audit while suppressing a false wipe alert | extend + reuse |
101
+ | Panel | `src/panel-data.ts` / `panel.ts` | `ORPHAN` row indicator (⌛), cumulative `reaped=N` sum in Archive tab, and interactive Config rows for orphan/reap thresholds | extend |
102
+ | Tests | `test/todo-reap.test.mts` | **new suite** + extend `todo-config` + `todo-health` + `todo-auto-prune` | new + extend |
103
+
104
+ ## 6. Config shape
105
+
106
+ ```ts
107
+ interface ReapConfig {
108
+ /** Non-fleet active todos older than this (by updatedAt) → ORPHAN flag, no mutation. */
109
+ orphanFlagAfterDays: number; // default 14
110
+ /** Per-source reap policy. Sources not listed are flagged-only (never auto-mutated). */
111
+ policy: Record<string, { reapAfterDays: number; reapTo: "cancelled" }>;
112
+ }
113
+
114
+ // DEFAULT_CONFIG.reap = {
115
+ // orphanFlagAfterDays: 14,
116
+ // policy: { "armory-fleet": { reapAfterDays: 2, reapTo: "cancelled" } }
117
+ // }
118
+ ```
119
+
120
+ `TodoConfig` gains a `reap: ReapConfig` field. The existing merge + corrupt-recovery
121
+ path in `loadConfig` handles the new section with the same pattern: missing →
122
+ defaults merged in; corrupt → bad file backed up to `todo.config.json.bad-<ts>`,
123
+ defaults rewritten.
124
+
125
+ ## 7. Data flow (one `session_start` pass)
126
+
127
+ | Step | Action | Mutates? | Audited? |
128
+ |---|---|---|---|
129
+ | 1 | `autoPruneOnSessionStart()` runs (existing) — moves done/cancelled >7d to archive | yes | yes (existing) |
130
+ | 2 | **`reapStaleActive()` runs** — for each active todo: compute `staleDays = (now − updatedAt) / 86400000` | no (read) | — |
131
+ | 3a | If `todo.source` ∈ `reap.policy` AND `staleDays ≥ reapAfterDays` → set `cancelled`, remove from live, append to archive, count it | yes (live → archive) | **yes** — live `.bak-drop-<ts>` snapshot + both-store audit + `REAP` marker; intentional drop suppresses false wipe sentinel |
132
+ | 3b | Else if `staleDays ≥ orphanFlagAfterDays` → set transient `__orphanDays` (in-memory health pass, **not persisted**) → surfaces `ORPHAN` flag + panel ⌛ | no | flag only |
133
+ | 4 | If any reaped → notify `♻ Reaped N stale <source> runs (oldest Kd) — restore via /todo` | — | — |
134
+ | 5 | If any orphaned → existing "N open" notify appends `+ M orphaned (oldest Kd untouched)` | — | — |
135
+
136
+ **Key invariants:**
137
+ - A todo with `source: undefined` (real agent work) is **never** auto-mutated by
138
+ the reap sweep. It can only get the `ORPHAN` flag, which is advisory.
139
+ - A todo with `source` not in `reap.policy` is also flag-only (same as no source).
140
+ - Reap target is always archived `cancelled` — `restore` reverses it immediately. Nothing is ever deleted.
141
+ - The orphan flag is **transient** — recomputed each session from `updatedAt`. It
142
+ is not written to disk (avoids a schema bump + migration just for a display hint).
143
+
144
+ ## 8. Error handling & safety
145
+
146
+ | Risk | Guard |
147
+ |---|---|
148
+ | Cancelling real work by mistake | Only `source` ∈ `reap.policy` ever auto-mutates; real todos are flag-only forever |
149
+ | Reap is irreversible | Reaped todos are immediately archived as `cancelled` (never deleted) → `todo restore <id>` reverses them immediately |
150
+ | Bad threshold wipes a batch | `.bak-drop-<ts>` snapshot before the reap write (v0.5.1 pattern) + audit log line — same recovery path as the 2026-07-21 wipe incident |
151
+ | Reap runs on a corrupt store | `loadConfig` already backs up corrupt config to `.bad-<ts>`; reap skips if store load throws |
152
+ | Reap double-fires in one session | `session_start` is once-per-boot; idempotent anyway (already-cancelled todos aren't re-counted) |
153
+ | New source added later | Config-driven — add to `policy` map, no code change |
154
+ | Reap fires on a fresh store with no `reap` config | Merge installs defaults; `armory-fleet` policy present from first load |
155
+
156
+ ## 9. Testing
157
+
158
+ | Suite | New/extend | Covers |
159
+ |---|---|---|
160
+ | `test/todo-reap.test.mts` | **new** | reap fleet at 2d ✓; immediate live→archive move ✓; immediate `restoreTodo` reversibility ✓; flag non-policy at 14d (no mutate) ✓; audit line + `.bak-drop` ✓; no false `.wipe-alert` ✓; corrupt-store skip ✓; idempotency ✓; `updatedAt` stale signal ✓ |
161
+ | `test/todo-config.test.mts` | extend | `reap` defaults + merge + corrupt recovery |
162
+ | `test/todo-health.test.mts` | extend | `ORPHAN` flag raised (transient, not persisted); no regression on existing flags |
163
+ | `test/panel-data.test.mts` | extend | ⌛ formatter input; audit parser sums `reaped=N` values (not marker lines); orphan/reap Config rows |
164
+ | `test/todo-auto-prune.test.mts` | extend | ordering: auto-prune then reap in the same `session_start` pass; both fire independently |
165
+
166
+ All new suites use the existing `TODO_DIR=tmp` isolation pattern (the
167
+ v0.5.3 wiper lesson — re-establish `process.env.TODO_DIR` at the start of any
168
+ appended section that writes).
169
+
170
+ ## 10. Shipping
171
+
172
+ - Version bump: `0.5.5` → `0.6.0` (`package.json`).
173
+ - npm publish via CI on `v0.6.0` tag (`release.yml`).
174
+ - `~/.pi/agent/settings.json` pin updated to `npm:@getpipher/armory-todo@0.6.0`.
175
+ - GitHub Release v0.6.0 synced with npm.
176
+ - AGENTS.md structure table + Notes section updated (new `reap` module + suite).
177
+ - Memory: `~/.pi/agent/memory/-Users-rector-local-dev-getpipher-armory-todo/v0.6.0-shipped.md`.
178
+
179
+ ## 11. Resolved review decisions
180
+
181
+ | Decision | Resolution |
182
+ |---|---|
183
+ | Notify copy | Accepted with lifecycle-correct immediate-archive restore guidance. |
184
+ | Panel marker | `⌛` for advisory ORPHAN rows. |
185
+ | First-run cleanup | No one-shot path; normal 2d policy catches existing fleet orphans. |
186
+ | Reaped destination | Option A (approved 2026-08-02): immediate archive as `cancelled`, so `restore` works immediately. |
187
+ | Reap metric placement | Archive tab, because reaped records are archived cancelled (Done intentionally excludes cancelled). |
@@ -39,6 +39,7 @@ import { healthReport } from "../src/health";
39
39
  import { hardPrune } from "../src/hard-prune";
40
40
  import { TodoPanel } from "../src/panel";
41
41
  import { autoPruneOnSessionStart } from "../src/auto-prune";
42
+ import { reapStaleActive } from "../src/reap";
42
43
  import { loadConfig, type TodoConfig } from "../src/config";
43
44
  import { projectsOverview } from "../src/projects";
44
45
  import { renameProject } from "../src/registry";
@@ -104,22 +105,38 @@ export default function (pi: ExtensionAPI) {
104
105
  } catch {
105
106
  // auto-prune optional — don't crash the session notify
106
107
  }
108
+ // v0.6.0: source-aware stale-active reap runs AFTER auto-prune. Reaped
109
+ // todos enter the archive as cancelled (immediately restorable), never deleted.
110
+ let reapMsg = "";
111
+ try {
112
+ const rp = reapStaleActive();
113
+ if (rp) {
114
+ const shownIds = rp.ids.slice(0, 5).map((id) => `[${id}]`).join(" ");
115
+ const more = rp.ids.length > 5 ? ` +${rp.ids.length - 5} more` : "";
116
+ reapMsg = ` · ♻ reaped ${rp.reaped} stale ${rp.reaped === 1 ? "run" : "runs"} (oldest ${rp.oldestDays}d) · ${shownIds}${more} — restore via \`todo restore <id>\``;
117
+ }
118
+ } catch {
119
+ // reap optional — never crash the session notify
120
+ }
107
121
  const showCount = cfg?.notify?.sessionStartCount !== false;
108
122
  const open = listTodos();
109
123
  let msg = "";
110
124
  if (showCount) {
111
- msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}${autoMsg}`;
125
+ msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}${autoMsg}${reapMsg}`;
112
126
  try {
113
127
  const report = healthReport();
114
128
  if (report.flags.length > 0) {
115
129
  msg += `${autoMsg ? "\n" : " — "}` + `⚠ ${report.flags.length} bloat signal${report.flags.length === 1 ? "" : "s"} (run /todo health)`;
116
130
  }
131
+ if (report.orphan.count > 0) {
132
+ msg += ` · ${report.orphan.count} orphaned (oldest ${report.orphan.oldestDays}d untouched, non-policy — review in /todo)`;
133
+ }
117
134
  } catch {
118
135
  // health check optional
119
136
  }
120
- } else if (autoMsg) {
121
- // Count line suppressed; still surface the auto-prune undo info.
122
- msg = `armory-todo${autoMsg}`;
137
+ } else if (autoMsg || reapMsg) {
138
+ // Count line suppressed; still surface auto-prune/reap safety messages.
139
+ msg = `armory-todo${autoMsg}${reapMsg}`;
123
140
  }
124
141
  if (ctx.hasUI) {
125
142
  const out = (wipeMsg ? wipeMsg + "\n" : "") + msg;
@@ -569,4 +586,4 @@ export default function (pi: ExtensionAPI) {
569
586
  }
570
587
  },
571
588
  });
572
- }
589
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-todo",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "Global, cross-session TODO for pi \u2014 persists across all sessions and is auto-injected into every prompt. The disk-backed counterpart to branch-scoped pi todo extensions.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -41,7 +41,7 @@
41
41
  ]
42
42
  },
43
43
  "scripts": {
44
- "test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune registry projects panel-data todo-caps todo-backup public-api; do node test/$t.test.mts || exit 1; done"
44
+ "test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune registry projects panel-data todo-caps todo-backup todo-reap public-api; do node test/$t.test.mts || exit 1; done"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@earendil-works/pi-ai": "*",
package/src/archive.ts CHANGED
@@ -137,7 +137,7 @@ export function pruneTodos(opts: PruneInput = {}): PruneResult {
137
137
 
138
138
  live.todos = kept;
139
139
  archive.todos.push(...moved);
140
- saveStore(live);
140
+ saveStore(live, { intentionalDrop: "prune" });
141
141
  saveArchive(archive);
142
142
 
143
143
  const items: PruneItem[] = moved.map((t) => ({
package/src/config.ts CHANGED
@@ -30,16 +30,32 @@ export interface HealthConfig {
30
30
 
31
31
  export interface NotifyConfig {
32
32
  /** Show the `armory-todo: N open TODOs` session-start count line.
33
- * Safety messages (wipe-recovery alert, auto-prune undo info) still surface
34
- * when this is false. Default true. */
33
+ * Safety messages (wipe recovery, auto-prune, reap) still surface when this
34
+ * is false. Default true. */
35
35
  sessionStartCount: boolean;
36
36
  }
37
37
 
38
+ export interface ReapPolicyEntry {
39
+ /** Active todos from this source older than this (by updatedAt) are auto-`reapTo`'d. */
40
+ reapAfterDays: number;
41
+ /** Terminal status applied. v0.6.0 only supports "cancelled" (reversible via restore). */
42
+ reapTo: "cancelled";
43
+ }
44
+
45
+ export interface ReapConfig {
46
+ /** Active todos whose `source` is NOT in `reap.policy`, older than this (by
47
+ * updatedAt) → ORPHAN flag (advisory, transient — no mutation). */
48
+ orphanFlagAfterDays: number;
49
+ /** Per-source reap policy. Sources not listed are flag-only (never auto-mutated). */
50
+ policy: Record<string, ReapPolicyEntry>;
51
+ }
52
+
38
53
  export interface TodoConfig {
39
54
  version: 1;
40
55
  prune: PruneConfig;
41
56
  health: HealthConfig;
42
57
  notify: NotifyConfig;
58
+ reap: ReapConfig;
43
59
  }
44
60
 
45
61
  export const DEFAULT_CONFIG: TodoConfig = {
@@ -62,6 +78,12 @@ export const DEFAULT_CONFIG: TodoConfig = {
62
78
  notify: {
63
79
  sessionStartCount: true,
64
80
  },
81
+ reap: {
82
+ orphanFlagAfterDays: 14,
83
+ policy: {
84
+ "armory-fleet": { reapAfterDays: 2, reapTo: "cancelled" },
85
+ },
86
+ },
65
87
  };
66
88
 
67
89
  /** Deep clone of DEFAULT_CONFIG (so callers can't mutate the constant). */
@@ -90,11 +112,22 @@ export function loadConfig(): TodoConfig {
90
112
  }
91
113
  const notify = { ...DEFAULT_CONFIG.notify, ...(parsed.notify ?? {}) };
92
114
  if (typeof notify.sessionStartCount !== "boolean") notify.sessionStartCount = true;
115
+ const reap = { ...DEFAULT_CONFIG.reap, ...(parsed.reap ?? {}) };
116
+ if (reap.orphanFlagAfterDays === undefined || typeof reap.orphanFlagAfterDays !== "number" || Number.isNaN(reap.orphanFlagAfterDays) || reap.orphanFlagAfterDays < 0) {
117
+ reap.orphanFlagAfterDays = DEFAULT_CONFIG.reap.orphanFlagAfterDays;
118
+ }
119
+ if (!reap.policy || typeof reap.policy !== "object") reap.policy = {};
120
+ for (const [src, entry] of Object.entries(reap.policy)) {
121
+ if (!entry || typeof entry.reapAfterDays !== "number" || entry.reapAfterDays < 0 || entry.reapTo !== "cancelled") {
122
+ delete reap.policy[src];
123
+ }
124
+ }
93
125
  return {
94
126
  version: 1,
95
127
  prune: { ...DEFAULT_CONFIG.prune, ...parsed.prune },
96
128
  health,
97
129
  notify,
130
+ reap,
98
131
  };
99
132
  } catch {
100
133
  try {
package/src/health.ts CHANGED
@@ -37,7 +37,8 @@ export type HealthFlag =
37
37
  | "PARKED_LARGE" | "PARKED_STALE"
38
38
  | "ARCHIVE_LARGE" | "ARCHIVE_OLD"
39
39
  | "NOTES_OVER"
40
- | "PROJECT_OVER" | "PROJECT_TYPO" | "PROJECT_LARGE" | "PROJECT_STALE";
40
+ | "PROJECT_OVER" | "PROJECT_TYPO" | "PROJECT_LARGE" | "PROJECT_STALE"
41
+ | "ORPHAN";
41
42
 
42
43
  export interface ProjectHealth {
43
44
  name: string;
@@ -55,6 +56,7 @@ export interface HealthReport {
55
56
  parked: ParkedHealth;
56
57
  archive: ArchiveHealth;
57
58
  notesBytes: NotesBytes;
59
+ orphan: { count: number; oldestDays: number; ids: string[] };
58
60
  flags: HealthFlag[];
59
61
  suggestions: string[];
60
62
  projects: ProjectHealth[]; // only projects with ≥1 flag, sorted open desc
@@ -81,6 +83,20 @@ export function healthReport(): HealthReport {
81
83
  const parkedTodos = live.todos.filter((t) => t.status === "parked");
82
84
  const actionable = [...openTodos, ...ipTodos];
83
85
 
86
+ // v0.6.0: policy-source stale actives are auto-reaped elsewhere; non-policy
87
+ // stale actives are advisory-only ORPHANs. Derived on every read, never persisted.
88
+ const policySources = new Set(Object.keys(config.reap.policy));
89
+ const orphanTodos = actionable.filter((t) =>
90
+ !policySources.has(t.source) && daysAgo(t.updatedAt) >= config.reap.orphanFlagAfterDays
91
+ );
92
+ const orphan = {
93
+ count: orphanTodos.length,
94
+ oldestDays: orphanTodos.length
95
+ ? Math.floor(Math.max(...orphanTodos.map((t) => daysAgo(t.updatedAt))))
96
+ : 0,
97
+ ids: orphanTodos.map((t) => t.id),
98
+ };
99
+
84
100
  const activeStale = openTodos.filter((t) => daysAgo(t.updatedAt) > h.activeStaleDays).length;
85
101
  const parkedStale = parkedTodos.filter((t) => daysAgo(t.updatedAt) > h.parkedStaleDays).length;
86
102
  const archiveOld = archive.todos.filter((t) => t.closedAt && daysAgo(t.closedAt) > h.archiveOldDays).length;
@@ -115,6 +131,7 @@ export function healthReport(): HealthReport {
115
131
  if (actionable.length > h.activeMaxOpen) flags.push("ACTIVE_LARGE");
116
132
  if (notesBytes.max > h.maxNotesBytes) flags.push("NOTES_OVER");
117
133
  if (activeStale > 0) flags.push("ACTIVE_STALE");
134
+ if (orphan.count > 0) flags.push("ORPHAN");
118
135
  if (parkedTodos.length > h.parkedMax) flags.push("PARKED_LARGE");
119
136
  if (parkedStale > 0) flags.push("PARKED_STALE");
120
137
  if (archive.todos.length > h.archiveMax) flags.push("ARCHIVE_LARGE");
@@ -123,6 +140,7 @@ export function healthReport(): HealthReport {
123
140
  const suggestions: string[] = [];
124
141
  if (archiveOld > 0) suggestions.push(`archive: ${archiveOld} items older than ${h.archiveOldDays}d → consider \`prune --hard --box archive --older-than ${h.archiveOldDays} --confirm\``);
125
142
  if (activeStale > 0) suggestions.push(`active: ${activeStale} open TODOs untouched for ${h.activeStaleDays}d → park or close them`);
143
+ if (orphan.count > 0) suggestions.push(`orphan: ${orphan.count} active TODOs untouched >= ${config.reap.orphanFlagAfterDays}d (non-policy source) → review + close/park (oldest ${orphan.oldestDays}d)`);
126
144
  if (parkedStale > 0) suggestions.push(`parked: ${parkedStale} parked > ${h.parkedStaleDays}d → restore or hard-prune`);
127
145
  if (actionable.length > h.activeMaxOpen) suggestions.push(`active: ${actionable.length} open+in_progress (max ${h.activeMaxOpen}) → close or park some before adding more`);
128
146
  if (notesBytes.max > h.maxNotesBytes) {
@@ -167,5 +185,5 @@ export function healthReport(): HealthReport {
167
185
 
168
186
  const noProject = { open: live.todos.filter((t) => t.project.trim() === "" && t.status === "open").length };
169
187
 
170
- return { active, parked, archive: arch, notesBytes, flags, suggestions, projects: projectHealth, noProject };
171
- }
188
+ return { active, parked, archive: arch, notesBytes, orphan, flags, suggestions, projects: projectHealth, noProject };
189
+ }
package/src/index.d.ts CHANGED
@@ -62,6 +62,10 @@ export interface Store {
62
62
  todos: Todo[];
63
63
  }
64
64
 
65
+ export interface SaveStoreOptions {
66
+ intentionalDrop?: "reap" | "prune";
67
+ }
68
+
65
69
  export class TodoError extends Error {}
66
70
 
67
71
  export function addTodo(input: AddInput): Todo;
@@ -75,4 +79,4 @@ export function clearTodos(status?: Status): number;
75
79
  export function renderOpenBlock(max?: number): string;
76
80
  export function getStorePath(): string;
77
81
  export function loadStore(): Store;
78
- export function saveStore(store: Store): void;
82
+ export function saveStore(store: Store, options?: SaveStoreOptions): void;
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ export type {
34
34
  Priority,
35
35
  Status,
36
36
  Store,
37
+ SaveStoreOptions,
37
38
  } from "./todo-store.ts";
38
39
 
39
40
  export { TodoError } from "./todo-store.ts";
package/src/panel-data.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  // panel.ts so they're unit-testable without a terminal — the panel component
3
3
  // itself is manual-gate only.
4
4
 
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
5
7
  import type { SelectItem, SettingItem } from "@earendil-works/pi-tui";
6
8
  import type { Todo } from "./todo-store.ts";
9
+ import { getTodoDir } from "./paths.ts";
7
10
  import type { DoneItem } from "./archive.ts";
8
11
  import type { ArchiveSummary } from "./archive.ts";
9
12
  import type { TodoConfig } from "./config.ts";
@@ -12,13 +15,14 @@ import type { TodoConfig } from "./config.ts";
12
15
  * title is already ≤120 chars (enforced at write time), so no truncation is
13
16
  * needed. The • marker shows when notes is non-empty (signals "open the
14
17
  * detail view / use `todo get` for context"). */
15
- export function todoToItem(t: Todo): SelectItem {
18
+ export function todoToItem(t: Todo, orphan = false): SelectItem {
19
+ const warning = orphan ? "⌛ " : "";
16
20
  const pin = t.status === "in_progress" ? " ⏵" : "";
17
21
  const proj = t.project ? ` (${t.project})` : "";
18
22
  const dot = t.notes.trim() ? " •" : "";
19
23
  return {
20
24
  value: t.id,
21
- label: `[${t.id}] (${t.priority})${pin}${proj}${dot} ${t.title}`,
25
+ label: `${warning}[${t.id}] (${t.priority})${pin}${proj}${dot} ${t.title}`,
22
26
  };
23
27
  }
24
28
 
@@ -56,6 +60,8 @@ export function configToSettingItems(cfg: TodoConfig): SettingItem[] {
56
60
  { id: "hardAgeDays", label: "Hard-prune age (days)", currentValue: String(cfg.prune.hardAgeDays), values: ["90", "180", "365"], description: "Archive items older than this → suggested for hard-prune." },
57
61
  { id: "activeMaxOpen", label: "Active max open", currentValue: String(cfg.health.activeMaxOpen), values: ["10", "15", "20", "25"], description: "Bloat flag when open+in_progress exceeds this." },
58
62
  { id: "activeStaleDays", label: "Active stale (days)", currentValue: String(cfg.health.activeStaleDays), values: ["14", "30", "60"], description: "Bloat flag when open todos untouched longer than this." },
63
+ { id: "orphanFlagAfterDays", label: "Orphan flag (days)", currentValue: String(cfg.reap.orphanFlagAfterDays), values: ["7", "14", "30", "60"], description: "Advisory ORPHAN flag for non-policy active todos; never auto-mutates." },
64
+ { id: "armoryFleetReapAfterDays", label: "Fleet reap (days)", currentValue: String(cfg.reap.policy["armory-fleet"]?.reapAfterDays ?? 2), values: ["1", "2", "3", "7"], description: "Stale armory-fleet runs → archived cancelled (immediately restorable)." },
59
65
  { id: "parkedMax", label: "Parked max", currentValue: String(cfg.health.parkedMax), values: ["5", "10", "15"], description: "Bloat flag when parked exceeds this." },
60
66
  { id: "parkedStaleDays", label: "Parked stale (days)", currentValue: String(cfg.health.parkedStaleDays), values: ["30", "60", "90"], description: "Bloat flag when parked longer than this." },
61
67
  { id: "archiveMax", label: "Archive max", currentValue: String(cfg.health.archiveMax), values: ["100", "200", "500"], description: "Bloat flag when archive exceeds this." },
@@ -111,3 +117,21 @@ export function actionsForProject(): { label: string; action: string }[] {
111
117
  export function noProjectSummaryItem(o: ProjectsOverview): SelectItem {
112
118
  return { value: "__noproject__", label: `(no project): ${o.noProject.count} total · ${o.noProject.open} open` };
113
119
  }
120
+
121
+ /** Sum the number of runs auto-reaped across REAP audit markers. Best-effort;
122
+ * malformed/missing logs report zero and never break the panel. */
123
+ export function countReapedFromAudit(): number {
124
+ try {
125
+ const path = join(getTodoDir(), "todo-audit.log");
126
+ if (!existsSync(path)) return 0;
127
+ let total = 0;
128
+ for (const line of readFileSync(path, "utf8").split("\n")) {
129
+ if (!line.startsWith("REAP ")) continue;
130
+ const match = line.match(/\breaped=(\d+)\b/);
131
+ if (match) total += Number(match[1]);
132
+ }
133
+ return total;
134
+ } catch {
135
+ return 0;
136
+ }
137
+ }
package/src/panel.ts CHANGED
@@ -26,7 +26,7 @@ import { loadConfig, saveConfig, type TodoConfig } from "./config.ts";
26
26
  import { healthReport } from "./health.ts";
27
27
  import { projectsOverview } from "./projects.ts";
28
28
  import { renameProject, setProjectMaxOpen, loadRegistry, saveRegistry } from "./registry.ts";
29
- import { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems, todoDoneItem, actionsForDoneTodo, projectOverviewToItems, actionsForProject, noProjectSummaryItem } from "./panel-data.ts";
29
+ import { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems, todoDoneItem, actionsForDoneTodo, projectOverviewToItems, actionsForProject, noProjectSummaryItem, countReapedFromAudit } from "./panel-data.ts";
30
30
 
31
31
  export type Box = "active" | "parked" | "done" | "archive" | "projects" | "config";
32
32
  const BOXES: Box[] = ["active", "parked", "done", "archive", "projects", "config"];
@@ -54,6 +54,7 @@ export class TodoPanel extends Container {
54
54
  private settingsList: SettingsList | null = null;
55
55
  private config: TodoConfig;
56
56
  private healthFlags: string[] = [];
57
+ private orphanIds = new Set<string>();
57
58
  private projectFilterName = ""; // set by the "Filter active to project" action
58
59
  private projectEditKind: "rename" | "setmax" | null = null;
59
60
  private projectEditName = ""; // which project is being edited
@@ -64,7 +65,6 @@ export class TodoPanel extends Container {
64
65
  this.onDone = opts.onDone;
65
66
  this.onNotify = opts.onNotify;
66
67
  this.config = loadConfig();
67
- try { this.healthFlags = healthReport().flags; } catch { /* optional */ }
68
68
 
69
69
  const accent = (s: string) => this.theme.fg("accent", s);
70
70
  this.addChild(new DynamicBorder(accent));
@@ -132,6 +132,11 @@ export class TodoPanel extends Container {
132
132
  } else if (this.currentBox === "config") {
133
133
  this.renderConfigBox();
134
134
  } else {
135
+ if (this.currentBox === "archive") {
136
+ const reaped = countReapedFromAudit();
137
+ this.addChild(new Text(this.theme.fg("muted", ` reaped: ${reaped} runs auto-cancelled · restore with todo restore <id>`), 0, 0));
138
+ this.addChild(new Spacer(1));
139
+ }
135
140
  this.addChild(this.selectList);
136
141
  }
137
142
 
@@ -144,13 +149,20 @@ export class TodoPanel extends Container {
144
149
 
145
150
  private refreshList(): void {
146
151
  const filter = this.filterInput.getValue();
152
+ try {
153
+ const report = healthReport();
154
+ this.healthFlags = report.flags;
155
+ this.orphanIds = new Set(report.orphan.ids);
156
+ } catch {
157
+ // health is advisory; retain the last successful snapshot
158
+ }
147
159
  if (this.currentBox === "active") {
148
160
  const project = this.projectFilterName || undefined;
149
161
  const todos = listTodos({ project, text: filter || undefined, limit: 50 });
150
- this.setSelectItems(todos.map(todoToItem));
162
+ this.setSelectItems(todos.map((t) => todoToItem(t, this.orphanIds.has(t.id))));
151
163
  } else if (this.currentBox === "parked") {
152
164
  const todos = listTodos({ status: "parked", text: filter || undefined, limit: 50 });
153
- this.setSelectItems(todos.map(todoToItem));
165
+ this.setSelectItems(todos.map((t) => todoToItem(t)));
154
166
  } else if (this.currentBox === "done") {
155
167
  const items = listDoneUnified({ text: filter || undefined, limit: 50 });
156
168
  this.setSelectItems(items.map(todoDoneItem));
@@ -160,7 +172,7 @@ export class TodoPanel extends Container {
160
172
  this.setSelectItems(archiveSummaryToItems(s));
161
173
  } else {
162
174
  const res = listArchived({ text: filter, limit: 50 });
163
- this.setSelectItems(res.items.map(todoToItem));
175
+ this.setSelectItems(res.items.map((t) => todoToItem(t)));
164
176
  }
165
177
  } else if (this.currentBox === "projects") {
166
178
  const overview = projectsOverview();
@@ -388,6 +400,8 @@ export class TodoPanel extends Container {
388
400
  case "hardAgeDays": return String(c.prune.hardAgeDays);
389
401
  case "activeMaxOpen": return String(c.health.activeMaxOpen);
390
402
  case "activeStaleDays": return String(c.health.activeStaleDays);
403
+ case "orphanFlagAfterDays": return String(c.reap.orphanFlagAfterDays);
404
+ case "armoryFleetReapAfterDays": return String(c.reap.policy["armory-fleet"]?.reapAfterDays ?? 2);
391
405
  case "parkedMax": return String(c.health.parkedMax);
392
406
  case "parkedStaleDays": return String(c.health.parkedStaleDays);
393
407
  case "archiveMax": return String(c.health.archiveMax);
@@ -405,6 +419,8 @@ export class TodoPanel extends Container {
405
419
  case "hardAgeDays": this.config.prune.hardAgeDays = n; break;
406
420
  case "activeMaxOpen": this.config.health.activeMaxOpen = n; break;
407
421
  case "activeStaleDays": this.config.health.activeStaleDays = n; break;
422
+ case "orphanFlagAfterDays": this.config.reap.orphanFlagAfterDays = n; break;
423
+ case "armoryFleetReapAfterDays": this.config.reap.policy["armory-fleet"] = { reapAfterDays: n, reapTo: "cancelled" }; break;
408
424
  case "parkedMax": this.config.health.parkedMax = n; break;
409
425
  case "parkedStaleDays": this.config.health.parkedStaleDays = n; break;
410
426
  case "archiveMax": this.config.health.archiveMax = n; break;
@@ -483,4 +499,4 @@ export class TodoPanel extends Container {
483
499
  this.refreshList();
484
500
  this.invalidate();
485
501
  }
486
- }
502
+ }