@ozzylabs/feedradar 0.1.3 → 0.1.5

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 (144) hide show
  1. package/README.ja.md +31 -6
  2. package/README.md +31 -6
  3. package/dist/agents/claude-code.d.ts +12 -1
  4. package/dist/agents/claude-code.d.ts.map +1 -1
  5. package/dist/agents/claude-code.js +9 -5
  6. package/dist/agents/claude-code.js.map +1 -1
  7. package/dist/agents/codex-cli.d.ts +7 -1
  8. package/dist/agents/codex-cli.d.ts.map +1 -1
  9. package/dist/agents/codex-cli.js +9 -5
  10. package/dist/agents/codex-cli.js.map +1 -1
  11. package/dist/agents/copilot.d.ts +7 -1
  12. package/dist/agents/copilot.d.ts.map +1 -1
  13. package/dist/agents/copilot.js +9 -5
  14. package/dist/agents/copilot.js.map +1 -1
  15. package/dist/agents/gemini-cli.d.ts +7 -1
  16. package/dist/agents/gemini-cli.d.ts.map +1 -1
  17. package/dist/agents/gemini-cli.js +9 -5
  18. package/dist/agents/gemini-cli.js.map +1 -1
  19. package/dist/agents/index.d.ts +1 -1
  20. package/dist/agents/index.d.ts.map +1 -1
  21. package/dist/agents/types.d.ts +33 -0
  22. package/dist/agents/types.d.ts.map +1 -1
  23. package/dist/cli/_progress.d.ts +138 -0
  24. package/dist/cli/_progress.d.ts.map +1 -0
  25. package/dist/cli/_progress.js +176 -0
  26. package/dist/cli/_progress.js.map +1 -0
  27. package/dist/cli/doctor.d.ts +20 -0
  28. package/dist/cli/doctor.d.ts.map +1 -1
  29. package/dist/cli/doctor.js +291 -2
  30. package/dist/cli/doctor.js.map +1 -1
  31. package/dist/cli/index.d.ts.map +1 -1
  32. package/dist/cli/index.js +2 -0
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/research.d.ts +18 -20
  35. package/dist/cli/research.d.ts.map +1 -1
  36. package/dist/cli/research.js +318 -203
  37. package/dist/cli/research.js.map +1 -1
  38. package/dist/cli/respawn.d.ts +53 -0
  39. package/dist/cli/respawn.d.ts.map +1 -0
  40. package/dist/cli/respawn.js +120 -0
  41. package/dist/cli/respawn.js.map +1 -0
  42. package/dist/cli/review.d.ts +7 -0
  43. package/dist/cli/review.d.ts.map +1 -1
  44. package/dist/cli/review.js +46 -1
  45. package/dist/cli/review.js.map +1 -1
  46. package/dist/cli/source.d.ts +23 -2
  47. package/dist/cli/source.d.ts.map +1 -1
  48. package/dist/cli/source.js +425 -7
  49. package/dist/cli/source.js.map +1 -1
  50. package/dist/cli/update.d.ts +7 -0
  51. package/dist/cli/update.d.ts.map +1 -1
  52. package/dist/cli/update.js +41 -1
  53. package/dist/cli/update.js.map +1 -1
  54. package/dist/cli/watch.d.ts.map +1 -1
  55. package/dist/cli/watch.js +65 -3
  56. package/dist/cli/watch.js.map +1 -1
  57. package/dist/cli/workflow/generate-combined.d.ts +100 -0
  58. package/dist/cli/workflow/generate-combined.d.ts.map +1 -0
  59. package/dist/cli/workflow/generate-combined.js +387 -0
  60. package/dist/cli/workflow/generate-combined.js.map +1 -0
  61. package/dist/cli/workflow/generate-watch.d.ts +142 -0
  62. package/dist/cli/workflow/generate-watch.d.ts.map +1 -0
  63. package/dist/cli/workflow/generate-watch.js +338 -0
  64. package/dist/cli/workflow/generate-watch.js.map +1 -0
  65. package/dist/cli/workflow.d.ts +29 -0
  66. package/dist/cli/workflow.d.ts.map +1 -0
  67. package/dist/cli/workflow.js +66 -0
  68. package/dist/cli/workflow.js.map +1 -0
  69. package/dist/core/feeds/_fetch.d.ts +103 -0
  70. package/dist/core/feeds/_fetch.d.ts.map +1 -0
  71. package/dist/core/feeds/_fetch.js +364 -0
  72. package/dist/core/feeds/_fetch.js.map +1 -0
  73. package/dist/core/feeds/_jsonpath.d.ts +57 -0
  74. package/dist/core/feeds/_jsonpath.d.ts.map +1 -0
  75. package/dist/core/feeds/_jsonpath.js +207 -0
  76. package/dist/core/feeds/_jsonpath.js.map +1 -0
  77. package/dist/core/feeds/github-api.d.ts.map +1 -1
  78. package/dist/core/feeds/github-api.js +2 -1
  79. package/dist/core/feeds/github-api.js.map +1 -1
  80. package/dist/core/feeds/html-js.d.ts +29 -0
  81. package/dist/core/feeds/html-js.d.ts.map +1 -1
  82. package/dist/core/feeds/html-js.js +86 -2
  83. package/dist/core/feeds/html-js.js.map +1 -1
  84. package/dist/core/feeds/html.d.ts.map +1 -1
  85. package/dist/core/feeds/html.js +2 -1
  86. package/dist/core/feeds/html.js.map +1 -1
  87. package/dist/core/feeds/index.d.ts +1 -1
  88. package/dist/core/feeds/index.d.ts.map +1 -1
  89. package/dist/core/feeds/index.js +4 -0
  90. package/dist/core/feeds/index.js.map +1 -1
  91. package/dist/core/feeds/json-api.d.ts +3 -0
  92. package/dist/core/feeds/json-api.d.ts.map +1 -0
  93. package/dist/core/feeds/json-api.js +723 -0
  94. package/dist/core/feeds/json-api.js.map +1 -0
  95. package/dist/core/feeds/json-feed.d.ts +11 -0
  96. package/dist/core/feeds/json-feed.d.ts.map +1 -0
  97. package/dist/core/feeds/json-feed.js +242 -0
  98. package/dist/core/feeds/json-feed.js.map +1 -0
  99. package/dist/core/feeds/npm-registry.d.ts.map +1 -1
  100. package/dist/core/feeds/npm-registry.js +2 -1
  101. package/dist/core/feeds/npm-registry.js.map +1 -1
  102. package/dist/core/feeds/rss.d.ts.map +1 -1
  103. package/dist/core/feeds/rss.js +2 -1
  104. package/dist/core/feeds/rss.js.map +1 -1
  105. package/dist/core/feeds/types.d.ts +123 -0
  106. package/dist/core/feeds/types.d.ts.map +1 -1
  107. package/dist/core/progress.d.ts +101 -0
  108. package/dist/core/progress.d.ts.map +1 -0
  109. package/dist/core/progress.js +212 -0
  110. package/dist/core/progress.js.map +1 -0
  111. package/dist/core/proxy.d.ts +87 -0
  112. package/dist/core/proxy.d.ts.map +1 -0
  113. package/dist/core/proxy.js +146 -0
  114. package/dist/core/proxy.js.map +1 -0
  115. package/dist/core/recipes.d.ts +138 -0
  116. package/dist/core/recipes.d.ts.map +1 -0
  117. package/dist/core/recipes.js +238 -0
  118. package/dist/core/recipes.js.map +1 -0
  119. package/dist/core/watcher.d.ts +61 -1
  120. package/dist/core/watcher.d.ts.map +1 -1
  121. package/dist/core/watcher.js +99 -2
  122. package/dist/core/watcher.js.map +1 -1
  123. package/dist/index.js +17 -4
  124. package/dist/index.js.map +1 -1
  125. package/dist/recipes/aws-whats-new.yaml +61 -0
  126. package/dist/recipes/dev-to.yaml +40 -0
  127. package/dist/schemas/index.d.ts +1 -0
  128. package/dist/schemas/index.d.ts.map +1 -1
  129. package/dist/schemas/index.js +1 -0
  130. package/dist/schemas/index.js.map +1 -1
  131. package/dist/schemas/recipe.d.ts +115 -0
  132. package/dist/schemas/recipe.d.ts.map +1 -0
  133. package/dist/schemas/recipe.js +54 -0
  134. package/dist/schemas/recipe.js.map +1 -0
  135. package/dist/schemas/source.d.ts +130 -0
  136. package/dist/schemas/source.d.ts.map +1 -1
  137. package/dist/schemas/source.js +130 -0
  138. package/dist/schemas/source.js.map +1 -1
  139. package/dist/templates/agents/AGENTS.md +31 -3
  140. package/dist/templates/feedradar.md +23 -8
  141. package/dist/templates/workflows/combined.template.yaml.tmpl +110 -0
  142. package/dist/templates/workflows/watch.template.yaml.tmpl +103 -0
  143. package/dist/templates/workflows/watch.yaml +5 -1
  144. package/package.json +2 -3
@@ -21,7 +21,18 @@ import { getAgentAdapter } from "../agents/index.js";
21
21
  import { getDefaultAgent, loadRadarConfig, RadarConfigError } from "../core/config.js";
22
22
  import { loadItems, saveItems } from "../core/items.js";
23
23
  import { loadTemplate } from "../core/templates.js";
24
- import { AgentIdSchema, ResearchFrontmatterSchema } from "../schemas/index.js";
24
+ import { AgentIdSchema, ItemStatusSchema, ResearchFrontmatterSchema } from "../schemas/index.js";
25
+ import { buildAgentProgressCallback, buildReporter, ProgressFlagError, parseProgressFlags, pollOutputFileSize, } from "./_progress.js";
26
+ /**
27
+ * Default hard-cap for `radar research --batch`.
28
+ *
29
+ * ADR-0014 D3a pins the default to 10: 2-10x the empirical detection rate
30
+ * (1-5 items per cron tick) while keeping LLM cost-per-tick bounded
31
+ * (~$0.01/item * 10 ~= $0.1). Generated workflow YAML embeds this same
32
+ * literal via `combined.template.yaml`, but the CLI re-enforces it so the
33
+ * cap also applies when the YAML is hand-edited.
34
+ */
35
+ export const RESEARCH_BATCH_DEFAULT_MAX_ITEMS = 10;
25
36
  function parseArgs(args) {
26
37
  const out = { itemIds: [] };
27
38
  for (let i = 0; i < args.length; i++) {
@@ -34,6 +45,10 @@ function parseArgs(args) {
34
45
  out.digest = true;
35
46
  continue;
36
47
  }
48
+ if (a === "--batch") {
49
+ out.batch = true;
50
+ continue;
51
+ }
37
52
  if (a === "--agent") {
38
53
  out.agent = args[++i];
39
54
  continue;
@@ -42,6 +57,18 @@ function parseArgs(args) {
42
57
  out.template = args[++i];
43
58
  continue;
44
59
  }
60
+ if (a === "--status") {
61
+ out.status = args[++i];
62
+ continue;
63
+ }
64
+ if (a === "--max-items") {
65
+ out.maxItems = args[++i];
66
+ continue;
67
+ }
68
+ if (a === "--filter-tags") {
69
+ out.filterTags = args[++i];
70
+ continue;
71
+ }
45
72
  if (a?.startsWith("--")) {
46
73
  throw new Error(`unknown option: ${a}`);
47
74
  }
@@ -55,19 +82,34 @@ function printHelp(log) {
55
82
  log("Usage:");
56
83
  log(" radar research <item-id> [--agent <agent-id>] [--template <template-id>]");
57
84
  log(" radar research --digest <item-id> <item-id> ... [--agent <agent-id>] [--template <id>]");
85
+ log(` radar research --batch [--status <status>] [--max-items N] [--filter-tags <list>] [--agent <id>]`);
58
86
  log("");
59
87
  log("Arguments:");
60
88
  log(" <item-id> Item id (matches items/<sourceId>/<item-id>.yaml)");
61
89
  log(" Pass 2 or more ids together with --digest to bundle them.");
90
+ log(" Omit positional ids with --batch — items are discovered.");
62
91
  log("");
63
92
  log("Options:");
64
93
  log(" --agent <agent-id> claude-code | codex-cli | gemini-cli | copilot (default: claude-code)");
65
94
  log(" --template <id> Template id under templates/ (default: default; digest: digest)");
66
95
  log(" --digest Bundle multiple items into a single digest report (ADR-0011)");
96
+ log(" --batch Research every item matching --status (and --filter-tags)");
97
+ 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).");
100
+ log(` --max-items N Batch-mode hard-cap on processed items (default: ${RESEARCH_BATCH_DEFAULT_MAX_ITEMS}).`);
101
+ log(" Excess items are dropped and announced via warn() so a runaway");
102
+ log(" detection cannot blow the cap from inside a workflow.");
103
+ log(" --filter-tags <list> Batch-mode comma-separated allow-list matched against");
104
+ log(" each item's matchedKeywords (case-insensitive). Default: all.");
105
+ log(" --verbose Stream the agent CLI's stdout/stderr in addition to phase markers.");
106
+ log(" --quiet Suppress phase markers and spinner; print only the completion line.");
107
+ log(" Equivalent to setting RADAR_NO_PROGRESS=1 (ADR-0015 D2).");
67
108
  log("");
68
109
  log("Output:");
69
110
  log(" single-item: research/<YYYYMMDD>_<slug>_v1.md (ADR-0003)");
70
111
  log(" digest: research/<YYYYMMDD>_digest_<slug>_v1.md (ADR-0011)");
112
+ log(" batch: one single-item report per matched item (no digest aggregation).");
71
113
  }
72
114
  async function pathExists(p) {
73
115
  try {
@@ -78,14 +120,6 @@ async function pathExists(p) {
78
120
  return false;
79
121
  }
80
122
  }
81
- /**
82
- * Slug an Item into the `<sourceId>-<short-slug>` form used by the research
83
- * filename (`research/<YYYYMMDD>_<slug>_v1.md`).
84
- *
85
- * Falls back to the item id when the title is empty/unicode-only. We trim to
86
- * 60 chars so the resulting filename stays inside typical filesystem limits
87
- * after adding date prefix and version suffix.
88
- */
89
123
  function buildSlug(item) {
90
124
  const baseSource = item.sourceId.toLowerCase().replace(/[^a-z0-9]+/g, "-");
91
125
  const titleSlug = item.title
@@ -100,39 +134,19 @@ function buildSlug(item) {
100
134
  .slice(0, 60);
101
135
  return `${baseSource}-${tail}`;
102
136
  }
103
- /**
104
- * Pick `YYYYMMDD` from the item's `publishedAt`, falling back to today (UTC)
105
- * when the source feed did not provide a publish date.
106
- */
107
137
  function buildDatePrefix(item, now) {
108
138
  const iso = item.publishedAt ?? now.toISOString();
109
139
  return iso.slice(0, 10).replace(/-/g, "");
110
140
  }
111
- /**
112
- * Pick `YYYYMMDD` for digest output. Per ADR-0011 §1, digest filenames use the
113
- * **generation date** (UTC, CLI invocation time) rather than any item's
114
- * `publishedAt`. Constituent items rarely share a publish date and the digest
115
- * is a synthesis artifact, so the generation date is the only stable key.
116
- */
117
141
  function buildDigestDatePrefix(now) {
118
142
  return now.toISOString().slice(0, 10).replace(/-/g, "");
119
143
  }
120
- /**
121
- * Kebab-case helper used by both digest slug derivation and the clamp routine.
122
- * Mirrors the algorithm pinned in ADR-0011 §2: lowercase, non-alphanumerics
123
- * collapse to a single hyphen, leading/trailing hyphens are stripped.
124
- */
125
144
  function kebabCase(s) {
126
145
  return s
127
146
  .toLowerCase()
128
147
  .replace(/[^a-z0-9]+/g, "-")
129
148
  .replace(/^-+|-+$/g, "");
130
149
  }
131
- /**
132
- * Trim a slug to `max` characters, but never end mid-word: cut at the last
133
- * hyphen boundary inside the limit so the slug stays readable when truncated
134
- * (ADR-0011 §2). When no hyphen is present we fall back to a hard cut.
135
- */
136
150
  function clampSlug(s, max = 60) {
137
151
  if (s.length <= max)
138
152
  return s;
@@ -140,26 +154,6 @@ function clampSlug(s, max = 60) {
140
154
  const lastHyphen = cut.lastIndexOf("-");
141
155
  return lastHyphen > 0 ? cut.slice(0, lastHyphen) : cut;
142
156
  }
143
- /**
144
- * Derive the digest slug from the constituent items' `matchedKeywords`
145
- * (ADR-0011 §2).
146
- *
147
- * Algorithm:
148
- * 1. Aggregate every item's `matchedKeywords` into a frequency map keyed by
149
- * the normalized (lowercased, trimmed) keyword. Empty strings are
150
- * ignored so a noisy `[""]` entry cannot dominate the ranking.
151
- * 2. Sort by frequency descending, ties broken by lexicographic ascending so
152
- * the output is deterministic regardless of input item order.
153
- * 3. Take the top 1-2 entries, kebab-case each, and join with `-`. If no
154
- * keywords are present we fall back to the literal `"digest"` so the
155
- * ADR-0011 filename pattern (`<date>_digest_<slug>_v<n>.md`) still
156
- * produces a recognizable `<date>_digest_digest_v1.md` artifact.
157
- * 4. Clamp to 60 chars on hyphen boundaries (`clampSlug`).
158
- *
159
- * Stable ordering matters because the digest id is content-addressed: the
160
- * same item set must always yield the same filename so re-runs can detect
161
- * collisions deterministically.
162
- */
163
157
  function deriveDigestSlug(items) {
164
158
  const freq = new Map();
165
159
  for (const item of items) {
@@ -178,14 +172,6 @@ function deriveDigestSlug(items) {
178
172
  return "digest";
179
173
  return clampSlug(top.map(kebabCase).join("-"));
180
174
  }
181
- /**
182
- * Locate `items/<sourceId>/<item-id>.yaml` across all source directories,
183
- * since the CLI only takes `<item-id>` (sourceId is not on the command line).
184
- *
185
- * `loadItems` already walks every source subdir, so we delegate to it and
186
- * then match by id. Returning the full Item (rather than just the path) lets
187
- * the caller compute slug / date without a second read.
188
- */
189
175
  async function findItem(cwd, itemId) {
190
176
  const itemsDir = join(cwd, "items");
191
177
  if (!(await pathExists(itemsDir)))
@@ -196,15 +182,6 @@ async function findItem(cwd, itemId) {
196
182
  return null;
197
183
  return { item: match };
198
184
  }
199
- /**
200
- * Locate multiple items at once. We do a single `loadItems` walk and then
201
- * map the requested ids against the result, preserving the caller's order
202
- * for the eventual `itemIds` frontmatter array (ADR-0011 keeps no constraint
203
- * on order, but stable user-supplied order is the least surprising default).
204
- *
205
- * Returns `null` for the first missing id so the caller can emit a targeted
206
- * error rather than walking `items/` repeatedly.
207
- */
208
185
  async function findItems(cwd, itemIds) {
209
186
  const itemsDir = join(cwd, "items");
210
187
  if (!(await pathExists(itemsDir)))
@@ -220,145 +197,41 @@ async function findItems(cwd, itemIds) {
220
197
  }
221
198
  return { items: matched };
222
199
  }
223
- /**
224
- * Implementation of `radar research <item-id>` (single-item) and
225
- * `radar research --digest <id1> <id2> ...` (multi-item digest, ADR-0011).
226
- *
227
- * High-level flow (Phase 1):
228
- * 1. Parse + validate args (agent defaults to `claude-code`, template to
229
- * `default` for single-item or `digest` for `--digest`).
230
- * 2. Locate `items/<sourceId>/<item-id>.yaml` for each id and parse it.
231
- * 3. Load `templates/<template-id>.md` (empty body when default is absent).
232
- * 4. Compute the output path:
233
- * - single-item: `research/<YYYYMMDD>_<slug>_v1.md` (ADR-0003)
234
- * - digest: `research/<YYYYMMDD>_digest_<slug>_v1.md` (ADR-0011)
235
- * Refuse to overwrite an existing file — re-runs go through `update`.
236
- * 5. Invoke the registered agent adapter; the agent writes the report.
237
- * 6. Validate the report's frontmatter against `ResearchFrontmatterSchema`.
238
- * 7. Transition every included item: `status` → `researched` and persist.
239
- *
240
- * Any step from 4 onward that fails surfaces a non-zero exit code with a
241
- * targeted message so the user can debug without re-reading the source.
242
- */
243
- export async function runResearch(args, options = {}) {
244
- const cwd = options.cwd ?? process.cwd();
245
- const log = options.io?.log ?? ((m) => console.log(m));
246
- const warn = options.io?.warn ?? ((m) => console.warn(m));
247
- const error = options.io?.error ?? ((m) => console.error(m));
248
- let parsed;
249
- try {
250
- parsed = parseArgs(args);
251
- }
252
- catch (e) {
253
- error(`research: ${e instanceof Error ? e.message : String(e)}`);
254
- return 2;
255
- }
256
- if (parsed.help) {
257
- printHelp(log);
258
- return 0;
259
- }
260
- if (parsed.itemIds.length === 0) {
261
- error("research: missing <item-id>");
262
- printHelp(error);
263
- return 2;
264
- }
265
- // Single-item mode: enforce exactly one positional id so the existing
266
- // behavior of `radar research <id>` is unambiguous. The user must opt into
267
- // multi-item mode via `--digest`.
268
- if (!parsed.digest && parsed.itemIds.length > 1) {
269
- error(`research: multiple <item-id> arguments require --digest (got ${parsed.itemIds.length}: ${parsed.itemIds.join(", ")})`);
270
- return 2;
271
- }
272
- // Digest mode: must bundle 2+ items per ADR-0011 §1 (a single-item digest
273
- // is indistinguishable from a regular research run and would muddy the ID
274
- // space). Reject early with exit 2 (argument error).
275
- if (parsed.digest && parsed.itemIds.length < 2) {
276
- error(`research: --digest requires 2 or more <item-id> arguments (got ${parsed.itemIds.length})`);
277
- return 2;
278
- }
279
- // Resolve the agent honoring the priority chain:
280
- // explicit --agent > radar.config.yaml defaultResearchAgent > "claude-code"
281
- // The explicit value is validated against AgentIdSchema first so a bogus
282
- // --agent never reaches the config / fallback path.
200
+ async function resolveAgent(cwd, rawAgent, error) {
283
201
  let explicitAgent;
284
- if (parsed.agent !== undefined) {
285
- const agentResult = AgentIdSchema.safeParse(parsed.agent);
202
+ if (rawAgent !== undefined) {
203
+ const agentResult = AgentIdSchema.safeParse(rawAgent);
286
204
  if (!agentResult.success) {
287
- error(`research: invalid --agent '${parsed.agent}' (expected: claude-code | codex-cli | gemini-cli | copilot)`);
288
- return 2;
205
+ error(`research: invalid --agent '${rawAgent}' (expected: claude-code | codex-cli | gemini-cli | copilot)`);
206
+ return { exitCode: 2 };
289
207
  }
290
208
  explicitAgent = agentResult.data;
291
209
  }
292
- let agent;
293
210
  try {
294
211
  const config = await loadRadarConfig(cwd);
295
- agent = await getDefaultAgent("research", { explicit: explicitAgent, configOverride: config });
212
+ const agent = await getDefaultAgent("research", {
213
+ explicit: explicitAgent,
214
+ configOverride: config,
215
+ });
216
+ return { agent };
296
217
  }
297
218
  catch (e) {
298
219
  if (e instanceof RadarConfigError) {
299
220
  error(`research: ${e.message}`);
300
- return 2;
221
+ return { exitCode: 2 };
301
222
  }
302
223
  throw e;
303
224
  }
304
- // Phase 2 sub-issues B / C / D / E ship all four adapters. AgentIdSchema
305
- // already rejected invalid `--agent` values upstream, so any value that
306
- // reaches here is supported.
307
- // Default template depends on mode: ADR-0011 §6 pins `digest` as the digest
308
- // templateId so the bundled `templates/digest.md` is picked up automatically
309
- // when the user doesn't pass `--template`.
310
- const templateId = parsed.template ?? (parsed.digest ? "digest" : "default");
311
- // Locate the item(s). Digest mode collects every id up front so a missing id
312
- // fails before we invoke the agent (cheap fail-fast vs. burning tokens).
313
- let items;
314
- if (parsed.digest) {
315
- const result = await findItems(cwd, parsed.itemIds);
316
- if ("missing" in result) {
317
- error(`research: item '${result.missing}' not found under items/`);
318
- return 1;
319
- }
320
- items = result.items;
321
- // ADR-0011 §5: a `dismissed` item must not appear in a digest. Validate
322
- // up-front so the user can either remove the id or re-detect the source
323
- // before re-running.
324
- const dismissed = items.filter((i) => i.status === "dismissed");
325
- if (dismissed.length > 0) {
326
- error(`research: cannot include dismissed items in a digest: ${dismissed.map((i) => i.id).join(", ")}`);
327
- return 1;
328
- }
329
- }
330
- else {
331
- const found = await findItem(cwd, parsed.itemIds[0]);
332
- if (!found) {
333
- error(`research: item '${parsed.itemIds[0]}' not found under items/`);
334
- return 1;
335
- }
336
- items = [found.item];
337
- }
338
- // Surface any prompt-injection pre-filter hits recorded by the watcher
339
- // (ADR-0009 M1a / M5a — Adopt). Audit-only: the agent still runs against
340
- // the original content, but the user gets an explicit warning so they can
341
- // `radar dismiss` and re-evaluate before committing tokens.
225
+ }
226
+ async function processResearchInvocation(params) {
227
+ const { cwd, items, digest, agent, templateId, template, now, log, warn, error, progress } = params;
342
228
  for (const item of items) {
343
229
  if (item.injectionFlags.length > 0) {
344
230
  warn(`research: item '${item.id}' has ${item.injectionFlags.length} injection flag(s): ${item.injectionFlags.join(", ")} (audit-only; use \`radar dismiss\` to skip)`);
345
231
  }
346
232
  }
347
- // Load template.
348
- const templatesDir = join(cwd, "templates");
349
- let template;
350
- try {
351
- template = await loadTemplate(templateId, templatesDir);
352
- }
353
- catch (e) {
354
- error(`research: ${e instanceof Error ? e.message : String(e)}`);
355
- return 1;
356
- }
357
- // Compute output path. Refuse to overwrite an existing file — re-runs go
358
- // through `radar update` per ADR-0003 / ADR-0011 (immutable history).
359
- const now = new Date();
360
233
  let filename;
361
- if (parsed.digest) {
234
+ if (digest) {
362
235
  const datePrefix = buildDigestDatePrefix(now);
363
236
  const slug = deriveDigestSlug(items);
364
237
  filename = `${datePrefix}_digest_${slug}_v1.md`;
@@ -374,14 +247,26 @@ export async function runResearch(args, options = {}) {
374
247
  error(`research: ${outputPath} already exists (use \`radar update\` to re-research)`);
375
248
  return 1;
376
249
  }
377
- const itemDescription = parsed.digest
250
+ const itemDescription = digest
378
251
  ? `${items.length} items (${items.map((i) => i.id).join(", ")})`
379
252
  : `item '${items[0].id}'`;
253
+ // Phase marker: items resolved (ADR-0015 D4 "Loaded <noun>"). One marker
254
+ // per invocation regardless of digest cardinality so the progress stream
255
+ // stays uniform between single / digest / batch modes.
256
+ progress.phase(digest ? `Loaded ${items.length} items` : `Loaded item: ${items[0].id}`, items.map((i) => i.id).join(", "));
257
+ // Phase marker: template resolved. Echoes the actual template id so a
258
+ // user running `--template deep-dive` sees the value flow through.
259
+ progress.phase(`Loaded template: ${templateId}.md`);
380
260
  log(`research: invoking ${agent} adapter for ${itemDescription} -> ${filename}`);
381
- // Invoke adapter. The multi-item signature lands via #140; the adapter
382
- // already handles `items.length === 1` byte-equivalently for single-item
383
- // callers so no branching is needed here.
384
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;
385
270
  try {
386
271
  await adapter.research({
387
272
  agent,
@@ -390,13 +275,22 @@ export async function runResearch(args, options = {}) {
390
275
  items,
391
276
  outputPath,
392
277
  cwd,
278
+ onProgress: buildAgentProgressCallback(progress),
393
279
  });
394
280
  }
395
281
  catch (e) {
282
+ adapterExitCode = 1;
283
+ polling.stop();
284
+ progress.fail("Agent failed", e instanceof Error ? e.message : String(e));
396
285
  error(`research: adapter failed: ${e instanceof Error ? e.message : String(e)}`);
397
286
  return 1;
398
287
  }
399
- // Validate the produced file: frontmatter must parse and match schema.
288
+ finally {
289
+ polling.stop();
290
+ }
291
+ if (adapterExitCode === 0) {
292
+ progress.succeed(`Agent completed (exit ${adapterExitCode})`, Date.now() - adapterStartedAt);
293
+ }
400
294
  if (!(await pathExists(outputPath))) {
401
295
  error(`research: adapter completed but did not write ${outputPath} (agent ignored the output path?)`);
402
296
  return 1;
@@ -425,11 +319,10 @@ export async function runResearch(args, options = {}) {
425
319
  }
426
320
  return 1;
427
321
  }
428
- // Phase 1 contract: review fields must be null. The schema permits null;
429
- // we additionally enforce that the agent did not jump ahead and stamp them.
430
- // Phase 5 contract: `supersedes` is null on v1 by definition (no predecessor
431
- // exists). Defensive reset applies if a misbehaving agent populates it; the
432
- // `update` command (Sub-issue B / #41) is the only writer that may set it.
322
+ // Phase marker: schema check passed. Emitted before the status transition
323
+ // so the user sees the validation outcome separately from the items.yaml
324
+ // write that follows.
325
+ progress.phase("Frontmatter validated");
433
326
  const reviewedDrift = fmResult.data.reviewedAt !== null || fmResult.data.reviewedBy !== null;
434
327
  const supersedesDrift = fmResult.data.supersedes !== null;
435
328
  if (reviewedDrift || supersedesDrift) {
@@ -439,8 +332,8 @@ export async function runResearch(args, options = {}) {
439
332
  if (supersedesDrift) {
440
333
  warn("research: agent populated supersedes; resetting to null (Phase 5 contract — v1 has no predecessor; `update` writes supersedes)");
441
334
  }
442
- const parsed = matter(body, matterOptions);
443
- const rewritten = matter.stringify(parsed.content, {
335
+ const parsedReport = matter(body, matterOptions);
336
+ const rewritten = matter.stringify(parsedReport.content, {
444
337
  ...fmResult.data,
445
338
  reviewedAt: null,
446
339
  reviewedBy: null,
@@ -448,14 +341,8 @@ export async function runResearch(args, options = {}) {
448
341
  }, matterOptions);
449
342
  await writeFile(outputPath, rewritten, "utf8");
450
343
  }
451
- // Transition item status. ADR-0011 §5: every included item transitions
452
- // `detected` → `researched`; terminal states (`researched` / `reviewed`)
453
- // are protected and pass through unchanged so a digest that re-includes
454
- // an already-researched item does not regress its status.
455
344
  const updated = items.map((item) => item.status === "detected" ? { ...item, status: "researched" } : item);
456
345
  try {
457
- // saveItems writes by sourceId+id, so it will overwrite the existing file
458
- // in place. We rely on this rather than constructing the path manually.
459
346
  await saveItems(join(cwd, "items"), updated);
460
347
  }
461
348
  catch (e) {
@@ -466,11 +353,239 @@ export async function runResearch(args, options = {}) {
466
353
  log(`research: wrote ${outputPath}`);
467
354
  for (const item of updated) {
468
355
  if (item.status === "researched") {
356
+ // Phase marker: status transition. We emit one phase per item rather
357
+ // than collapsing them so the digest case stays explicit about what
358
+ // moved. The arrow uses U+2192 (→) per ADR-0015 D4 examples.
359
+ progress.phase(`Status: detected → researched`, `items/${item.sourceId}/${item.id}.yaml`);
469
360
  log(`research: items/${item.sourceId}/${item.id}.yaml status -> researched`);
470
361
  }
471
362
  }
472
363
  return 0;
473
364
  }
365
+ function parseMaxItems(raw, error) {
366
+ if (raw === undefined)
367
+ return RESEARCH_BATCH_DEFAULT_MAX_ITEMS;
368
+ if (!/^[0-9]+$/.test(raw)) {
369
+ error(`research: invalid --max-items '${raw}' (expected positive integer)`);
370
+ return null;
371
+ }
372
+ const n = Number.parseInt(raw, 10);
373
+ if (!Number.isFinite(n) || n <= 0) {
374
+ error(`research: invalid --max-items '${raw}' (must be > 0)`);
375
+ return null;
376
+ }
377
+ return n;
378
+ }
379
+ function parseFilterTags(raw) {
380
+ if (raw === undefined || raw.trim() === "")
381
+ return [];
382
+ return [
383
+ ...new Set(raw
384
+ .split(",")
385
+ .map((s) => s.trim().toLowerCase())
386
+ .filter((s) => s.length > 0)),
387
+ ];
388
+ }
389
+ async function runResearchBatch(parsed, cwd, log, warn, error, progress) {
390
+ if (parsed.itemIds.length > 0) {
391
+ error(`research: --batch is incompatible with positional <item-id> arguments (got ${parsed.itemIds.length})`);
392
+ return 2;
393
+ }
394
+ if (parsed.digest) {
395
+ error("research: --batch is incompatible with --digest");
396
+ return 2;
397
+ }
398
+ 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)`);
402
+ return 2;
403
+ }
404
+ const status = statusResult.data;
405
+ const maxItems = parseMaxItems(parsed.maxItems, error);
406
+ if (maxItems === null)
407
+ return 2;
408
+ const filterTags = parseFilterTags(parsed.filterTags);
409
+ const agentResult = await resolveAgent(cwd, parsed.agent, error);
410
+ if ("exitCode" in agentResult)
411
+ return agentResult.exitCode;
412
+ const agent = agentResult.agent;
413
+ const templateId = parsed.template ?? "default";
414
+ const templatesDir = join(cwd, "templates");
415
+ let template;
416
+ try {
417
+ template = await loadTemplate(templateId, templatesDir);
418
+ }
419
+ catch (e) {
420
+ error(`research: ${e instanceof Error ? e.message : String(e)}`);
421
+ return 1;
422
+ }
423
+ const itemsDir = join(cwd, "items");
424
+ const all = await loadItems(itemsDir);
425
+ const lowerFilterTags = filterTags;
426
+ const matches = all
427
+ .filter((it) => it.status === status)
428
+ .filter((it) => {
429
+ if (lowerFilterTags.length === 0)
430
+ return true;
431
+ const haystack = new Set(it.matchedKeywords.map((k) => k.toLowerCase()));
432
+ return lowerFilterTags.some((t) => haystack.has(t));
433
+ })
434
+ .sort((a, b) => {
435
+ const ap = a.publishedAt ?? a.fetchedAt;
436
+ const bp = b.publishedAt ?? b.fetchedAt;
437
+ if (ap !== bp)
438
+ return ap < bp ? -1 : 1;
439
+ return a.id.localeCompare(b.id);
440
+ });
441
+ if (matches.length === 0) {
442
+ log(`research: no items matched --batch filters (status=${status}${filterTags.length > 0 ? `, tags=${filterTags.join(",")}` : ""})`);
443
+ return 0;
444
+ }
445
+ let selected = matches;
446
+ if (matches.length > maxItems) {
447
+ const dropped = matches.length - maxItems;
448
+ warn(`research: --max-items ${maxItems} cap reached; dropping ${dropped} excess item(s) (matched ${matches.length})`);
449
+ selected = matches.slice(0, maxItems);
450
+ }
451
+ log(`research: --batch will process ${selected.length} item(s) (status=${status}${filterTags.length > 0 ? `, tags=${filterTags.join(",")}` : ""}, agent=${agent}, cap=${maxItems})`);
452
+ const now = new Date();
453
+ for (const item of selected) {
454
+ const exitCode = await processResearchInvocation({
455
+ cwd,
456
+ items: [item],
457
+ digest: false,
458
+ agent,
459
+ templateId,
460
+ template,
461
+ now,
462
+ log,
463
+ warn,
464
+ error,
465
+ progress,
466
+ });
467
+ if (exitCode !== 0) {
468
+ error(`research: --batch halted on item '${item.id}' (exit ${exitCode})`);
469
+ return exitCode;
470
+ }
471
+ }
472
+ log(`research: --batch completed ${selected.length} item(s)`);
473
+ return 0;
474
+ }
475
+ export async function runResearch(args, options = {}) {
476
+ const cwd = options.cwd ?? process.cwd();
477
+ const log = options.io?.log ?? ((m) => console.log(m));
478
+ const warn = options.io?.warn ?? ((m) => console.warn(m));
479
+ const error = options.io?.error ?? ((m) => console.error(m));
480
+ // Strip the global --verbose / --quiet flags BEFORE the command-specific
481
+ // parser sees argv. This keeps `parseArgs` ignorant of progress concerns
482
+ // (and means a future `radar review --verbose` works the same way without
483
+ // duplicating flag plumbing per command).
484
+ let progressState;
485
+ try {
486
+ progressState = parseProgressFlags(args);
487
+ }
488
+ catch (e) {
489
+ if (e instanceof ProgressFlagError) {
490
+ error(`research: ${e.message}`);
491
+ return 2;
492
+ }
493
+ throw e;
494
+ }
495
+ // Tests inject a reporter directly; production constructs one from the
496
+ // flag state. Either way the per-invocation state stays local — there is
497
+ // no shared global reporter, so concurrent CLI invocations are isolated.
498
+ const progress = options.progress ?? buildReporter({ level: progressState.level });
499
+ let parsed;
500
+ try {
501
+ parsed = parseArgs(progressState.rest);
502
+ }
503
+ catch (e) {
504
+ error(`research: ${e instanceof Error ? e.message : String(e)}`);
505
+ return 2;
506
+ }
507
+ if (parsed.help) {
508
+ printHelp(log);
509
+ return 0;
510
+ }
511
+ if (parsed.batch) {
512
+ return runResearchBatch(parsed, cwd, log, warn, error, progress);
513
+ }
514
+ if (parsed.status !== undefined) {
515
+ error("research: --status requires --batch");
516
+ return 2;
517
+ }
518
+ if (parsed.maxItems !== undefined) {
519
+ error("research: --max-items requires --batch");
520
+ return 2;
521
+ }
522
+ if (parsed.filterTags !== undefined) {
523
+ error("research: --filter-tags requires --batch");
524
+ return 2;
525
+ }
526
+ if (parsed.itemIds.length === 0) {
527
+ error("research: missing <item-id>");
528
+ printHelp(error);
529
+ return 2;
530
+ }
531
+ if (!parsed.digest && parsed.itemIds.length > 1) {
532
+ error(`research: multiple <item-id> arguments require --digest (got ${parsed.itemIds.length}: ${parsed.itemIds.join(", ")})`);
533
+ return 2;
534
+ }
535
+ if (parsed.digest && parsed.itemIds.length < 2) {
536
+ error(`research: --digest requires 2 or more <item-id> arguments (got ${parsed.itemIds.length})`);
537
+ return 2;
538
+ }
539
+ const agentResult = await resolveAgent(cwd, parsed.agent, error);
540
+ if ("exitCode" in agentResult)
541
+ return agentResult.exitCode;
542
+ const agent = agentResult.agent;
543
+ const templateId = parsed.template ?? (parsed.digest ? "digest" : "default");
544
+ let items;
545
+ if (parsed.digest) {
546
+ const result = await findItems(cwd, parsed.itemIds);
547
+ if ("missing" in result) {
548
+ error(`research: item '${result.missing}' not found under items/`);
549
+ return 1;
550
+ }
551
+ items = result.items;
552
+ const dismissed = items.filter((i) => i.status === "dismissed");
553
+ if (dismissed.length > 0) {
554
+ error(`research: cannot include dismissed items in a digest: ${dismissed.map((i) => i.id).join(", ")}`);
555
+ return 1;
556
+ }
557
+ }
558
+ else {
559
+ const found = await findItem(cwd, parsed.itemIds[0]);
560
+ if (!found) {
561
+ error(`research: item '${parsed.itemIds[0]}' not found under items/`);
562
+ return 1;
563
+ }
564
+ items = [found.item];
565
+ }
566
+ const templatesDir = join(cwd, "templates");
567
+ let template;
568
+ try {
569
+ template = await loadTemplate(templateId, templatesDir);
570
+ }
571
+ catch (e) {
572
+ error(`research: ${e instanceof Error ? e.message : String(e)}`);
573
+ return 1;
574
+ }
575
+ return processResearchInvocation({
576
+ cwd,
577
+ items,
578
+ digest: parsed.digest ?? false,
579
+ agent,
580
+ templateId,
581
+ template,
582
+ now: new Date(),
583
+ log,
584
+ warn,
585
+ error,
586
+ progress,
587
+ });
588
+ }
474
589
  export const researchCommand = {
475
590
  name: "research",
476
591
  summary: "Generate Markdown research reports from items via an AI agent",