@hegemonart/get-design-done 1.20.0 → 1.22.0

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 (69) hide show
  1. package/.claude-plugin/marketplace.json +9 -12
  2. package/.claude-plugin/plugin.json +8 -31
  3. package/CHANGELOG.md +200 -0
  4. package/README.md +48 -7
  5. package/bin/gdd-sdk +55 -0
  6. package/hooks/_hook-emit.js +81 -0
  7. package/hooks/gdd-bash-guard.js +8 -0
  8. package/hooks/gdd-decision-injector.js +2 -0
  9. package/hooks/gdd-protected-paths.js +8 -0
  10. package/hooks/gdd-trajectory-capture.js +64 -0
  11. package/hooks/hooks.json +9 -0
  12. package/package.json +19 -47
  13. package/reference/codex-tools.md +53 -0
  14. package/reference/gemini-tools.md +53 -0
  15. package/reference/registry.json +14 -0
  16. package/scripts/cli/gdd-events.mjs +283 -0
  17. package/scripts/e2e/run-headless.ts +514 -0
  18. package/scripts/lib/cli/commands/audit.ts +382 -0
  19. package/scripts/lib/cli/commands/init.ts +217 -0
  20. package/scripts/lib/cli/commands/query.ts +329 -0
  21. package/scripts/lib/cli/commands/run.ts +656 -0
  22. package/scripts/lib/cli/commands/stage.ts +468 -0
  23. package/scripts/lib/cli/index.ts +167 -0
  24. package/scripts/lib/cli/parse-args.ts +336 -0
  25. package/scripts/lib/connection-probe/index.cjs +263 -0
  26. package/scripts/lib/context-engine/index.ts +116 -0
  27. package/scripts/lib/context-engine/manifest.ts +69 -0
  28. package/scripts/lib/context-engine/truncate.ts +282 -0
  29. package/scripts/lib/context-engine/types.ts +59 -0
  30. package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
  31. package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
  32. package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
  33. package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
  34. package/scripts/lib/event-chain.cjs +177 -0
  35. package/scripts/lib/event-stream/index.ts +31 -1
  36. package/scripts/lib/event-stream/reader.ts +139 -0
  37. package/scripts/lib/event-stream/types.ts +155 -1
  38. package/scripts/lib/event-stream/writer.ts +65 -8
  39. package/scripts/lib/explore-parallel-runner/index.ts +294 -0
  40. package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
  41. package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
  42. package/scripts/lib/explore-parallel-runner/types.ts +139 -0
  43. package/scripts/lib/harness/detect.ts +90 -0
  44. package/scripts/lib/harness/index.ts +64 -0
  45. package/scripts/lib/harness/tool-map.ts +142 -0
  46. package/scripts/lib/init-runner/index.ts +396 -0
  47. package/scripts/lib/init-runner/researchers.ts +245 -0
  48. package/scripts/lib/init-runner/scaffold.ts +224 -0
  49. package/scripts/lib/init-runner/synthesizer.ts +224 -0
  50. package/scripts/lib/init-runner/types.ts +143 -0
  51. package/scripts/lib/logger/index.ts +251 -0
  52. package/scripts/lib/logger/sinks.ts +269 -0
  53. package/scripts/lib/logger/types.ts +110 -0
  54. package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
  55. package/scripts/lib/pipeline-runner/index.ts +527 -0
  56. package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
  57. package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
  58. package/scripts/lib/pipeline-runner/types.ts +183 -0
  59. package/scripts/lib/redact.cjs +122 -0
  60. package/scripts/lib/session-runner/errors.ts +406 -0
  61. package/scripts/lib/session-runner/index.ts +715 -0
  62. package/scripts/lib/session-runner/transcript.ts +189 -0
  63. package/scripts/lib/session-runner/types.ts +144 -0
  64. package/scripts/lib/tool-scoping/index.ts +219 -0
  65. package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
  66. package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
  67. package/scripts/lib/tool-scoping/types.ts +77 -0
  68. package/scripts/lib/trajectory/index.cjs +126 -0
  69. package/scripts/lib/transports/ws.cjs +179 -0
@@ -0,0 +1,396 @@
1
+ // scripts/lib/init-runner/index.ts — public entry for the `gdd-sdk init`
2
+ // runner (Plan 21-08, SDK-20).
3
+ //
4
+ // Public surface:
5
+ //
6
+ // run(opts: InitRunnerOptions): Promise<InitRunnerResult>
7
+ // DEFAULT_RESEARCHERS: readonly ResearcherSpec[]
8
+ // + re-exports of submodule types + helpers.
9
+ //
10
+ // Orchestration algorithm:
11
+ //
12
+ // 1. Resolve cwd + .design/ path.
13
+ // 2. Existence check on .design/STATE.md:
14
+ // - exists + !force → return status: 'already-initialized'
15
+ // - exists + force → backup via backupExistingDesignDir
16
+ // 3. ensureDesignDirs (idempotent).
17
+ // 4. writeStateFromTemplate. Missing template → status: 'error'.
18
+ // 5. spawnResearchersParallel with concurrency ?? 4.
19
+ // 6. Filter to successful outcomes. Zero → status: 'no-researchers-succeeded'.
20
+ // 7. Read each successful output; build SynthesizerInput[].
21
+ // 8. spawnSynthesizer.
22
+ // 9. Aggregate usage + emit lifecycle logs.
23
+ // 10. Return InitRunnerResult.
24
+ //
25
+ // This module emits two logger events:
26
+ // * init.runner.started — before researcher dispatch.
27
+ // * init.runner.completed — regardless of status.
28
+
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import { resolve } from 'node:path';
31
+
32
+ import { getLogger } from '../logger/index.ts';
33
+ import {
34
+ spawnResearcher,
35
+ spawnResearchersParallel,
36
+ } from './researchers.ts';
37
+ import {
38
+ buildSynthesizerPrompt,
39
+ DEFAULT_SYNTHESIZER_PROMPT,
40
+ spawnSynthesizer,
41
+ } from './synthesizer.ts';
42
+ import {
43
+ backupExistingDesignDir,
44
+ ensureDesignDirs,
45
+ resolveStateTemplatePath,
46
+ writeStateFromTemplate,
47
+ } from './scaffold.ts';
48
+ import type {
49
+ InitRunnerOptions,
50
+ InitRunnerResult,
51
+ InitStatus,
52
+ ResearcherName,
53
+ ResearcherOutcome,
54
+ ResearcherSpec,
55
+ } from './types.ts';
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Re-exports — consumers import everything from this file.
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export type {
62
+ InitRunnerOptions,
63
+ InitRunnerResult,
64
+ InitStatus,
65
+ ResearcherName,
66
+ ResearcherOutcome,
67
+ ResearcherSpec,
68
+ } from './types.ts';
69
+ export { spawnResearcher, spawnResearchersParallel } from './researchers.ts';
70
+ export {
71
+ buildSynthesizerPrompt,
72
+ DEFAULT_SYNTHESIZER_PROMPT,
73
+ spawnSynthesizer,
74
+ } from './synthesizer.ts';
75
+ export type {
76
+ SpawnSynthesizerArgs,
77
+ SpawnSynthesizerResult,
78
+ SynthesizerInput,
79
+ } from './synthesizer.ts';
80
+ export type {
81
+ SpawnParallelOptions,
82
+ SpawnResearcherOptions,
83
+ } from './researchers.ts';
84
+ export {
85
+ backupExistingDesignDir,
86
+ ensureDesignDirs,
87
+ resolveStateTemplatePath,
88
+ writeStateFromTemplate,
89
+ } from './scaffold.ts';
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // DEFAULT_RESEARCHERS — frozen roster of four
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * The locked 4-researcher roster run by default. Callers can override
97
+ * via `opts.researchers`, but the roster is stable enough that widening
98
+ * it would be a breaking change (CLI summary rendering, telemetry
99
+ * aggregation, and the synthesizer prompt all depend on exactly four).
100
+ */
101
+ export const DEFAULT_RESEARCHERS: readonly ResearcherSpec[] = Object.freeze([
102
+ Object.freeze({
103
+ name: 'design-system-audit' as const,
104
+ prompt:
105
+ 'Audit this repo for existing design system surface: design tokens (CSS vars, Tailwind config, JS const exports), components (file names + prop shapes), patterns (recurring UI idioms). Output .design/research/design-system-audit.md with findings organized as: Tokens, Components, Patterns, Gaps.',
106
+ outputPath: '.design/research/design-system-audit.md',
107
+ }),
108
+ Object.freeze({
109
+ name: 'brand-context' as const,
110
+ prompt:
111
+ 'Scan this repo for brand signals: README, marketing/landing pages, style guides, voice/tone docs. Infer archetype (per reference/typography.md), voice, visual tone. Output .design/research/brand-context.md.',
112
+ outputPath: '.design/research/brand-context.md',
113
+ }),
114
+ Object.freeze({
115
+ name: 'accessibility-baseline' as const,
116
+ prompt:
117
+ 'Scan this repo for WCAG conformance baseline: color contrast, keyboard navigation, ARIA labels, focus management, motion preferences. Output .design/research/accessibility-baseline.md with findings + a conformance score (AA / partial / fail).',
118
+ outputPath: '.design/research/accessibility-baseline.md',
119
+ }),
120
+ Object.freeze({
121
+ name: 'competitive-references' as const,
122
+ prompt:
123
+ 'Identify 3-5 peer products in the same domain as this repo. Use WebSearch + WebFetch. For each, extract design patterns worth referencing. Output .design/research/competitive-references.md.',
124
+ outputPath: '.design/research/competitive-references.md',
125
+ }),
126
+ ]);
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // run — top-level orchestrator
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /** Default concurrency when not specified. Matches the 4-researcher roster. */
133
+ const DEFAULT_CONCURRENCY = 4;
134
+
135
+ /**
136
+ * Bootstrap a `.design/` directory for a fresh project. See module
137
+ * header for the algorithm. Never throws; every failure mode surfaces
138
+ * as `InitRunnerResult.status`.
139
+ */
140
+ export async function run(opts: InitRunnerOptions): Promise<InitRunnerResult> {
141
+ const logger = getLogger();
142
+ const cwd = resolve(opts.cwd ?? process.cwd());
143
+ const designDir = resolve(cwd, '.design');
144
+ const researchers = opts.researchers ?? DEFAULT_RESEARCHERS;
145
+
146
+ logger.info('init.runner.started', {
147
+ cwd,
148
+ design_dir: designDir,
149
+ researcher_count: researchers.length,
150
+ force: opts.force === true,
151
+ });
152
+
153
+ // ------------------------------------------------------------------
154
+ // 1. Re-init safety check.
155
+ // ------------------------------------------------------------------
156
+ const stateMdPath = resolve(designDir, 'STATE.md');
157
+ let backupDir: string | null = null;
158
+ if (existsSync(stateMdPath)) {
159
+ if (opts.force !== true) {
160
+ const result = buildResult({
161
+ status: 'already-initialized',
162
+ cwd,
163
+ designDir,
164
+ researchers: [],
165
+ stateMdWritten: false,
166
+ designContextMdWritten: false,
167
+ totalUsage: zeroUsage(),
168
+ });
169
+ logger.info('init.runner.completed', logPayloadFor(result));
170
+ return result;
171
+ }
172
+ backupDir = backupExistingDesignDir(cwd);
173
+ }
174
+
175
+ // ------------------------------------------------------------------
176
+ // 2. Ensure .design/ + .design/research/ exist.
177
+ // ------------------------------------------------------------------
178
+ ensureDesignDirs(cwd);
179
+
180
+ // ------------------------------------------------------------------
181
+ // 3. Write STATE.md from template.
182
+ // ------------------------------------------------------------------
183
+ const templatePath =
184
+ opts.stateTemplatePath ?? resolveStateTemplatePath() ?? '';
185
+ const stateWritten = templatePath === ''
186
+ ? false
187
+ : writeStateFromTemplate({
188
+ cwd,
189
+ templatePath,
190
+ destPath: stateMdPath,
191
+ });
192
+
193
+ if (!stateWritten) {
194
+ const result = buildResult({
195
+ status: 'error',
196
+ cwd,
197
+ designDir,
198
+ researchers: [],
199
+ stateMdWritten: false,
200
+ designContextMdWritten: false,
201
+ totalUsage: zeroUsage(),
202
+ ...(backupDir !== null ? { backupDir } : {}),
203
+ });
204
+ logger.error('init.runner.completed', {
205
+ ...logPayloadFor(result),
206
+ reason: 'STATE-TEMPLATE.md not found or unreadable',
207
+ template_path: templatePath,
208
+ });
209
+ return result;
210
+ }
211
+
212
+ // ------------------------------------------------------------------
213
+ // 4. Spawn researchers in parallel.
214
+ // ------------------------------------------------------------------
215
+ const outcomes = await spawnResearchersParallel(researchers, {
216
+ concurrency: opts.concurrency ?? DEFAULT_CONCURRENCY,
217
+ budget: opts.budget,
218
+ maxTurns: opts.maxTurnsPerResearcher,
219
+ cwd,
220
+ ...(opts.runOverride !== undefined
221
+ ? { runOverride: opts.runOverride }
222
+ : {}),
223
+ });
224
+
225
+ const successful = outcomes.filter(
226
+ (o) => o.status === 'completed' && o.output_exists,
227
+ );
228
+ if (successful.length === 0) {
229
+ const totalUsage = aggregateUsage(outcomes);
230
+ const result = buildResult({
231
+ status: 'no-researchers-succeeded',
232
+ cwd,
233
+ designDir,
234
+ researchers: outcomes,
235
+ stateMdWritten: true,
236
+ designContextMdWritten: false,
237
+ totalUsage,
238
+ ...(backupDir !== null ? { backupDir } : {}),
239
+ });
240
+ logger.warn('init.runner.completed', {
241
+ ...logPayloadFor(result),
242
+ reason: 'all researchers failed or produced no output',
243
+ });
244
+ return result;
245
+ }
246
+
247
+ // ------------------------------------------------------------------
248
+ // 5. Load successful researcher outputs and spawn synthesizer.
249
+ // ------------------------------------------------------------------
250
+ const specByName = new Map<ResearcherName, ResearcherSpec>(
251
+ researchers.map((s) => [s.name, s]),
252
+ );
253
+ const synthesizerInputs = successful
254
+ .map((o) => {
255
+ const spec = specByName.get(o.name);
256
+ if (spec === undefined) return null;
257
+ const absPath = resolve(cwd, spec.outputPath);
258
+ let content: string;
259
+ try {
260
+ content = readFileSync(absPath, 'utf8');
261
+ } catch {
262
+ return null;
263
+ }
264
+ return { name: o.name, path: absPath, content };
265
+ })
266
+ .filter((x): x is { name: ResearcherName; path: string; content: string } =>
267
+ x !== null,
268
+ );
269
+
270
+ const synth = await spawnSynthesizer({
271
+ researcherOutputs: synthesizerInputs,
272
+ cwd,
273
+ budget: opts.synthesizerBudget,
274
+ maxTurns: opts.synthesizerMaxTurns,
275
+ ...(opts.runOverride !== undefined
276
+ ? { runOverride: opts.runOverride }
277
+ : {}),
278
+ ...(opts.synthesizerPromptOverride !== undefined
279
+ ? { promptOverride: opts.synthesizerPromptOverride }
280
+ : {}),
281
+ });
282
+
283
+ // ------------------------------------------------------------------
284
+ // 6. Aggregate + return.
285
+ // ------------------------------------------------------------------
286
+ const totalUsage = aggregateUsageAll(outcomes, synth.usage);
287
+ const designContextWritten = synth.status === 'completed';
288
+ const status: InitStatus = 'completed';
289
+
290
+ const result = buildResult({
291
+ status,
292
+ cwd,
293
+ designDir,
294
+ researchers: outcomes,
295
+ stateMdWritten: true,
296
+ designContextMdWritten: designContextWritten,
297
+ totalUsage,
298
+ ...(backupDir !== null ? { backupDir } : {}),
299
+ });
300
+
301
+ if (synth.status !== 'completed') {
302
+ logger.warn('init.runner.completed', {
303
+ ...logPayloadFor(result),
304
+ synthesizer_error: synth.error ?? 'unknown',
305
+ });
306
+ } else {
307
+ logger.info('init.runner.completed', logPayloadFor(result));
308
+ }
309
+
310
+ return result;
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Helpers
315
+ // ---------------------------------------------------------------------------
316
+
317
+ function zeroUsage(): InitRunnerResult['total_usage'] {
318
+ return { input_tokens: 0, output_tokens: 0, usd_cost: 0 };
319
+ }
320
+
321
+ function aggregateUsage(
322
+ outcomes: readonly ResearcherOutcome[],
323
+ ): InitRunnerResult['total_usage'] {
324
+ let input = 0;
325
+ let output = 0;
326
+ let cost = 0;
327
+ for (const o of outcomes) {
328
+ input += o.usage.input_tokens;
329
+ output += o.usage.output_tokens;
330
+ cost += o.usage.usd_cost;
331
+ }
332
+ return { input_tokens: input, output_tokens: output, usd_cost: cost };
333
+ }
334
+
335
+ function aggregateUsageAll(
336
+ outcomes: readonly ResearcherOutcome[],
337
+ synthUsage: { input_tokens: number; output_tokens: number; usd_cost: number },
338
+ ): InitRunnerResult['total_usage'] {
339
+ const researcherUsage = aggregateUsage(outcomes);
340
+ return {
341
+ input_tokens: researcherUsage.input_tokens + synthUsage.input_tokens,
342
+ output_tokens: researcherUsage.output_tokens + synthUsage.output_tokens,
343
+ usd_cost: researcherUsage.usd_cost + synthUsage.usd_cost,
344
+ };
345
+ }
346
+
347
+ interface BuildResultInput {
348
+ readonly status: InitStatus;
349
+ readonly cwd: string;
350
+ readonly designDir: string;
351
+ readonly researchers: readonly ResearcherOutcome[];
352
+ readonly stateMdWritten: boolean;
353
+ readonly designContextMdWritten: boolean;
354
+ readonly totalUsage: InitRunnerResult['total_usage'];
355
+ readonly backupDir?: string;
356
+ }
357
+
358
+ function buildResult(input: BuildResultInput): InitRunnerResult {
359
+ const scaffold: InitRunnerResult['scaffold'] = input.backupDir !== undefined
360
+ ? {
361
+ state_md_written: input.stateMdWritten,
362
+ design_context_md_written: input.designContextMdWritten,
363
+ backup_dir: input.backupDir,
364
+ }
365
+ : {
366
+ state_md_written: input.stateMdWritten,
367
+ design_context_md_written: input.designContextMdWritten,
368
+ };
369
+
370
+ return Object.freeze({
371
+ status: input.status,
372
+ cwd: input.cwd,
373
+ design_dir: input.designDir,
374
+ researchers: input.researchers,
375
+ scaffold,
376
+ total_usage: input.totalUsage,
377
+ });
378
+ }
379
+
380
+ function logPayloadFor(result: InitRunnerResult): Record<string, unknown> {
381
+ return {
382
+ status: result.status,
383
+ cwd: result.cwd,
384
+ design_dir: result.design_dir,
385
+ researcher_total: result.researchers.length,
386
+ researcher_succeeded: result.researchers.filter(
387
+ (r) => r.status === 'completed' && r.output_exists,
388
+ ).length,
389
+ state_md_written: result.scaffold.state_md_written,
390
+ design_context_md_written: result.scaffold.design_context_md_written,
391
+ ...(result.scaffold.backup_dir !== undefined
392
+ ? { backup_dir: result.scaffold.backup_dir }
393
+ : {}),
394
+ total_usage: result.total_usage,
395
+ };
396
+ }
@@ -0,0 +1,245 @@
1
+ // scripts/lib/init-runner/researchers.ts — researcher dispatch for the
2
+ // `gdd-sdk init` runner (Plan 21-08, SDK-20).
3
+ //
4
+ // Two exports:
5
+ //
6
+ // * spawnResearcher(spec, opts) — one session, returns ResearcherOutcome.
7
+ // * spawnResearchersParallel(specs, opts) — semaphore-bound concurrent dispatch.
8
+ //
9
+ // Each researcher runs through `session-runner.run()` so the session
10
+ // layer owns budget + turn-cap + sanitizer + transcript policy. This
11
+ // module only orchestrates and packages outcomes.
12
+ //
13
+ // Tool scope resolution:
14
+ // * If `spec.agentPath` is set and the file exists, parse its
15
+ // frontmatter tools list via `parseAgentTools`.
16
+ // * Otherwise (or if parse returns `null` meaning wildcard/absent),
17
+ // use the `init` stage scope from `tool-scoping`.
18
+ //
19
+ // Never-throws contract: a thrown session + a session with `status !==
20
+ // 'completed'` both land as `ResearcherOutcome.status = 'error'`. The
21
+ // outer `spawnResearchersParallel` therefore never rejects even if
22
+ // every researcher explodes.
23
+
24
+ import { existsSync } from 'node:fs';
25
+
26
+ import { run as runSession } from '../session-runner/index.ts';
27
+ import type {
28
+ BudgetCap,
29
+ QueryOverride,
30
+ SessionResult,
31
+ } from '../session-runner/types.ts';
32
+ import { enforceScope, parseAgentTools } from '../tool-scoping/index.ts';
33
+ import type { ResearcherOutcome, ResearcherSpec } from './types.ts';
34
+ import { fileSize } from './scaffold.ts';
35
+
36
+ /** Monotonic-enough wall-clock helper. Used for duration_ms measurement.
37
+ * We use `Date.now()` rather than `performance.now()` because the Node
38
+ * `perf_hooks` module is unavailable in some sandboxed test runners;
39
+ * ms-precision is plenty for a researcher that runs for seconds. */
40
+ function nowMs(): number {
41
+ return Date.now();
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // spawnResearcher — single researcher session
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export interface SpawnResearcherOptions {
49
+ readonly budget: BudgetCap;
50
+ readonly maxTurns: number;
51
+ /** Test-injectable `queryOverride` forwarded into session-runner. */
52
+ readonly runOverride?: QueryOverride;
53
+ readonly cwd: string;
54
+ }
55
+
56
+ /**
57
+ * Spawn one researcher session through `session-runner.run()`. Returns
58
+ * a structured `ResearcherOutcome` regardless of outcome; never throws
59
+ * for any session-level failure mode.
60
+ *
61
+ * Dual measurement:
62
+ * * `output_exists` + `output_bytes` — measured on disk AFTER the
63
+ * session returns; a session that claimed success but failed to
64
+ * call the Write tool lands with `output_exists: false`.
65
+ * * `usage`, `duration_ms`, `error` — read off the `SessionResult`.
66
+ */
67
+ export async function spawnResearcher(
68
+ spec: ResearcherSpec,
69
+ opts: SpawnResearcherOptions,
70
+ ): Promise<ResearcherOutcome> {
71
+ const start = nowMs();
72
+
73
+ // Resolve allowed tool list via tool-scoping.
74
+ //
75
+ // `parseAgentTools` returns:
76
+ // null — file missing, no frontmatter, tools absent, or wildcard
77
+ // [] — explicit MCP-only (tools: [])
78
+ // string[] — declared list
79
+ //
80
+ // Pass `agentTools` to `enforceScope` ONLY when we got a concrete list
81
+ // or an explicit empty array; `null` → omit so the stage default wins.
82
+ let agentTools: readonly string[] | null = null;
83
+ if (spec.agentPath !== undefined && existsSync(spec.agentPath)) {
84
+ agentTools = parseAgentTools(spec.agentPath);
85
+ }
86
+
87
+ let allowedTools: readonly string[];
88
+ try {
89
+ allowedTools = enforceScope({
90
+ stage: 'init',
91
+ ...(agentTools !== null ? { agentTools } : {}),
92
+ });
93
+ } catch (err) {
94
+ // Scope enforcement failure — package as a researcher error without
95
+ // throwing. This is a precondition bug; the caller can present it
96
+ // alongside any other researcher failures.
97
+ return packageErrorOutcome(spec, start, 'SCOPE_ENFORCEMENT', err);
98
+ }
99
+
100
+ // Build the session options. `runOverride` is forwarded as the
101
+ // session-runner's `queryOverride` (same shape).
102
+ let session: SessionResult;
103
+ try {
104
+ session = await runSession({
105
+ prompt: spec.prompt,
106
+ stage: 'init',
107
+ budget: opts.budget,
108
+ turnCap: { maxTurns: opts.maxTurns },
109
+ allowedTools: [...allowedTools],
110
+ ...(opts.runOverride !== undefined
111
+ ? { queryOverride: opts.runOverride }
112
+ : {}),
113
+ });
114
+ } catch (err) {
115
+ // session-runner.run() is documented to never throw — but be
116
+ // defensive: if a test injects a runOverride that throws during
117
+ // setup we still package a clean outcome.
118
+ return packageErrorOutcome(spec, start, 'SESSION_THREW', err);
119
+ }
120
+
121
+ const duration = nowMs() - start;
122
+ const outputExists = existsSync(spec.outputPath);
123
+ const outputBytes = outputExists ? fileSize(spec.outputPath) : 0;
124
+
125
+ if (session.status === 'completed') {
126
+ return Object.freeze({
127
+ name: spec.name,
128
+ status: 'completed' as const,
129
+ output_exists: outputExists,
130
+ output_bytes: outputBytes,
131
+ usage: {
132
+ input_tokens: session.usage.input_tokens,
133
+ output_tokens: session.usage.output_tokens,
134
+ usd_cost: session.usage.usd_cost,
135
+ },
136
+ duration_ms: duration,
137
+ });
138
+ }
139
+
140
+ // Non-completed statuses (budget_exceeded, turn_cap_exceeded,
141
+ // aborted, error) all land here as researcher errors. Preserve the
142
+ // session-runner's error code/message when present, otherwise
143
+ // synthesize one from the status.
144
+ const code = session.error?.code ?? session.status.toUpperCase();
145
+ const message = session.error?.message ?? `session ended: ${session.status}`;
146
+ return Object.freeze({
147
+ name: spec.name,
148
+ status: 'error' as const,
149
+ output_exists: outputExists,
150
+ output_bytes: outputBytes,
151
+ usage: {
152
+ input_tokens: session.usage.input_tokens,
153
+ output_tokens: session.usage.output_tokens,
154
+ usd_cost: session.usage.usd_cost,
155
+ },
156
+ duration_ms: duration,
157
+ error: { code, message },
158
+ });
159
+ }
160
+
161
+ /** Build a ResearcherOutcome for a local (non-session) error. */
162
+ function packageErrorOutcome(
163
+ spec: ResearcherSpec,
164
+ start: number,
165
+ code: string,
166
+ err: unknown,
167
+ ): ResearcherOutcome {
168
+ const message = err instanceof Error ? err.message : String(err);
169
+ const outputExists = existsSync(spec.outputPath);
170
+ const outputBytes = outputExists ? fileSize(spec.outputPath) : 0;
171
+ return Object.freeze({
172
+ name: spec.name,
173
+ status: 'error' as const,
174
+ output_exists: outputExists,
175
+ output_bytes: outputBytes,
176
+ usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
177
+ duration_ms: nowMs() - start,
178
+ error: { code, message },
179
+ });
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // spawnResearchersParallel — semaphore-bound dispatch
184
+ // ---------------------------------------------------------------------------
185
+
186
+ export interface SpawnParallelOptions {
187
+ readonly concurrency: number;
188
+ readonly budget: BudgetCap;
189
+ readonly maxTurns: number;
190
+ readonly runOverride?: QueryOverride;
191
+ readonly cwd: string;
192
+ }
193
+
194
+ /**
195
+ * Run all `specs` in parallel with a semaphore cap of
196
+ * `opts.concurrency`. The returned outcomes are ordered to match the
197
+ * input `specs` order so consumers can zip the two arrays directly;
198
+ * completion ordering within a batch is timing-dependent and not
199
+ * stable.
200
+ *
201
+ * Never rejects: every outcome is packaged via `spawnResearcher`, which
202
+ * itself never throws.
203
+ */
204
+ export async function spawnResearchersParallel(
205
+ specs: readonly ResearcherSpec[],
206
+ opts: SpawnParallelOptions,
207
+ ): Promise<readonly ResearcherOutcome[]> {
208
+ const concurrency = Math.max(1, Math.floor(opts.concurrency));
209
+ const outcomes: ResearcherOutcome[] = new Array<ResearcherOutcome>(
210
+ specs.length,
211
+ );
212
+
213
+ // Simple index-based worker pool. Workers race for the next slot
214
+ // until all indices are claimed.
215
+ let next = 0;
216
+ async function worker(): Promise<void> {
217
+ for (;;) {
218
+ const i = next;
219
+ next += 1;
220
+ if (i >= specs.length) return;
221
+ const spec = specs[i];
222
+ // specs[i] is guaranteed present (i < specs.length) but the
223
+ // noUncheckedIndexedAccess flag forces the guard.
224
+ if (spec === undefined) return;
225
+ outcomes[i] = await spawnResearcher(spec, {
226
+ budget: opts.budget,
227
+ maxTurns: opts.maxTurns,
228
+ cwd: opts.cwd,
229
+ ...(opts.runOverride !== undefined
230
+ ? { runOverride: opts.runOverride }
231
+ : {}),
232
+ });
233
+ }
234
+ }
235
+
236
+ // Launch `concurrency` workers and await all.
237
+ const workers: Promise<void>[] = [];
238
+ const workerCount = Math.min(concurrency, specs.length);
239
+ for (let w = 0; w < workerCount; w += 1) {
240
+ workers.push(worker());
241
+ }
242
+ await Promise.all(workers);
243
+
244
+ return outcomes;
245
+ }