@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.
Files changed (38) hide show
  1. package/README.ja.md +6 -3
  2. package/README.md +6 -3
  3. package/dist/agents/_boundary.d.ts +44 -0
  4. package/dist/agents/_boundary.d.ts.map +1 -1
  5. package/dist/agents/_boundary.js +80 -0
  6. package/dist/agents/_boundary.js.map +1 -1
  7. package/dist/cli/index.d.ts.map +1 -1
  8. package/dist/cli/index.js +2 -0
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/cli/init.d.ts +1 -1
  11. package/dist/cli/init.d.ts.map +1 -1
  12. package/dist/cli/init.js +10 -3
  13. package/dist/cli/init.js.map +1 -1
  14. package/dist/cli/routine/fire.d.ts +100 -0
  15. package/dist/cli/routine/fire.d.ts.map +1 -0
  16. package/dist/cli/routine/fire.js +196 -0
  17. package/dist/cli/routine/fire.js.map +1 -0
  18. package/dist/cli/routine/generate-pipeline.d.ts +162 -0
  19. package/dist/cli/routine/generate-pipeline.d.ts.map +1 -0
  20. package/dist/cli/routine/generate-pipeline.js +480 -0
  21. package/dist/cli/routine/generate-pipeline.js.map +1 -0
  22. package/dist/cli/routine/generate-watch.d.ts +174 -0
  23. package/dist/cli/routine/generate-watch.d.ts.map +1 -0
  24. package/dist/cli/routine/generate-watch.js +450 -0
  25. package/dist/cli/routine/generate-watch.js.map +1 -0
  26. package/dist/cli/routine.d.ts +32 -0
  27. package/dist/cli/routine.d.ts.map +1 -0
  28. package/dist/cli/routine.js +74 -0
  29. package/dist/cli/routine.js.map +1 -0
  30. package/dist/cli/triage.d.ts.map +1 -1
  31. package/dist/cli/triage.js +383 -78
  32. package/dist/cli/triage.js.map +1 -1
  33. package/dist/templates/agents/AGENTS.md +1 -1
  34. package/dist/templates/routines/pipeline.yaml.tmpl +222 -0
  35. package/dist/templates/routines/watch-daily.yaml +157 -0
  36. package/dist/templates/routines/watch.yaml.tmpl +151 -0
  37. package/package.json +1 -1
  38. package/dist/templates/routines/watch-daily.md +0 -42
@@ -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
- * 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]`.
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
- * 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`.
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 runTriageRun(args, options, io) {
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. items already
429
- // triaged / researched / dismissed are excluded so re-running `radar
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
- // 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 = [];
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` should fail fast.
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 {