@ozzylabs/feedradar 0.1.9 → 0.2.1
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.ja.md +6 -3
- package/README.md +6 -3
- package/dist/agents/_boundary.d.ts +44 -0
- package/dist/agents/_boundary.d.ts.map +1 -1
- package/dist/agents/_boundary.js +80 -0
- package/dist/agents/_boundary.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +10 -3
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/routine/fire.d.ts +100 -0
- package/dist/cli/routine/fire.d.ts.map +1 -0
- package/dist/cli/routine/fire.js +196 -0
- package/dist/cli/routine/fire.js.map +1 -0
- package/dist/cli/routine/generate-pipeline.d.ts +162 -0
- package/dist/cli/routine/generate-pipeline.d.ts.map +1 -0
- package/dist/cli/routine/generate-pipeline.js +480 -0
- package/dist/cli/routine/generate-pipeline.js.map +1 -0
- package/dist/cli/routine/generate-watch.d.ts +174 -0
- package/dist/cli/routine/generate-watch.d.ts.map +1 -0
- package/dist/cli/routine/generate-watch.js +450 -0
- package/dist/cli/routine/generate-watch.js.map +1 -0
- package/dist/cli/routine.d.ts +32 -0
- package/dist/cli/routine.d.ts.map +1 -0
- package/dist/cli/routine.js +74 -0
- package/dist/cli/routine.js.map +1 -0
- package/dist/cli/triage.d.ts.map +1 -1
- package/dist/cli/triage.js +383 -78
- package/dist/cli/triage.js.map +1 -1
- package/dist/templates/agents/AGENTS.md +1 -1
- package/dist/templates/routines/pipeline.yaml.tmpl +222 -0
- package/dist/templates/routines/watch-daily.yaml +157 -0
- package/dist/templates/routines/watch.yaml.tmpl +151 -0
- package/package.json +1 -1
- package/dist/templates/routines/watch-daily.md +0 -42
package/dist/cli/triage.js
CHANGED
|
@@ -3,13 +3,17 @@ import { access, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { parse as parseYaml } from "yaml";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { renderTriagePayloadBlock } from "../agents/_boundary.js";
|
|
6
8
|
import { loadItems, saveItems } from "../core/items.js";
|
|
7
9
|
import { createProgressReporter } from "../core/progress.js";
|
|
8
10
|
import { statusForTriageDecision } from "../core/transitions.js";
|
|
9
|
-
import { triageItems } from "../core/triage/index.js";
|
|
11
|
+
import { buildTriagePrompt, parseTriageResponse, TriageResponseParseError, triageItems, } from "../core/triage/index.js";
|
|
10
12
|
import { loadSources } from "../core/watcher.js";
|
|
13
|
+
import { TriageDecisionValueSchema } from "../schemas/item.js";
|
|
11
14
|
import { AgentIdSchema } from "../schemas/research.js";
|
|
12
15
|
import { SourceTriagePolicySchema } from "../schemas/source.js";
|
|
16
|
+
import { resolveCommitPathInside } from "./_commit-path.js";
|
|
13
17
|
function splitCsv(value) {
|
|
14
18
|
return value
|
|
15
19
|
.split(",")
|
|
@@ -90,6 +94,17 @@ function parseTriageRunArgs(args) {
|
|
|
90
94
|
out.auditLog = value;
|
|
91
95
|
continue;
|
|
92
96
|
}
|
|
97
|
+
if (a === "--emit-payload") {
|
|
98
|
+
out.emitPayload = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (a === "--commit") {
|
|
102
|
+
const value = args[++i];
|
|
103
|
+
if (value === undefined)
|
|
104
|
+
throw new Error(`option ${a} requires a value`);
|
|
105
|
+
out.commit = value;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
93
108
|
if (a === "--verbose" || a === "-v") {
|
|
94
109
|
out.verbose = true;
|
|
95
110
|
continue;
|
|
@@ -144,6 +159,8 @@ function parseTriageFeedbackArgs(args) {
|
|
|
144
159
|
}
|
|
145
160
|
function printRunHelp(log) {
|
|
146
161
|
log("Usage: radar triage [--dry-run | --apply | --interactive] [options]");
|
|
162
|
+
log(" radar triage --emit-payload [--source <id>] [options]");
|
|
163
|
+
log(" radar triage --commit <path>");
|
|
147
164
|
log("");
|
|
148
165
|
log("Classify `detected` items using the configured per-source triage policy.");
|
|
149
166
|
log("");
|
|
@@ -159,6 +176,16 @@ function printRunHelp(log) {
|
|
|
159
176
|
log(" --policy <path> override per-source policy with a YAML file");
|
|
160
177
|
log(" --max-items N hard cap on items triaged in this run");
|
|
161
178
|
log(" --audit-log <path> append JSONL audit records of every triage call");
|
|
179
|
+
log(" --emit-payload Host-agent mode (ADR-0019): print the triage payload to");
|
|
180
|
+
log(" stdout and DO NOT spawn an agent. The interactive host");
|
|
181
|
+
log(" session classifies the items itself, writes a decisions");
|
|
182
|
+
log(" JSON, then finalizes with `radar triage --commit <path>`.");
|
|
183
|
+
log(" Requires a single source group: pass --source unless only");
|
|
184
|
+
log(" one source has detected items. Interactive/opt-in only —");
|
|
185
|
+
log(" CI/headless must use the default spawn path.");
|
|
186
|
+
log(" --commit <path> Host-agent mode (ADR-0019): validate a host-written");
|
|
187
|
+
log(" decisions JSON (under <cwd>/triage/) against the source's");
|
|
188
|
+
log(" policy + detected items and apply the status transitions.");
|
|
162
189
|
log(" -v, --verbose verbose progress output");
|
|
163
190
|
log(" -q, --quiet suppress progress output entirely");
|
|
164
191
|
log("");
|
|
@@ -335,87 +362,42 @@ async function promptConfirm(message) {
|
|
|
335
362
|
});
|
|
336
363
|
}
|
|
337
364
|
/**
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
|
|
341
|
-
|
|
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]`.
|
|
365
|
+
* Shared PRE block for the spawn run path and `--emit-payload`: load `detected`
|
|
366
|
+
* items, apply `--source` / `--filter-tags` / `--max-items`, group by source,
|
|
367
|
+
* resolve each group's policy (honoring `--policy` override) + triage agent
|
|
368
|
+
* (honoring `--triage-agent`, validated against `AgentIdSchema`).
|
|
363
369
|
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
370
|
+
* Extracted so the host-agent payload path computes the exact same group /
|
|
371
|
+
* policy / agent resolution the spawn path uses — keeping the two contracts
|
|
372
|
+
* from drifting (the spawn agent and the host session triage the same item set
|
|
373
|
+
* under the same policy).
|
|
374
|
+
*
|
|
375
|
+
* Returns `{ exitCode }` for the caller to propagate on error / no-op, or the
|
|
376
|
+
* resolved groups plus the full `detected` set (for the decision table) and the
|
|
377
|
+
* items dir.
|
|
368
378
|
*/
|
|
369
|
-
async function
|
|
379
|
+
async function prepareTriageGroups(parsed, cwd, io) {
|
|
370
380
|
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
381
|
const sourcesDir = join(cwd, "sources");
|
|
396
382
|
if (!(await pathExists(sourcesDir))) {
|
|
397
383
|
error("triage: no sources/ directory (run `radar init` first)");
|
|
398
|
-
return 1;
|
|
384
|
+
return { exitCode: 1 };
|
|
399
385
|
}
|
|
400
386
|
const sources = await loadSources(sourcesDir, error);
|
|
401
387
|
if (sources.length === 0) {
|
|
402
388
|
log("triage: no sources defined; nothing to triage");
|
|
403
|
-
return 0;
|
|
389
|
+
return { exitCode: 0 };
|
|
404
390
|
}
|
|
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
391
|
let policyOverride = null;
|
|
409
392
|
if (parsed.policy) {
|
|
410
393
|
policyOverride = await loadPolicyOverride(parsed.policy, error);
|
|
411
394
|
if (!policyOverride)
|
|
412
|
-
return 2;
|
|
395
|
+
return { exitCode: 2 };
|
|
413
396
|
}
|
|
414
|
-
// 3. Load detected items, narrowing by `--source` and `--filter-tags`.
|
|
415
397
|
const itemsDir = join(cwd, "items");
|
|
416
398
|
if (!(await pathExists(itemsDir))) {
|
|
417
399
|
log("triage: no items/ directory; nothing to triage");
|
|
418
|
-
return 0;
|
|
400
|
+
return { exitCode: 0 };
|
|
419
401
|
}
|
|
420
402
|
let allItems;
|
|
421
403
|
try {
|
|
@@ -423,11 +405,10 @@ async function runTriageRun(args, options, io) {
|
|
|
423
405
|
}
|
|
424
406
|
catch (e) {
|
|
425
407
|
error(`triage: ${e instanceof Error ? e.message : String(e)}`);
|
|
426
|
-
return 1;
|
|
408
|
+
return { exitCode: 1 };
|
|
427
409
|
}
|
|
428
|
-
// ADR-0018 §W-B: triage only operates on `detected` items
|
|
429
|
-
//
|
|
430
|
-
// triage` is idempotent.
|
|
410
|
+
// ADR-0018 §W-B: triage only operates on `detected` items so re-running
|
|
411
|
+
// `radar triage` is idempotent.
|
|
431
412
|
let detected = allItems.filter((i) => i.status === "detected");
|
|
432
413
|
if (parsed.filterTags && parsed.filterTags.length > 0) {
|
|
433
414
|
const tags = new Set(parsed.filterTags);
|
|
@@ -435,15 +416,12 @@ async function runTriageRun(args, options, io) {
|
|
|
435
416
|
}
|
|
436
417
|
if (detected.length === 0) {
|
|
437
418
|
log("triage: no detected items match the filter (nothing to do)");
|
|
438
|
-
return 0;
|
|
419
|
+
return { exitCode: 0 };
|
|
439
420
|
}
|
|
440
421
|
if (parsed.maxItems !== undefined && detected.length > parsed.maxItems) {
|
|
441
422
|
warn(`triage: ${detected.length} detected item(s) exceed --max-items ${parsed.maxItems}; processing the first ${parsed.maxItems} only`);
|
|
442
423
|
detected = detected.slice(0, parsed.maxItems);
|
|
443
424
|
}
|
|
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
425
|
const sourcesById = new Map(sources.map((s) => [s.id, s]));
|
|
448
426
|
const grouped = new Map();
|
|
449
427
|
for (const item of detected) {
|
|
@@ -453,12 +431,7 @@ async function runTriageRun(args, options, io) {
|
|
|
453
431
|
else
|
|
454
432
|
grouped.set(item.sourceId, [item]);
|
|
455
433
|
}
|
|
456
|
-
|
|
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 = [];
|
|
434
|
+
const groups = [];
|
|
462
435
|
for (const [sourceId, groupItems] of grouped) {
|
|
463
436
|
const source = sourcesById.get(sourceId);
|
|
464
437
|
const policy = policyOverride ?? source?.triagePolicy;
|
|
@@ -468,12 +441,344 @@ async function runTriageRun(args, options, io) {
|
|
|
468
441
|
}
|
|
469
442
|
const triageAgent = parsed.triageAgent ?? policy.agent;
|
|
470
443
|
// Validate `--triage-agent` against `AgentIdSchema` before spending an
|
|
471
|
-
// agent call — typos like `gemnini-cli`
|
|
444
|
+
// agent call (or emitting a payload) — typos like `gemnini-cli` fail fast.
|
|
472
445
|
const validated = AgentIdSchema.safeParse(triageAgent);
|
|
473
446
|
if (!validated.success) {
|
|
474
447
|
error(`triage: --triage-agent '${triageAgent}' is not a valid agent id (claude-code | codex-cli | gemini-cli | copilot)`);
|
|
448
|
+
return { exitCode: 2 };
|
|
449
|
+
}
|
|
450
|
+
groups.push({ sourceId, items: groupItems, policy, triageAgent });
|
|
451
|
+
}
|
|
452
|
+
return { groups, detected, itemsDir };
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Decisions-file schema for `radar triage --commit` (#279 / ADR-0019).
|
|
456
|
+
*
|
|
457
|
+
* The host session writes a self-describing envelope: the triage `agent` id
|
|
458
|
+
* (stamped into each `TriageDecision.agent` + the `dismissedBy` origin), the
|
|
459
|
+
* `sourceId` the decisions belong to (so the CLI re-resolves the matching
|
|
460
|
+
* policy), and the `decisions` array — the same JSON the spawned triage agent
|
|
461
|
+
* emits on stdout (see `core/triage/prompt.ts` output schema). `itemIds` /
|
|
462
|
+
* `decisionsPath` from the payload's JSON fence are accepted but ignored on
|
|
463
|
+
* commit (the CLI re-derives the item set from disk), so the host can echo the
|
|
464
|
+
* whole payload fence back without breaking parse.
|
|
465
|
+
*/
|
|
466
|
+
const TriageDecisionsFileSchema = z.object({
|
|
467
|
+
agent: z.string().min(1),
|
|
468
|
+
sourceId: z.string().min(1),
|
|
469
|
+
decisions: z.array(z.object({
|
|
470
|
+
id: z.string().min(1),
|
|
471
|
+
decision: TriageDecisionValueSchema,
|
|
472
|
+
confidence: z.number().min(0).max(1),
|
|
473
|
+
reason: z.string().min(1),
|
|
474
|
+
group: z.string().min(1).optional(),
|
|
475
|
+
})),
|
|
476
|
+
});
|
|
477
|
+
/**
|
|
478
|
+
* Host-agent emit path (#279 / ADR-0019): run the same group / policy / agent
|
|
479
|
+
* resolution as the spawn path (`prepareTriageGroups`), then print the
|
|
480
|
+
* agent-neutral triage payload to stdout instead of spawning. The host session
|
|
481
|
+
* reads the payload, classifies the items itself, writes the decisions JSON,
|
|
482
|
+
* and finalizes via `radar triage --commit`.
|
|
483
|
+
*
|
|
484
|
+
* Constrained to a SINGLE source group: triage's commit contract re-resolves
|
|
485
|
+
* one source's policy, and the host writes one decisions file, so a multi-source
|
|
486
|
+
* emit would need multiple files / commits. When more than one source has
|
|
487
|
+
* detected items the user must narrow with `--source` (mirrors the ADR-0020
|
|
488
|
+
* "one item set at a time" host-mode posture).
|
|
489
|
+
*/
|
|
490
|
+
async function runTriageEmitPayload(parsed, cwd, io) {
|
|
491
|
+
const { log, error } = io;
|
|
492
|
+
const prepared = await prepareTriageGroups(parsed, cwd, io);
|
|
493
|
+
if ("exitCode" in prepared)
|
|
494
|
+
return prepared.exitCode;
|
|
495
|
+
const { groups } = prepared;
|
|
496
|
+
if (groups.length === 0) {
|
|
497
|
+
log("triage: no items were triaged (all sources skipped)");
|
|
498
|
+
return 0;
|
|
499
|
+
}
|
|
500
|
+
if (groups.length > 1) {
|
|
501
|
+
error(`triage: --emit-payload requires a single source group, but ${groups.length} sources have detected items (${groups
|
|
502
|
+
.map((g) => g.sourceId)
|
|
503
|
+
.join(", ")}). Narrow with --source <id>.`);
|
|
504
|
+
return 2;
|
|
505
|
+
}
|
|
506
|
+
const group = groups[0];
|
|
507
|
+
const triagePrompt = buildTriagePrompt({ items: group.items, policy: group.policy });
|
|
508
|
+
const decisionsPath = join(cwd, "triage", `${group.sourceId}_decisions.json`);
|
|
509
|
+
log(renderTriagePayloadBlock({
|
|
510
|
+
agent: group.triageAgent,
|
|
511
|
+
sourceId: group.sourceId,
|
|
512
|
+
triagePrompt,
|
|
513
|
+
itemIds: group.items.map((i) => i.id),
|
|
514
|
+
decisionsPath,
|
|
515
|
+
}));
|
|
516
|
+
return 0;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Host-agent commit path (#279 / ADR-0019): finalize a decisions file the host
|
|
520
|
+
* session wrote out-of-band. The CLI keeps owning validation + the state
|
|
521
|
+
* machine (ADR-0019 finalize SSoT): it re-loads the source's `detected` items,
|
|
522
|
+
* re-resolves the policy, and runs the host-written decisions through the SAME
|
|
523
|
+
* `parseTriageResponse` validator the spawn path uses (hallucinated-id reject,
|
|
524
|
+
* duplicate reject, confidence-threshold + digest-without-group demotion) before
|
|
525
|
+
* applying `buildUpdatedItems` + `saveItems`.
|
|
526
|
+
*
|
|
527
|
+
* The path is constrained to `<cwd>/triage/` first (M3b enforced in code) so a
|
|
528
|
+
* host misled by injected content into committing an arbitrary path is rejected
|
|
529
|
+
* at the CLI boundary.
|
|
530
|
+
*/
|
|
531
|
+
async function runTriageCommit(parsed, commitPath, cwd, options, io) {
|
|
532
|
+
const { log, warn, error } = io;
|
|
533
|
+
const now = options.now ?? defaultNow;
|
|
534
|
+
const guard = await resolveCommitPathInside(cwd, "triage", commitPath);
|
|
535
|
+
if ("error" in guard) {
|
|
536
|
+
error(`triage: ${guard.error}`);
|
|
537
|
+
return 2;
|
|
538
|
+
}
|
|
539
|
+
const resolved = guard.resolved;
|
|
540
|
+
if (!(await pathExists(resolved))) {
|
|
541
|
+
error(`triage: decisions file not found: ${resolved}`);
|
|
542
|
+
return 1;
|
|
543
|
+
}
|
|
544
|
+
let raw;
|
|
545
|
+
try {
|
|
546
|
+
raw = await readFile(resolved, "utf8");
|
|
547
|
+
}
|
|
548
|
+
catch (e) {
|
|
549
|
+
error(`triage: failed to read decisions file: ${e instanceof Error ? e.message : String(e)}`);
|
|
550
|
+
return 1;
|
|
551
|
+
}
|
|
552
|
+
let parsedJson;
|
|
553
|
+
try {
|
|
554
|
+
parsedJson = JSON.parse(raw);
|
|
555
|
+
}
|
|
556
|
+
catch (e) {
|
|
557
|
+
error(`triage: decisions file is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
558
|
+
return 1;
|
|
559
|
+
}
|
|
560
|
+
const fileResult = TriageDecisionsFileSchema.safeParse(parsedJson);
|
|
561
|
+
if (!fileResult.success) {
|
|
562
|
+
error("triage: decisions file does not match the expected shape:");
|
|
563
|
+
for (const issue of fileResult.error.issues) {
|
|
564
|
+
error(` - ${issue.path.join(".") || "<root>"}: ${issue.message}`);
|
|
565
|
+
}
|
|
566
|
+
return 1;
|
|
567
|
+
}
|
|
568
|
+
const file = fileResult.data;
|
|
569
|
+
// The triage agent must be a valid adapter id (it drives `dismissedBy` +
|
|
570
|
+
// `triage.agent`). Reject upfront rather than persisting a bogus origin.
|
|
571
|
+
const agentValid = AgentIdSchema.safeParse(file.agent);
|
|
572
|
+
if (!agentValid.success) {
|
|
573
|
+
error(`triage: decisions file agent '${file.agent}' is not a valid agent id (claude-code | codex-cli | gemini-cli | copilot)`);
|
|
574
|
+
return 1;
|
|
575
|
+
}
|
|
576
|
+
const triageAgent = file.agent;
|
|
577
|
+
// Re-resolve the source's policy. `--policy` override wins (parity with the
|
|
578
|
+
// run path); otherwise the per-source `triagePolicy`. The policy drives the
|
|
579
|
+
// confidence-threshold demotion the CLI re-applies below, so committing
|
|
580
|
+
// without a resolvable policy is rejected rather than guessed.
|
|
581
|
+
let policyOverride = null;
|
|
582
|
+
if (parsed.policy) {
|
|
583
|
+
policyOverride = await loadPolicyOverride(parsed.policy, error);
|
|
584
|
+
if (!policyOverride)
|
|
475
585
|
return 2;
|
|
586
|
+
}
|
|
587
|
+
const sourcesDir = join(cwd, "sources");
|
|
588
|
+
if (!policyOverride && !(await pathExists(sourcesDir))) {
|
|
589
|
+
error("triage: no sources/ directory (run `radar init` first)");
|
|
590
|
+
return 1;
|
|
591
|
+
}
|
|
592
|
+
let policy = policyOverride;
|
|
593
|
+
if (!policy) {
|
|
594
|
+
const sources = await loadSources(sourcesDir, error);
|
|
595
|
+
const source = sources.find((s) => s.id === file.sourceId);
|
|
596
|
+
if (!source) {
|
|
597
|
+
error(`triage: decisions file references unknown source '${file.sourceId}'`);
|
|
598
|
+
return 1;
|
|
476
599
|
}
|
|
600
|
+
if (!source.triagePolicy) {
|
|
601
|
+
error(`triage: source '${file.sourceId}' has no triagePolicy (cannot validate decisions; pass --policy <path>)`);
|
|
602
|
+
return 1;
|
|
603
|
+
}
|
|
604
|
+
policy = source.triagePolicy;
|
|
605
|
+
}
|
|
606
|
+
// Re-load the source's `detected` items from disk: the decisions file is NOT
|
|
607
|
+
// trusted to enumerate the item set (a host misled by injected content could
|
|
608
|
+
// omit or invent ids). `parseTriageResponse` then rejects hallucinated ids
|
|
609
|
+
// and the coverage check fills omitted items with `unsure` — exactly the
|
|
610
|
+
// spawn path's behavior.
|
|
611
|
+
const itemsDir = join(cwd, "items");
|
|
612
|
+
if (!(await pathExists(itemsDir))) {
|
|
613
|
+
error("triage: no items/ directory; nothing to commit");
|
|
614
|
+
return 1;
|
|
615
|
+
}
|
|
616
|
+
let allItems;
|
|
617
|
+
try {
|
|
618
|
+
allItems = await loadItems(itemsDir, file.sourceId);
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
error(`triage: ${e instanceof Error ? e.message : String(e)}`);
|
|
622
|
+
return 1;
|
|
623
|
+
}
|
|
624
|
+
const detected = allItems.filter((i) => i.status === "detected");
|
|
625
|
+
if (detected.length === 0) {
|
|
626
|
+
error(`triage: no detected items remain for source '${file.sourceId}' (already triaged, or wrong source?)`);
|
|
627
|
+
return 1;
|
|
628
|
+
}
|
|
629
|
+
// Re-validate through the SAME parser the spawn path runs. We feed the raw
|
|
630
|
+
// decisions array as JSON so `parseTriageResponse` applies its full rule set
|
|
631
|
+
// (schema, hallucinated-id reject, duplicate reject, confidence/digest
|
|
632
|
+
// demotion) — keeping validation a single source of truth (ADR-0019).
|
|
633
|
+
const triagedAt = now();
|
|
634
|
+
let parsedResponse;
|
|
635
|
+
try {
|
|
636
|
+
parsedResponse = parseTriageResponse(JSON.stringify(file.decisions), detected, policy);
|
|
637
|
+
}
|
|
638
|
+
catch (e) {
|
|
639
|
+
const message = e instanceof TriageResponseParseError
|
|
640
|
+
? e.message
|
|
641
|
+
: e instanceof Error
|
|
642
|
+
? e.message
|
|
643
|
+
: String(e);
|
|
644
|
+
error(`triage: decisions validation failed: ${message}`);
|
|
645
|
+
return 1;
|
|
646
|
+
}
|
|
647
|
+
for (const w of parsedResponse.warnings)
|
|
648
|
+
warn(`triage: ${w}`);
|
|
649
|
+
// Fill omitted items with an `unsure` fallback so every detected item gets a
|
|
650
|
+
// decision (mirrors `triageItems`'s coverage invariant). The CLI owns the
|
|
651
|
+
// transition for the whole detected set, not just the ids the host returned.
|
|
652
|
+
const decisions = new Map();
|
|
653
|
+
for (const item of detected) {
|
|
654
|
+
const entry = parsedResponse.entries.get(item.id);
|
|
655
|
+
if (entry === undefined) {
|
|
656
|
+
warn(`triage: item '${item.id}' was not classified by the host; recording as unsure`);
|
|
657
|
+
decisions.set(item.id, {
|
|
658
|
+
decision: "unsure",
|
|
659
|
+
confidence: 0,
|
|
660
|
+
reason: "host-omitted",
|
|
661
|
+
agent: triageAgent,
|
|
662
|
+
triagedAt,
|
|
663
|
+
feedback: [],
|
|
664
|
+
});
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
decisions.set(item.id, {
|
|
668
|
+
decision: entry.decision,
|
|
669
|
+
confidence: entry.confidence,
|
|
670
|
+
reason: entry.reason,
|
|
671
|
+
group: entry.group,
|
|
672
|
+
agent: triageAgent,
|
|
673
|
+
triagedAt,
|
|
674
|
+
feedback: [],
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
const updated = buildUpdatedItems(detected, decisions, triageAgent);
|
|
678
|
+
try {
|
|
679
|
+
await saveItems(itemsDir, updated);
|
|
680
|
+
}
|
|
681
|
+
catch (e) {
|
|
682
|
+
error(`triage: failed to write items: ${e instanceof Error ? e.message : String(e)}`);
|
|
683
|
+
return 1;
|
|
684
|
+
}
|
|
685
|
+
log(`triage: committed ${updated.length} decision(s) for source '${file.sourceId}'`);
|
|
686
|
+
for (const row of formatDecisionTable(detected, decisions))
|
|
687
|
+
log(row);
|
|
688
|
+
return 0;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Top-level dispatcher for `radar triage`. Routes to the feedback subcommand
|
|
692
|
+
* when the first positional is `feedback`, otherwise runs the triage flow.
|
|
693
|
+
*/
|
|
694
|
+
export async function runTriage(args, options = {}) {
|
|
695
|
+
const log = options.io?.log ?? ((m) => console.log(m));
|
|
696
|
+
const warn = options.io?.warn ?? ((m) => console.warn(m));
|
|
697
|
+
const error = options.io?.error ?? ((m) => console.error(m));
|
|
698
|
+
const [first, ...rest] = args;
|
|
699
|
+
if (first === "feedback") {
|
|
700
|
+
return runTriageFeedback(rest, options);
|
|
701
|
+
}
|
|
702
|
+
if (first === "stats") {
|
|
703
|
+
return runTriageStats(rest, options, { log, warn, error });
|
|
704
|
+
}
|
|
705
|
+
if (first === "help") {
|
|
706
|
+
printTriageHelp(log);
|
|
707
|
+
return 0;
|
|
708
|
+
}
|
|
709
|
+
// `--help` / `-h` flow through to the run-mode parser so the user sees
|
|
710
|
+
// the full option list (the `feedback` subcommand has its own help).
|
|
711
|
+
// Otherwise the entire args list is the run-mode flag set.
|
|
712
|
+
return runTriageRun(args, options, { log, warn, error });
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Implementation of `radar triage [--dry-run | --apply | --interactive]`.
|
|
716
|
+
*
|
|
717
|
+
* The function does the bookkeeping (parsing, source/item loading, mode
|
|
718
|
+
* dispatch, status transitions) but delegates the actual classification to
|
|
719
|
+
* `triageItems()` so the CLI tests can swap in `tests/helpers/triage-mock.ts`
|
|
720
|
+
* via `options.runner`.
|
|
721
|
+
*/
|
|
722
|
+
async function runTriageRun(args, options, io) {
|
|
723
|
+
const { log, warn, error } = io;
|
|
724
|
+
const cwd = options.cwd ?? process.cwd();
|
|
725
|
+
const now = options.now ?? defaultNow;
|
|
726
|
+
let parsed;
|
|
727
|
+
try {
|
|
728
|
+
parsed = parseTriageRunArgs(args);
|
|
729
|
+
}
|
|
730
|
+
catch (e) {
|
|
731
|
+
error(`triage: ${e instanceof Error ? e.message : String(e)}`);
|
|
732
|
+
return 2;
|
|
733
|
+
}
|
|
734
|
+
if (parsed.help) {
|
|
735
|
+
printRunHelp(log);
|
|
736
|
+
return 0;
|
|
737
|
+
}
|
|
738
|
+
// Host-agent commit (#279 / ADR-0019). Independent of the run modes: it takes
|
|
739
|
+
// a decisions-file <path> and re-validates against disk. Handled first since
|
|
740
|
+
// it must not be confused with `--dry-run` / `--apply` / `--interactive`.
|
|
741
|
+
if (parsed.commit !== undefined) {
|
|
742
|
+
if (parsed.mode) {
|
|
743
|
+
error("triage: --commit is incompatible with --dry-run / --apply / --interactive");
|
|
744
|
+
return 2;
|
|
745
|
+
}
|
|
746
|
+
if (parsed.emitPayload) {
|
|
747
|
+
error("triage: --commit is incompatible with --emit-payload");
|
|
748
|
+
return 2;
|
|
749
|
+
}
|
|
750
|
+
return runTriageCommit(parsed, parsed.commit, cwd, options, io);
|
|
751
|
+
}
|
|
752
|
+
// Host-agent emit (#279 / ADR-0019). Mutually exclusive with the apply / dry
|
|
753
|
+
// / interactive run modes — it prints a payload instead of running an agent.
|
|
754
|
+
if (parsed.emitPayload) {
|
|
755
|
+
if (parsed.mode) {
|
|
756
|
+
error("triage: --emit-payload is incompatible with --dry-run / --apply / --interactive");
|
|
757
|
+
return 2;
|
|
758
|
+
}
|
|
759
|
+
return runTriageEmitPayload(parsed, cwd, io);
|
|
760
|
+
}
|
|
761
|
+
const mode = parsed.mode ?? "dry-run";
|
|
762
|
+
// Progress reporter (#197 / ADR-0015). The triage CLI uses the spinner
|
|
763
|
+
// sparingly — one phase marker before the agent call and one after — so
|
|
764
|
+
// even verbose mode stays scannable. Real progress chunks (agent stdout
|
|
765
|
+
// passthrough) live inside the adapter and only surface when --verbose is
|
|
766
|
+
// set.
|
|
767
|
+
const level = parsed.quiet ? "quiet" : parsed.verbose ? "verbose" : "normal";
|
|
768
|
+
const reporter = createProgressReporter({ level });
|
|
769
|
+
// Shared PRE block: load + filter + group detected items and resolve each
|
|
770
|
+
// group's policy / agent (also used by `--emit-payload`).
|
|
771
|
+
const prepared = await prepareTriageGroups(parsed, cwd, io);
|
|
772
|
+
if ("exitCode" in prepared)
|
|
773
|
+
return prepared.exitCode;
|
|
774
|
+
const { groups, detected, itemsDir } = prepared;
|
|
775
|
+
// Run triageItems() per source group. Aggregate decisions + updated items
|
|
776
|
+
// across groups so a single dry-run / apply pass surfaces every decision in
|
|
777
|
+
// one operation.
|
|
778
|
+
const allUpdated = [];
|
|
779
|
+
const allDecisions = new Map();
|
|
780
|
+
const allErrors = [];
|
|
781
|
+
for (const { sourceId, items: groupItems, policy, triageAgent } of groups) {
|
|
477
782
|
reporter.phase(`Triaging ${groupItems.length} item(s) from source '${sourceId}' via ${triageAgent}`);
|
|
478
783
|
let result;
|
|
479
784
|
try {
|