@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/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
+ }