@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.
- package/README.md +2 -1
- package/dist/agents/_boundary.d.ts +74 -1
- package/dist/agents/_boundary.d.ts.map +1 -1
- package/dist/agents/_boundary.js +152 -0
- package/dist/agents/_boundary.js.map +1 -1
- package/dist/claude-skills/dismiss/SKILL.md +18 -12
- package/dist/claude-skills/research/SKILL.md +21 -1
- package/dist/claude-skills/review/SKILL.md +23 -1
- package/dist/claude-skills/update/SKILL.md +24 -2
- package/dist/cli/_commit-path.d.ts +33 -0
- package/dist/cli/_commit-path.d.ts.map +1 -0
- package/dist/cli/_commit-path.js +43 -0
- package/dist/cli/_commit-path.js.map +1 -0
- package/dist/cli/dismiss.d.ts +38 -7
- package/dist/cli/dismiss.d.ts.map +1 -1
- package/dist/cli/dismiss.js +239 -54
- package/dist/cli/dismiss.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +7 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/items.d.ts +44 -0
- package/dist/cli/items.d.ts.map +1 -0
- package/dist/cli/items.js +288 -0
- package/dist/cli/items.js.map +1 -0
- package/dist/cli/research.d.ts +21 -0
- package/dist/cli/research.d.ts.map +1 -1
- package/dist/cli/research.js +360 -54
- package/dist/cli/research.js.map +1 -1
- package/dist/cli/review.d.ts +23 -0
- package/dist/cli/review.d.ts.map +1 -1
- package/dist/cli/review.js +462 -2
- package/dist/cli/review.js.map +1 -1
- package/dist/cli/source.d.ts.map +1 -1
- package/dist/cli/source.js +18 -0
- package/dist/cli/source.js.map +1 -1
- package/dist/cli/triage.d.ts +136 -0
- package/dist/cli/triage.d.ts.map +1 -0
- package/dist/cli/triage.js +1110 -0
- package/dist/cli/triage.js.map +1 -0
- package/dist/cli/undismiss.d.ts +30 -0
- package/dist/cli/undismiss.d.ts.map +1 -0
- package/dist/cli/undismiss.js +133 -0
- package/dist/cli/undismiss.js.map +1 -0
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +429 -141
- package/dist/cli/update.js.map +1 -1
- package/dist/cli/workflow/generate-combined-with-triage.d.ts +163 -0
- package/dist/cli/workflow/generate-combined-with-triage.d.ts.map +1 -0
- package/dist/cli/workflow/generate-combined-with-triage.js +582 -0
- package/dist/cli/workflow/generate-combined-with-triage.js.map +1 -0
- package/dist/cli/workflow.d.ts +6 -5
- package/dist/cli/workflow.d.ts.map +1 -1
- package/dist/cli/workflow.js +13 -8
- package/dist/cli/workflow.js.map +1 -1
- package/dist/core/feeds/json-api.d.ts +5 -2
- package/dist/core/feeds/json-api.d.ts.map +1 -1
- package/dist/core/feeds/json-api.js +99 -13
- package/dist/core/feeds/json-api.js.map +1 -1
- package/dist/core/feeds/types.d.ts +26 -0
- package/dist/core/feeds/types.d.ts.map +1 -1
- package/dist/core/recipes.d.ts.map +1 -1
- package/dist/core/recipes.js +6 -0
- package/dist/core/recipes.js.map +1 -1
- package/dist/core/transitions.d.ts +30 -0
- package/dist/core/transitions.d.ts.map +1 -0
- package/dist/core/transitions.js +103 -0
- package/dist/core/transitions.js.map +1 -0
- package/dist/core/triage/adapter.d.ts +80 -0
- package/dist/core/triage/adapter.d.ts.map +1 -0
- package/dist/core/triage/adapter.js +128 -0
- package/dist/core/triage/adapter.js.map +1 -0
- package/dist/core/triage/index.d.ts +105 -0
- package/dist/core/triage/index.d.ts.map +1 -0
- package/dist/core/triage/index.js +246 -0
- package/dist/core/triage/index.js.map +1 -0
- package/dist/core/triage/prompt.d.ts +30 -0
- package/dist/core/triage/prompt.d.ts.map +1 -0
- package/dist/core/triage/prompt.js +157 -0
- package/dist/core/triage/prompt.js.map +1 -0
- package/dist/core/triage/response.d.ts +114 -0
- package/dist/core/triage/response.d.ts.map +1 -0
- package/dist/core/triage/response.js +188 -0
- package/dist/core/triage/response.js.map +1 -0
- package/dist/gemini-commands/research.toml +1 -1
- package/dist/gemini-commands/review.toml +1 -1
- package/dist/gemini-commands/update.toml +1 -1
- package/dist/recipes/aws-whats-new.yaml +36 -1
- package/dist/recipes/dev-to.yaml +24 -0
- package/dist/schemas/item.d.ts +151 -5
- package/dist/schemas/item.d.ts.map +1 -1
- package/dist/schemas/item.js +164 -4
- package/dist/schemas/item.js.map +1 -1
- package/dist/schemas/recipe.d.ts +11 -1
- package/dist/schemas/recipe.d.ts.map +1 -1
- package/dist/schemas/recipe.js +10 -1
- package/dist/schemas/recipe.js.map +1 -1
- package/dist/schemas/source.d.ts +65 -4
- package/dist/schemas/source.d.ts.map +1 -1
- package/dist/schemas/source.js +65 -3
- package/dist/schemas/source.js.map +1 -1
- package/dist/skills/research/SKILL.md +57 -1
- package/dist/skills/review/SKILL.md +65 -1
- package/dist/skills/update/SKILL.md +54 -1
- package/dist/templates/agents/AGENTS.md +30 -0
- package/dist/templates/workflows/combined-with-triage.template.yaml.tmpl +132 -0
- package/package.json +1 -1
package/dist/cli/research.js
CHANGED
|
@@ -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 {
|
|
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 |
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
400
|
-
|
|
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 =
|
|
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,
|