@ozzylabs/feedradar 0.1.6 → 0.1.7

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.
Files changed (75) hide show
  1. package/dist/cli/dismiss.d.ts +2 -1
  2. package/dist/cli/dismiss.d.ts.map +1 -1
  3. package/dist/cli/dismiss.js +4 -1
  4. package/dist/cli/dismiss.js.map +1 -1
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +7 -1
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/items.d.ts +44 -0
  9. package/dist/cli/items.d.ts.map +1 -0
  10. package/dist/cli/items.js +288 -0
  11. package/dist/cli/items.js.map +1 -0
  12. package/dist/cli/research.d.ts +21 -0
  13. package/dist/cli/research.d.ts.map +1 -1
  14. package/dist/cli/research.js +54 -10
  15. package/dist/cli/research.js.map +1 -1
  16. package/dist/cli/review.d.ts +23 -0
  17. package/dist/cli/review.d.ts.map +1 -1
  18. package/dist/cli/review.js +293 -2
  19. package/dist/cli/review.js.map +1 -1
  20. package/dist/cli/triage.d.ts +136 -0
  21. package/dist/cli/triage.d.ts.map +1 -0
  22. package/dist/cli/triage.js +1110 -0
  23. package/dist/cli/triage.js.map +1 -0
  24. package/dist/cli/undismiss.d.ts +30 -0
  25. package/dist/cli/undismiss.d.ts.map +1 -0
  26. package/dist/cli/undismiss.js +133 -0
  27. package/dist/cli/undismiss.js.map +1 -0
  28. package/dist/cli/workflow/generate-combined-with-triage.d.ts +115 -0
  29. package/dist/cli/workflow/generate-combined-with-triage.d.ts.map +1 -0
  30. package/dist/cli/workflow/generate-combined-with-triage.js +446 -0
  31. package/dist/cli/workflow/generate-combined-with-triage.js.map +1 -0
  32. package/dist/cli/workflow.d.ts +6 -5
  33. package/dist/cli/workflow.d.ts.map +1 -1
  34. package/dist/cli/workflow.js +13 -8
  35. package/dist/cli/workflow.js.map +1 -1
  36. package/dist/core/recipes.d.ts.map +1 -1
  37. package/dist/core/recipes.js +6 -0
  38. package/dist/core/recipes.js.map +1 -1
  39. package/dist/core/transitions.d.ts +30 -0
  40. package/dist/core/transitions.d.ts.map +1 -0
  41. package/dist/core/transitions.js +103 -0
  42. package/dist/core/transitions.js.map +1 -0
  43. package/dist/core/triage/adapter.d.ts +80 -0
  44. package/dist/core/triage/adapter.d.ts.map +1 -0
  45. package/dist/core/triage/adapter.js +128 -0
  46. package/dist/core/triage/adapter.js.map +1 -0
  47. package/dist/core/triage/index.d.ts +105 -0
  48. package/dist/core/triage/index.d.ts.map +1 -0
  49. package/dist/core/triage/index.js +246 -0
  50. package/dist/core/triage/index.js.map +1 -0
  51. package/dist/core/triage/prompt.d.ts +30 -0
  52. package/dist/core/triage/prompt.d.ts.map +1 -0
  53. package/dist/core/triage/prompt.js +157 -0
  54. package/dist/core/triage/prompt.js.map +1 -0
  55. package/dist/core/triage/response.d.ts +114 -0
  56. package/dist/core/triage/response.d.ts.map +1 -0
  57. package/dist/core/triage/response.js +188 -0
  58. package/dist/core/triage/response.js.map +1 -0
  59. package/dist/recipes/aws-whats-new.yaml +29 -0
  60. package/dist/recipes/dev-to.yaml +24 -0
  61. package/dist/schemas/item.d.ts +151 -5
  62. package/dist/schemas/item.d.ts.map +1 -1
  63. package/dist/schemas/item.js +164 -4
  64. package/dist/schemas/item.js.map +1 -1
  65. package/dist/schemas/recipe.d.ts +10 -0
  66. package/dist/schemas/recipe.d.ts.map +1 -1
  67. package/dist/schemas/recipe.js +10 -1
  68. package/dist/schemas/recipe.js.map +1 -1
  69. package/dist/schemas/source.d.ts +43 -0
  70. package/dist/schemas/source.d.ts.map +1 -1
  71. package/dist/schemas/source.js +34 -0
  72. package/dist/schemas/source.js.map +1 -1
  73. package/dist/templates/agents/AGENTS.md +30 -0
  74. package/dist/templates/workflows/combined-with-triage.template.yaml.tmpl +133 -0
  75. package/package.json +1 -1
@@ -0,0 +1,1110 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { access, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { parse as parseYaml } from "yaml";
6
+ import { loadItems, saveItems } from "../core/items.js";
7
+ import { createProgressReporter } from "../core/progress.js";
8
+ import { statusForTriageDecision } from "../core/transitions.js";
9
+ import { triageItems } from "../core/triage/index.js";
10
+ import { loadSources } from "../core/watcher.js";
11
+ import { AgentIdSchema } from "../schemas/research.js";
12
+ import { SourceTriagePolicySchema } from "../schemas/source.js";
13
+ function splitCsv(value) {
14
+ return value
15
+ .split(",")
16
+ .map((s) => s.trim())
17
+ .filter((s) => s.length > 0);
18
+ }
19
+ function parseIntFlag(flag, raw, min) {
20
+ if (raw === undefined)
21
+ throw new Error(`option ${flag} requires a value`);
22
+ const n = Number(raw);
23
+ if (!Number.isInteger(n) || n < min) {
24
+ throw new Error(`option ${flag} expects an integer >= ${min}, got '${raw}'`);
25
+ }
26
+ return n;
27
+ }
28
+ function parseTriageRunArgs(args) {
29
+ const out = {};
30
+ for (let i = 0; i < args.length; i++) {
31
+ const a = args[i];
32
+ if (a === "-h" || a === "--help") {
33
+ out.help = true;
34
+ continue;
35
+ }
36
+ if (a === "--dry-run") {
37
+ if (out.mode)
38
+ throw new Error("--dry-run / --apply / --interactive are mutually exclusive");
39
+ out.mode = "dry-run";
40
+ continue;
41
+ }
42
+ if (a === "--apply") {
43
+ if (out.mode)
44
+ throw new Error("--dry-run / --apply / --interactive are mutually exclusive");
45
+ out.mode = "apply";
46
+ continue;
47
+ }
48
+ if (a === "--interactive") {
49
+ if (out.mode)
50
+ throw new Error("--dry-run / --apply / --interactive are mutually exclusive");
51
+ out.mode = "interactive";
52
+ continue;
53
+ }
54
+ if (a === "--source") {
55
+ const value = args[++i];
56
+ if (value === undefined)
57
+ throw new Error(`option ${a} requires a value`);
58
+ out.source = value;
59
+ continue;
60
+ }
61
+ if (a === "--filter-tags") {
62
+ const value = args[++i];
63
+ if (value === undefined)
64
+ throw new Error(`option ${a} requires a value`);
65
+ out.filterTags = splitCsv(value);
66
+ continue;
67
+ }
68
+ if (a === "--triage-agent") {
69
+ const value = args[++i];
70
+ if (value === undefined)
71
+ throw new Error(`option ${a} requires a value`);
72
+ out.triageAgent = value;
73
+ continue;
74
+ }
75
+ if (a === "--policy") {
76
+ const value = args[++i];
77
+ if (value === undefined)
78
+ throw new Error(`option ${a} requires a value`);
79
+ out.policy = value;
80
+ continue;
81
+ }
82
+ if (a === "--max-items") {
83
+ out.maxItems = parseIntFlag(a, args[++i], 1);
84
+ continue;
85
+ }
86
+ if (a === "--audit-log") {
87
+ const value = args[++i];
88
+ if (value === undefined)
89
+ throw new Error(`option ${a} requires a value`);
90
+ out.auditLog = value;
91
+ continue;
92
+ }
93
+ if (a === "--verbose" || a === "-v") {
94
+ out.verbose = true;
95
+ continue;
96
+ }
97
+ if (a === "--quiet" || a === "-q") {
98
+ out.quiet = true;
99
+ continue;
100
+ }
101
+ if (a?.startsWith("--") || a === "-h") {
102
+ throw new Error(`unknown option: ${a}`);
103
+ }
104
+ throw new Error(`unexpected positional argument: ${a}`);
105
+ }
106
+ if (out.verbose && out.quiet) {
107
+ throw new Error("--verbose and --quiet are mutually exclusive");
108
+ }
109
+ return out;
110
+ }
111
+ function parseTriageFeedbackArgs(args) {
112
+ const out = {};
113
+ for (let i = 0; i < args.length; i++) {
114
+ const a = args[i];
115
+ if (a === "-h" || a === "--help") {
116
+ out.help = true;
117
+ continue;
118
+ }
119
+ if (a === "--correct") {
120
+ out.correct = true;
121
+ continue;
122
+ }
123
+ if (a === "--wrong") {
124
+ out.wrong = true;
125
+ continue;
126
+ }
127
+ if (a === "--reason") {
128
+ const value = args[++i];
129
+ if (value === undefined)
130
+ throw new Error(`option ${a} requires a value`);
131
+ out.reason = value;
132
+ continue;
133
+ }
134
+ if (a?.startsWith("--")) {
135
+ throw new Error(`unknown option: ${a}`);
136
+ }
137
+ if (!out.itemId) {
138
+ out.itemId = a;
139
+ continue;
140
+ }
141
+ throw new Error(`unexpected positional argument: ${a}`);
142
+ }
143
+ return out;
144
+ }
145
+ function printRunHelp(log) {
146
+ log("Usage: radar triage [--dry-run | --apply | --interactive] [options]");
147
+ log("");
148
+ log("Classify `detected` items using the configured per-source triage policy.");
149
+ log("");
150
+ log("Modes (mutually exclusive; default: --dry-run):");
151
+ log(" --dry-run print proposed decisions to stdout (no disk writes)");
152
+ log(" --apply write decisions to items/<id>.yaml + transition status");
153
+ log(" --interactive --dry-run output → $EDITOR → confirm → apply");
154
+ log("");
155
+ log("Options:");
156
+ log(" --source <id> limit triage to a single source");
157
+ log(" --filter-tags <a,b> matchedKeywords allow-list (comma-separated)");
158
+ log(" --triage-agent <id> override policy.agent for this run");
159
+ log(" --policy <path> override per-source policy with a YAML file");
160
+ log(" --max-items N hard cap on items triaged in this run");
161
+ log(" --audit-log <path> append JSONL audit records of every triage call");
162
+ log(" -v, --verbose verbose progress output");
163
+ log(" -q, --quiet suppress progress output entirely");
164
+ log("");
165
+ log("Sources missing a `triagePolicy:` block are skipped with a warning. See");
166
+ log("ADR-0018 for the policy schema reference.");
167
+ }
168
+ function printFeedbackHelp(log) {
169
+ log("Usage: radar triage feedback <item-id> --correct | --wrong [--reason <text>]");
170
+ log("");
171
+ log("Record human feedback on a prior triage decision (ADR-0018 §W5).");
172
+ log("Feedback is appended to items/<id>.yaml > triage.feedback, used by");
173
+ log("`radar triage stats` (#242) for policy tuning.");
174
+ log("");
175
+ log("Options:");
176
+ log(" --correct mark the prior triage decision as correct");
177
+ log(" --wrong mark the prior triage decision as wrong");
178
+ log(" --reason <text> free-form rationale (recommended for --wrong)");
179
+ }
180
+ function printTriageHelp(log) {
181
+ log("Usage: radar triage <subcommand|--apply|--dry-run|--interactive> [...]");
182
+ log("");
183
+ log("Subcommands:");
184
+ log(" feedback <item-id> --correct | --wrong [--reason <text>]");
185
+ log(" stats [--since <duration>] [--source <id>] [--json]");
186
+ log("");
187
+ log("Run modes (when no subcommand given):");
188
+ log(" --dry-run print proposed decisions");
189
+ log(" --apply write decisions to items/<id>.yaml");
190
+ log(" --interactive edit decisions in $EDITOR before applying");
191
+ log("");
192
+ log("Run `radar triage --help` for the full option list.");
193
+ }
194
+ async function pathExists(p) {
195
+ try {
196
+ await access(p);
197
+ return true;
198
+ }
199
+ catch {
200
+ return false;
201
+ }
202
+ }
203
+ function defaultNow() {
204
+ return new Date().toISOString();
205
+ }
206
+ /**
207
+ * Load `--policy <path>` if supplied and validate it against
208
+ * `SourceTriagePolicySchema`. Returns the parsed policy or `null` on error
209
+ * (and reports the error via the supplied sink).
210
+ */
211
+ async function loadPolicyOverride(path, error) {
212
+ let raw;
213
+ try {
214
+ raw = await readFile(path, "utf8");
215
+ }
216
+ catch (e) {
217
+ error(`triage: failed to read --policy ${path}: ${e instanceof Error ? e.message : String(e)}`);
218
+ return null;
219
+ }
220
+ let parsed;
221
+ try {
222
+ parsed = parseYaml(raw);
223
+ }
224
+ catch (e) {
225
+ error(`triage: invalid YAML in --policy ${path}: ${e instanceof Error ? e.message : String(e)}`);
226
+ return null;
227
+ }
228
+ const result = SourceTriagePolicySchema.safeParse(parsed);
229
+ if (!result.success) {
230
+ error(`triage: --policy ${path} validation failed`);
231
+ for (const issue of result.error.issues) {
232
+ error(` - ${issue.path.join(".") || "<root>"}: ${issue.message}`);
233
+ }
234
+ return null;
235
+ }
236
+ return result.data;
237
+ }
238
+ /**
239
+ * Render a decision map as a deterministic block of stdout lines (one row
240
+ * per decision). Used for `--dry-run` and for the buffer fed to `$EDITOR`
241
+ * in interactive mode.
242
+ *
243
+ * Format (per row):
244
+ * <item-id> <decision> conf=<n.nn> group=<g|-> reason=<short>
245
+ */
246
+ function formatDecisionTable(items, decisions) {
247
+ const lines = [];
248
+ const idWidth = Math.max(...items.map((i) => i.id.length), 2);
249
+ lines.push(`${"ID".padEnd(idWidth)} DECISION CONFIDENCE GROUP REASON`);
250
+ for (const item of items) {
251
+ const d = decisions.get(item.id);
252
+ if (!d) {
253
+ lines.push(`${item.id.padEnd(idWidth)} (no decision)`);
254
+ continue;
255
+ }
256
+ const group = d.group ?? "-";
257
+ const conf = d.confidence.toFixed(2);
258
+ lines.push(`${item.id.padEnd(idWidth)} ${d.decision.padEnd(8)} ${conf.padEnd(10)} ${group.padEnd(6)} ${d.reason}`);
259
+ }
260
+ return lines;
261
+ }
262
+ /**
263
+ * Apply a triage decision map to the supplied items, returning the next
264
+ * persisted `Item` shape (status transitioned, `triage` populated,
265
+ * `dismissedBy` set for triage-origin dismisses).
266
+ *
267
+ * The function is pure: it does not write to disk. Callers (`--apply` and
268
+ * the interactive confirm step) invoke `saveItems()` after this.
269
+ */
270
+ function buildUpdatedItems(items, decisions, triageAgent) {
271
+ const out = [];
272
+ for (const item of items) {
273
+ const decision = decisions.get(item.id);
274
+ if (!decision) {
275
+ // Should never happen — triageItems guarantees decisions.size ===
276
+ // items.length. If it does, leave the item alone and skip.
277
+ continue;
278
+ }
279
+ const newStatus = statusForTriageDecision(decision.decision);
280
+ const next = {
281
+ ...item,
282
+ triage: decision,
283
+ status: newStatus,
284
+ };
285
+ if (decision.decision === "dismiss") {
286
+ // Record triage origin so `radar undismiss` can silently revert without
287
+ // requiring `--force` (ADR-0018 §W6).
288
+ const dismissedBy = `triage_${triageAgent}`;
289
+ next.dismissedBy = dismissedBy;
290
+ }
291
+ out.push(next);
292
+ }
293
+ return out;
294
+ }
295
+ /**
296
+ * Open `$EDITOR` on a temp file pre-filled with `body`. Returns the edited
297
+ * content. The function is best-effort: if `$EDITOR` is unset we fall back
298
+ * to `vi`. The shape of the buffer is intentionally human-readable (the
299
+ * `formatDecisionTable` output) but the post-edit content is NOT re-parsed
300
+ * — interactive mode treats the editor session as a confirmation gate
301
+ * rather than a structured editor (full structured editing is deferred to
302
+ * a future PR).
303
+ */
304
+ async function defaultEditor(path) {
305
+ const editor = process.env.EDITOR ?? "vi";
306
+ const result = spawnSync(editor, [path], { stdio: "inherit" });
307
+ if (result.status !== 0) {
308
+ throw new Error(`$EDITOR exited with status ${result.status}`);
309
+ }
310
+ }
311
+ /**
312
+ * Read stdin synchronously-ish for the interactive confirm prompt. Falls
313
+ * back to "n" when stdin is closed (test environments) so the operation is
314
+ * a no-op rather than throwing.
315
+ */
316
+ async function promptConfirm(message) {
317
+ process.stdout.write(`${message} `);
318
+ return await new Promise((resolve) => {
319
+ let buf = "";
320
+ const onData = (chunk) => {
321
+ buf += chunk.toString();
322
+ const nl = buf.indexOf("\n");
323
+ if (nl >= 0) {
324
+ process.stdin.off("data", onData);
325
+ process.stdin.off("end", onEnd);
326
+ const answer = buf.slice(0, nl).trim().toLowerCase();
327
+ resolve(answer === "y" || answer === "yes");
328
+ }
329
+ };
330
+ const onEnd = () => {
331
+ resolve(false);
332
+ };
333
+ process.stdin.on("data", onData);
334
+ process.stdin.on("end", onEnd);
335
+ });
336
+ }
337
+ /**
338
+ * Top-level dispatcher for `radar triage`. Routes to the feedback subcommand
339
+ * when the first positional is `feedback`, otherwise runs the triage flow.
340
+ */
341
+ export async function runTriage(args, options = {}) {
342
+ const log = options.io?.log ?? ((m) => console.log(m));
343
+ const warn = options.io?.warn ?? ((m) => console.warn(m));
344
+ const error = options.io?.error ?? ((m) => console.error(m));
345
+ const [first, ...rest] = args;
346
+ if (first === "feedback") {
347
+ return runTriageFeedback(rest, options);
348
+ }
349
+ if (first === "stats") {
350
+ return runTriageStats(rest, options, { log, warn, error });
351
+ }
352
+ if (first === "help") {
353
+ printTriageHelp(log);
354
+ return 0;
355
+ }
356
+ // `--help` / `-h` flow through to the run-mode parser so the user sees
357
+ // the full option list (the `feedback` subcommand has its own help).
358
+ // Otherwise the entire args list is the run-mode flag set.
359
+ return runTriageRun(args, options, { log, warn, error });
360
+ }
361
+ /**
362
+ * Implementation of `radar triage [--dry-run | --apply | --interactive]`.
363
+ *
364
+ * The function does the bookkeeping (parsing, source/item loading, mode
365
+ * dispatch, status transitions) but delegates the actual classification to
366
+ * `triageItems()` so the CLI tests can swap in `tests/helpers/triage-mock.ts`
367
+ * via `options.runner`.
368
+ */
369
+ async function runTriageRun(args, options, io) {
370
+ const { log, warn, error } = io;
371
+ const cwd = options.cwd ?? process.cwd();
372
+ const now = options.now ?? defaultNow;
373
+ let parsed;
374
+ try {
375
+ parsed = parseTriageRunArgs(args);
376
+ }
377
+ catch (e) {
378
+ error(`triage: ${e instanceof Error ? e.message : String(e)}`);
379
+ return 2;
380
+ }
381
+ if (parsed.help) {
382
+ printRunHelp(log);
383
+ return 0;
384
+ }
385
+ const mode = parsed.mode ?? "dry-run";
386
+ // Progress reporter (#197 / ADR-0015). The triage CLI uses the spinner
387
+ // sparingly — one phase marker before the agent call and one after — so
388
+ // even verbose mode stays scannable. Real progress chunks (agent stdout
389
+ // passthrough) live inside the adapter and only surface when --verbose is
390
+ // set.
391
+ const level = parsed.quiet ? "quiet" : parsed.verbose ? "verbose" : "normal";
392
+ const reporter = createProgressReporter({ level });
393
+ // 1. Load sources to discover per-source policies (and to map item.sourceId
394
+ // → policy when items span sources).
395
+ const sourcesDir = join(cwd, "sources");
396
+ if (!(await pathExists(sourcesDir))) {
397
+ error("triage: no sources/ directory (run `radar init` first)");
398
+ return 1;
399
+ }
400
+ const sources = await loadSources(sourcesDir, error);
401
+ if (sources.length === 0) {
402
+ log("triage: no sources defined; nothing to triage");
403
+ return 0;
404
+ }
405
+ // 2. Optional `--policy` override applies to every source in the run. This
406
+ // is documented as a 1-shot override — useful for trying a new policy
407
+ // against an existing source without editing the YAML.
408
+ let policyOverride = null;
409
+ if (parsed.policy) {
410
+ policyOverride = await loadPolicyOverride(parsed.policy, error);
411
+ if (!policyOverride)
412
+ return 2;
413
+ }
414
+ // 3. Load detected items, narrowing by `--source` and `--filter-tags`.
415
+ const itemsDir = join(cwd, "items");
416
+ if (!(await pathExists(itemsDir))) {
417
+ log("triage: no items/ directory; nothing to triage");
418
+ return 0;
419
+ }
420
+ let allItems;
421
+ try {
422
+ allItems = await loadItems(itemsDir, parsed.source);
423
+ }
424
+ catch (e) {
425
+ error(`triage: ${e instanceof Error ? e.message : String(e)}`);
426
+ return 1;
427
+ }
428
+ // ADR-0018 §W-B: triage only operates on `detected` items. items already
429
+ // triaged / researched / dismissed are excluded so re-running `radar
430
+ // triage` is idempotent.
431
+ let detected = allItems.filter((i) => i.status === "detected");
432
+ if (parsed.filterTags && parsed.filterTags.length > 0) {
433
+ const tags = new Set(parsed.filterTags);
434
+ detected = detected.filter((i) => i.matchedKeywords.some((k) => tags.has(k)));
435
+ }
436
+ if (detected.length === 0) {
437
+ log("triage: no detected items match the filter (nothing to do)");
438
+ return 0;
439
+ }
440
+ if (parsed.maxItems !== undefined && detected.length > parsed.maxItems) {
441
+ warn(`triage: ${detected.length} detected item(s) exceed --max-items ${parsed.maxItems}; processing the first ${parsed.maxItems} only`);
442
+ detected = detected.slice(0, parsed.maxItems);
443
+ }
444
+ // 4. Group by sourceId so each source uses its own policy. Items from
445
+ // sources without a policy (and no `--policy` override) are skipped
446
+ // with a warning — the CLI never invents a policy on the user's behalf.
447
+ const sourcesById = new Map(sources.map((s) => [s.id, s]));
448
+ const grouped = new Map();
449
+ for (const item of detected) {
450
+ const arr = grouped.get(item.sourceId);
451
+ if (arr)
452
+ arr.push(item);
453
+ else
454
+ grouped.set(item.sourceId, [item]);
455
+ }
456
+ // 5. Run triageItems() per source group. Aggregate decisions + updated
457
+ // items across groups so a single dry-run / apply pass surfaces every
458
+ // decision in one operation.
459
+ const allUpdated = [];
460
+ const allDecisions = new Map();
461
+ const allErrors = [];
462
+ for (const [sourceId, groupItems] of grouped) {
463
+ const source = sourcesById.get(sourceId);
464
+ const policy = policyOverride ?? source?.triagePolicy;
465
+ if (!policy) {
466
+ warn(`triage: skipping ${groupItems.length} item(s) from source '${sourceId}' (no triagePolicy configured)`);
467
+ continue;
468
+ }
469
+ const triageAgent = parsed.triageAgent ?? policy.agent;
470
+ // Validate `--triage-agent` against `AgentIdSchema` before spending an
471
+ // agent call — typos like `gemnini-cli` should fail fast.
472
+ const validated = AgentIdSchema.safeParse(triageAgent);
473
+ if (!validated.success) {
474
+ error(`triage: --triage-agent '${triageAgent}' is not a valid agent id (claude-code | codex-cli | gemini-cli | copilot)`);
475
+ return 2;
476
+ }
477
+ reporter.phase(`Triaging ${groupItems.length} item(s) from source '${sourceId}' via ${triageAgent}`);
478
+ let result;
479
+ try {
480
+ result = await triageItems(groupItems, {
481
+ policy,
482
+ agent: triageAgent,
483
+ cwd,
484
+ runner: options.runner,
485
+ auditLog: parsed.auditLog,
486
+ now,
487
+ });
488
+ }
489
+ catch (e) {
490
+ error(`triage: ${sourceId}: ${e instanceof Error ? e.message : String(e)}`);
491
+ return 1;
492
+ }
493
+ for (const [id, dec] of result.decisions) {
494
+ allDecisions.set(id, dec);
495
+ }
496
+ allErrors.push(...result.errors);
497
+ const updated = buildUpdatedItems(groupItems, result.decisions, triageAgent);
498
+ allUpdated.push(...updated);
499
+ }
500
+ if (allErrors.length > 0) {
501
+ for (const e of allErrors)
502
+ warn(`triage: ${e}`);
503
+ }
504
+ if (allDecisions.size === 0) {
505
+ log("triage: no items were triaged (all sources skipped)");
506
+ return 0;
507
+ }
508
+ // Render the decision table. Used by every mode.
509
+ const rows = formatDecisionTable(detected, allDecisions);
510
+ if (mode === "dry-run") {
511
+ log("triage: dry-run — no changes written");
512
+ for (const row of rows)
513
+ log(row);
514
+ return 0;
515
+ }
516
+ if (mode === "interactive") {
517
+ // Write the table to a temp file, open $EDITOR, then ask for
518
+ // confirmation. The edited content is NOT re-parsed — interactive mode
519
+ // is currently a confirmation gate, not a structured editor.
520
+ const dir = await mkdtemp(join(tmpdir(), "radar-triage-"));
521
+ const tmp = join(dir, "decisions.txt");
522
+ const header = [
523
+ "# radar triage --interactive",
524
+ "# Review the proposed decisions below. Save & close the editor to",
525
+ "# return to the confirmation prompt. (The edited content is not yet",
526
+ "# parsed back — this is a confirmation gate.)",
527
+ "",
528
+ ];
529
+ await writeFile(tmp, [...header, ...rows, ""].join("\n"), "utf8");
530
+ const editor = options.editor ?? defaultEditor;
531
+ try {
532
+ await editor(tmp);
533
+ }
534
+ catch (e) {
535
+ error(`triage: editor failed: ${e instanceof Error ? e.message : String(e)}`);
536
+ return 1;
537
+ }
538
+ const confirm = options.confirm ?? promptConfirm;
539
+ const confirmed = await confirm("Apply these decisions? [y/N]");
540
+ if (!confirmed) {
541
+ log("triage: aborted by user");
542
+ return 0;
543
+ }
544
+ }
545
+ // --apply (or --interactive after confirm). Persist to disk.
546
+ try {
547
+ await saveItems(itemsDir, allUpdated);
548
+ }
549
+ catch (e) {
550
+ error(`triage: failed to write items: ${e instanceof Error ? e.message : String(e)}`);
551
+ return 1;
552
+ }
553
+ log(`triage: applied ${allUpdated.length} decision(s)`);
554
+ for (const row of rows)
555
+ log(row);
556
+ return 0;
557
+ }
558
+ /**
559
+ * Implementation of `radar triage feedback <item-id> --correct | --wrong`.
560
+ *
561
+ * Per ADR-0018 §W5 / post-review comment, feedback writes overwrite the
562
+ * existing entry rather than appending — this CLI exposes the "human's
563
+ * current verdict on the prior triage decision" rather than a multi-reviewer
564
+ * audit log (the schema's `feedback: []` array supports the latter, we just
565
+ * don't expose it through the CLI yet).
566
+ */
567
+ async function runTriageFeedback(args, options) {
568
+ const cwd = options.cwd ?? process.cwd();
569
+ const log = options.io?.log ?? ((m) => console.log(m));
570
+ const error = options.io?.error ?? ((m) => console.error(m));
571
+ const now = options.now ?? defaultNow;
572
+ let parsed;
573
+ try {
574
+ parsed = parseTriageFeedbackArgs(args);
575
+ }
576
+ catch (e) {
577
+ error(`triage feedback: ${e instanceof Error ? e.message : String(e)}`);
578
+ return 2;
579
+ }
580
+ if (parsed.help) {
581
+ printFeedbackHelp(log);
582
+ return 0;
583
+ }
584
+ if (!parsed.itemId) {
585
+ error("triage feedback: missing <item-id>");
586
+ printFeedbackHelp(error);
587
+ return 2;
588
+ }
589
+ if (parsed.correct && parsed.wrong) {
590
+ error("triage feedback: --correct and --wrong are mutually exclusive");
591
+ return 2;
592
+ }
593
+ if (!parsed.correct && !parsed.wrong) {
594
+ error("triage feedback: one of --correct | --wrong is required");
595
+ return 2;
596
+ }
597
+ const itemsDir = join(cwd, "items");
598
+ if (!(await pathExists(itemsDir))) {
599
+ error(`triage feedback: items/ not found (run \`radar init\`)`);
600
+ return 1;
601
+ }
602
+ let items;
603
+ try {
604
+ items = await loadItems(itemsDir);
605
+ }
606
+ catch (e) {
607
+ error(`triage feedback: ${e instanceof Error ? e.message : String(e)}`);
608
+ return 1;
609
+ }
610
+ const item = items.find((i) => i.id === parsed.itemId);
611
+ if (!item) {
612
+ error(`triage feedback: item '${parsed.itemId}' not found under items/`);
613
+ return 1;
614
+ }
615
+ if (!item.triage) {
616
+ error(`triage feedback: item '${item.id}' has no prior triage decision to give feedback on`);
617
+ return 1;
618
+ }
619
+ // Overwrite-semantic: replace the existing feedback array with a single
620
+ // entry carrying the human's current verdict. The append-only schema
621
+ // shape is preserved (it stays an array) so a future multi-reviewer CLI
622
+ // can extend without changing the on-disk schema.
623
+ const feedback = [
624
+ {
625
+ correct: parsed.correct === true,
626
+ reason: parsed.reason,
627
+ feedbackAt: now(),
628
+ },
629
+ ];
630
+ const updated = {
631
+ ...item,
632
+ triage: {
633
+ ...item.triage,
634
+ feedback,
635
+ },
636
+ };
637
+ try {
638
+ await saveItems(itemsDir, [updated]);
639
+ }
640
+ catch (e) {
641
+ error(`triage feedback: failed to write item: ${e instanceof Error ? e.message : String(e)}`);
642
+ return 1;
643
+ }
644
+ const verdict = parsed.correct ? "correct" : "wrong";
645
+ log(`triage feedback: items/${item.sourceId}/${item.id}.yaml feedback -> ${verdict}`);
646
+ return 0;
647
+ }
648
+ function parseTriageStatsArgs(args) {
649
+ const out = {};
650
+ for (let i = 0; i < args.length; i++) {
651
+ const a = args[i];
652
+ if (a === "-h" || a === "--help") {
653
+ out.help = true;
654
+ continue;
655
+ }
656
+ if (a === "--since") {
657
+ const value = args[++i];
658
+ if (value === undefined)
659
+ throw new Error(`option ${a} requires a value`);
660
+ out.since = value;
661
+ continue;
662
+ }
663
+ if (a === "--source") {
664
+ const value = args[++i];
665
+ if (value === undefined)
666
+ throw new Error(`option ${a} requires a value`);
667
+ out.source = value;
668
+ continue;
669
+ }
670
+ if (a === "--json") {
671
+ out.json = true;
672
+ continue;
673
+ }
674
+ if (a?.startsWith("--")) {
675
+ throw new Error(`unknown option: ${a}`);
676
+ }
677
+ throw new Error(`unexpected positional argument: ${a}`);
678
+ }
679
+ return out;
680
+ }
681
+ function printStatsHelp(log) {
682
+ log("Usage: radar triage stats [--since <duration>] [--source <id>] [--json]");
683
+ log("");
684
+ log("Aggregate triage decisions and human feedback (ADR-0018 §W5, #242).");
685
+ log("Use after running `radar triage --apply` for some weeks; the output");
686
+ log("highlights precision / recall drift and suggests `triagePolicy.rules:`");
687
+ log("tweaks. See docs/user-guide.md `policy tuning workflow` for the");
688
+ log("recommended monthly loop.");
689
+ log("");
690
+ log("Options:");
691
+ log(" --since <duration> only count items triaged within the cutoff (e.g. 30d, 24h)");
692
+ log(" --source <id> limit stats to a single source (default: all sources)");
693
+ log(" --json emit machine-readable JSON instead of the text report");
694
+ }
695
+ /**
696
+ * Parse `Nd | Nh | Nm | Ns` into a `Date` cutoff. Returns `null` when the
697
+ * shape doesn't match — callers translate that into a CLI error. Mirrors
698
+ * `parseSinceCutoff` in items.ts so the two `--since` flags accept the same
699
+ * syntax.
700
+ */
701
+ function parseSinceCutoffForStats(value, now = new Date()) {
702
+ const match = value.match(/^(\d+)([smhd])$/);
703
+ if (!match)
704
+ return null;
705
+ const n = Number(match[1]);
706
+ const unit = match[2];
707
+ const ms = unit === "s"
708
+ ? n * 1000
709
+ : unit === "m"
710
+ ? n * 60_000
711
+ : unit === "h"
712
+ ? n * 3_600_000
713
+ : n * 86_400_000;
714
+ return new Date(now.getTime() - ms);
715
+ }
716
+ function sinceCutoffToDays(value) {
717
+ if (!value)
718
+ return null;
719
+ const match = value.match(/^(\d+)([smhd])$/);
720
+ if (!match)
721
+ return null;
722
+ const n = Number(match[1]);
723
+ const unit = match[2];
724
+ if (unit === "d")
725
+ return n;
726
+ if (unit === "h")
727
+ return Math.round((n / 24) * 10) / 10;
728
+ if (unit === "m")
729
+ return Math.round((n / 1440) * 10) / 10;
730
+ return Math.round((n / 86_400) * 10) / 10;
731
+ }
732
+ /**
733
+ * Derive the human-override breakdown for a single source's triaged items.
734
+ *
735
+ * Two pathways feed the same counters:
736
+ *
737
+ * 1. `triage.feedback[].correct === false` (research / digest / dismiss
738
+ * decisions) — an explicit signal the human disagreed. Each decision class
739
+ * only flips one direction (research → dismiss, dismiss → research), so a
740
+ * single boolean is enough.
741
+ * 2. `status` mutation downstream of `triaged_unsure` — the schema has no
742
+ * "unsure direction" feedback field; instead we infer from where the item
743
+ * landed (`researched` / `reviewed` → research; `dismissed` → dismiss).
744
+ * This is best-effort: items still sitting in `triaged_unsure` are
745
+ * excluded.
746
+ *
747
+ * The function only walks items whose `triage.decision` is set — items
748
+ * without a triage record (legacy or pending) are silently skipped.
749
+ */
750
+ function computeHumanOverrides(items) {
751
+ let triagedDismissToResearch = 0;
752
+ let triagedResearchToDismiss = 0;
753
+ let triagedUnsureToResearch = 0;
754
+ let triagedUnsureToDismiss = 0;
755
+ for (const item of items) {
756
+ const triage = item.triage;
757
+ if (!triage)
758
+ continue;
759
+ const latestFeedback = triage.feedback[triage.feedback.length - 1];
760
+ if (triage.decision === "research" || triage.decision === "digest") {
761
+ if (latestFeedback?.correct === false) {
762
+ triagedResearchToDismiss += 1;
763
+ }
764
+ }
765
+ else if (triage.decision === "dismiss") {
766
+ if (latestFeedback?.correct === false) {
767
+ triagedDismissToResearch += 1;
768
+ }
769
+ }
770
+ else if (triage.decision === "unsure") {
771
+ // Two reads: explicit feedback (with --correct flagging an outcome) and
772
+ // status-derived inference. Status wins because the schema doesn't have
773
+ // a per-direction field for unsure.
774
+ if (item.status === "researched" || item.status === "reviewed") {
775
+ triagedUnsureToResearch += 1;
776
+ }
777
+ else if (item.status === "dismissed") {
778
+ triagedUnsureToDismiss += 1;
779
+ }
780
+ else if (item.status === "triaged_research") {
781
+ triagedUnsureToResearch += 1;
782
+ }
783
+ }
784
+ }
785
+ return {
786
+ triagedDismissToResearch,
787
+ triagedResearchToDismiss,
788
+ triagedUnsureToResearch,
789
+ triagedUnsureToDismiss,
790
+ };
791
+ }
792
+ /**
793
+ * Heuristic policy-tuning hints (ADR-0018 §W5, #242).
794
+ *
795
+ * Triggered by 3 thresholds:
796
+ *
797
+ * - 3+ false negatives → recommend reviewing dismiss criteria, prefixing
798
+ * common `matchedKeywords` from the offending items so the user knows
799
+ * *which* dismissed items the agent missed.
800
+ * - 3+ false positives → recommend tightening research criteria with the
801
+ * same keyword extraction pattern (different message).
802
+ * - 5+ unsure decisions → recommend lowering `confidenceThreshold` or
803
+ * spelling out unsure cases in `rules:`.
804
+ *
805
+ * Below the thresholds we stay silent — surfacing 1-event "trends" would
806
+ * train users to ignore the section.
807
+ */
808
+ function buildSuggestions(items, overrides) {
809
+ const suggestions = [];
810
+ const falseNegativeItems = items.filter((i) => i.triage?.decision === "dismiss" &&
811
+ i.triage.feedback[i.triage.feedback.length - 1]?.correct === false);
812
+ const falsePositiveItems = items.filter((i) => (i.triage?.decision === "research" || i.triage?.decision === "digest") &&
813
+ i.triage.feedback[i.triage.feedback.length - 1]?.correct === false);
814
+ if (falseNegativeItems.length >= 3) {
815
+ const hint = extractTopKeywordHint(falseNegativeItems);
816
+ suggestions.push(`${falseNegativeItems.length} false negatives — review dismiss criteria${hint ? ` for ${hint} topics` : ""}`);
817
+ }
818
+ if (falsePositiveItems.length >= 3) {
819
+ const hint = extractTopKeywordHint(falsePositiveItems);
820
+ suggestions.push(`${falsePositiveItems.length} false positives — tighten research criteria${hint ? ` for ${hint} topics` : ""}`);
821
+ }
822
+ const unsureCount = overrides.triagedUnsureToResearch +
823
+ overrides.triagedUnsureToDismiss +
824
+ items.filter((i) => i.triage?.decision === "unsure" && i.status === "triaged_unsure").length;
825
+ if (unsureCount >= 5) {
826
+ suggestions.push(`${unsureCount} unsure decisions — lower confidenceThreshold or add "判断困難なら ..." clause to rules`);
827
+ }
828
+ return suggestions;
829
+ }
830
+ /**
831
+ * Extract the top 1-3 most common `matchedKeywords` (or title-derived words
832
+ * when keywords are absent) from a set of items, joined as ` / `. Used to
833
+ * give the suggestion a concrete "what to look at" hook without dumping
834
+ * every keyword in the source.
835
+ */
836
+ function extractTopKeywordHint(items) {
837
+ const counts = new Map();
838
+ for (const item of items) {
839
+ if (item.matchedKeywords.length > 0) {
840
+ for (const kw of item.matchedKeywords) {
841
+ counts.set(kw, (counts.get(kw) ?? 0) + 1);
842
+ }
843
+ }
844
+ else {
845
+ // Fall back to title tokens (best-effort). We only consider tokens of
846
+ // length >= 4 to skip prepositions / particles.
847
+ for (const token of item.title.split(/[\s/,.;:!?()[\]【】「」、。]+/)) {
848
+ if (token.length >= 4)
849
+ counts.set(token, (counts.get(token) ?? 0) + 1);
850
+ }
851
+ }
852
+ }
853
+ if (counts.size === 0)
854
+ return "";
855
+ const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
856
+ const top = sorted.slice(0, 3).map(([k]) => k);
857
+ return top.join(" / ");
858
+ }
859
+ /**
860
+ * Aggregate stats per source group. Pure: takes items + a per-source policy
861
+ * lookup, returns a sorted array of `PerSourceStats`. The CLI layer wires
862
+ * disk I/O around it.
863
+ */
864
+ function aggregatePerSource(items, policyMeta, sinceCutoff) {
865
+ const grouped = new Map();
866
+ for (const item of items) {
867
+ if (!item.triage)
868
+ continue;
869
+ if (sinceCutoff) {
870
+ const triagedAt = new Date(item.triage.triagedAt);
871
+ if (Number.isNaN(triagedAt.getTime()) || triagedAt < sinceCutoff)
872
+ continue;
873
+ }
874
+ const arr = grouped.get(item.sourceId);
875
+ if (arr)
876
+ arr.push(item);
877
+ else
878
+ grouped.set(item.sourceId, [item]);
879
+ }
880
+ const out = [];
881
+ for (const [sourceId, group] of [...grouped.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
882
+ const byDecision = { research: 0, digest: 0, dismiss: 0, unsure: 0 };
883
+ const groups = new Set();
884
+ const agentCounts = new Map();
885
+ for (const item of group) {
886
+ const triage = item.triage;
887
+ if (!triage)
888
+ continue;
889
+ byDecision[triage.decision] += 1;
890
+ if (triage.decision === "digest" && triage.group) {
891
+ groups.add(triage.group);
892
+ }
893
+ agentCounts.set(triage.agent, (agentCounts.get(triage.agent) ?? 0) + 1);
894
+ }
895
+ const overrides = computeHumanOverrides(group);
896
+ const suggestions = buildSuggestions(group, overrides);
897
+ const dominantAgent = [...agentCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ??
898
+ null;
899
+ const meta = policyMeta.get(sourceId);
900
+ out.push({
901
+ source: sourceId,
902
+ total: group.length,
903
+ byDecision,
904
+ digestGroups: groups.size,
905
+ humanOverrides: overrides,
906
+ agent: dominantAgent,
907
+ policyPath: meta?.path ?? null,
908
+ policyLastEditedDaysAgo: meta?.lastEditedDaysAgo ?? null,
909
+ suggestions,
910
+ });
911
+ }
912
+ return out;
913
+ }
914
+ function formatPercent(n, total) {
915
+ if (total === 0)
916
+ return "0.0%";
917
+ return `${((n / total) * 100).toFixed(1)}%`;
918
+ }
919
+ function pad(value, width) {
920
+ return value.length >= width ? value : `${value}${" ".repeat(width - value.length)}`;
921
+ }
922
+ function renderStatsBlock(stat, sinceDays) {
923
+ const lines = [];
924
+ const heading = sinceDays
925
+ ? `[${stat.source}] triage stats (last ${sinceDays} day${sinceDays === 1 ? "" : "s"})`
926
+ : `[${stat.source}] triage stats`;
927
+ lines.push(heading);
928
+ lines.push(` total triaged: ${stat.total}`);
929
+ lines.push(` research: ${stat.byDecision.research} (${formatPercent(stat.byDecision.research, stat.total)})`);
930
+ const digestSuffix = stat.byDecision.digest > 0
931
+ ? ` — ${stat.digestGroups} group${stat.digestGroups === 1 ? "" : "s"}`
932
+ : "";
933
+ lines.push(` digest: ${stat.byDecision.digest} (${formatPercent(stat.byDecision.digest, stat.total)})${digestSuffix}`);
934
+ lines.push(` dismiss: ${stat.byDecision.dismiss} (${formatPercent(stat.byDecision.dismiss, stat.total)})`);
935
+ lines.push(` unsure: ${stat.byDecision.unsure} (${formatPercent(stat.byDecision.unsure, stat.total)})`);
936
+ // Human overrides section — derived precision / recall from the feedback
937
+ // arrays. The "miss" percentages are computed against the relevant decision
938
+ // count (recall miss = false negatives / dismiss total, precision miss =
939
+ // false positives / (research + digest) total) so a high override count on
940
+ // a small decision class doesn't masquerade as a global problem.
941
+ const o = stat.humanOverrides;
942
+ const totalOverrides = o.triagedDismissToResearch +
943
+ o.triagedResearchToDismiss +
944
+ o.triagedUnsureToResearch +
945
+ o.triagedUnsureToDismiss;
946
+ if (totalOverrides > 0) {
947
+ lines.push("");
948
+ lines.push(" human overrides:");
949
+ if (o.triagedDismissToResearch > 0) {
950
+ const recallMiss = formatPercent(o.triagedDismissToResearch, stat.byDecision.dismiss);
951
+ lines.push(` triaged_dismiss → research: ${pad(String(o.triagedDismissToResearch), 2)} (false negatives, ${recallMiss} recall miss)`);
952
+ }
953
+ if (o.triagedResearchToDismiss > 0) {
954
+ const denom = stat.byDecision.research + stat.byDecision.digest;
955
+ const precisionMiss = formatPercent(o.triagedResearchToDismiss, denom);
956
+ lines.push(` triaged_research → dismiss: ${pad(String(o.triagedResearchToDismiss), 2)} (false positives, ${precisionMiss} precision miss)`);
957
+ }
958
+ if (o.triagedUnsureToResearch > 0) {
959
+ lines.push(` triaged_unsure → research: ${pad(String(o.triagedUnsureToResearch), 2)}`);
960
+ }
961
+ if (o.triagedUnsureToDismiss > 0) {
962
+ lines.push(` triaged_unsure → dismiss: ${pad(String(o.triagedUnsureToDismiss), 2)}`);
963
+ }
964
+ }
965
+ lines.push("");
966
+ if (stat.agent)
967
+ lines.push(` agent: ${stat.agent}`);
968
+ if (stat.policyPath) {
969
+ const ageSuffix = stat.policyLastEditedDaysAgo !== null
970
+ ? ` (last edited ${stat.policyLastEditedDaysAgo} day${stat.policyLastEditedDaysAgo === 1 ? "" : "s"} ago)`
971
+ : "";
972
+ lines.push(` policy: ${stat.policyPath}${ageSuffix}`);
973
+ }
974
+ if (stat.suggestions.length > 0) {
975
+ lines.push("");
976
+ lines.push(" Suggestions:");
977
+ for (const s of stat.suggestions) {
978
+ lines.push(` - ${s}`);
979
+ }
980
+ }
981
+ return lines;
982
+ }
983
+ /**
984
+ * Implementation of `radar triage stats`. Walks `items/` (optionally filtered
985
+ * by `--source` / `--since`), groups by source, and renders the per-source
986
+ * decision breakdown + override summary + heuristic suggestions.
987
+ *
988
+ * Pure failures (no items / no triaged items) return exit 0 with an
989
+ * informational message so cron-wrapped invocations don't trip alarms.
990
+ */
991
+ async function runTriageStats(args, options, io) {
992
+ const { log, error } = io;
993
+ const cwd = options.cwd ?? process.cwd();
994
+ const nowFn = options.now ?? defaultNow;
995
+ const now = new Date(nowFn());
996
+ let parsed;
997
+ try {
998
+ parsed = parseTriageStatsArgs(args);
999
+ }
1000
+ catch (e) {
1001
+ error(`triage stats: ${e instanceof Error ? e.message : String(e)}`);
1002
+ return 2;
1003
+ }
1004
+ if (parsed.help) {
1005
+ printStatsHelp(log);
1006
+ return 0;
1007
+ }
1008
+ let sinceCutoff = null;
1009
+ if (parsed.since) {
1010
+ sinceCutoff = parseSinceCutoffForStats(parsed.since, now);
1011
+ if (!sinceCutoff) {
1012
+ error(`triage stats: invalid --since '${parsed.since}' (expected Ns | Nm | Nh | Nd)`);
1013
+ return 2;
1014
+ }
1015
+ }
1016
+ const itemsDir = join(cwd, "items");
1017
+ if (!(await pathExists(itemsDir))) {
1018
+ if (parsed.json) {
1019
+ log(JSON.stringify({
1020
+ sinceDays: sinceCutoffToDays(parsed.since),
1021
+ generatedAt: now.toISOString(),
1022
+ perSource: [],
1023
+ }, null, 2));
1024
+ }
1025
+ else {
1026
+ log("triage stats: no items/ directory (run `radar init` first)");
1027
+ }
1028
+ return 0;
1029
+ }
1030
+ let items;
1031
+ try {
1032
+ items = await loadItems(itemsDir, parsed.source);
1033
+ }
1034
+ catch (e) {
1035
+ error(`triage stats: ${e instanceof Error ? e.message : String(e)}`);
1036
+ return 1;
1037
+ }
1038
+ // Build a per-source policy meta lookup so we can show `policy: sources/<id>.yaml
1039
+ // (last edited N days ago)`. Best-effort: missing files become null entries
1040
+ // and the rendering side omits the line.
1041
+ const sourcesDir = join(cwd, "sources");
1042
+ const policyMeta = new Map();
1043
+ if (await pathExists(sourcesDir)) {
1044
+ const sources = await loadSources(sourcesDir, () => {
1045
+ /* swallow load errors — stats is read-only and shouldn't fail loudly */
1046
+ });
1047
+ for (const source of sources) {
1048
+ if (!source.triagePolicy)
1049
+ continue;
1050
+ const relPath = `sources/${source.id}.yaml`;
1051
+ const abs = join(sourcesDir, `${source.id}.yaml`);
1052
+ let daysAgo = null;
1053
+ try {
1054
+ const st = await stat(abs);
1055
+ const diffMs = now.getTime() - st.mtime.getTime();
1056
+ daysAgo = Math.max(0, Math.floor(diffMs / 86_400_000));
1057
+ }
1058
+ catch {
1059
+ // File missing or stat failed — leave daysAgo null.
1060
+ }
1061
+ policyMeta.set(source.id, {
1062
+ agent: source.triagePolicy.agent,
1063
+ path: relPath,
1064
+ lastEditedDaysAgo: daysAgo,
1065
+ });
1066
+ }
1067
+ }
1068
+ const perSource = aggregatePerSource(items, policyMeta, sinceCutoff);
1069
+ const sinceDays = sinceCutoffToDays(parsed.since);
1070
+ if (parsed.json) {
1071
+ const payload = {
1072
+ sinceDays,
1073
+ generatedAt: now.toISOString(),
1074
+ perSource,
1075
+ };
1076
+ log(JSON.stringify(payload, null, 2));
1077
+ return 0;
1078
+ }
1079
+ if (perSource.length === 0) {
1080
+ log("triage stats: no triaged items match the filter (nothing to report)");
1081
+ return 0;
1082
+ }
1083
+ let first = true;
1084
+ for (const block of perSource) {
1085
+ if (!first)
1086
+ log("");
1087
+ first = false;
1088
+ for (const line of renderStatsBlock(block, sinceDays)) {
1089
+ log(line);
1090
+ }
1091
+ }
1092
+ return 0;
1093
+ }
1094
+ // Exported for unit tests (`tests/cli/triage-stats*.test.ts`) so the
1095
+ // aggregation / suggestion heuristics can be exercised without the surrounding
1096
+ // CLI plumbing. Not part of the public API.
1097
+ export const __test__ = {
1098
+ aggregatePerSource,
1099
+ buildSuggestions,
1100
+ computeHumanOverrides,
1101
+ extractTopKeywordHint,
1102
+ parseSinceCutoffForStats,
1103
+ renderStatsBlock,
1104
+ };
1105
+ export const triageCommand = {
1106
+ name: "triage",
1107
+ summary: "LLM-based triage of detected items (ADR-0018)",
1108
+ run: (args) => runTriage(args),
1109
+ };
1110
+ //# sourceMappingURL=triage.js.map