@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
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
|
+
}
|
package/src/reap.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Source-aware stale-active reaping (v0.6.0 safety protocol).
|
|
2
|
+
//
|
|
3
|
+
// On session_start, after auto-prune, scans active (open/in_progress) todos:
|
|
4
|
+
// - whose `source` is in config.reap.policy AND stale (updatedAt older than
|
|
5
|
+
// policy[source].reapAfterDays) → archived `cancelled` (immediately restorable).
|
|
6
|
+
// - other active todos older than config.reap.orphanFlagAfterDays → ORPHAN
|
|
7
|
+
// flag (advisory, computed in health.ts — reap does NOT mutate these).
|
|
8
|
+
//
|
|
9
|
+
// Batch: one loadStore, one saveStore. saveStore already calls backupFile +
|
|
10
|
+
// snapshotOnDrop + appendAudit internally (same guardrails as every store write).
|
|
11
|
+
|
|
12
|
+
import { appendFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { loadConfig } from "./config.ts";
|
|
15
|
+
import { loadStore, saveStore, type Todo } from "./todo-store.ts";
|
|
16
|
+
import { loadArchive, saveArchive } from "./archive.ts";
|
|
17
|
+
import { getLivePath } from "./paths.ts";
|
|
18
|
+
|
|
19
|
+
export interface ReapResult {
|
|
20
|
+
reaped: number;
|
|
21
|
+
flagged: number; // non-policy active todos older than orphanFlagAfterDays (advisory count)
|
|
22
|
+
ids: string[]; // reaped ids
|
|
23
|
+
oldestDays: number; // age of the oldest reaped todo (for notify copy)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DAY = 86_400_000;
|
|
27
|
+
|
|
28
|
+
/** Reap stale active todos per config.reap.policy into the archive. Returns
|
|
29
|
+
* the result if any moved, else null (caller stays silent). Non-policy stale
|
|
30
|
+
* todos are flagged via health.ts (ORPHAN) — counted but never mutated. */
|
|
31
|
+
export function reapStaleActive(): ReapResult | null {
|
|
32
|
+
const config = loadConfig();
|
|
33
|
+
const policy = config.reap.policy;
|
|
34
|
+
const orphanAfter = config.reap.orphanFlagAfterDays;
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
|
|
37
|
+
const store = loadStore();
|
|
38
|
+
const reaped: Todo[] = [];
|
|
39
|
+
const kept: Todo[] = [];
|
|
40
|
+
const reapedStaleDays: number[] = []; // stale-age (by updatedAt) at decision time, pre-mutation
|
|
41
|
+
const reapedAt = new Date(now).toISOString();
|
|
42
|
+
let flagged = 0;
|
|
43
|
+
|
|
44
|
+
for (const todo of store.todos) {
|
|
45
|
+
if (todo.status !== "open" && todo.status !== "in_progress") {
|
|
46
|
+
kept.push(todo);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const ageMs = now - Date.parse(todo.updatedAt);
|
|
50
|
+
const ageDays = ageMs / DAY;
|
|
51
|
+
const entry = policy[todo.source];
|
|
52
|
+
if (entry && ageDays >= entry.reapAfterDays) {
|
|
53
|
+
todo.status = "cancelled";
|
|
54
|
+
todo.closedAt = reapedAt;
|
|
55
|
+
todo.updatedAt = reapedAt;
|
|
56
|
+
reaped.push(todo);
|
|
57
|
+
reapedStaleDays.push(ageDays);
|
|
58
|
+
} else {
|
|
59
|
+
if (!entry && ageDays >= orphanAfter) flagged++;
|
|
60
|
+
kept.push(todo);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (reaped.length === 0) return null;
|
|
65
|
+
|
|
66
|
+
// Machine-reaped abandoned runs skip the live terminal-retention window and
|
|
67
|
+
// enter sealed history immediately, so `restoreTodo(id)` works at once.
|
|
68
|
+
const archive = loadArchive();
|
|
69
|
+
const archivedIds = new Set(archive.todos.map((t) => t.id));
|
|
70
|
+
for (const todo of reaped) {
|
|
71
|
+
if (!archivedIds.has(todo.id)) archive.todos.push(todo);
|
|
72
|
+
}
|
|
73
|
+
store.todos = kept;
|
|
74
|
+
// Persist archive first: an interrupted second write may temporarily duplicate
|
|
75
|
+
// an id across boxes, but the next idempotent reap removes the live copy. The
|
|
76
|
+
// inverse order could hide data from both primary stores until backup recovery.
|
|
77
|
+
saveArchive(archive);
|
|
78
|
+
saveStore(store, { intentionalDrop: "reap" }); // backups/snapshot/audit, no false wipe sentinel
|
|
79
|
+
|
|
80
|
+
// Append a reap-specific audit marker line (best-effort, no content)
|
|
81
|
+
try {
|
|
82
|
+
appendFileSync(
|
|
83
|
+
join(dirname(getLivePath()), "todo-audit.log"),
|
|
84
|
+
`REAP reaped=${reaped.length} flagged=${flagged} at ${new Date().toISOString()}\n`,
|
|
85
|
+
);
|
|
86
|
+
} catch { /* audit best-effort */ }
|
|
87
|
+
|
|
88
|
+
const oldestDays = reapedStaleDays.length ? Math.floor(Math.max(...reapedStaleDays)) : 0;
|
|
89
|
+
return { reaped: reaped.length, flagged, ids: reaped.map((t) => t.id), oldestDays };
|
|
90
|
+
}
|
package/src/todo-store.ts
CHANGED
|
@@ -53,6 +53,12 @@ export interface Store {
|
|
|
53
53
|
todos: Todo[];
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export interface SaveStoreOptions {
|
|
57
|
+
/** Expected count-drop operation. Backups/drop snapshots/audit still run;
|
|
58
|
+
* only the false-positive wipe sentinel is suppressed. */
|
|
59
|
+
intentionalDrop?: "reap" | "prune";
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
export interface AddInput {
|
|
57
63
|
title: string;
|
|
58
64
|
notes?: string;
|
|
@@ -148,7 +154,7 @@ export function loadStore(): Store {
|
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
/** Atomic, 0600 write. */
|
|
151
|
-
export function saveStore(store: Store): void {
|
|
157
|
+
export function saveStore(store: Store, options: SaveStoreOptions = {}): void {
|
|
152
158
|
store.updatedAt = now();
|
|
153
159
|
const path = getLivePath();
|
|
154
160
|
// v0.5.1 write-audit + backup (post data-loss hardening): back up the current
|
|
@@ -168,7 +174,7 @@ export function saveStore(store: Store): void {
|
|
|
168
174
|
}
|
|
169
175
|
renameSync(tmp, path);
|
|
170
176
|
appendAudit("todo", before, after, dropSnap);
|
|
171
|
-
if (after < before) writeWipeAlert(before, after, dropSnap);
|
|
177
|
+
if (after < before && !options.intentionalDrop) writeWipeAlert(before, after, dropSnap);
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
function assertPriority(p: unknown): asserts p is Priority {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The /todo triage validation rubric — a VERSIONED PROMPT CONSTANT.
|
|
2
|
+
//
|
|
3
|
+
// PRD ("The pipeline", stage 2): the agent prompt for the VALIDATE stage ships
|
|
4
|
+
// IN the package, not improvised per session — so the rubric evolves
|
|
5
|
+
// deliberately (bump TRIAGE_PROMPT_VERSION + tests when changing it) and every
|
|
6
|
+
// triage run, in any session, judges against the same contract.
|
|
7
|
+
//
|
|
8
|
+
// The rubric is derived from PRD §D3 (which itself was distilled from the
|
|
9
|
+
// 2026-08-30 manual triage). Evidence must be CHECKED (read-only git/gh/npm
|
|
10
|
+
// probes), never guessed. D2 is restated here because it is load-bearing:
|
|
11
|
+
// nothing mutates before the batch approval; the caller (extension) enforces
|
|
12
|
+
// it mechanically — the prompt is the judgment-side half of the same gate.
|
|
13
|
+
|
|
14
|
+
import type { Candidate } from "./triage.ts";
|
|
15
|
+
|
|
16
|
+
export const TRIAGE_PROMPT_VERSION = "triage-rubric/v1";
|
|
17
|
+
|
|
18
|
+
export const TRIAGE_RUBRIC = `## Triage rubric (${TRIAGE_PROMPT_VERSION})
|
|
19
|
+
|
|
20
|
+
You are validating TODO-triage candidates. For EACH candidate, judge it against
|
|
21
|
+
this rubric and produce one decision. Work top to bottom; do not skip rows.
|
|
22
|
+
|
|
23
|
+
### Verdicts
|
|
24
|
+
|
|
25
|
+
| Signal (checked, not guessed) | Verdict |
|
|
26
|
+
|---|---|
|
|
27
|
+
| source is an agent/fleet run, title is a subagent prompt, and the project work is verifiably merged/released | close (reason: debris) |
|
|
28
|
+
| near-identical title/body to another LIVE todo in the same project | close (reason: duplicate) — name the surviving id |
|
|
29
|
+
| done-able in <5 minutes, or explicitly deadline-bound | keep — surface it to the owner in your summary |
|
|
30
|
+
| real work, low priority, no date | park |
|
|
31
|
+
| stale >=30d and you could NOT verify anything | close (reason: stale-unverified) — this stays a proposal for the human |
|
|
32
|
+
| fresh, in_progress, or produced by a configured auto-reap source | (never a candidate — the engine already excluded these) |
|
|
33
|
+
|
|
34
|
+
### Evidence rules (D3 — checked, not guessed)
|
|
35
|
+
|
|
36
|
+
- Use READ-ONLY probes only: \`git -C <repo> log --oneline -10\`,
|
|
37
|
+
\`gh run list -R <org>/<repo> --limit 3\`, \`npm view <pkg> version\`,
|
|
38
|
+
\`gh pr list -R <org>/<repo> --state merged --search <title>\`.
|
|
39
|
+
- NEVER run mutating commands during validation (no push, no gh issue create,
|
|
40
|
+
no npm publish). Filing happens later, mechanically, by the tool.
|
|
41
|
+
- One line of evidence per decision. If you found nothing, say what you
|
|
42
|
+
checked: "git log + npm view @x/y — no trace of the described work".
|
|
43
|
+
- Confidence: high = direct proof (merged PR / published version); medium =
|
|
44
|
+
strong indirect (branch gone, issue closed by commit); low = judgment call.
|
|
45
|
+
|
|
46
|
+
### Safety contract (D2 — load-bearing)
|
|
47
|
+
|
|
48
|
+
- You NEVER mutate anything. Your output is proposals only.
|
|
49
|
+
- The tool executes strictly what the user approves in the batch confirm.
|
|
50
|
+
- A todo you cannot verify is a PROPOSAL, never an auto-close. Zero
|
|
51
|
+
false-closes is the success metric: a closed item that had to be restored
|
|
52
|
+
is a rubric bug.
|
|
53
|
+
|
|
54
|
+
### Output contract
|
|
55
|
+
|
|
56
|
+
Return one decision object per candidate, in order:
|
|
57
|
+
|
|
58
|
+
{ "id": "<td-...>", "verdict": "close" | "park" | "keep",
|
|
59
|
+
"reason": "debris" | "duplicate" | "stale-unverified" | "verified-shipped", // close only
|
|
60
|
+
"evidence": "<one checked line>",
|
|
61
|
+
"confidence": "high" | "medium" | "low",
|
|
62
|
+
"survivorId": "<td-...>" } // duplicate only
|
|
63
|
+
|
|
64
|
+
Then present the full proposal table (id / title / project / verdict /
|
|
65
|
+
evidence / confidence) to the user and wait for ONE batch approval. On
|
|
66
|
+
approval, submit all decisions in a single
|
|
67
|
+
todo(action:"triage", approve:[...]) call.`;
|
|
68
|
+
|
|
69
|
+
/** Compose the full agent prompt: rubric + the concrete candidate rows. */
|
|
70
|
+
export function buildTriagePrompt(candidates: Candidate[], scope?: string): string {
|
|
71
|
+
const rows = candidates.map((c) => {
|
|
72
|
+
const cats = c.categories.join("+");
|
|
73
|
+
return `- [${c.todo.id}] (${c.todo.project || "no project"}, age ${c.ageDays}d, ${cats}${c.mechanicalSafe ? ", mechanical-safe" : ""}) ${c.todo.title}`;
|
|
74
|
+
});
|
|
75
|
+
return [
|
|
76
|
+
TRIAGE_RUBRIC,
|
|
77
|
+
"",
|
|
78
|
+
`## Candidates (${candidates.length}${scope ? `, scope: ${scope}` : ""})`,
|
|
79
|
+
rows.join("\n"),
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|