@getpipher/armory-todo 0.5.5 → 0.7.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.
- package/README.md +74 -7
- package/docs/superpowers/plans/2026-07-29-reap-safety-protocol.md +858 -0
- package/docs/superpowers/specs/2026-07-29-reap-safety-protocol-design.md +187 -0
- package/extensions/todo.ts +112 -7
- package/package.json +2 -2
- package/src/archive.ts +1 -1
- package/src/config.ts +35 -2
- package/src/health.ts +21 -3
- package/src/index.d.ts +5 -1
- package/src/index.ts +1 -0
- package/src/panel-data.ts +26 -2
- package/src/panel.ts +22 -6
- package/src/reap.ts +90 -0
- package/src/todo-store.ts +8 -2
- package/src/triage-prompt.ts +81 -0
- package/src/triage.ts +474 -0
|
@@ -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). |
|
package/extensions/todo.ts
CHANGED
|
@@ -39,12 +39,22 @@ 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";
|
|
43
|
+
import {
|
|
44
|
+
gatherCandidates,
|
|
45
|
+
executeTriage,
|
|
46
|
+
executeSafeClass,
|
|
47
|
+
renderProposalTable,
|
|
48
|
+
renderReport,
|
|
49
|
+
type TriageDecision,
|
|
50
|
+
} from "../src/triage";
|
|
51
|
+
import { buildTriagePrompt, TRIAGE_PROMPT_VERSION } from "../src/triage-prompt";
|
|
42
52
|
import { loadConfig, type TodoConfig } from "../src/config";
|
|
43
53
|
import { projectsOverview } from "../src/projects";
|
|
44
54
|
import { renameProject } from "../src/registry";
|
|
45
55
|
import { readAndClearWipeAlert } from "../src/backup";
|
|
46
56
|
|
|
47
|
-
const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health", "projects", "project_rename"] as const;
|
|
57
|
+
const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health", "projects", "project_rename", "triage"] as const;
|
|
48
58
|
|
|
49
59
|
function fmt(t: ReturnType<typeof listTodos>[number]): string {
|
|
50
60
|
const tag = t.project ? ` (${t.project})` : "";
|
|
@@ -104,22 +114,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
104
114
|
} catch {
|
|
105
115
|
// auto-prune optional — don't crash the session notify
|
|
106
116
|
}
|
|
117
|
+
// v0.6.0: source-aware stale-active reap runs AFTER auto-prune. Reaped
|
|
118
|
+
// todos enter the archive as cancelled (immediately restorable), never deleted.
|
|
119
|
+
let reapMsg = "";
|
|
120
|
+
try {
|
|
121
|
+
const rp = reapStaleActive();
|
|
122
|
+
if (rp) {
|
|
123
|
+
const shownIds = rp.ids.slice(0, 5).map((id) => `[${id}]`).join(" ");
|
|
124
|
+
const more = rp.ids.length > 5 ? ` +${rp.ids.length - 5} more` : "";
|
|
125
|
+
reapMsg = ` · ♻ reaped ${rp.reaped} stale ${rp.reaped === 1 ? "run" : "runs"} (oldest ${rp.oldestDays}d) · ${shownIds}${more} — restore via \`todo restore <id>\``;
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// reap optional — never crash the session notify
|
|
129
|
+
}
|
|
107
130
|
const showCount = cfg?.notify?.sessionStartCount !== false;
|
|
108
131
|
const open = listTodos();
|
|
109
132
|
let msg = "";
|
|
110
133
|
if (showCount) {
|
|
111
|
-
msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}${autoMsg}`;
|
|
134
|
+
msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}${autoMsg}${reapMsg}`;
|
|
112
135
|
try {
|
|
113
136
|
const report = healthReport();
|
|
114
137
|
if (report.flags.length > 0) {
|
|
115
138
|
msg += `${autoMsg ? "\n" : " — "}` + `⚠ ${report.flags.length} bloat signal${report.flags.length === 1 ? "" : "s"} (run /todo health)`;
|
|
116
139
|
}
|
|
140
|
+
if (report.orphan.count > 0) {
|
|
141
|
+
msg += ` · ${report.orphan.count} orphaned (oldest ${report.orphan.oldestDays}d untouched, non-policy — review in /todo)`;
|
|
142
|
+
}
|
|
117
143
|
} catch {
|
|
118
144
|
// health check optional
|
|
119
145
|
}
|
|
120
|
-
} else if (autoMsg) {
|
|
121
|
-
// Count line suppressed; still surface
|
|
122
|
-
msg = `armory-todo${autoMsg}`;
|
|
146
|
+
} else if (autoMsg || reapMsg) {
|
|
147
|
+
// Count line suppressed; still surface auto-prune/reap safety messages.
|
|
148
|
+
msg = `armory-todo${autoMsg}${reapMsg}`;
|
|
123
149
|
}
|
|
124
150
|
if (ctx.hasUI) {
|
|
125
151
|
const out = (wipeMsg ? wipeMsg + "\n" : "") + msg;
|
|
@@ -170,6 +196,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
170
196
|
"Use todo (action:'prune', hard:true, confirm:true, box?, olderThan?) for PERMANENT deletion (the only irreversible action). ALWAYS run health first, show the user the report + the exact proposed command, and wait for an explicit yes before passing confirm:true. Never hard-prune without explicit user confirmation.",
|
|
171
197
|
"Use todo (action:'projects') for a per-project scope overview (open/in_progress/parked/done counts + maxOpen + OVER/?typo markers). Run when the user asks 'which projects have open work' or to see backlog shape by project.",
|
|
172
198
|
"Use todo (action:'project_rename', oldName, newName) to rename or merge a project (rewrites live + archive + registry). Use it to fix typo'd project strings (e.g. foo-bat → foo-bar). Rename onto an existing name merges (consolidates the old project into the new). Per-project maxOpen caps are ENFORCED (block-on-add); they also drive a PROJECT_OVER health flag when breached.",
|
|
199
|
+
"Use todo (action:'triage') for agent-validated pruning: phase 1 gathers candidates (stale 30d + orphans 14d + over-cap projects + agent-source debris) and returns them with the versioned rubric — NOTHING mutates. Validate each candidate with read-only probes (git log / gh / npm view), present the proposal table (verdict + evidence + confidence), get ONE batch approval from the user, then call phase 2: todo(action:'triage', approve:[{id, verdict:'close'|'park'|'keep', reason?, evidence?, confidence?, survivorId?}]). Closed items are archived (reversible) and filed as CLOSED issues in the private getpipher/todo-ledger. autoSafe:true (--yes) executes ONLY the mechanical safe class (fleet-run prompt debris) — never auto-close anything you could not verify (zero false-closes). Scope with scope:'<project>'.",
|
|
173
200
|
],
|
|
174
201
|
parameters: Type.Object({
|
|
175
202
|
action: StringEnum(ACTIONS),
|
|
@@ -201,6 +228,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
201
228
|
// project actions (v0.4.0)
|
|
202
229
|
oldName: Type.Optional(Type.String({ description: "project_rename: current project name" })),
|
|
203
230
|
newName: Type.Optional(Type.String({ description: "project_rename: new project name (merge if it already exists)" })),
|
|
231
|
+
// triage (PRD 2026-08-30) — two phases within one action:
|
|
232
|
+
// phase 1 (no approve): gather candidates + return the versioned rubric. NOTHING mutates.
|
|
233
|
+
// phase 2 (approve): execute exactly the approved decisions, then file closed items to the ledger.
|
|
234
|
+
scope: Type.Optional(Type.String({ description: "triage: restrict candidates to one project name (empty = all)" })),
|
|
235
|
+
autoSafe: Type.Optional(Type.Boolean({ description: "triage (--yes): auto-execute ONLY the mechanical safe class (fleet-run prompt debris). Everything else stays a proposal — never auto-closed (D2)." })),
|
|
236
|
+
approve: Type.Optional(Type.Array(Type.Object({
|
|
237
|
+
id: Type.String({ description: "Candidate todo id (td-…)" }),
|
|
238
|
+
verdict: StringEnum(["close", "park", "keep"] as const),
|
|
239
|
+
reason: Type.Optional(Type.String({ description: "close only: debris | duplicate | stale-unverified | verified-shipped" })),
|
|
240
|
+
evidence: Type.Optional(Type.String({ description: "One CHECKED line (git/gh/npm probe result). Required for verified-shipped closes." })),
|
|
241
|
+
confidence: Type.Optional(StringEnum(["high", "medium", "low"] as const)),
|
|
242
|
+
survivorId: Type.Optional(Type.String({ description: "duplicate closes: the surviving todo id" })),
|
|
243
|
+
}), { description: "triage phase 2: the user-approved decisions. Executes exactly these; nothing else." })),
|
|
204
244
|
}),
|
|
205
245
|
async execute(_toolCallId, params) {
|
|
206
246
|
try {
|
|
@@ -374,6 +414,44 @@ export default function (pi: ExtensionAPI) {
|
|
|
374
414
|
const r = renameProject(params.oldName, params.newName);
|
|
375
415
|
return { content: [{ type: "text" as const, text: `Renamed ${params.oldName} → ${r.newName}: ${r.liveRenamed} live + ${r.archivedRenamed} archived${r.merged ? " (merged)" : ""}` }] };
|
|
376
416
|
}
|
|
417
|
+
case "triage": {
|
|
418
|
+
// Phase 2: execute exactly the user-approved decisions (D2 gate).
|
|
419
|
+
if (params.approve?.length) {
|
|
420
|
+
const before = gatherCandidates(params.scope).before;
|
|
421
|
+
const report = await executeTriage(params.approve as TriageDecision[]);
|
|
422
|
+
return { content: [{ type: "text" as const, text: renderReport(report, before) }] };
|
|
423
|
+
}
|
|
424
|
+
// Phase 1: gather + propose. NOTHING mutates unless autoSafe (--yes),
|
|
425
|
+
// which executes ONLY the mechanical safe class (fleet-run debris).
|
|
426
|
+
let safeMsg = "";
|
|
427
|
+
if (params.autoSafe) {
|
|
428
|
+
const { report, remaining } = await executeSafeClass(params.scope);
|
|
429
|
+
safeMsg = report
|
|
430
|
+
? `--yes executed the safe class ONLY (fleet-run debris). Everything else needs the batch approval.\n\n${renderReport(report)}\n`
|
|
431
|
+
: "--yes: no mechanical-safe debris found — nothing auto-closed.\n";
|
|
432
|
+
if (report && remaining === 0) {
|
|
433
|
+
return { content: [{ type: "text" as const, text: safeMsg + "No remaining candidates — triage complete." }] };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const g = gatherCandidates(params.scope);
|
|
437
|
+
if (g.candidates.length === 0) {
|
|
438
|
+
return { content: [{ type: "text" as const, text: `${safeMsg}${safeMsg ? "\n" : ""}Triage: no candidates (before: ${g.before.active} active / ${g.before.parked} parked / ${g.before.archive} archive${g.scope ? `, scope: ${g.scope}` : ""}). Nothing to propose, nothing mutated.` }] };
|
|
439
|
+
}
|
|
440
|
+
const lines = [
|
|
441
|
+
`## Triage — proposal (NOTHING mutated yet)`,
|
|
442
|
+
`before: ${g.before.active} active / ${g.before.parked} parked / ${g.before.archive} archive${g.scope ? ` · scope: ${g.scope}` : ""} · candidates: ${g.candidates.length} · rubric ${TRIAGE_PROMPT_VERSION}`,
|
|
443
|
+
"",
|
|
444
|
+
renderProposalTable(g),
|
|
445
|
+
"",
|
|
446
|
+
safeMsg,
|
|
447
|
+
`Validate each candidate against the rubric below (read-only probes: git log, gh, npm view — checked, not guessed). Then present the proposal table (verdict + evidence + confidence) to the user and get ONE batch approval. On approval execute all decisions in a single call:`,
|
|
448
|
+
` todo(action:"triage", approve:[{ id, verdict:"close"|"park"|"keep", reason?, evidence?, confidence?, survivorId? }, ...])`,
|
|
449
|
+
`Safety: close needs reason (debris|duplicate|stale-unverified|verified-shipped); duplicate needs survivorId; verified-shipped needs evidence. Items you cannot verify stay proposals — zero false-closes is the metric. The safe-class column marks what --yes may close WITHOUT approval.`,
|
|
450
|
+
"",
|
|
451
|
+
buildTriagePrompt(g.candidates, g.scope),
|
|
452
|
+
];
|
|
453
|
+
return { content: [{ type: "text" as const, text: lines.filter((l) => l !== "").join("\n") }] };
|
|
454
|
+
}
|
|
377
455
|
default:
|
|
378
456
|
return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }] };
|
|
379
457
|
}
|
|
@@ -389,7 +467,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
389
467
|
"Global cross-session TODO list. " +
|
|
390
468
|
"/todo / /todo all / /todo add <title> / /todo done <id> / /todo rm <id> / " +
|
|
391
469
|
"/todo park <id> / /todo restore <id> / /todo prune [--all|--hard --box <b> --older-than <d>] / " +
|
|
392
|
-
"/todo archive [project:X|text:Y] / /todo finished / /todo projects / /todo health / /todo clean / /todo path",
|
|
470
|
+
"/todo archive [project:X|text:Y] / /todo finished / /todo projects / /todo health / /todo triage [scope] [--yes] / /todo clean / /todo path",
|
|
393
471
|
handler: async (args, ctx) => {
|
|
394
472
|
const a = (args ?? "").trim();
|
|
395
473
|
const [sub, ...rest] = a.split(/\s+/);
|
|
@@ -466,6 +544,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
466
544
|
}
|
|
467
545
|
return;
|
|
468
546
|
}
|
|
547
|
+
if (sub === "triage") {
|
|
548
|
+
// Thin mirror of todo(action:'triage') — humans get the proposal table;
|
|
549
|
+
// the validation loop runs through the agent (rubric ships in-package).
|
|
550
|
+
const yes = rest.includes("--yes");
|
|
551
|
+
const scopeArg = rest.find((r) => !r.startsWith("--"));
|
|
552
|
+
let safeMsg = "";
|
|
553
|
+
if (yes) {
|
|
554
|
+
const { report, remaining } = await executeSafeClass(scopeArg);
|
|
555
|
+
safeMsg = report ? renderReport(report) + `\nremaining candidates: ${remaining}\n` : "--yes: no mechanical-safe debris — nothing auto-closed.\n";
|
|
556
|
+
}
|
|
557
|
+
const g = gatherCandidates(scopeArg);
|
|
558
|
+
const head = `Triage (before: ${g.before.active} active / ${g.before.parked} parked / ${g.before.archive} archive${scopeArg ? `, scope: ${scopeArg}` : ""})`;
|
|
559
|
+
if (g.candidates.length === 0) {
|
|
560
|
+
if (ctx.hasUI) ctx.ui.notify(`${head}\n${safeMsg || "No candidates — nothing to propose, nothing mutated."}`, "info");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const msg = [
|
|
564
|
+
head,
|
|
565
|
+
safeMsg,
|
|
566
|
+
`candidates (${g.candidates.length}) — NOTHING mutated${yes ? " beyond the safe class above" : ""}:`,
|
|
567
|
+
renderProposalTable(g),
|
|
568
|
+
"",
|
|
569
|
+
`Ask the agent to validate these against the triage rubric (${TRIAGE_PROMPT_VERSION}) using read-only probes, then approve the batch. The agent executes via todo(action:"triage", approve:[…]); closed items are filed to ${"getpipher/todo-ledger"} (private).`,
|
|
570
|
+
].filter((l) => l !== "").join("\n");
|
|
571
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "info");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
469
574
|
if (sub === "health") {
|
|
470
575
|
const report = healthReport();
|
|
471
576
|
const projLines = report.projects.length
|
|
@@ -569,4 +674,4 @@ export default function (pi: ExtensionAPI) {
|
|
|
569
674
|
}
|
|
570
675
|
},
|
|
571
676
|
});
|
|
572
|
-
}
|
|
677
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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 triage 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
|
|
34
|
-
*
|
|
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
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:
|
|
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
|
+
}
|