@getpipher/armory-todo 0.6.0 → 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 +43 -0
- package/extensions/todo.ts +90 -2
- package/package.json +2 -2
- package/src/triage-prompt.ts +81 -0
- package/src/triage.ts +474 -0
package/README.md
CHANGED
|
@@ -148,6 +148,47 @@ Three caps keep the store (and its auto-injected prompt block) from bloating sil
|
|
|
148
148
|
|
|
149
149
|
**Backwards-compat:** zero migration (store v3, config v1, registry v1 unchanged in shape). Oversize notes grandfathered. The `maxOpen` advisory→enforced graduation is a documented behavior change for any v0.4.0 user who set a slot (the block message tells them how to raise/clear).
|
|
150
150
|
|
|
151
|
+
## Agent-validated triage (`/todo triage`)
|
|
152
|
+
|
|
153
|
+
Rules alone can't clean a store that accretes agent-run debris, stale backlog,
|
|
154
|
+
and prune hesitation. Triage adds judgment: one command that gathers
|
|
155
|
+
candidates, has the agent validate each against a versioned rubric, and closes
|
|
156
|
+
nothing without an explicit batch approval.
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
/todo triage [scope] [--yes]
|
|
160
|
+
1. GATHER stale(30d) + orphans(14d) + over-cap projects + agent-source items
|
|
161
|
+
2. VALIDATE agent checks each candidate (read-only git/gh/npm probes — rubric ships in-package: src/triage-prompt.ts)
|
|
162
|
+
3. PROPOSE table: verdict + evidence + confidence
|
|
163
|
+
4. APPROVE one batch confirm — nothing mutates before it (the load-bearing gate)
|
|
164
|
+
5. EXECUTE close(cancel) / park / keep → one prune --all sweep (reversible)
|
|
165
|
+
6. FILE each closed item as a CLOSED issue in the private getpipher/todo-ledger
|
|
166
|
+
7. REPORT before/after counts, filed links, anything deferred
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Safety rails (the point of the feature):
|
|
170
|
+
|
|
171
|
+
- **No silent close, ever.** The agent classifies; only the user's batch
|
|
172
|
+
approval mutates. `--yes` (`autoSafe:true`) executes ONLY the mechanical safe
|
|
173
|
+
class — fleet-run prompt debris (prompt-shaped title + agent context) — and
|
|
174
|
+
still reports what it closed. Unverifiable items stay proposals; zero
|
|
175
|
+
false-closes is the success metric.
|
|
176
|
+
- **The ledger never blocks.** Issues are matched by client-side title search
|
|
177
|
+
(idempotent — GitHub's search index lags fresh issues), created then PATCHed
|
|
178
|
+
closed, labeled `todo-archive` + `project/<name>` + `verdict/cancel`, and
|
|
179
|
+
embed the full original note. gh down → items archive locally anyway and the
|
|
180
|
+
skip is reported; `TODO_TRIAGE_SKIP_FILING=1` forces that path (air-gapped
|
|
181
|
+
runs). `TODO_LEDGER_REPO` overrides the repo for scratch runs.
|
|
182
|
+
- **The rubric is versioned** (`triage-rubric/v1`) and ships in the package —
|
|
183
|
+
judgment quality evolves deliberately, not per-session.
|
|
184
|
+
- Composes with the existing machinery: thresholds come from
|
|
185
|
+
`health.activeStaleDays` / `reap.orphanFlagAfterDays`, over-cap from the
|
|
186
|
+
registry, and reap-policy sources stay owned by the v0.6.0 auto-reap.
|
|
187
|
+
|
|
188
|
+
A Triage tab in the interactive panel is planned for v2; today the loop runs
|
|
189
|
+
through the agent (the tool returns the candidates + rubric, the agent
|
|
190
|
+
validates and proposes, you approve, one `approve:[…]` call executes + files).
|
|
191
|
+
|
|
151
192
|
## Interactive panel (SPEC-3)
|
|
152
193
|
|
|
153
194
|
Run `/todo` (no arg) in a TUI session to open the interactive triage panel:
|
|
@@ -189,6 +230,7 @@ Typed subcommands (`/todo park <id>`, `/todo prune`, etc.) all still work alongs
|
|
|
189
230
|
/todo prune --hard permanent deletion (interactive confirm prompt)
|
|
190
231
|
/todo archive [filter] archive summary, or filtered slice (project:X / text:Y)
|
|
191
232
|
/todo health bloat report across all boxes + flags + suggestions
|
|
233
|
+
/todo triage [scope] [--yes] agent-validated prune: gather → validate → propose → approve → close/park → file to ledger
|
|
192
234
|
/todo clean clear all done (deprecated — use prune)
|
|
193
235
|
/todo path show the store file path
|
|
194
236
|
```
|
|
@@ -208,6 +250,7 @@ Typed subcommands (`/todo park <id>`, `/todo prune`, etc.) all still work alongs
|
|
|
208
250
|
| `restore` | `id` | bring an archived TODO back as open |
|
|
209
251
|
| `health` | (none) | bloat report across active/parked/archive + flags + suggestions |
|
|
210
252
|
| `prune` (hard) | `hard:true`, `confirm:true`, `box?`, `olderThan?`, `project?`, `tag?` | PERMANENT deletion — the only irreversible action |
|
|
253
|
+
| `triage` | `scope?`, `autoSafe?`, `approve?: [{id, verdict, reason?, evidence?, confidence?, survivorId?}]` | two-phase agent-validated prune: bare call gathers candidates + returns the versioned rubric (nothing mutates); `autoSafe` executes ONLY mechanical debris; `approve` executes the user-approved batch, sweeps the archive, and files closed items to the private ledger
|
|
211
254
|
| `clear` | `status?` (default `done`) | bulk-clear a status (deprecated — use prune) |
|
|
212
255
|
|
|
213
256
|
Each TODO carries `id, title (≤120 chars), notes (any length), project, tags, priority (low|med|high|critical), status (open|in_progress|parked|done|cancelled), source, createdAt, updatedAt, closedAt`. The auto-injected block + list/panel show `title` only; `notes` is read via `get` and never injected.
|
package/extensions/todo.ts
CHANGED
|
@@ -40,12 +40,21 @@ import { hardPrune } from "../src/hard-prune";
|
|
|
40
40
|
import { TodoPanel } from "../src/panel";
|
|
41
41
|
import { autoPruneOnSessionStart } from "../src/auto-prune";
|
|
42
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";
|
|
43
52
|
import { loadConfig, type TodoConfig } from "../src/config";
|
|
44
53
|
import { projectsOverview } from "../src/projects";
|
|
45
54
|
import { renameProject } from "../src/registry";
|
|
46
55
|
import { readAndClearWipeAlert } from "../src/backup";
|
|
47
56
|
|
|
48
|
-
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;
|
|
49
58
|
|
|
50
59
|
function fmt(t: ReturnType<typeof listTodos>[number]): string {
|
|
51
60
|
const tag = t.project ? ` (${t.project})` : "";
|
|
@@ -187,6 +196,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
187
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.",
|
|
188
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.",
|
|
189
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>'.",
|
|
190
200
|
],
|
|
191
201
|
parameters: Type.Object({
|
|
192
202
|
action: StringEnum(ACTIONS),
|
|
@@ -218,6 +228,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
218
228
|
// project actions (v0.4.0)
|
|
219
229
|
oldName: Type.Optional(Type.String({ description: "project_rename: current project name" })),
|
|
220
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." })),
|
|
221
244
|
}),
|
|
222
245
|
async execute(_toolCallId, params) {
|
|
223
246
|
try {
|
|
@@ -391,6 +414,44 @@ export default function (pi: ExtensionAPI) {
|
|
|
391
414
|
const r = renameProject(params.oldName, params.newName);
|
|
392
415
|
return { content: [{ type: "text" as const, text: `Renamed ${params.oldName} → ${r.newName}: ${r.liveRenamed} live + ${r.archivedRenamed} archived${r.merged ? " (merged)" : ""}` }] };
|
|
393
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
|
+
}
|
|
394
455
|
default:
|
|
395
456
|
return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }] };
|
|
396
457
|
}
|
|
@@ -406,7 +467,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
406
467
|
"Global cross-session TODO list. " +
|
|
407
468
|
"/todo / /todo all / /todo add <title> / /todo done <id> / /todo rm <id> / " +
|
|
408
469
|
"/todo park <id> / /todo restore <id> / /todo prune [--all|--hard --box <b> --older-than <d>] / " +
|
|
409
|
-
"/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",
|
|
410
471
|
handler: async (args, ctx) => {
|
|
411
472
|
const a = (args ?? "").trim();
|
|
412
473
|
const [sub, ...rest] = a.split(/\s+/);
|
|
@@ -483,6 +544,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
483
544
|
}
|
|
484
545
|
return;
|
|
485
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
|
+
}
|
|
486
574
|
if (sub === "health") {
|
|
487
575
|
const report = healthReport();
|
|
488
576
|
const projLines = report.projects.length
|
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 todo-reap 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": "*",
|
|
@@ -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
|
+
}
|
package/src/triage.ts
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// /todo triage engine (PRD: agent-validated pruning + GH ledger) — v1 thin slice.
|
|
2
|
+
//
|
|
3
|
+
// Pipeline: GATHER -> VALIDATE (agent, rubric in triage-prompt.ts) -> PROPOSE
|
|
4
|
+
// -> APPROVE (batch) -> EXECUTE (cancel/park/keep + prune --all sweep) -> FILE
|
|
5
|
+
// (private ledger repo, idempotent, non-blocking) -> REPORT.
|
|
6
|
+
//
|
|
7
|
+
// Composition rules (PRD "Non-goals" + repo conventions):
|
|
8
|
+
// - Staleness thresholds come from the EXISTING config: health.activeStaleDays
|
|
9
|
+
// (30d) and reap.orphanFlagAfterDays (14d). No new config knobs, no
|
|
10
|
+
// duplicated semantics.
|
|
11
|
+
// - in_progress todos are NEVER candidates (D3: fresh/in_progress/policy-source
|
|
12
|
+
// stay untouched). Policy auto-reap sources are owned by reap.ts — triage
|
|
13
|
+
// never treats them as mechanical debris.
|
|
14
|
+
// - D2: nothing mutates before a batch approval — except --yes, which executes
|
|
15
|
+
// ONLY the mechanical safe class (fleet-run prompt debris). Everything else,
|
|
16
|
+
// including verified-shipped closes, goes through `approve`.
|
|
17
|
+
// - D4: ledger filing is idempotent (search "td-<id> in:title" first, issues
|
|
18
|
+
// are created CLOSED) and NEVER blocking — a gh failure archives locally
|
|
19
|
+
// and is reported as skipped. The gh runner is dependency-injected so tests
|
|
20
|
+
// stay hermetic (no network, no live store).
|
|
21
|
+
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { loadStore, updateTodo, type Todo } from "./todo-store.ts";
|
|
25
|
+
import { loadArchive, pruneTodos } from "./archive.ts";
|
|
26
|
+
import { loadConfig } from "./config.ts";
|
|
27
|
+
import { loadRegistry } from "./registry.ts";
|
|
28
|
+
import { overBudgetProjects } from "./caps.ts";
|
|
29
|
+
|
|
30
|
+
const execFileP = promisify(execFile);
|
|
31
|
+
|
|
32
|
+
const DAY = 86_400_000;
|
|
33
|
+
|
|
34
|
+
/** Private ledger repo (D4 — TODO notes are sensitive; never a public repo).
|
|
35
|
+
* Production default: getpipher/todo-ledger. TODO_LEDGER_REPO env override
|
|
36
|
+
* exists for scratch/manual runs against a throwaway repo — resolved at call
|
|
37
|
+
* time so a long-lived session picks it up. */
|
|
38
|
+
export const TRIAGE_LEDGER_REPO_DEFAULT = "getpipher/todo-ledger";
|
|
39
|
+
export function ledgerRepo(): string {
|
|
40
|
+
return process.env.TODO_LEDGER_REPO || TRIAGE_LEDGER_REPO_DEFAULT;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Types
|
|
45
|
+
|
|
46
|
+
export type TriageVerdict = "close" | "park" | "keep";
|
|
47
|
+
export type CloseReason = "debris" | "duplicate" | "stale-unverified" | "verified-shipped";
|
|
48
|
+
export type Confidence = "high" | "medium" | "low";
|
|
49
|
+
|
|
50
|
+
export type GhRunner = (args: string[]) => Promise<{ code: number; stdout: string; stderr: string }>;
|
|
51
|
+
|
|
52
|
+
export interface Candidate {
|
|
53
|
+
todo: Todo;
|
|
54
|
+
/** Which gather categories matched: stale-30d / orphan-14d / agent-source / over-cap-project. */
|
|
55
|
+
categories: string[];
|
|
56
|
+
/** Age by updatedAt, whole days down. */
|
|
57
|
+
ageDays: number;
|
|
58
|
+
/** Mechanical safe class (D2 --yes): fleet-run prompt debris, closable without judgment. */
|
|
59
|
+
mechanicalSafe: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GatherResult {
|
|
63
|
+
scope?: string;
|
|
64
|
+
before: { active: number; parked: number; archive: number };
|
|
65
|
+
candidates: Candidate[];
|
|
66
|
+
overCapProjects: { name: string; open: number; maxOpen: number }[];
|
|
67
|
+
staleDays: number;
|
|
68
|
+
orphanDays: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface TriageDecision {
|
|
72
|
+
id: string;
|
|
73
|
+
verdict: TriageVerdict;
|
|
74
|
+
reason?: CloseReason;
|
|
75
|
+
evidence?: string;
|
|
76
|
+
confidence?: Confidence;
|
|
77
|
+
/** duplicate closes: the todo that survives. */
|
|
78
|
+
survivorId?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface LedgerFiling {
|
|
82
|
+
id: string;
|
|
83
|
+
status: "filed" | "skipped-existing" | "skipped-gh-error" | "skipped-duplicate-id";
|
|
84
|
+
url?: string;
|
|
85
|
+
error?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface TriageReport {
|
|
89
|
+
closed: { id: string; title: string; project: string }[];
|
|
90
|
+
parked: { id: string; title: string; project: string }[];
|
|
91
|
+
kept: { id: string; title: string }[];
|
|
92
|
+
/** Items rejected before any mutation (bad id, not open, invalid decision). */
|
|
93
|
+
rejected: { id: string; error: string }[];
|
|
94
|
+
pruned: number;
|
|
95
|
+
filings: LedgerFiling[];
|
|
96
|
+
after: { active: number; parked: number; archive: number };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface TriageOptions {
|
|
100
|
+
/** Dependency-injected gh runner (tests). Default: real `gh` CLI. */
|
|
101
|
+
gh?: GhRunner;
|
|
102
|
+
/** Skip ledger filing entirely (hermetic runs that must not touch gh).
|
|
103
|
+
* Also honored via TODO_TRIAGE_SKIP_FILING=1 (air-gapped / offline runs —
|
|
104
|
+
* D4: pruning must never depend on the network). */
|
|
105
|
+
skipFiling?: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Gather (pure read — no mutation, no config writes)
|
|
110
|
+
|
|
111
|
+
/** Prompt-shaped title: fleet runs auto-track subagent PROMPTS as titles
|
|
112
|
+
* ("You are COMPLETING Task 9: ...", "[general-purpose] You are ...").
|
|
113
|
+
* Deliberately narrow — a human-written title rarely starts this way, and
|
|
114
|
+
* zero false-closes is the success metric. */
|
|
115
|
+
export function isPromptShapedTitle(title: string): boolean {
|
|
116
|
+
return /^you (are|'re|will|r)\b/i.test(title) || /^\[[\w.-]+\]\s/.test(title);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Agent context: a source/project/tag that names an agent-run producer.
|
|
120
|
+
* Reap-policy sources are excluded upstream — reap.ts owns those. */
|
|
121
|
+
function hasAgentContext(todo: Todo): boolean {
|
|
122
|
+
const hay = [todo.source, todo.project, ...todo.tags].join(" ").toLowerCase();
|
|
123
|
+
return /\b(fleet|agent|subagent|run)\b/.test(hay);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Mechanical safe class (D2 --yes): prompt-shaped debris with agent context.
|
|
127
|
+
* Policy auto-reap sources never qualify — reap.ts already owns them. */
|
|
128
|
+
export function isMechanicalSafe(todo: Todo, policySources: Set<string>): boolean {
|
|
129
|
+
if (todo.status !== "open") return false;
|
|
130
|
+
if (policySources.has(todo.source)) return false;
|
|
131
|
+
return isPromptShapedTitle(todo.title) && hasAgentContext(todo);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** GATHER: stale(30d) + orphans(14d) + over-cap projects + agent-source items.
|
|
135
|
+
* Pure read: loads store/config/registry, mutates nothing, persists nothing. */
|
|
136
|
+
export function gatherCandidates(scope?: string): GatherResult {
|
|
137
|
+
const config = loadConfig();
|
|
138
|
+
const staleDays = config.health.activeStaleDays;
|
|
139
|
+
const orphanDays = config.reap.orphanFlagAfterDays;
|
|
140
|
+
const policySources = new Set(Object.keys(config.reap.policy));
|
|
141
|
+
|
|
142
|
+
const store = loadStore();
|
|
143
|
+
const archiveCount = loadArchive().todos.length;
|
|
144
|
+
const active = store.todos.filter((t) => t.status === "open" || t.status === "in_progress");
|
|
145
|
+
const parked = store.todos.filter((t) => t.status === "parked");
|
|
146
|
+
|
|
147
|
+
// Over-cap projects (advisory registry caps — same definition as health PROJECT_OVER).
|
|
148
|
+
const registry = loadRegistry();
|
|
149
|
+
const overCap = overBudgetProjects(store.todos, registry);
|
|
150
|
+
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
const candidates: Candidate[] = [];
|
|
153
|
+
|
|
154
|
+
for (const todo of store.todos) {
|
|
155
|
+
if (todo.status !== "open") continue; // D3: in_progress/parked/terminal — untouched
|
|
156
|
+
if (policySources.has(todo.source)) continue; // D3: policy-source — reap.ts owns them
|
|
157
|
+
if (scope && todo.project !== scope) continue;
|
|
158
|
+
|
|
159
|
+
const ageDays = Math.floor((now - Date.parse(todo.updatedAt)) / DAY);
|
|
160
|
+
const categories: string[] = [];
|
|
161
|
+
if (ageDays > staleDays) categories.push("stale-30d");
|
|
162
|
+
if (!policySources.has(todo.source) && ageDays >= orphanDays) categories.push("orphan-14d");
|
|
163
|
+
if (hasAgentContext(todo) && ageDays >= orphanDays) categories.push("agent-source");
|
|
164
|
+
if (overCap.some((p) => p.name === todo.project && todo.status === "open")) categories.push("over-cap-project");
|
|
165
|
+
if (categories.length === 0) continue;
|
|
166
|
+
|
|
167
|
+
candidates.push({ todo, categories, ageDays, mechanicalSafe: isMechanicalSafe(todo, policySources) });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
scope,
|
|
172
|
+
before: { active: active.length, parked: parked.length, archive: archiveCount },
|
|
173
|
+
candidates,
|
|
174
|
+
overCapProjects: overCap,
|
|
175
|
+
staleDays,
|
|
176
|
+
orphanDays,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Execute (the ONLY mutation path — behind an explicit decision list)
|
|
182
|
+
|
|
183
|
+
const CLOSE_REASONS: CloseReason[] = ["debris", "duplicate", "stale-unverified", "verified-shipped"];
|
|
184
|
+
|
|
185
|
+
function validateDecisions(decisions: TriageDecision[]): { id: string; error: string }[] {
|
|
186
|
+
const errors: { id: string; error: string }[] = [];
|
|
187
|
+
decisions.forEach((d, i) => {
|
|
188
|
+
const label = d.id || `decisions[${i}]`;
|
|
189
|
+
if (!d.verdict || !["close", "park", "keep"].includes(d.verdict)) {
|
|
190
|
+
errors.push({ id: label, error: `invalid verdict "${String(d.verdict)}" (close|park|keep)` });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (d.verdict === "close") {
|
|
194
|
+
if (!d.reason || !CLOSE_REASONS.includes(d.reason)) {
|
|
195
|
+
errors.push({ id: label, error: `close requires reason (${CLOSE_REASONS.join("|")})` });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (d.reason === "duplicate" && !d.survivorId) {
|
|
199
|
+
errors.push({ id: label, error: `duplicate close requires survivorId` });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (d.reason === "verified-shipped" && !d.evidence) {
|
|
203
|
+
errors.push({ id: label, error: `verified-shipped close requires evidence` });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
return errors;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** EXECUTE + FILE + REPORT (PRD pipeline steps 5-7).
|
|
212
|
+
*
|
|
213
|
+
* Mutates exactly the approved decisions: close -> cancelled, park -> parked,
|
|
214
|
+
* keep -> untouched. Then one `prune --all` sweep (closed items enter the
|
|
215
|
+
* archive — reversible), then ledger filing for the closed set (idempotent,
|
|
216
|
+
* never blocking), then the before/after report. Snapshots are taken BEFORE
|
|
217
|
+
* mutation so filing carries the full original note even after the sweep.
|
|
218
|
+
*
|
|
219
|
+
* Per-item rejections (unknown id, not open, malformed decision) never abort
|
|
220
|
+
* the batch — they land in `rejected` and the rest executes. */
|
|
221
|
+
export async function executeTriage(decisions: TriageDecision[], opts: TriageOptions = {}): Promise<TriageReport> {
|
|
222
|
+
const rejected = validateDecisions(decisions);
|
|
223
|
+
const rejectedIds = new Set(rejected.map((r) => r.id));
|
|
224
|
+
|
|
225
|
+
const store = loadStore();
|
|
226
|
+
const before = {
|
|
227
|
+
active: store.todos.filter((t) => t.status === "open" || t.status === "in_progress").length,
|
|
228
|
+
parked: store.todos.filter((t) => t.status === "parked").length,
|
|
229
|
+
archive: loadArchive().todos.length,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const closed: TriageReport["closed"] = [];
|
|
233
|
+
const parked: TriageReport["parked"] = [];
|
|
234
|
+
const kept: TriageReport["kept"] = [];
|
|
235
|
+
const snapshots = new Map<string, Todo>();
|
|
236
|
+
|
|
237
|
+
for (const d of decisions) {
|
|
238
|
+
if (rejectedIds.has(d.id)) continue;
|
|
239
|
+
const todo = loadStore().todos.find((t) => t.id === d.id);
|
|
240
|
+
if (!todo) {
|
|
241
|
+
rejected.push({ id: d.id, error: `no todo with id ${d.id}` });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (todo.status !== "open") {
|
|
245
|
+
rejected.push({ id: d.id, error: `not open (status: ${todo.status}) — triage only rules on open items` });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (d.verdict === "keep") {
|
|
249
|
+
kept.push({ id: d.id, title: todo.title });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
// Snapshot before mutation — filing needs the full original record.
|
|
253
|
+
snapshots.set(d.id, { ...todo });
|
|
254
|
+
if (d.verdict === "close") {
|
|
255
|
+
updateTodo(d.id, { status: "cancelled" });
|
|
256
|
+
closed.push({ id: d.id, title: todo.title, project: todo.project });
|
|
257
|
+
} else {
|
|
258
|
+
updateTodo(d.id, { status: "parked" });
|
|
259
|
+
parked.push({ id: d.id, title: todo.title, project: todo.project });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Reversible sweep: everything terminal in the live store enters the archive.
|
|
264
|
+
let pruned = 0;
|
|
265
|
+
if (closed.length > 0) {
|
|
266
|
+
pruned = pruneTodos({ all: true }).moved;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// FILE — closed items only, idempotent, never blocking (D4).
|
|
270
|
+
const filings: LedgerFiling[] = [];
|
|
271
|
+
if (closed.length > 0 && !opts.skipFiling && process.env.TODO_TRIAGE_SKIP_FILING !== "1") {
|
|
272
|
+
const gh = opts.gh ?? defaultGhRunner;
|
|
273
|
+
for (const item of closed) {
|
|
274
|
+
const snapshot = snapshots.get(item.id)!;
|
|
275
|
+
const decision = decisions.find((d) => d.id === item.id)!;
|
|
276
|
+
filings.push(await fileClosedTodo(gh, snapshot, decision));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const afterStore = loadStore();
|
|
281
|
+
const after = {
|
|
282
|
+
active: afterStore.todos.filter((t) => t.status === "open" || t.status === "in_progress").length,
|
|
283
|
+
parked: afterStore.todos.filter((t) => t.status === "parked").length,
|
|
284
|
+
archive: loadArchive().todos.length,
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
return { closed, parked, kept, rejected, pruned, filings, after };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** --yes path (D2): execute ONLY the mechanical safe class. Everything else
|
|
291
|
+
* stays a proposal. Scope-filtered when a scope is given. Returns the report
|
|
292
|
+
* plus the remaining (unexecuted) candidate count. */
|
|
293
|
+
export async function executeSafeClass(scope?: string, opts: TriageOptions = {}): Promise<{ report: TriageReport | null; remaining: number }> {
|
|
294
|
+
const gather = gatherCandidates(scope);
|
|
295
|
+
const policySources = new Set(Object.keys(loadConfig().reap.policy));
|
|
296
|
+
const safe = gather.candidates.filter((c) => isMechanicalSafe(c.todo, policySources));
|
|
297
|
+
if (safe.length === 0) return { report: null, remaining: gather.candidates.length };
|
|
298
|
+
const decisions: TriageDecision[] = safe.map((c) => ({
|
|
299
|
+
id: c.todo.id,
|
|
300
|
+
verdict: "close",
|
|
301
|
+
reason: "debris",
|
|
302
|
+
evidence: "mechanical: fleet-run prompt debris (prompt-shaped title + agent context)",
|
|
303
|
+
confidence: "high",
|
|
304
|
+
}));
|
|
305
|
+
const report = await executeTriage(decisions, opts);
|
|
306
|
+
return { report, remaining: gather.candidates.length - safe.length };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Ledger filing (D4: private repo, idempotent, created CLOSED, never blocking)
|
|
311
|
+
|
|
312
|
+
export const defaultGhRunner: GhRunner = async (args) => {
|
|
313
|
+
try {
|
|
314
|
+
const { stdout, stderr } = await execFileP("gh", args, { timeout: 20_000, maxBuffer: 4 * 1024 * 1024 });
|
|
315
|
+
return { code: 0, stdout, stderr };
|
|
316
|
+
} catch (e) {
|
|
317
|
+
const err = e as { code?: number; stdout?: string; stderr?: string; message: string };
|
|
318
|
+
return { code: err.code ?? 1, stdout: err.stdout ?? "", stderr: err.stderr ?? err.message };
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
function projectLabel(project: string): string {
|
|
323
|
+
return `project/${project.trim().replace(/\s+/g, "-").toLowerCase() || "none"}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Make sure the ledger repo exists (private). Returns null when usable,
|
|
327
|
+
* or an error string when gh cannot see/create it (caller skips filing). */
|
|
328
|
+
export async function ensureLedgerRepo(gh: GhRunner): Promise<string | null> {
|
|
329
|
+
const view = await gh(["repo", "view", ledgerRepo(), "--json", "name,visibility"]);
|
|
330
|
+
if (view.code === 0) return null;
|
|
331
|
+
const create = await gh(["repo", "create", ledgerRepo(), "--private"]);
|
|
332
|
+
if (create.code !== 0) {
|
|
333
|
+
return `gh cannot see or create ${ledgerRepo()}: ${(create.stderr || view.stderr).trim().slice(0, 200)}`;
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Idempotency probe: an existing issue titled with this td-<id> short-circuits creation.
|
|
339
|
+
* Client-side title match on a full list — deliberately NOT `--search`, which
|
|
340
|
+
* rides GitHub's search index and LAGS fresh issues (proven in smoke: a
|
|
341
|
+
* same-second re-file sailed past the probe and created a duplicate). */
|
|
342
|
+
export async function findLedgerIssue(gh: GhRunner, id: string): Promise<string | null> {
|
|
343
|
+
const res = await gh([
|
|
344
|
+
"issue", "list", "-R", ledgerRepo(),
|
|
345
|
+
"--state", "all", "--json", "number,title,url", "--limit", "1000",
|
|
346
|
+
]);
|
|
347
|
+
if (res.code !== 0) return null; // probe failure -> fall through to create attempt; create failing is what skips
|
|
348
|
+
try {
|
|
349
|
+
const found = (JSON.parse(res.stdout) as { title: string; url: string }[]).filter((i) => i.title.includes(id));
|
|
350
|
+
return found.length > 0 ? found[0].url : null;
|
|
351
|
+
} catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function ledgerBody(todo: Todo, decision: TriageDecision): string {
|
|
357
|
+
return [
|
|
358
|
+
`## Archived TODO \`${todo.id}\``,
|
|
359
|
+
"",
|
|
360
|
+
`- **project:** ${todo.project || "(none)"}`,
|
|
361
|
+
`- **closed:** ${todo.closedAt ?? "(unknown)"} as \`${todo.status}\``,
|
|
362
|
+
`- **close reason:** ${decision.reason ?? "(unspecified)"}`,
|
|
363
|
+
`- **confidence:** ${decision.confidence ?? "(unspecified)"}`,
|
|
364
|
+
`- **evidence:** ${decision.evidence || "(none recorded)"}`,
|
|
365
|
+
decision.survivorId ? `- **duplicate of:** \`${decision.survivorId}\`` : "",
|
|
366
|
+
"",
|
|
367
|
+
"### Original title",
|
|
368
|
+
"",
|
|
369
|
+
todo.title,
|
|
370
|
+
"",
|
|
371
|
+
"### Original notes",
|
|
372
|
+
"",
|
|
373
|
+
"```",
|
|
374
|
+
todo.notes || "(empty)",
|
|
375
|
+
"```",
|
|
376
|
+
"",
|
|
377
|
+
"---",
|
|
378
|
+
"Filed by `/todo triage` (@getpipher/armory-todo). This is a sealed record, not open work — the issue is intentionally closed.",
|
|
379
|
+
].filter((l) => l !== "").join("\n");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** File one closed todo as a CLOSED issue. Idempotent by title search; every
|
|
383
|
+
* failure mode degrades to a `skipped-*` filing — never throws, never blocks
|
|
384
|
+
* the local archive (D4). */
|
|
385
|
+
export async function fileClosedTodo(gh: GhRunner, todo: Todo, decision: TriageDecision): Promise<LedgerFiling> {
|
|
386
|
+
try {
|
|
387
|
+
const existing = await findLedgerIssue(gh, todo.id);
|
|
388
|
+
if (existing) return { id: todo.id, status: "skipped-existing", url: existing };
|
|
389
|
+
|
|
390
|
+
const repoErr = await ensureLedgerRepo(gh);
|
|
391
|
+
if (repoErr) return { id: todo.id, status: "skipped-gh-error", error: repoErr };
|
|
392
|
+
|
|
393
|
+
const title = `[archive] ${todo.id} ${todo.title}`.slice(0, 220);
|
|
394
|
+
// D4 verdict labels: triage closes are cancellations (reversible via restore).
|
|
395
|
+
const labels = ["todo-archive", projectLabel(todo.project), `verdict/${decision.verdict === "close" ? "cancel" : "close"}`];
|
|
396
|
+
// NOTE: the create-issues REST endpoint has no `state` field — it silently
|
|
397
|
+
// ignores one (proven in smoke). Create, then PATCH closed.
|
|
398
|
+
const create = await gh([
|
|
399
|
+
"api", `repos/${ledgerRepo()}/issues`,
|
|
400
|
+
"-f", `title=${title}`,
|
|
401
|
+
"-f", `body=${ledgerBody(todo, decision)}`,
|
|
402
|
+
...labels.flatMap((l) => ["-f", `labels[]=${l}`]),
|
|
403
|
+
]);
|
|
404
|
+
if (create.code !== 0) {
|
|
405
|
+
return { id: todo.id, status: "skipped-gh-error", error: (create.stderr || "gh api failed").trim().slice(0, 200) };
|
|
406
|
+
}
|
|
407
|
+
let url = "";
|
|
408
|
+
let number = 0;
|
|
409
|
+
try {
|
|
410
|
+
const parsed = JSON.parse(create.stdout) as { html_url?: string; number?: number };
|
|
411
|
+
url = parsed.html_url ?? "";
|
|
412
|
+
number = parsed.number ?? 0;
|
|
413
|
+
} catch { /* url/number best-effort */ }
|
|
414
|
+
if (number > 0) {
|
|
415
|
+
// Records, not work: close immediately. A failed close still files the
|
|
416
|
+
// record (url below) but flags it — the issue would need a manual close.
|
|
417
|
+
const close = await gh(["api", "-X", "PATCH", `repos/${ledgerRepo()}/issues/${number}`, "-f", "state=closed"]);
|
|
418
|
+
if (close.code !== 0) {
|
|
419
|
+
return { id: todo.id, status: "filed", url: url || undefined, error: `filed but CLOSE FAILED (needs manual close): ${(close.stderr || "patch failed").trim().slice(0, 150)}` };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { id: todo.id, status: "filed", url: url || undefined };
|
|
423
|
+
} catch (e) {
|
|
424
|
+
return { id: todo.id, status: "skipped-gh-error", error: (e as Error).message.slice(0, 200) };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Rendering (shared by the tool action + the slash mirror)
|
|
430
|
+
|
|
431
|
+
export function renderProposalTable(gather: GatherResult): string {
|
|
432
|
+
const rows = gather.candidates.map((c) => {
|
|
433
|
+
const proj = c.todo.project || "(none)";
|
|
434
|
+
const safe = c.mechanicalSafe ? " yes" : "";
|
|
435
|
+
return `| ${c.todo.id} | ${c.todo.title} | ${proj} | ${c.categories.join("+")} | ${c.ageDays}d |${safe} |`;
|
|
436
|
+
});
|
|
437
|
+
return [
|
|
438
|
+
`| id | title | project | categories | age | safe(--yes) |`,
|
|
439
|
+
`|---|---|---|---|---|---|`,
|
|
440
|
+
...rows,
|
|
441
|
+
].join("\n");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function renderReport(report: TriageReport, before?: GatherResult["before"]): string {
|
|
445
|
+
const lines: string[] = ["## Triage — executed"];
|
|
446
|
+
if (before) lines.push(`before: ${before.active} active / ${before.parked} parked / ${before.archive} archive`);
|
|
447
|
+
if (report.closed.length) {
|
|
448
|
+
lines.push(`closed (${report.closed.length}):`);
|
|
449
|
+
for (const c of report.closed) {
|
|
450
|
+
const filing = report.filings.find((f) => f.id === c.id);
|
|
451
|
+
const ledger = filing?.status === "filed" ? ` · ledger: ${filing.url}`
|
|
452
|
+
: filing?.status === "skipped-existing" ? " · ledger: already filed"
|
|
453
|
+
: filing?.status === "skipped-gh-error" ? ` · ledger: SKIPPED (${filing.error})`
|
|
454
|
+
: filing?.status === "skipped-duplicate-id" ? " · ledger: skipped (duplicate)"
|
|
455
|
+
: " · ledger: not filed";
|
|
456
|
+
lines.push(` [${c.id}] ${c.title}${c.project ? ` (${c.project})` : ""}${ledger}`);
|
|
457
|
+
}
|
|
458
|
+
} else {
|
|
459
|
+
lines.push("closed: (none)");
|
|
460
|
+
}
|
|
461
|
+
if (report.parked.length) {
|
|
462
|
+
lines.push(`parked (${report.parked.length}): ${report.parked.map((p) => `[${p.id}] ${p.title}`).join(", ")}`);
|
|
463
|
+
}
|
|
464
|
+
if (report.kept.length) {
|
|
465
|
+
lines.push(`kept (${report.kept.length}): ${report.kept.map((k) => `[${k.id}] ${k.title}`).join(", ")}`);
|
|
466
|
+
}
|
|
467
|
+
if (report.rejected.length) {
|
|
468
|
+
lines.push(`rejected (${report.rejected.length}):`);
|
|
469
|
+
for (const r of report.rejected) lines.push(` [${r.id}] ${r.error}`);
|
|
470
|
+
}
|
|
471
|
+
if (report.closed.length) lines.push(`prune sweep: ${report.pruned} moved to archive (reversible via todo restore <id>)`);
|
|
472
|
+
lines.push(`after: ${report.after.active} active / ${report.after.parked} parked / ${report.after.archive} archive`);
|
|
473
|
+
return lines.join("\n");
|
|
474
|
+
}
|