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