@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.
- package/.claude-plugin/marketplace.json +9 -12
- package/.claude-plugin/plugin.json +8 -31
- package/CHANGELOG.md +200 -0
- package/README.md +48 -7
- package/bin/gdd-sdk +55 -0
- package/hooks/_hook-emit.js +81 -0
- package/hooks/gdd-bash-guard.js +8 -0
- package/hooks/gdd-decision-injector.js +2 -0
- package/hooks/gdd-protected-paths.js +8 -0
- package/hooks/gdd-trajectory-capture.js +64 -0
- package/hooks/hooks.json +9 -0
- package/package.json +19 -47
- package/reference/codex-tools.md +53 -0
- package/reference/gemini-tools.md +53 -0
- package/reference/registry.json +14 -0
- package/scripts/cli/gdd-events.mjs +283 -0
- package/scripts/e2e/run-headless.ts +514 -0
- package/scripts/lib/cli/commands/audit.ts +382 -0
- package/scripts/lib/cli/commands/init.ts +217 -0
- package/scripts/lib/cli/commands/query.ts +329 -0
- package/scripts/lib/cli/commands/run.ts +656 -0
- package/scripts/lib/cli/commands/stage.ts +468 -0
- package/scripts/lib/cli/index.ts +167 -0
- package/scripts/lib/cli/parse-args.ts +336 -0
- package/scripts/lib/connection-probe/index.cjs +263 -0
- package/scripts/lib/context-engine/index.ts +116 -0
- package/scripts/lib/context-engine/manifest.ts +69 -0
- package/scripts/lib/context-engine/truncate.ts +282 -0
- package/scripts/lib/context-engine/types.ts +59 -0
- package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
- package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
- package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
- package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
- package/scripts/lib/event-chain.cjs +177 -0
- package/scripts/lib/event-stream/index.ts +31 -1
- package/scripts/lib/event-stream/reader.ts +139 -0
- package/scripts/lib/event-stream/types.ts +155 -1
- package/scripts/lib/event-stream/writer.ts +65 -8
- package/scripts/lib/explore-parallel-runner/index.ts +294 -0
- package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
- package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
- package/scripts/lib/explore-parallel-runner/types.ts +139 -0
- package/scripts/lib/harness/detect.ts +90 -0
- package/scripts/lib/harness/index.ts +64 -0
- package/scripts/lib/harness/tool-map.ts +142 -0
- package/scripts/lib/init-runner/index.ts +396 -0
- package/scripts/lib/init-runner/researchers.ts +245 -0
- package/scripts/lib/init-runner/scaffold.ts +224 -0
- package/scripts/lib/init-runner/synthesizer.ts +224 -0
- package/scripts/lib/init-runner/types.ts +143 -0
- package/scripts/lib/logger/index.ts +251 -0
- package/scripts/lib/logger/sinks.ts +269 -0
- package/scripts/lib/logger/types.ts +110 -0
- package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
- package/scripts/lib/pipeline-runner/index.ts +527 -0
- package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
- package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
- package/scripts/lib/pipeline-runner/types.ts +183 -0
- package/scripts/lib/redact.cjs +122 -0
- package/scripts/lib/session-runner/errors.ts +406 -0
- package/scripts/lib/session-runner/index.ts +715 -0
- package/scripts/lib/session-runner/transcript.ts +189 -0
- package/scripts/lib/session-runner/types.ts +144 -0
- package/scripts/lib/tool-scoping/index.ts +219 -0
- package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
- package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
- package/scripts/lib/tool-scoping/types.ts +77 -0
- package/scripts/lib/trajectory/index.cjs +126 -0
- 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
|
+
}
|