@ozzylabs/feedradar 0.1.5 → 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.
- package/README.md +1 -1
- package/dist/cli/dismiss.d.ts +2 -1
- package/dist/cli/dismiss.d.ts.map +1 -1
- package/dist/cli/dismiss.js +4 -1
- package/dist/cli/dismiss.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +7 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/items.d.ts +44 -0
- package/dist/cli/items.d.ts.map +1 -0
- package/dist/cli/items.js +288 -0
- package/dist/cli/items.js.map +1 -0
- package/dist/cli/research.d.ts +21 -0
- package/dist/cli/research.d.ts.map +1 -1
- package/dist/cli/research.js +54 -10
- package/dist/cli/research.js.map +1 -1
- package/dist/cli/review.d.ts +23 -0
- package/dist/cli/review.d.ts.map +1 -1
- package/dist/cli/review.js +293 -2
- package/dist/cli/review.js.map +1 -1
- package/dist/cli/source.d.ts.map +1 -1
- package/dist/cli/source.js +3 -0
- package/dist/cli/source.js.map +1 -1
- package/dist/cli/triage.d.ts +136 -0
- package/dist/cli/triage.d.ts.map +1 -0
- package/dist/cli/triage.js +1110 -0
- package/dist/cli/triage.js.map +1 -0
- package/dist/cli/undismiss.d.ts +30 -0
- package/dist/cli/undismiss.d.ts.map +1 -0
- package/dist/cli/undismiss.js +133 -0
- package/dist/cli/undismiss.js.map +1 -0
- package/dist/cli/watch.d.ts.map +1 -1
- package/dist/cli/watch.js +2 -0
- package/dist/cli/watch.js.map +1 -1
- package/dist/cli/workflow/generate-combined-with-triage.d.ts +115 -0
- package/dist/cli/workflow/generate-combined-with-triage.d.ts.map +1 -0
- package/dist/cli/workflow/generate-combined-with-triage.js +446 -0
- package/dist/cli/workflow/generate-combined-with-triage.js.map +1 -0
- package/dist/cli/workflow.d.ts +6 -5
- package/dist/cli/workflow.d.ts.map +1 -1
- package/dist/cli/workflow.js +13 -8
- package/dist/cli/workflow.js.map +1 -1
- package/dist/core/feeds/json-api.d.ts +26 -0
- package/dist/core/feeds/json-api.d.ts.map +1 -1
- package/dist/core/feeds/json-api.js +360 -223
- package/dist/core/feeds/json-api.js.map +1 -1
- package/dist/core/recipes.d.ts.map +1 -1
- package/dist/core/recipes.js +10 -0
- package/dist/core/recipes.js.map +1 -1
- package/dist/core/transitions.d.ts +30 -0
- package/dist/core/transitions.d.ts.map +1 -0
- package/dist/core/transitions.js +103 -0
- package/dist/core/transitions.js.map +1 -0
- package/dist/core/triage/adapter.d.ts +80 -0
- package/dist/core/triage/adapter.d.ts.map +1 -0
- package/dist/core/triage/adapter.js +128 -0
- package/dist/core/triage/adapter.js.map +1 -0
- package/dist/core/triage/index.d.ts +105 -0
- package/dist/core/triage/index.d.ts.map +1 -0
- package/dist/core/triage/index.js +246 -0
- package/dist/core/triage/index.js.map +1 -0
- package/dist/core/triage/prompt.d.ts +30 -0
- package/dist/core/triage/prompt.d.ts.map +1 -0
- package/dist/core/triage/prompt.js +157 -0
- package/dist/core/triage/prompt.js.map +1 -0
- package/dist/core/triage/response.d.ts +114 -0
- package/dist/core/triage/response.d.ts.map +1 -0
- package/dist/core/triage/response.js +188 -0
- package/dist/core/triage/response.js.map +1 -0
- package/dist/recipes/aws-whats-new.yaml +62 -7
- package/dist/recipes/dev-to.yaml +24 -0
- package/dist/schemas/item.d.ts +151 -5
- package/dist/schemas/item.d.ts.map +1 -1
- package/dist/schemas/item.js +164 -4
- package/dist/schemas/item.js.map +1 -1
- package/dist/schemas/recipe.d.ts +22 -0
- package/dist/schemas/recipe.d.ts.map +1 -1
- package/dist/schemas/recipe.js +13 -1
- package/dist/schemas/recipe.js.map +1 -1
- package/dist/schemas/source.d.ts +135 -0
- package/dist/schemas/source.d.ts.map +1 -1
- package/dist/schemas/source.js +138 -0
- package/dist/schemas/source.js.map +1 -1
- package/dist/templates/agents/AGENTS.md +36 -4
- package/dist/templates/workflows/combined-with-triage.template.yaml.tmpl +133 -0
- 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
|