@ozzylabs/feedradar 0.1.6 → 0.1.8

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 (106) hide show
  1. package/README.md +2 -1
  2. package/dist/agents/_boundary.d.ts +74 -1
  3. package/dist/agents/_boundary.d.ts.map +1 -1
  4. package/dist/agents/_boundary.js +152 -0
  5. package/dist/agents/_boundary.js.map +1 -1
  6. package/dist/claude-skills/dismiss/SKILL.md +18 -12
  7. package/dist/claude-skills/research/SKILL.md +21 -1
  8. package/dist/claude-skills/review/SKILL.md +23 -1
  9. package/dist/claude-skills/update/SKILL.md +24 -2
  10. package/dist/cli/_commit-path.d.ts +33 -0
  11. package/dist/cli/_commit-path.d.ts.map +1 -0
  12. package/dist/cli/_commit-path.js +43 -0
  13. package/dist/cli/_commit-path.js.map +1 -0
  14. package/dist/cli/dismiss.d.ts +38 -7
  15. package/dist/cli/dismiss.d.ts.map +1 -1
  16. package/dist/cli/dismiss.js +239 -54
  17. package/dist/cli/dismiss.js.map +1 -1
  18. package/dist/cli/index.d.ts.map +1 -1
  19. package/dist/cli/index.js +7 -1
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/cli/items.d.ts +44 -0
  22. package/dist/cli/items.d.ts.map +1 -0
  23. package/dist/cli/items.js +288 -0
  24. package/dist/cli/items.js.map +1 -0
  25. package/dist/cli/research.d.ts +21 -0
  26. package/dist/cli/research.d.ts.map +1 -1
  27. package/dist/cli/research.js +360 -54
  28. package/dist/cli/research.js.map +1 -1
  29. package/dist/cli/review.d.ts +23 -0
  30. package/dist/cli/review.d.ts.map +1 -1
  31. package/dist/cli/review.js +462 -2
  32. package/dist/cli/review.js.map +1 -1
  33. package/dist/cli/source.d.ts.map +1 -1
  34. package/dist/cli/source.js +18 -0
  35. package/dist/cli/source.js.map +1 -1
  36. package/dist/cli/triage.d.ts +136 -0
  37. package/dist/cli/triage.d.ts.map +1 -0
  38. package/dist/cli/triage.js +1110 -0
  39. package/dist/cli/triage.js.map +1 -0
  40. package/dist/cli/undismiss.d.ts +30 -0
  41. package/dist/cli/undismiss.d.ts.map +1 -0
  42. package/dist/cli/undismiss.js +133 -0
  43. package/dist/cli/undismiss.js.map +1 -0
  44. package/dist/cli/update.d.ts.map +1 -1
  45. package/dist/cli/update.js +429 -141
  46. package/dist/cli/update.js.map +1 -1
  47. package/dist/cli/workflow/generate-combined-with-triage.d.ts +163 -0
  48. package/dist/cli/workflow/generate-combined-with-triage.d.ts.map +1 -0
  49. package/dist/cli/workflow/generate-combined-with-triage.js +582 -0
  50. package/dist/cli/workflow/generate-combined-with-triage.js.map +1 -0
  51. package/dist/cli/workflow.d.ts +6 -5
  52. package/dist/cli/workflow.d.ts.map +1 -1
  53. package/dist/cli/workflow.js +13 -8
  54. package/dist/cli/workflow.js.map +1 -1
  55. package/dist/core/feeds/json-api.d.ts +5 -2
  56. package/dist/core/feeds/json-api.d.ts.map +1 -1
  57. package/dist/core/feeds/json-api.js +99 -13
  58. package/dist/core/feeds/json-api.js.map +1 -1
  59. package/dist/core/feeds/types.d.ts +26 -0
  60. package/dist/core/feeds/types.d.ts.map +1 -1
  61. package/dist/core/recipes.d.ts.map +1 -1
  62. package/dist/core/recipes.js +6 -0
  63. package/dist/core/recipes.js.map +1 -1
  64. package/dist/core/transitions.d.ts +30 -0
  65. package/dist/core/transitions.d.ts.map +1 -0
  66. package/dist/core/transitions.js +103 -0
  67. package/dist/core/transitions.js.map +1 -0
  68. package/dist/core/triage/adapter.d.ts +80 -0
  69. package/dist/core/triage/adapter.d.ts.map +1 -0
  70. package/dist/core/triage/adapter.js +128 -0
  71. package/dist/core/triage/adapter.js.map +1 -0
  72. package/dist/core/triage/index.d.ts +105 -0
  73. package/dist/core/triage/index.d.ts.map +1 -0
  74. package/dist/core/triage/index.js +246 -0
  75. package/dist/core/triage/index.js.map +1 -0
  76. package/dist/core/triage/prompt.d.ts +30 -0
  77. package/dist/core/triage/prompt.d.ts.map +1 -0
  78. package/dist/core/triage/prompt.js +157 -0
  79. package/dist/core/triage/prompt.js.map +1 -0
  80. package/dist/core/triage/response.d.ts +114 -0
  81. package/dist/core/triage/response.d.ts.map +1 -0
  82. package/dist/core/triage/response.js +188 -0
  83. package/dist/core/triage/response.js.map +1 -0
  84. package/dist/gemini-commands/research.toml +1 -1
  85. package/dist/gemini-commands/review.toml +1 -1
  86. package/dist/gemini-commands/update.toml +1 -1
  87. package/dist/recipes/aws-whats-new.yaml +36 -1
  88. package/dist/recipes/dev-to.yaml +24 -0
  89. package/dist/schemas/item.d.ts +151 -5
  90. package/dist/schemas/item.d.ts.map +1 -1
  91. package/dist/schemas/item.js +164 -4
  92. package/dist/schemas/item.js.map +1 -1
  93. package/dist/schemas/recipe.d.ts +11 -1
  94. package/dist/schemas/recipe.d.ts.map +1 -1
  95. package/dist/schemas/recipe.js +10 -1
  96. package/dist/schemas/recipe.js.map +1 -1
  97. package/dist/schemas/source.d.ts +65 -4
  98. package/dist/schemas/source.d.ts.map +1 -1
  99. package/dist/schemas/source.js +65 -3
  100. package/dist/schemas/source.js.map +1 -1
  101. package/dist/skills/research/SKILL.md +57 -1
  102. package/dist/skills/review/SKILL.md +65 -1
  103. package/dist/skills/update/SKILL.md +54 -1
  104. package/dist/templates/agents/AGENTS.md +30 -0
  105. package/dist/templates/workflows/combined-with-triage.template.yaml.tmpl +132 -0
  106. package/package.json +1 -1
@@ -17,11 +17,14 @@ const matterOptions = {
17
17
  },
18
18
  },
19
19
  };
20
+ import { renderResearchPayloadBlock } from "../agents/_boundary.js";
20
21
  import { getAgentAdapter } from "../agents/index.js";
21
22
  import { getDefaultAgent, loadRadarConfig, RadarConfigError } from "../core/config.js";
22
23
  import { loadItems, saveItems } from "../core/items.js";
23
24
  import { loadTemplate } from "../core/templates.js";
24
- import { AgentIdSchema, ItemStatusSchema, ResearchFrontmatterSchema } from "../schemas/index.js";
25
+ import { isValidTransition } from "../core/transitions.js";
26
+ import { AgentIdSchema, ResearchFrontmatterSchema } from "../schemas/index.js";
27
+ import { resolveCommitPathInside } from "./_commit-path.js";
25
28
  import { buildAgentProgressCallback, buildReporter, ProgressFlagError, parseProgressFlags, pollOutputFileSize, } from "./_progress.js";
26
29
  /**
27
30
  * Default hard-cap for `radar research --batch`.
@@ -33,6 +36,27 @@ import { buildAgentProgressCallback, buildReporter, ProgressFlagError, parseProg
33
36
  * cap also applies when the YAML is hand-edited.
34
37
  */
35
38
  export const RESEARCH_BATCH_DEFAULT_MAX_ITEMS = 10;
39
+ /**
40
+ * Whitelist of `Item.status` values accepted by `radar research --batch
41
+ * --status <status>`.
42
+ *
43
+ * Constrained to the two "ready-to-research" statuses defined by the
44
+ * ADR-0008 / ADR-0018 state machine:
45
+ *
46
+ * - `detected` — legacy pre-triage path (`radar research` directly)
47
+ * - `triaged_research` — triage adapter promoted the item for research
48
+ * (ADR-0018 §W-B); workflow YAML generated by
49
+ * `radar workflow generate combined-with-triage`
50
+ * drives this branch (PR #249).
51
+ *
52
+ * Other statuses (`researched`, `reviewed`, `dismissed`, `triaged_digest`,
53
+ * `triaged_unsure`) are rejected with an explicit error rather than silently
54
+ * matching zero items, so a typo in scheduled YAML fails loud instead of
55
+ * decaying into a silent no-op (issue #250). `triaged_digest` requires
56
+ * `--digest` aggregation per group and is intentionally not a `--batch`
57
+ * single-item input; the workflow YAML walks groups explicitly.
58
+ */
59
+ export const RESEARCH_BATCH_ALLOWED_STATUSES = ["detected", "triaged_research"];
36
60
  function parseArgs(args) {
37
61
  const out = { itemIds: [] };
38
62
  for (let i = 0; i < args.length; i++) {
@@ -69,6 +93,18 @@ function parseArgs(args) {
69
93
  out.filterTags = args[++i];
70
94
  continue;
71
95
  }
96
+ if (a === "--triage-group") {
97
+ out.triageGroup = args[++i];
98
+ continue;
99
+ }
100
+ if (a === "--emit-payload") {
101
+ out.emitPayload = true;
102
+ continue;
103
+ }
104
+ if (a === "--commit") {
105
+ out.commit = args[++i];
106
+ continue;
107
+ }
72
108
  if (a?.startsWith("--")) {
73
109
  throw new Error(`unknown option: ${a}`);
74
110
  }
@@ -81,8 +117,10 @@ function parseArgs(args) {
81
117
  function printHelp(log) {
82
118
  log("Usage:");
83
119
  log(" radar research <item-id> [--agent <agent-id>] [--template <template-id>]");
84
- log(" radar research --digest <item-id> <item-id> ... [--agent <agent-id>] [--template <id>]");
120
+ log(" radar research --digest <item-id> <item-id> ... [--triage-group <group>] [--agent <agent-id>] [--template <id>]");
85
121
  log(` radar research --batch [--status <status>] [--max-items N] [--filter-tags <list>] [--agent <id>]`);
122
+ log(" radar research <item-id> --emit-payload [--digest <ids...>] [--template <id>]");
123
+ log(" radar research --commit <path>");
86
124
  log("");
87
125
  log("Arguments:");
88
126
  log(" <item-id> Item id (matches items/<sourceId>/<item-id>.yaml)");
@@ -93,15 +131,30 @@ function printHelp(log) {
93
131
  log(" --agent <agent-id> claude-code | codex-cli | gemini-cli | copilot (default: claude-code)");
94
132
  log(" --template <id> Template id under templates/ (default: default; digest: digest)");
95
133
  log(" --digest Bundle multiple items into a single digest report (ADR-0011)");
134
+ log(" --triage-group <group> Digest-mode slug source (ADR-0018 §W-H): name the digest");
135
+ log(" file after this triage.group instead of the matchedKeywords");
136
+ log(" frequency. Required to keep per-group digests unique on the");
137
+ log(" same day when a single-keyword source emits multiple groups");
138
+ log(" (#255). Falls back to the matchedKeywords slug when omitted.");
96
139
  log(" --batch Research every item matching --status (and --filter-tags)");
97
140
  log(" respecting the --max-items hard-cap (ADR-0014 D3a).");
98
- log(" --status <status> Batch-mode filter: detected | researched | reviewed | dismissed");
99
- log(" (default: detected).");
141
+ log(" --status <status> Batch-mode filter: detected | triaged_research");
142
+ log(" (default: detected). `triaged_research` consumes items");
143
+ log(" the triage adapter promoted (ADR-0018 §W-B) and");
144
+ log(" transitions them to `researched` on success.");
100
145
  log(` --max-items N Batch-mode hard-cap on processed items (default: ${RESEARCH_BATCH_DEFAULT_MAX_ITEMS}).`);
101
146
  log(" Excess items are dropped and announced via warn() so a runaway");
102
147
  log(" detection cannot blow the cap from inside a workflow.");
103
148
  log(" --filter-tags <list> Batch-mode comma-separated allow-list matched against");
104
149
  log(" each item's matchedKeywords (case-insensitive). Default: all.");
150
+ log(" --emit-payload Host-agent mode (ADR-0019): print the research payload to");
151
+ log(" stdout and DO NOT spawn an agent. The interactive host");
152
+ log(" session runs the SKILL procedure itself, then finalizes");
153
+ log(" with `radar research --commit <path>`. Interactive/opt-in");
154
+ log(" only — CI/headless must use the default spawn path.");
155
+ log(" --commit <path> Host-agent mode (ADR-0019): validate an externally-written");
156
+ log(" report (under <cwd>/research/) against ResearchFrontmatter-");
157
+ log(" Schema and apply the detected → researched transition.");
105
158
  log(" --verbose Stream the agent CLI's stdout/stderr in addition to phase markers.");
106
159
  log(" --quiet Suppress phase markers and spinner; print only the completion line.");
107
160
  log(" Equivalent to setting RADAR_NO_PROGRESS=1 (ADR-0015 D2).");
@@ -154,7 +207,26 @@ function clampSlug(s, max = 60) {
154
207
  const lastHyphen = cut.lastIndexOf("-");
155
208
  return lastHyphen > 0 ? cut.slice(0, lastHyphen) : cut;
156
209
  }
157
- function deriveDigestSlug(items) {
210
+ /**
211
+ * Derive the `<slug>` segment of a digest filename
212
+ * (`<date>_digest_<slug>_v1.md`, ADR-0011 §2).
213
+ *
214
+ * Resolution order (#255):
215
+ *
216
+ * 1. `triageGroup` override (explicit `--triage-group`, or — when omitted —
217
+ * a single `triage.group` shared by every item). The triage workflow
218
+ * digests one group per `radar research --digest` call, so this is the
219
+ * semantically correct discriminator. Crucially it keeps two same-day
220
+ * groups distinct even when a single-keyword source gives every item the
221
+ * same `matchedKeywords` (which the frequency slug below cannot).
222
+ * 2. `matchedKeywords` frequency (top-2). Back-compat path for callers that
223
+ * do not carry triage groups (e.g. ad-hoc `radar research --digest a b`).
224
+ * 3. Literal `"digest"` when neither yields anything.
225
+ */
226
+ function deriveDigestSlug(items, triageGroup) {
227
+ const groupSlug = resolveTriageGroupSlug(items, triageGroup);
228
+ if (groupSlug !== undefined)
229
+ return clampSlug(groupSlug);
158
230
  const freq = new Map();
159
231
  for (const item of items) {
160
232
  for (const kw of item.matchedKeywords) {
@@ -172,6 +244,32 @@ function deriveDigestSlug(items) {
172
244
  return "digest";
173
245
  return clampSlug(top.map(kebabCase).join("-"));
174
246
  }
247
+ /**
248
+ * Pick the triage-group slug for {@link deriveDigestSlug}, or `undefined` when
249
+ * no group applies (so the matchedKeywords fallback runs).
250
+ *
251
+ * An explicit `triageGroup` always wins. Otherwise we only use a group when
252
+ * every item agrees on a single non-empty `triage.group`: a mixed set has no
253
+ * unambiguous group to name the file after, so we defer to the keyword slug.
254
+ */
255
+ function resolveTriageGroupSlug(items, triageGroup) {
256
+ const explicit = triageGroup?.trim();
257
+ if (explicit !== undefined && explicit !== "") {
258
+ const slug = kebabCase(explicit);
259
+ return slug === "" ? undefined : slug;
260
+ }
261
+ const groups = new Set();
262
+ for (const item of items) {
263
+ const g = item.triage?.group?.trim();
264
+ if (g === undefined || g === "")
265
+ return undefined;
266
+ groups.add(g);
267
+ }
268
+ if (groups.size !== 1)
269
+ return undefined;
270
+ const slug = kebabCase([...groups][0]);
271
+ return slug === "" ? undefined : slug;
272
+ }
175
273
  async function findItem(cwd, itemId) {
176
274
  const itemsDir = join(cwd, "items");
177
275
  if (!(await pathExists(itemsDir)))
@@ -223,8 +321,18 @@ async function resolveAgent(cwd, rawAgent, error) {
223
321
  throw e;
224
322
  }
225
323
  }
226
- async function processResearchInvocation(params) {
227
- const { cwd, items, digest, agent, templateId, template, now, log, warn, error, progress } = params;
324
+ /**
325
+ * PRE block (shared by the spawn path and `--emit-payload`): emit injection
326
+ * warnings, derive the deterministic `outputPath`, and guard against
327
+ * overwriting an existing report. Returns the resolved path or an `exitCode`
328
+ * for the caller to propagate.
329
+ *
330
+ * Extracted from `processResearchInvocation` so the host-agent emit path
331
+ * (#254 / ADR-0019) computes the exact same `outputPath` and reuses the same
332
+ * collision backstop as the spawn path, without the model-call step.
333
+ */
334
+ async function prepareResearch(params) {
335
+ const { cwd, items, digest, templateId, now, triageGroup, warn, error, progress } = params;
228
336
  for (const item of items) {
229
337
  if (item.injectionFlags.length > 0) {
230
338
  warn(`research: item '${item.id}' has ${item.injectionFlags.length} injection flag(s): ${item.injectionFlags.join(", ")} (audit-only; use \`radar dismiss\` to skip)`);
@@ -233,7 +341,7 @@ async function processResearchInvocation(params) {
233
341
  let filename;
234
342
  if (digest) {
235
343
  const datePrefix = buildDigestDatePrefix(now);
236
- const slug = deriveDigestSlug(items);
344
+ const slug = deriveDigestSlug(items, triageGroup);
237
345
  filename = `${datePrefix}_digest_${slug}_v1.md`;
238
346
  }
239
347
  else {
@@ -245,11 +353,8 @@ async function processResearchInvocation(params) {
245
353
  const outputPath = join(cwd, "research", filename);
246
354
  if (await pathExists(outputPath)) {
247
355
  error(`research: ${outputPath} already exists (use \`radar update\` to re-research)`);
248
- return 1;
356
+ return { exitCode: 1 };
249
357
  }
250
- const itemDescription = digest
251
- ? `${items.length} items (${items.map((i) => i.id).join(", ")})`
252
- : `item '${items[0].id}'`;
253
358
  // Phase marker: items resolved (ADR-0015 D4 "Loaded <noun>"). One marker
254
359
  // per invocation regardless of digest cardinality so the progress stream
255
360
  // stays uniform between single / digest / batch modes.
@@ -257,42 +362,28 @@ async function processResearchInvocation(params) {
257
362
  // Phase marker: template resolved. Echoes the actual template id so a
258
363
  // user running `--template deep-dive` sees the value flow through.
259
364
  progress.phase(`Loaded template: ${templateId}.md`);
260
- log(`research: invoking ${agent} adapter for ${itemDescription} -> ${filename}`);
261
- const adapter = getAgentAdapter(agent);
262
- // Phase marker + spinner: agent spawn. We pair `phase("Spawning …")` with
263
- // `start("Agent running…")` so the marker is printed once for scrollback
264
- // and the spinner row carries the live `[mm:ss]` heartbeat + metrics.
265
- progress.phase(`Spawning ${agent}`, `cwd: ${cwd}`);
266
- progress.start("Agent running");
267
- const adapterStartedAt = Date.now();
268
- const polling = pollOutputFileSize({ path: outputPath, reporter: progress });
269
- let adapterExitCode = 0;
270
- try {
271
- await adapter.research({
272
- agent,
273
- templateId,
274
- templateBody: template.body,
275
- items,
276
- outputPath,
277
- cwd,
278
- onProgress: buildAgentProgressCallback(progress),
279
- });
280
- }
281
- catch (e) {
282
- adapterExitCode = 1;
283
- polling.stop();
284
- progress.fail("Agent failed", e instanceof Error ? e.message : String(e));
285
- error(`research: adapter failed: ${e instanceof Error ? e.message : String(e)}`);
286
- return 1;
287
- }
288
- finally {
289
- polling.stop();
290
- }
291
- if (adapterExitCode === 0) {
292
- progress.succeed(`Agent completed (exit ${adapterExitCode})`, Date.now() - adapterStartedAt);
293
- }
365
+ return { outputPath, filename };
366
+ }
367
+ /**
368
+ * POST block (shared by the spawn path and `--commit`): validate the written
369
+ * report against `ResearchFrontmatterSchema`, reset Phase-1-contract drift,
370
+ * and apply the `detected researched` status transition.
371
+ *
372
+ * This is the single source of truth for "finalize" so the spawn and
373
+ * host-agent paths (#254 / ADR-0019) cannot diverge on schema validation or
374
+ * the state-machine transition — the acceptance-condition that the CLI keeps
375
+ * owning both is satisfied structurally, not by a second copy of the logic.
376
+ *
377
+ * `items` is the transition target set. The spawn path passes the items it
378
+ * already resolved; `--commit` passes `undefined` and lets finalize derive the
379
+ * set from the report's `itemIds` frontmatter (reverse-lookup against
380
+ * `items/`), so a host-written digest with multiple `itemIds` transitions all
381
+ * of them.
382
+ */
383
+ async function finalizeResearch(params) {
384
+ const { cwd, outputPath, log, warn, error, progress } = params;
294
385
  if (!(await pathExists(outputPath))) {
295
- error(`research: adapter completed but did not write ${outputPath} (agent ignored the output path?)`);
386
+ error(`research: report was not written to ${outputPath} (did not write the output path?)`);
296
387
  return 1;
297
388
  }
298
389
  let body;
@@ -341,7 +432,49 @@ async function processResearchInvocation(params) {
341
432
  }, matterOptions);
342
433
  await writeFile(outputPath, rewritten, "utf8");
343
434
  }
344
- const updated = items.map((item) => item.status === "detected" ? { ...item, status: "researched" } : item);
435
+ // Resolve the transition target set. Spawn passes the items it already
436
+ // loaded; `--commit` derives them from the report's `itemIds` frontmatter so
437
+ // the host-written file is self-describing (digest reports transition every
438
+ // linked item).
439
+ let targetItems;
440
+ if (params.items !== undefined) {
441
+ targetItems = params.items;
442
+ }
443
+ else {
444
+ const all = await loadItems(join(cwd, "items"));
445
+ const byId = new Map(all.map((i) => [i.id, i]));
446
+ const resolved = [];
447
+ for (const id of fmResult.data.itemIds) {
448
+ const match = byId.get(id);
449
+ if (!match) {
450
+ error(`research: --commit report references unknown item id '${id}' (no items/*/${id}.yaml under ${cwd})`);
451
+ return 1;
452
+ }
453
+ resolved.push(match);
454
+ }
455
+ targetItems = resolved;
456
+ }
457
+ // Defer the "which prior statuses can transition into `researched`"
458
+ // decision to `isValidTransition()` (src/core/transitions.ts). That
459
+ // module enumerates the ADR-0008 / ADR-0018 state machine edges in one
460
+ // place; re-deriving the rule here would risk drift the next time the
461
+ // matrix changes (e.g. a future `triaged_*` status).
462
+ //
463
+ // Today this resolves to {detected, triaged_research, triaged_digest} →
464
+ // researched. Items in any other status (already researched, dismissed,
465
+ // terminal reviewed, etc.) are passed through unchanged: the batch
466
+ // filter upstream enforces input selection, and this guard is defense
467
+ // in depth for the single-item path where a user invokes
468
+ // `radar research <item-id>` against an item already past the
469
+ // pre-research stage.
470
+ const transitions = new Map();
471
+ const updated = targetItems.map((item) => {
472
+ if (isValidTransition(item.status, "researched")) {
473
+ transitions.set(item.id, item.status);
474
+ return { ...item, status: "researched" };
475
+ }
476
+ return item;
477
+ });
345
478
  try {
346
479
  await saveItems(join(cwd, "items"), updated);
347
480
  }
@@ -352,16 +485,131 @@ async function processResearchInvocation(params) {
352
485
  }
353
486
  log(`research: wrote ${outputPath}`);
354
487
  for (const item of updated) {
355
- if (item.status === "researched") {
488
+ const from = transitions.get(item.id);
489
+ if (from !== undefined && item.status === "researched") {
356
490
  // Phase marker: status transition. We emit one phase per item rather
357
491
  // than collapsing them so the digest case stays explicit about what
358
492
  // moved. The arrow uses U+2192 (→) per ADR-0015 D4 examples.
359
- progress.phase(`Status: detected → researched`, `items/${item.sourceId}/${item.id}.yaml`);
493
+ progress.phase(`Status: ${from} → researched`, `items/${item.sourceId}/${item.id}.yaml`);
360
494
  log(`research: items/${item.sourceId}/${item.id}.yaml status -> researched`);
361
495
  }
362
496
  }
363
497
  return 0;
364
498
  }
499
+ async function processResearchInvocation(params) {
500
+ const { cwd, items, digest, agent, templateId, template, now, triageGroup, log, warn, error, progress, } = params;
501
+ const prepared = await prepareResearch({
502
+ cwd,
503
+ items,
504
+ digest,
505
+ templateId,
506
+ now,
507
+ triageGroup,
508
+ warn,
509
+ error,
510
+ progress,
511
+ });
512
+ if ("exitCode" in prepared)
513
+ return prepared.exitCode;
514
+ const { outputPath, filename } = prepared;
515
+ const itemDescription = digest
516
+ ? `${items.length} items (${items.map((i) => i.id).join(", ")})`
517
+ : `item '${items[0].id}'`;
518
+ log(`research: invoking ${agent} adapter for ${itemDescription} -> ${filename}`);
519
+ const adapter = getAgentAdapter(agent);
520
+ // Phase marker + spinner: agent spawn. We pair `phase("Spawning …")` with
521
+ // `start("Agent running…")` so the marker is printed once for scrollback
522
+ // and the spinner row carries the live `[mm:ss]` heartbeat + metrics.
523
+ progress.phase(`Spawning ${agent}`, `cwd: ${cwd}`);
524
+ progress.start("Agent running");
525
+ const adapterStartedAt = Date.now();
526
+ const polling = pollOutputFileSize({ path: outputPath, reporter: progress });
527
+ let adapterExitCode = 0;
528
+ try {
529
+ await adapter.research({
530
+ agent,
531
+ templateId,
532
+ templateBody: template.body,
533
+ items,
534
+ outputPath,
535
+ cwd,
536
+ onProgress: buildAgentProgressCallback(progress),
537
+ });
538
+ }
539
+ catch (e) {
540
+ adapterExitCode = 1;
541
+ polling.stop();
542
+ progress.fail("Agent failed", e instanceof Error ? e.message : String(e));
543
+ error(`research: adapter failed: ${e instanceof Error ? e.message : String(e)}`);
544
+ return 1;
545
+ }
546
+ finally {
547
+ polling.stop();
548
+ }
549
+ if (adapterExitCode === 0) {
550
+ progress.succeed(`Agent completed (exit ${adapterExitCode})`, Date.now() - adapterStartedAt);
551
+ }
552
+ return finalizeResearch({ cwd, outputPath, items, log, warn, error, progress });
553
+ }
554
+ /**
555
+ * Host-agent emit path (#254 / ADR-0019): run the same PRE block as the spawn
556
+ * path (`prepareResearch`) to derive `outputPath` + collision guard, then print
557
+ * the agent-neutral payload to stdout instead of spawning. The host session
558
+ * reads the payload, executes the SKILL procedure itself, and finalizes via
559
+ * `radar research --commit`.
560
+ */
561
+ async function runResearchEmitPayload(params) {
562
+ const { cwd, items, digest, agent, templateId, template, now, triageGroup, log, warn, error, progress, } = params;
563
+ const prepared = await prepareResearch({
564
+ cwd,
565
+ items,
566
+ digest,
567
+ templateId,
568
+ now,
569
+ triageGroup,
570
+ warn,
571
+ error,
572
+ progress,
573
+ });
574
+ if ("exitCode" in prepared)
575
+ return prepared.exitCode;
576
+ log(renderResearchPayloadBlock({
577
+ agent,
578
+ templateId,
579
+ templateBody: template.body,
580
+ items,
581
+ outputPath: prepared.outputPath,
582
+ }));
583
+ return 0;
584
+ }
585
+ /**
586
+ * Host-agent commit path (#254 / ADR-0019): finalize a report the host session
587
+ * wrote out-of-band. Independent of agent / template / item resolution — the
588
+ * report is self-describing via its `itemIds` frontmatter, which
589
+ * `finalizeResearch` reverse-looks-up.
590
+ *
591
+ * Before finalize, the path is constrained to `<cwd>/research/` so a host that
592
+ * was misled by injected content into committing an arbitrary path (e.g.
593
+ * `../../etc/...`) is rejected at the CLI boundary (ADR-0009 M3b enforced in
594
+ * code, not just SKILL guidance).
595
+ */
596
+ async function runResearchCommit(params) {
597
+ const { cwd, commitPath, log, warn, error, progress } = params;
598
+ const guard = await resolveCommitPathInside(cwd, "research", commitPath);
599
+ if ("error" in guard) {
600
+ error(`research: ${guard.error}`);
601
+ return 2;
602
+ }
603
+ return finalizeResearch({
604
+ cwd,
605
+ outputPath: guard.resolved,
606
+ items: undefined,
607
+ log,
608
+ warn,
609
+ error,
610
+ progress,
611
+ });
612
+ }
365
613
  function parseMaxItems(raw, error) {
366
614
  if (raw === undefined)
367
615
  return RESEARCH_BATCH_DEFAULT_MAX_ITEMS;
@@ -395,13 +643,16 @@ async function runResearchBatch(parsed, cwd, log, warn, error, progress) {
395
643
  error("research: --batch is incompatible with --digest");
396
644
  return 2;
397
645
  }
646
+ if (parsed.triageGroup !== undefined) {
647
+ error("research: --batch is incompatible with --triage-group");
648
+ return 2;
649
+ }
398
650
  const rawStatus = parsed.status ?? "detected";
399
- const statusResult = ItemStatusSchema.safeParse(rawStatus);
400
- if (!statusResult.success) {
401
- error(`research: invalid --status '${rawStatus}' (expected: detected | dismissed | researched | reviewed)`);
651
+ if (!RESEARCH_BATCH_ALLOWED_STATUSES.includes(rawStatus)) {
652
+ error(`research: invalid --status '${rawStatus}' (expected: ${RESEARCH_BATCH_ALLOWED_STATUSES.join(" | ")})`);
402
653
  return 2;
403
654
  }
404
- const status = statusResult.data;
655
+ const status = rawStatus;
405
656
  const maxItems = parseMaxItems(parsed.maxItems, error);
406
657
  if (maxItems === null)
407
658
  return 2;
@@ -508,6 +759,37 @@ export async function runResearch(args, options = {}) {
508
759
  printHelp(log);
509
760
  return 0;
510
761
  }
762
+ // Host-agent commit (#254 / ADR-0019). Independent of agent / template /
763
+ // item resolution: the report is self-describing via its `itemIds`
764
+ // frontmatter. Handled before the other modes since it takes a path, not
765
+ // <item-id> arguments.
766
+ if (parsed.commit !== undefined) {
767
+ if (parsed.batch) {
768
+ error("research: --commit is incompatible with --batch");
769
+ return 2;
770
+ }
771
+ if (parsed.digest) {
772
+ error("research: --commit is incompatible with --digest");
773
+ return 2;
774
+ }
775
+ if (parsed.emitPayload) {
776
+ error("research: --commit is incompatible with --emit-payload");
777
+ return 2;
778
+ }
779
+ if (parsed.triageGroup !== undefined) {
780
+ error("research: --commit is incompatible with --triage-group");
781
+ return 2;
782
+ }
783
+ if (parsed.itemIds.length > 0) {
784
+ error(`research: --commit takes a <path>, not <item-id> arguments (got ${parsed.itemIds.length}: ${parsed.itemIds.join(", ")})`);
785
+ return 2;
786
+ }
787
+ return runResearchCommit({ cwd, commitPath: parsed.commit, log, warn, error, progress });
788
+ }
789
+ if (parsed.emitPayload && parsed.batch) {
790
+ error("research: --emit-payload is incompatible with --batch");
791
+ return 2;
792
+ }
511
793
  if (parsed.batch) {
512
794
  return runResearchBatch(parsed, cwd, log, warn, error, progress);
513
795
  }
@@ -523,6 +805,10 @@ export async function runResearch(args, options = {}) {
523
805
  error("research: --filter-tags requires --batch");
524
806
  return 2;
525
807
  }
808
+ if (parsed.triageGroup !== undefined && !parsed.digest) {
809
+ error("research: --triage-group requires --digest");
810
+ return 2;
811
+ }
526
812
  if (parsed.itemIds.length === 0) {
527
813
  error("research: missing <item-id>");
528
814
  printHelp(error);
@@ -572,6 +858,25 @@ export async function runResearch(args, options = {}) {
572
858
  error(`research: ${e instanceof Error ? e.message : String(e)}`);
573
859
  return 1;
574
860
  }
861
+ // Host-agent emit (#254 / ADR-0019): same item / template resolution as the
862
+ // spawn path, but print the payload instead of spawning. `--digest` is
863
+ // allowed (emits a digest payload).
864
+ if (parsed.emitPayload) {
865
+ return runResearchEmitPayload({
866
+ cwd,
867
+ items,
868
+ digest: parsed.digest ?? false,
869
+ agent,
870
+ templateId,
871
+ template,
872
+ now: new Date(),
873
+ triageGroup: parsed.triageGroup,
874
+ log,
875
+ warn,
876
+ error,
877
+ progress,
878
+ });
879
+ }
575
880
  return processResearchInvocation({
576
881
  cwd,
577
882
  items,
@@ -580,6 +885,7 @@ export async function runResearch(args, options = {}) {
580
885
  templateId,
581
886
  template,
582
887
  now: new Date(),
888
+ triageGroup: parsed.triageGroup,
583
889
  log,
584
890
  warn,
585
891
  error,