@ozzylabs/feedradar 0.1.7 → 0.1.9

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 (72) hide show
  1. package/README.md +2 -1
  2. package/dist/agents/_boundary.d.ts +87 -1
  3. package/dist/agents/_boundary.d.ts.map +1 -1
  4. package/dist/agents/_boundary.js +197 -0
  5. package/dist/agents/_boundary.js.map +1 -1
  6. package/dist/agents/claude-code.d.ts.map +1 -1
  7. package/dist/agents/claude-code.js +33 -93
  8. package/dist/agents/claude-code.js.map +1 -1
  9. package/dist/agents/codex-cli.d.ts.map +1 -1
  10. package/dist/agents/codex-cli.js +34 -93
  11. package/dist/agents/codex-cli.js.map +1 -1
  12. package/dist/agents/copilot.d.ts.map +1 -1
  13. package/dist/agents/copilot.js +33 -93
  14. package/dist/agents/copilot.js.map +1 -1
  15. package/dist/agents/gemini-cli.d.ts.map +1 -1
  16. package/dist/agents/gemini-cli.js +33 -93
  17. package/dist/agents/gemini-cli.js.map +1 -1
  18. package/dist/claude-skills/dismiss/SKILL.md +18 -12
  19. package/dist/claude-skills/research/SKILL.md +21 -1
  20. package/dist/claude-skills/review/SKILL.md +23 -1
  21. package/dist/claude-skills/update/SKILL.md +24 -2
  22. package/dist/cli/_commit-path.d.ts +33 -0
  23. package/dist/cli/_commit-path.d.ts.map +1 -0
  24. package/dist/cli/_commit-path.js +43 -0
  25. package/dist/cli/_commit-path.js.map +1 -0
  26. package/dist/cli/dismiss.d.ts +38 -8
  27. package/dist/cli/dismiss.d.ts.map +1 -1
  28. package/dist/cli/dismiss.js +237 -55
  29. package/dist/cli/dismiss.js.map +1 -1
  30. package/dist/cli/research.d.ts.map +1 -1
  31. package/dist/cli/research.js +307 -45
  32. package/dist/cli/research.js.map +1 -1
  33. package/dist/cli/review.d.ts.map +1 -1
  34. package/dist/cli/review.js +169 -0
  35. package/dist/cli/review.js.map +1 -1
  36. package/dist/cli/source.d.ts.map +1 -1
  37. package/dist/cli/source.js +18 -0
  38. package/dist/cli/source.js.map +1 -1
  39. package/dist/cli/update.d.ts.map +1 -1
  40. package/dist/cli/update.js +429 -141
  41. package/dist/cli/update.js.map +1 -1
  42. package/dist/cli/workflow/generate-combined-with-triage.d.ts +49 -1
  43. package/dist/cli/workflow/generate-combined-with-triage.d.ts.map +1 -1
  44. package/dist/cli/workflow/generate-combined-with-triage.js +139 -3
  45. package/dist/cli/workflow/generate-combined-with-triage.js.map +1 -1
  46. package/dist/core/feeds/json-api.d.ts +5 -2
  47. package/dist/core/feeds/json-api.d.ts.map +1 -1
  48. package/dist/core/feeds/json-api.js +113 -13
  49. package/dist/core/feeds/json-api.js.map +1 -1
  50. package/dist/core/feeds/types.d.ts +40 -0
  51. package/dist/core/feeds/types.d.ts.map +1 -1
  52. package/dist/core/triage/adapter.d.ts +45 -0
  53. package/dist/core/triage/adapter.d.ts.map +1 -1
  54. package/dist/core/triage/adapter.js +50 -11
  55. package/dist/core/triage/adapter.js.map +1 -1
  56. package/dist/core/watcher.d.ts.map +1 -1
  57. package/dist/core/watcher.js +12 -2
  58. package/dist/core/watcher.js.map +1 -1
  59. package/dist/gemini-commands/research.toml +1 -1
  60. package/dist/gemini-commands/review.toml +1 -1
  61. package/dist/gemini-commands/update.toml +1 -1
  62. package/dist/recipes/aws-whats-new.yaml +7 -1
  63. package/dist/schemas/recipe.d.ts +1 -1
  64. package/dist/schemas/source.d.ts +22 -4
  65. package/dist/schemas/source.d.ts.map +1 -1
  66. package/dist/schemas/source.js +31 -3
  67. package/dist/schemas/source.js.map +1 -1
  68. package/dist/skills/research/SKILL.md +75 -8
  69. package/dist/skills/review/SKILL.md +79 -7
  70. package/dist/skills/update/SKILL.md +68 -7
  71. package/dist/templates/workflows/combined-with-triage.template.yaml.tmpl +29 -30
  72. package/package.json +1 -1
@@ -2,11 +2,13 @@ import { access, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import matter from "gray-matter";
4
4
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
5
+ import { renderUpdatePayloadBlock } from "../agents/_boundary.js";
5
6
  import { getAgentAdapter } from "../agents/index.js";
6
7
  import { getDefaultAgent, loadRadarConfig, RadarConfigError } from "../core/config.js";
7
8
  import { loadItems } from "../core/items.js";
8
9
  import { loadTemplate } from "../core/templates.js";
9
10
  import { AgentIdSchema, ResearchFrontmatterSchema } from "../schemas/index.js";
11
+ import { resolveCommitPathInside } from "./_commit-path.js";
10
12
  import { buildAgentProgressCallback, buildReporter, ProgressFlagError, parseProgressFlags, pollOutputFileSize, } from "./_progress.js";
11
13
  /**
12
14
  * gray-matter defaults to js-yaml which auto-converts ISO 8601 strings to
@@ -39,6 +41,14 @@ function parseArgs(args) {
39
41
  out.template = args[++i];
40
42
  continue;
41
43
  }
44
+ if (a === "--emit-payload") {
45
+ out.emitPayload = true;
46
+ continue;
47
+ }
48
+ if (a === "--commit") {
49
+ out.commit = args[++i];
50
+ continue;
51
+ }
42
52
  if (a?.startsWith("--")) {
43
53
  throw new Error(`unknown option: ${a}`);
44
54
  }
@@ -51,7 +61,10 @@ function parseArgs(args) {
51
61
  return out;
52
62
  }
53
63
  function printHelp(log) {
54
- log("Usage: radar update <research-id> [--agent <agent-id>] [--template <template-id>]");
64
+ log("Usage:");
65
+ log(" radar update <research-id> [--agent <agent-id>] [--template <template-id>]");
66
+ log(" radar update <research-id> --emit-payload [--template <id>]");
67
+ log(" radar update --commit <path>");
55
68
  log("");
56
69
  log("Arguments:");
57
70
  log(" <research-id> Research id (basename of research/<id>.md without .md)");
@@ -59,6 +72,15 @@ function printHelp(log) {
59
72
  log("Options:");
60
73
  log(" --agent <agent-id> claude-code | codex-cli | gemini-cli | copilot (default: claude-code)");
61
74
  log(" --template <id> Template id under templates/ (default: default)");
75
+ log(" --emit-payload Host-agent mode (ADR-0019): print the update payload to");
76
+ log(" stdout and DO NOT spawn an agent. The interactive host");
77
+ log(" session runs the SKILL procedure itself, then finalizes");
78
+ log(" with `radar update --commit <path>`. Interactive/opt-in");
79
+ log(" only — CI/headless must use the default spawn path.");
80
+ log(" --commit <path> Host-agent mode (ADR-0019): validate an externally-written");
81
+ log(" v+1 report (under <cwd>/research/) against ResearchFrontmatter-");
82
+ log(" Schema, assert the v+1 invariants against the `supersedes`");
83
+ log(" predecessor, and leave items.yaml untouched (ADR-0008).");
62
84
  log(" --verbose Stream the agent CLI's stdout/stderr in addition to phase markers.");
63
85
  log(" --quiet Suppress phase markers and spinner; print only the completion line.");
64
86
  log(" Equivalent to setting RADAR_NO_PROGRESS=1 (ADR-0015 D2).");
@@ -132,117 +154,67 @@ async function findItemsForResearch(cwd, itemIds) {
132
154
  return out;
133
155
  }
134
156
  /**
135
- * Implementation of `radar update <research-id>`.
157
+ * Resolve the effective agent honoring the priority chain
158
+ * (`explicit --agent > defaultResearchAgent > hard-coded claude-code`).
136
159
  *
137
- * High-level flow (Phase 5, [#41](https://github.com/ozzy-labs/feedradar/issues/41)):
138
- * 1. Parse + validate args (agent defaults to `claude-code`, template to `default`).
139
- * 2. Resolve `research/<research-id>.md` and parse its frontmatter.
140
- * 3. Compute the new id `<base>_v<n+1>` and refuse to overwrite an existing
141
- * `research/<new-id>.md` (idempotent re-runs are user-facing errors).
142
- * 4. Locate the linked items (read-only; their `status` is preserved per
143
- * ADR-0008 / skill-design.md §8.4).
144
- * 5. Reject `update` when any linked item is in `detected` / `dismissed`
145
- * (no v1 research exists, or the user opted out of researching it).
146
- * 6. Load `templates/<template-id>.md` (empty body when default is absent).
147
- * 7. Invoke the adapter; the agent writes the new file at `outputPath`.
148
- * 8. Re-read the new file, validate frontmatter against
149
- * `ResearchFrontmatterSchema`, and assert v+1 invariants:
150
- * - `id` matches the computed new id
151
- * - `itemIds` / `templateId` / `createdAt` preserved from v(N)
152
- * - `supersedes` equals the v(N) id
153
- * - `reviewedAt` / `reviewedBy` are `null` (review state does NOT carry
154
- * across versions per ADR-0003 / skill-design.md §8.3)
155
- * 9. **No items.yaml mutation** — `update` is intentionally inert wrt item
156
- * status. The v1 review event (if any) remains the authoritative record.
157
- *
158
- * Unlike `review`, `update` does not snapshot/restore: writing a new file is
159
- * additive, so a failed adapter just leaves a missing / partial new file that
160
- * the user can re-run after fixing the underlying issue. The predecessor file
161
- * is never opened for writing.
160
+ * `update` borrows the `research` default because it shares the research SKILL
161
+ * body (rewrite-and-supersede strategy; skill-design.md §8.2). Extracted from
162
+ * the spawn path so the host-agent emit path resolves the same agent without
163
+ * duplicating the priority chain.
162
164
  */
163
- export async function runUpdate(args, options = {}) {
164
- const cwd = options.cwd ?? process.cwd();
165
- const log = options.io?.log ?? ((m) => console.log(m));
166
- const warn = options.io?.warn ?? ((m) => console.warn(m));
167
- const error = options.io?.error ?? ((m) => console.error(m));
168
- // Two-stage argv parse (see `src/cli/research.ts` for the full rationale).
169
- let progressState;
170
- try {
171
- progressState = parseProgressFlags(args);
172
- }
173
- catch (e) {
174
- if (e instanceof ProgressFlagError) {
175
- error(`update: ${e.message}`);
176
- return 2;
177
- }
178
- throw e;
179
- }
180
- const progress = options.progress ?? buildReporter({ level: progressState.level });
181
- let parsed;
182
- try {
183
- parsed = parseArgs(progressState.rest);
184
- }
185
- catch (e) {
186
- error(`update: ${e instanceof Error ? e.message : String(e)}`);
187
- return 2;
188
- }
189
- if (parsed.help) {
190
- printHelp(log);
191
- return 0;
192
- }
193
- if (!parsed.researchId) {
194
- error("update: missing <research-id>");
195
- printHelp(error);
196
- return 2;
197
- }
198
- // Resolve the agent honoring the priority chain. We do not yet have a
199
- // `defaultUpdateAgent` field in `radar.config.yaml` (out of scope for #41
200
- // per the Issue body), so resolution is:
201
- // explicit --agent > defaultResearchAgent > hard-coded claude-code
202
- // We pick `research` as the borrowed default because `update` shares its
203
- // SKILL body (rewrite-and-supersede strategy reuses the research procedure;
204
- // skill-design.md §8.2). When `radar.config.yaml` grows a dedicated
205
- // `defaultUpdateAgent`, this fallback chain becomes a thin pass-through.
165
+ async function resolveUpdateAgent(cwd, rawAgent, error) {
206
166
  let explicitAgent;
207
- if (parsed.agent !== undefined) {
208
- const agentResult = AgentIdSchema.safeParse(parsed.agent);
167
+ if (rawAgent !== undefined) {
168
+ const agentResult = AgentIdSchema.safeParse(rawAgent);
209
169
  if (!agentResult.success) {
210
- error(`update: invalid --agent '${parsed.agent}' (expected: claude-code | codex-cli | gemini-cli | copilot)`);
211
- return 2;
170
+ error(`update: invalid --agent '${rawAgent}' (expected: claude-code | codex-cli | gemini-cli | copilot)`);
171
+ return { exitCode: 2 };
212
172
  }
213
173
  explicitAgent = agentResult.data;
214
174
  }
215
- let agent;
216
175
  try {
217
176
  const config = await loadRadarConfig(cwd);
218
- agent = await getDefaultAgent("research", {
177
+ const agent = await getDefaultAgent("research", {
219
178
  explicit: explicitAgent,
220
179
  configOverride: config,
221
180
  });
181
+ return { agent };
222
182
  }
223
183
  catch (e) {
224
184
  if (e instanceof RadarConfigError) {
225
185
  error(`update: ${e.message}`);
226
- return 2;
186
+ return { exitCode: 2 };
227
187
  }
228
188
  throw e;
229
189
  }
230
- const templateId = parsed.template ?? "default";
190
+ }
191
+ /**
192
+ * PRE block (shared by the spawn path and `--emit-payload`): resolve the
193
+ * predecessor research, validate its frontmatter, compute the deterministic
194
+ * v+1 `outputPath`, guard against overwriting an existing v+1, and resolve the
195
+ * linked items (read-only — their status is invariant under update, ADR-0008).
196
+ *
197
+ * Extracted from `runUpdate` so the host-agent emit path (#254 / ADR-0019)
198
+ * derives the exact same `newId` / `outputPath` and reuses the same collision +
199
+ * status guards as the spawn path, without the model-call step.
200
+ */
201
+ async function prepareUpdate(params) {
202
+ const { cwd, researchId, warn, error, progress } = params;
231
203
  // Resolve predecessor research path.
232
204
  let prevId;
233
205
  let prevPath;
234
206
  try {
235
- const resolved = resolveResearchPath(cwd, parsed.researchId);
207
+ const resolved = resolveResearchPath(cwd, researchId);
236
208
  prevId = resolved.id;
237
209
  prevPath = resolved.path;
238
210
  }
239
211
  catch (e) {
240
212
  error(`update: ${e instanceof Error ? e.message : String(e)}`);
241
- return 2;
213
+ return { exitCode: 2 };
242
214
  }
243
215
  if (!(await pathExists(prevPath))) {
244
216
  error(`update: research file not found: ${prevPath}`);
245
- return 1;
217
+ return { exitCode: 1 };
246
218
  }
247
219
  // Parse predecessor frontmatter.
248
220
  let prevBody;
@@ -251,7 +223,7 @@ export async function runUpdate(args, options = {}) {
251
223
  }
252
224
  catch (e) {
253
225
  error(`update: failed to read research file: ${e instanceof Error ? e.message : String(e)}`);
254
- return 1;
226
+ return { exitCode: 1 };
255
227
  }
256
228
  let prevFrontmatterRaw;
257
229
  try {
@@ -259,7 +231,7 @@ export async function runUpdate(args, options = {}) {
259
231
  }
260
232
  catch (e) {
261
233
  error(`update: failed to parse frontmatter: ${e instanceof Error ? e.message : String(e)}`);
262
- return 1;
234
+ return { exitCode: 1 };
263
235
  }
264
236
  const prevResult = ResearchFrontmatterSchema.safeParse(prevFrontmatterRaw);
265
237
  if (!prevResult.success) {
@@ -267,14 +239,14 @@ export async function runUpdate(args, options = {}) {
267
239
  for (const issue of prevResult.error.issues) {
268
240
  error(` - ${issue.path.join(".") || "<root>"}: ${issue.message}`);
269
241
  }
270
- return 1;
242
+ return { exitCode: 1 };
271
243
  }
272
244
  const prevFm = prevResult.data;
273
245
  // Sanity check: the id in the frontmatter must match the filename id so
274
246
  // `supersedes: <prev id>` we write below points at a real predecessor.
275
247
  if (prevFm.id !== prevId) {
276
248
  error(`update: predecessor frontmatter id '${prevFm.id}' does not match filename id '${prevId}'`);
277
- return 1;
249
+ return { exitCode: 1 };
278
250
  }
279
251
  // Compute the new id: increment the version suffix on the predecessor id.
280
252
  let base;
@@ -286,14 +258,14 @@ export async function runUpdate(args, options = {}) {
286
258
  }
287
259
  catch (e) {
288
260
  error(`update: ${e instanceof Error ? e.message : String(e)}`);
289
- return 1;
261
+ return { exitCode: 1 };
290
262
  }
291
263
  const newVersion = prevVersion + 1;
292
264
  const newId = `${base}_v${newVersion}`;
293
265
  const outputPath = join(cwd, "research", `${newId}.md`);
294
266
  if (await pathExists(outputPath)) {
295
267
  error(`update: ${outputPath} already exists. v${newVersion} was already generated — pick a different predecessor or remove the stale file.`);
296
- return 1;
268
+ return { exitCode: 1 };
297
269
  }
298
270
  // Resolve linked items. They are needed by the adapter (passed through as
299
271
  // context) but `update` does NOT mutate them — per ADR-0008 / ADR-0003 the
@@ -301,13 +273,13 @@ export async function runUpdate(args, options = {}) {
301
273
  const linkedItems = await findItemsForResearch(cwd, prevFm.itemIds);
302
274
  if (linkedItems.length === 0) {
303
275
  error(`update: no items/<id>.yaml found for itemIds=[${prevFm.itemIds.join(", ")}] referenced by ${prevFm.id}`);
304
- return 1;
276
+ return { exitCode: 1 };
305
277
  }
306
278
  if (linkedItems.length !== prevFm.itemIds.length) {
307
279
  const found = new Set(linkedItems.map((i) => i.id));
308
280
  const missing = prevFm.itemIds.filter((id) => !found.has(id));
309
281
  error(`update: ${missing.length} linked item(s) not found: ${missing.join(", ")} (referenced by ${prevFm.id})`);
310
- return 1;
282
+ return { exitCode: 1 };
311
283
  }
312
284
  // Per skill-design.md §8.4, `update` requires an existing research file —
313
285
  // i.e. the item must have been at `researched` or `reviewed` at some point.
@@ -318,7 +290,7 @@ export async function runUpdate(args, options = {}) {
318
290
  error(`update: linked items must be in status 'researched' or 'reviewed'. Offenders: ${invalidStatus
319
291
  .map((i) => `${i.id}=${i.status}`)
320
292
  .join(", ")}`);
321
- return 1;
293
+ return { exitCode: 1 };
322
294
  }
323
295
  // Surface any prompt-injection pre-filter hits recorded by the watcher
324
296
  // (ADR-0009 M1a / M5a — Adopt). Audit-only: `update` still generates a v+1
@@ -333,55 +305,28 @@ export async function runUpdate(args, options = {}) {
333
305
  progress.phase(linkedItems.length === 1
334
306
  ? `Loaded item: ${linkedItems[0].id}`
335
307
  : `Loaded ${linkedItems.length} items`, linkedItems.map((i) => i.id).join(", "));
336
- // Load template.
337
- const templatesDir = join(cwd, "templates");
338
- let template;
339
- try {
340
- template = await loadTemplate(templateId, templatesDir);
341
- }
342
- catch (e) {
343
- error(`update: ${e instanceof Error ? e.message : String(e)}`);
344
- return 1;
345
- }
346
- progress.phase(`Loaded template: ${templateId}.md`);
347
- log(`update: invoking ${agent} adapter for research '${prevFm.id}' -> ${base}_v${newVersion}.md`);
348
- // Phase marker + spinner for the agent run. See `research.ts` for the
349
- // shared pattern.
350
- progress.phase(`Spawning ${agent}`, `cwd: ${cwd}`);
351
- progress.start("Agent running");
352
- const adapterStartedAt = Date.now();
353
- const polling = pollOutputFileSize({ path: outputPath, reporter: progress });
354
- // Invoke adapter. We do not snapshot the predecessor file: the adapter
355
- // writes a new file at outputPath; if it fails, no rollback is necessary
356
- // because the predecessor was never touched and the new file simply does
357
- // not appear (or is partial, in which case the user removes it and retries).
358
- const adapter = getAgentAdapter(agent);
359
- try {
360
- await adapter.update({
361
- agent,
362
- templateId,
363
- templateBody: template.body,
364
- prevResearch: {
365
- frontmatter: prevFm,
366
- body: prevBody,
367
- },
368
- items: linkedItems,
369
- outputPath,
370
- cwd,
371
- onProgress: buildAgentProgressCallback(progress),
372
- });
373
- }
374
- catch (e) {
375
- polling.stop();
376
- progress.fail("Agent failed", e instanceof Error ? e.message : String(e));
377
- error(`update: adapter failed: ${e instanceof Error ? e.message : String(e)}`);
378
- return 1;
379
- }
380
- polling.stop();
381
- progress.succeed("Agent completed (exit 0)", Date.now() - adapterStartedAt);
382
- // Re-read and validate the produced file.
308
+ return { prevId, prevBody, prevFm, base, newVersion, newId, outputPath, linkedItems };
309
+ }
310
+ /**
311
+ * POST block (shared by the spawn path and `--commit`): re-read the written
312
+ * v+1 report, validate it against `ResearchFrontmatterSchema`, assert the v+1
313
+ * invariants against the predecessor frontmatter (auto-correcting drift), and
314
+ * deliberately leave items.yaml untouched (ADR-0008).
315
+ *
316
+ * This is the single source of truth for "finalize an update" so the spawn and
317
+ * host-agent paths (#254 / ADR-0019) cannot diverge on schema validation, the
318
+ * supersedes/createdAt/itemIds drift checks, or the items.yaml status
319
+ * invariance the CLI keeps owning those regardless of who wrote the file.
320
+ *
321
+ * `linkedItems` is optional: the spawn path passes the items it already
322
+ * resolved (drives the status phase marker); `--commit` omits it because the
323
+ * commit path validates the on-disk file without re-loading items (status is
324
+ * never mutated either way).
325
+ */
326
+ async function finalizeUpdate(params) {
327
+ const { outputPath, prevFm, newId, agent, linkedItems, log, warn, error, progress } = params;
383
328
  if (!(await pathExists(outputPath))) {
384
- error(`update: adapter completed but did not write ${outputPath} (agent ignored the output path?)`);
329
+ error(`update: did not write ${outputPath} (agent / host ignored the output path?)`);
385
330
  return 1;
386
331
  }
387
332
  let newBody;
@@ -409,7 +354,7 @@ export async function runUpdate(args, options = {}) {
409
354
  return 1;
410
355
  }
411
356
  const newFm = newResult.data;
412
- // v+1 invariants. We enforce these in the CLI so a misbehaving agent
357
+ // v+1 invariants. We enforce these in the CLI so a misbehaving agent / host
413
358
  // cannot silently corrupt the versioning chain. Drift is collected before
414
359
  // we attempt repair, then a single corrected file is written.
415
360
  const drift = [];
@@ -455,12 +400,355 @@ export async function runUpdate(args, options = {}) {
455
400
  progress.phase("Frontmatter validated");
456
401
  // `update` deliberately preserves items.yaml status (ADR-0008). We still
457
402
  // surface a status phase marker so the progress stream stays uniform with
458
- // research / review — the value just records the no-op transition.
459
- progress.phase(`Status: ${linkedItems[0].status} ${linkedItems[0].status}`, "items.yaml unchanged per ADR-0008");
403
+ // research / review — the value just records the no-op transition. The
404
+ // commit path has no resolved linked items, so it skips the per-item marker.
405
+ if (linkedItems !== undefined && linkedItems.length > 0) {
406
+ progress.phase(`Status: ${linkedItems[0].status} → ${linkedItems[0].status}`, "items.yaml unchanged per ADR-0008");
407
+ }
460
408
  log(`update: wrote ${outputPath}`);
461
409
  log(`update: supersedes ${prevFm.id} (items.yaml status unchanged per ADR-0008)`);
462
410
  return 0;
463
411
  }
412
+ /**
413
+ * Host-agent emit path (#254 / ADR-0019): run the same PRE block as the spawn
414
+ * path (`prepareUpdate`) to derive `outputPath` + predecessor context, then
415
+ * print the agent-neutral payload to stdout instead of spawning. The host
416
+ * session reads the payload, executes the SKILL procedure itself, and finalizes
417
+ * via `radar update --commit`.
418
+ */
419
+ async function runUpdateEmitPayload(params) {
420
+ const { cwd, researchId, agent, templateId, log, warn, error, progress } = params;
421
+ const prepared = await prepareUpdate({ cwd, researchId, warn, error, progress });
422
+ if ("exitCode" in prepared)
423
+ return prepared.exitCode;
424
+ const templatesDir = join(cwd, "templates");
425
+ let template;
426
+ try {
427
+ template = await loadTemplate(templateId, templatesDir);
428
+ }
429
+ catch (e) {
430
+ error(`update: ${e instanceof Error ? e.message : String(e)}`);
431
+ return 1;
432
+ }
433
+ progress.phase(`Loaded template: ${templateId}.md`);
434
+ log(renderUpdatePayloadBlock({
435
+ agent,
436
+ templateId,
437
+ templateBody: template.body,
438
+ prevResearch: { frontmatter: prepared.prevFm, body: prepared.prevBody },
439
+ items: prepared.linkedItems,
440
+ outputPath: prepared.outputPath,
441
+ }));
442
+ return 0;
443
+ }
444
+ /**
445
+ * Host-agent commit path (#254 / ADR-0019): finalize a v+1 report the host
446
+ * session wrote out-of-band. The report is self-describing via its `supersedes`
447
+ * frontmatter, which names the predecessor; we read `research/<supersedes>.md`
448
+ * to recover the v(N) frontmatter and run the same drift checks as the spawn
449
+ * path. `supersedes: null` is rejected — update always has a predecessor.
450
+ *
451
+ * Before finalize, the committed path is constrained to `<cwd>/research/` so a
452
+ * host misled by injected content into committing an arbitrary path (e.g.
453
+ * `../../etc/...`) is rejected at the CLI boundary (ADR-0009 M3b enforced in
454
+ * code, not just SKILL guidance).
455
+ */
456
+ async function runUpdateCommit(params) {
457
+ const { cwd, commitPath, log, warn, error, progress } = params;
458
+ const guard = await resolveCommitPathInside(cwd, "research", commitPath);
459
+ if ("error" in guard) {
460
+ error(`update: ${guard.error}`);
461
+ return 2;
462
+ }
463
+ const outputPath = guard.resolved;
464
+ if (!(await pathExists(outputPath))) {
465
+ error(`update: report was not written to ${outputPath} (did not write the output path?)`);
466
+ return 1;
467
+ }
468
+ let newBody;
469
+ try {
470
+ newBody = await readFile(outputPath, "utf8");
471
+ }
472
+ catch (e) {
473
+ error(`update: failed to read generated report: ${e instanceof Error ? e.message : String(e)}`);
474
+ return 1;
475
+ }
476
+ let newFrontmatterRaw;
477
+ try {
478
+ newFrontmatterRaw = matter(newBody, matterOptions).data;
479
+ }
480
+ catch (e) {
481
+ error(`update: failed to parse new frontmatter: ${e instanceof Error ? e.message : String(e)}`);
482
+ return 1;
483
+ }
484
+ const newResult = ResearchFrontmatterSchema.safeParse(newFrontmatterRaw);
485
+ if (!newResult.success) {
486
+ error(`update: new frontmatter does not match ResearchFrontmatterSchema:`);
487
+ for (const issue of newResult.error.issues) {
488
+ error(` - ${issue.path.join(".") || "<root>"}: ${issue.message}`);
489
+ }
490
+ return 1;
491
+ }
492
+ const newFm = newResult.data;
493
+ // `supersedes` names the predecessor. Update always has one (it generates
494
+ // v+1 from v(N)); a null supersedes means the host wrote a v1, which is a
495
+ // `research` artifact, not an `update` artifact.
496
+ if (newFm.supersedes === null) {
497
+ error("update: --commit report has `supersedes: null`. update finalizes a v+1 (use `radar research --commit` for a v1).");
498
+ return 1;
499
+ }
500
+ // Recover the predecessor frontmatter from research/<supersedes>.md so the
501
+ // drift checks (createdAt / itemIds preservation, supersedes wiring) run
502
+ // against the real v(N), exactly as the spawn path does.
503
+ let prevPath;
504
+ try {
505
+ prevPath = resolveResearchPath(cwd, newFm.supersedes).path;
506
+ }
507
+ catch (e) {
508
+ error(`update: invalid supersedes id in committed report: ${e instanceof Error ? e.message : String(e)}`);
509
+ return 1;
510
+ }
511
+ if (!(await pathExists(prevPath))) {
512
+ error(`update: predecessor research/${newFm.supersedes}.md (named by supersedes) not found under ${cwd}`);
513
+ return 1;
514
+ }
515
+ let prevBody;
516
+ try {
517
+ prevBody = await readFile(prevPath, "utf8");
518
+ }
519
+ catch (e) {
520
+ error(`update: failed to read predecessor research file: ${e instanceof Error ? e.message : String(e)}`);
521
+ return 1;
522
+ }
523
+ const prevResult = ResearchFrontmatterSchema.safeParse(matter(prevBody, matterOptions).data);
524
+ if (!prevResult.success) {
525
+ error(`update: predecessor frontmatter does not match ResearchFrontmatterSchema:`);
526
+ for (const issue of prevResult.error.issues) {
527
+ error(` - ${issue.path.join(".") || "<root>"}: ${issue.message}`);
528
+ }
529
+ return 1;
530
+ }
531
+ const prevFm = prevResult.data;
532
+ if (prevFm.id !== newFm.supersedes) {
533
+ error(`update: predecessor frontmatter id '${prevFm.id}' does not match supersedes '${newFm.supersedes}'`);
534
+ return 1;
535
+ }
536
+ // The expected new id is the basename of the committed file. finalizeUpdate
537
+ // re-validates the frontmatter id against it (drift auto-correction).
538
+ const newId = outputPath.replace(/^.*\//, "").replace(/\.md$/, "");
539
+ // Version monotonicity: the committed filename must be exactly one version
540
+ // above the predecessor of the same base, mirroring the spawn path which
541
+ // derives `newId = <base>_v<prev+1>` deterministically. Without this a host
542
+ // — possibly misled by injected content — could commit `foo_v9.md`
543
+ // superseding `foo_v2.md` and skip versions, breaking the ADR-0003 lineage
544
+ // contract. parseResearchId throws on a malformed predecessor id; that is a
545
+ // corrupt workspace, surfaced as an error rather than a silent pass.
546
+ let prevParsed;
547
+ try {
548
+ prevParsed = parseResearchId(prevFm.id);
549
+ }
550
+ catch (e) {
551
+ error(`update: predecessor id '${prevFm.id}' is not a valid <base>_v<n> id: ${e instanceof Error ? e.message : String(e)}`);
552
+ return 1;
553
+ }
554
+ const expectedNewId = `${prevParsed.base}_v${prevParsed.version + 1}`;
555
+ if (newId !== expectedNewId) {
556
+ error(`update: committed report '${newId}' must be '${expectedNewId}' — exactly v${prevParsed.version + 1} of '${prevParsed.base}' (predecessor '${prevFm.id}'). update finalizes a single version increment.`);
557
+ return 1;
558
+ }
559
+ // The committed file's `agent` is authoritative for the commit path: there is
560
+ // no `--agent` flag in play, so we accept whatever valid agent the host
561
+ // stamped (still schema-validated by ResearchFrontmatterSchema above).
562
+ return finalizeUpdate({
563
+ outputPath,
564
+ prevFm,
565
+ newId,
566
+ agent: newFm.agent,
567
+ linkedItems: undefined,
568
+ log,
569
+ warn,
570
+ error,
571
+ progress,
572
+ });
573
+ }
574
+ /**
575
+ * Implementation of `radar update <research-id>`.
576
+ *
577
+ * High-level flow (Phase 5, [#41](https://github.com/ozzy-labs/feedradar/issues/41)):
578
+ * 1. Parse + validate args (agent defaults to `claude-code`, template to `default`).
579
+ * 2. Resolve `research/<research-id>.md` and parse its frontmatter.
580
+ * 3. Compute the new id `<base>_v<n+1>` and refuse to overwrite an existing
581
+ * `research/<new-id>.md` (idempotent re-runs are user-facing errors).
582
+ * 4. Locate the linked items (read-only; their `status` is preserved per
583
+ * ADR-0008 / skill-design.md §8.4).
584
+ * 5. Reject `update` when any linked item is in `detected` / `dismissed`
585
+ * (no v1 research exists, or the user opted out of researching it).
586
+ * 6. Load `templates/<template-id>.md` (empty body when default is absent).
587
+ * 7. Invoke the adapter; the agent writes the new file at `outputPath`.
588
+ * 8. Re-read the new file, validate frontmatter against
589
+ * `ResearchFrontmatterSchema`, and assert v+1 invariants:
590
+ * - `id` matches the computed new id
591
+ * - `itemIds` / `templateId` / `createdAt` preserved from v(N)
592
+ * - `supersedes` equals the v(N) id
593
+ * - `reviewedAt` / `reviewedBy` are `null` (review state does NOT carry
594
+ * across versions per ADR-0003 / skill-design.md §8.3)
595
+ * 9. **No items.yaml mutation** — `update` is intentionally inert wrt item
596
+ * status. The v1 review event (if any) remains the authoritative record.
597
+ *
598
+ * Unlike `review`, `update` does not snapshot/restore: writing a new file is
599
+ * additive, so a failed adapter just leaves a missing / partial new file that
600
+ * the user can re-run after fixing the underlying issue. The predecessor file
601
+ * is never opened for writing.
602
+ */
603
+ export async function runUpdate(args, options = {}) {
604
+ const cwd = options.cwd ?? process.cwd();
605
+ const log = options.io?.log ?? ((m) => console.log(m));
606
+ const warn = options.io?.warn ?? ((m) => console.warn(m));
607
+ const error = options.io?.error ?? ((m) => console.error(m));
608
+ // Two-stage argv parse (see `src/cli/research.ts` for the full rationale).
609
+ let progressState;
610
+ try {
611
+ progressState = parseProgressFlags(args);
612
+ }
613
+ catch (e) {
614
+ if (e instanceof ProgressFlagError) {
615
+ error(`update: ${e.message}`);
616
+ return 2;
617
+ }
618
+ throw e;
619
+ }
620
+ const progress = options.progress ?? buildReporter({ level: progressState.level });
621
+ let parsed;
622
+ try {
623
+ parsed = parseArgs(progressState.rest);
624
+ }
625
+ catch (e) {
626
+ error(`update: ${e instanceof Error ? e.message : String(e)}`);
627
+ return 2;
628
+ }
629
+ if (parsed.help) {
630
+ printHelp(log);
631
+ return 0;
632
+ }
633
+ // Host-agent commit (#254 / ADR-0019). Independent of agent / template /
634
+ // predecessor resolution: the report is self-describing via its `supersedes`
635
+ // frontmatter. Handled before the other modes since it takes a path, not a
636
+ // <research-id>.
637
+ if (parsed.commit !== undefined) {
638
+ if (parsed.emitPayload) {
639
+ error("update: --commit is incompatible with --emit-payload");
640
+ return 2;
641
+ }
642
+ if (parsed.researchId !== undefined) {
643
+ error(`update: --commit takes a <path>, not a <research-id> (got '${parsed.researchId}')`);
644
+ return 2;
645
+ }
646
+ return runUpdateCommit({ cwd, commitPath: parsed.commit, log, warn, error, progress });
647
+ }
648
+ if (!parsed.researchId) {
649
+ error("update: missing <research-id>");
650
+ printHelp(error);
651
+ return 2;
652
+ }
653
+ // Resolve the agent honoring the priority chain. We do not yet have a
654
+ // `defaultUpdateAgent` field in `radar.config.yaml` (out of scope for #41
655
+ // per the Issue body), so resolution is:
656
+ // explicit --agent > defaultResearchAgent > hard-coded claude-code
657
+ // We pick `research` as the borrowed default because `update` shares its
658
+ // SKILL body (rewrite-and-supersede strategy reuses the research procedure;
659
+ // skill-design.md §8.2). When `radar.config.yaml` grows a dedicated
660
+ // `defaultUpdateAgent`, this fallback chain becomes a thin pass-through.
661
+ const agentResult = await resolveUpdateAgent(cwd, parsed.agent, error);
662
+ if ("exitCode" in agentResult)
663
+ return agentResult.exitCode;
664
+ const agent = agentResult.agent;
665
+ const templateId = parsed.template ?? "default";
666
+ // Host-agent emit (#254 / ADR-0019): same predecessor / item resolution as
667
+ // the spawn path (`prepareUpdate`), but print the payload instead of
668
+ // spawning the adapter.
669
+ if (parsed.emitPayload) {
670
+ return runUpdateEmitPayload({
671
+ cwd,
672
+ researchId: parsed.researchId,
673
+ agent,
674
+ templateId,
675
+ log,
676
+ warn,
677
+ error,
678
+ progress,
679
+ });
680
+ }
681
+ // PRE block (shared with --emit-payload): resolve predecessor, compute the
682
+ // v+1 outputPath, collision-check, and resolve linked items.
683
+ const prepared = await prepareUpdate({
684
+ cwd,
685
+ researchId: parsed.researchId,
686
+ warn,
687
+ error,
688
+ progress,
689
+ });
690
+ if ("exitCode" in prepared)
691
+ return prepared.exitCode;
692
+ const { prevFm, prevBody, base, newVersion, newId, outputPath, linkedItems } = prepared;
693
+ // Load template.
694
+ const templatesDir = join(cwd, "templates");
695
+ let template;
696
+ try {
697
+ template = await loadTemplate(templateId, templatesDir);
698
+ }
699
+ catch (e) {
700
+ error(`update: ${e instanceof Error ? e.message : String(e)}`);
701
+ return 1;
702
+ }
703
+ progress.phase(`Loaded template: ${templateId}.md`);
704
+ log(`update: invoking ${agent} adapter for research '${prevFm.id}' -> ${base}_v${newVersion}.md`);
705
+ // Phase marker + spinner for the agent run. See `research.ts` for the
706
+ // shared pattern.
707
+ progress.phase(`Spawning ${agent}`, `cwd: ${cwd}`);
708
+ progress.start("Agent running");
709
+ const adapterStartedAt = Date.now();
710
+ const polling = pollOutputFileSize({ path: outputPath, reporter: progress });
711
+ // Invoke adapter. We do not snapshot the predecessor file: the adapter
712
+ // writes a new file at outputPath; if it fails, no rollback is necessary
713
+ // because the predecessor was never touched and the new file simply does
714
+ // not appear (or is partial, in which case the user removes it and retries).
715
+ const adapter = getAgentAdapter(agent);
716
+ try {
717
+ await adapter.update({
718
+ agent,
719
+ templateId,
720
+ templateBody: template.body,
721
+ prevResearch: {
722
+ frontmatter: prevFm,
723
+ body: prevBody,
724
+ },
725
+ items: linkedItems,
726
+ outputPath,
727
+ cwd,
728
+ onProgress: buildAgentProgressCallback(progress),
729
+ });
730
+ }
731
+ catch (e) {
732
+ polling.stop();
733
+ progress.fail("Agent failed", e instanceof Error ? e.message : String(e));
734
+ error(`update: adapter failed: ${e instanceof Error ? e.message : String(e)}`);
735
+ return 1;
736
+ }
737
+ polling.stop();
738
+ progress.succeed("Agent completed (exit 0)", Date.now() - adapterStartedAt);
739
+ // POST block (shared with --commit): re-read, validate, drift-correct, log.
740
+ return finalizeUpdate({
741
+ outputPath,
742
+ prevFm,
743
+ newId,
744
+ agent,
745
+ linkedItems,
746
+ log,
747
+ warn,
748
+ error,
749
+ progress,
750
+ });
751
+ }
464
752
  export const updateCommand = {
465
753
  name: "update",
466
754
  summary: "Refresh existing research reports against the latest items",