@hegemonart/get-design-done 1.20.0 → 1.21.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 (54) hide show
  1. package/.claude-plugin/marketplace.json +9 -12
  2. package/.claude-plugin/plugin.json +8 -31
  3. package/CHANGELOG.md +78 -0
  4. package/README.md +48 -7
  5. package/bin/gdd-sdk +55 -0
  6. package/package.json +15 -47
  7. package/reference/codex-tools.md +53 -0
  8. package/reference/gemini-tools.md +53 -0
  9. package/reference/registry.json +14 -0
  10. package/scripts/e2e/run-headless.ts +514 -0
  11. package/scripts/lib/cli/commands/audit.ts +382 -0
  12. package/scripts/lib/cli/commands/init.ts +217 -0
  13. package/scripts/lib/cli/commands/query.ts +329 -0
  14. package/scripts/lib/cli/commands/run.ts +656 -0
  15. package/scripts/lib/cli/commands/stage.ts +468 -0
  16. package/scripts/lib/cli/index.ts +167 -0
  17. package/scripts/lib/cli/parse-args.ts +336 -0
  18. package/scripts/lib/context-engine/index.ts +116 -0
  19. package/scripts/lib/context-engine/manifest.ts +69 -0
  20. package/scripts/lib/context-engine/truncate.ts +282 -0
  21. package/scripts/lib/context-engine/types.ts +59 -0
  22. package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
  23. package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
  24. package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
  25. package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
  26. package/scripts/lib/event-stream/index.ts +11 -1
  27. package/scripts/lib/explore-parallel-runner/index.ts +294 -0
  28. package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
  29. package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
  30. package/scripts/lib/explore-parallel-runner/types.ts +139 -0
  31. package/scripts/lib/harness/detect.ts +90 -0
  32. package/scripts/lib/harness/index.ts +64 -0
  33. package/scripts/lib/harness/tool-map.ts +142 -0
  34. package/scripts/lib/init-runner/index.ts +396 -0
  35. package/scripts/lib/init-runner/researchers.ts +245 -0
  36. package/scripts/lib/init-runner/scaffold.ts +224 -0
  37. package/scripts/lib/init-runner/synthesizer.ts +224 -0
  38. package/scripts/lib/init-runner/types.ts +143 -0
  39. package/scripts/lib/logger/index.ts +251 -0
  40. package/scripts/lib/logger/sinks.ts +269 -0
  41. package/scripts/lib/logger/types.ts +110 -0
  42. package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
  43. package/scripts/lib/pipeline-runner/index.ts +527 -0
  44. package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
  45. package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
  46. package/scripts/lib/pipeline-runner/types.ts +183 -0
  47. package/scripts/lib/session-runner/errors.ts +406 -0
  48. package/scripts/lib/session-runner/index.ts +715 -0
  49. package/scripts/lib/session-runner/transcript.ts +189 -0
  50. package/scripts/lib/session-runner/types.ts +144 -0
  51. package/scripts/lib/tool-scoping/index.ts +219 -0
  52. package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
  53. package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
  54. package/scripts/lib/tool-scoping/types.ts +77 -0
@@ -0,0 +1,184 @@
1
+ // scripts/lib/discuss-parallel-runner/types.ts — Plan 21-07 (SDK-19).
2
+ //
3
+ // Public type surface for the parallel discussion runner. The runner
4
+ // spawns N design-discussant variants (each a different persona / angle
5
+ // — user-journey, technical-constraint, brand-fit, accessibility)
6
+ // concurrently via `session-runner`, collects each discussant's open
7
+ // questions and concerns as a structured `DiscussionContribution`, and
8
+ // runs an aggregator pass that deduplicates, clusters by theme, and
9
+ // surfaces a single ranked question list for the user.
10
+ //
11
+ // Consumers: the `discuss` skill (standalone leaf) and the `gdd-sdk
12
+ // discuss` CLI subcommand (Plan 21-09).
13
+ //
14
+ // Design invariants (see PLAN.md Context):
15
+ // * Discussant sessions are independent — none write to shared files;
16
+ // parallelism is always safe.
17
+ // * The aggregator runs AFTER all discussants complete.
18
+ // * Error isolation: one discussant's failure never cascades into
19
+ // other discussant sessions.
20
+ // * `AggregatedQuestion.key` is SHA-256-based (stable across runs)
21
+ // per the aggregator prompt contract.
22
+
23
+ import type {
24
+ BudgetCap,
25
+ SessionResult,
26
+ SessionRunnerOptions,
27
+ } from '../session-runner/types.ts';
28
+
29
+ /**
30
+ * Named discussant variants. The default roster is the four values in
31
+ * the union below, but callers can pass any string (arbitrary custom
32
+ * discussant).
33
+ */
34
+ export type DiscussantName =
35
+ | 'user-journey'
36
+ | 'technical-constraint'
37
+ | 'brand-fit'
38
+ | 'accessibility'
39
+ | string;
40
+
41
+ /** Severity ordering is blocker > major > minor > nice-to-have. */
42
+ export type Severity = 'blocker' | 'major' | 'minor' | 'nice-to-have';
43
+
44
+ /**
45
+ * One discussant specification. The runner passes `prompt` verbatim
46
+ * through `session-runner.run()` as the prompt body.
47
+ *
48
+ * `agentPath` is optional; when absent, the runner defaults to the
49
+ * stage scope from tool-scoping (`discuss` maps to `custom` — see
50
+ * Plan 21-03). When present, the runner reads the agent markdown's
51
+ * `tools:` frontmatter via `parseAgentToolsByName` and passes the
52
+ * resolved list as `allowedTools`.
53
+ */
54
+ export interface DiscussantSpec {
55
+ name: DiscussantName;
56
+ /** Optional agent frontmatter path; missing → stage scope from tool-scoping. */
57
+ agentPath?: string;
58
+ /** Per-discussant prompt body. */
59
+ prompt: string;
60
+ }
61
+
62
+ /**
63
+ * One parsed item from a discussant's DISCUSSION COMPLETE block.
64
+ *
65
+ * `kind` discriminates questions (things the discussant wants
66
+ * answered) from concerns (things they want to flag).
67
+ *
68
+ * `tag` captures the per-item annotation from the discussant output
69
+ * (`Concern: <stakeholder>` for questions, `Area: <scope>` for
70
+ * concerns). Optional because lenient parse allows missing.
71
+ */
72
+ export interface DiscussionItem {
73
+ kind: 'question' | 'concern';
74
+ text: string;
75
+ /** Area / angle / concern tag per discussant output. */
76
+ tag?: string;
77
+ severity: Severity;
78
+ rationale?: string;
79
+ }
80
+
81
+ /**
82
+ * One discussant's complete contribution. `items` is empty when the
83
+ * session errored or the block was missing/malformed.
84
+ *
85
+ * `status`:
86
+ * * `completed` — session ended cleanly AND a DISCUSSION COMPLETE
87
+ * block was parsed successfully.
88
+ * * `parse-error` — session ended cleanly but the block was absent
89
+ * or malformed (items: []).
90
+ * * `error` — session failed (budget, turn cap, aborted, error);
91
+ * `error` populated; items: [].
92
+ */
93
+ export interface DiscussionContribution {
94
+ discussant: DiscussantName;
95
+ items: readonly DiscussionItem[];
96
+ /** Raw final_text captured for audit / aggregator input. */
97
+ raw: string;
98
+ usage: { input_tokens: number; output_tokens: number; usd_cost: number };
99
+ status: 'completed' | 'error' | 'parse-error';
100
+ error?: { code: string; message: string };
101
+ }
102
+
103
+ /**
104
+ * One aggregated (post-dedup, post-cluster) question.
105
+ *
106
+ * * `key` — SHA-256 of the normalized question text (lowercase,
107
+ * whitespace-collapsed) truncated to 8 hex chars.
108
+ * Stable across runs.
109
+ * * `raised_by` — discussants that raised (a semantic variant of)
110
+ * this question.
111
+ * * `theme` — cluster/theme name from aggregator.
112
+ * * `rank` — 0-indexed priority (0 = highest). Ranking combines
113
+ * severity + frequency per the aggregator prompt.
114
+ */
115
+ export interface AggregatedQuestion {
116
+ /** Stable key across runs (hash of normalized question text). */
117
+ key: string;
118
+ text: string;
119
+ severity: Severity;
120
+ /** Discussants that raised this question. */
121
+ raised_by: readonly DiscussantName[];
122
+ /** Cluster/theme assignment from aggregator. */
123
+ theme: string;
124
+ /** Aggregator-assigned rank (0 = highest priority). */
125
+ rank: number;
126
+ }
127
+
128
+ /**
129
+ * The aggregator's final output. `output_path` is the Markdown file
130
+ * written to disk (`.design/DISCUSSION.md` by default). `usage` is
131
+ * the aggregator session's token/cost spend — separate from the
132
+ * per-discussant usage aggregated in `DiscussRunnerResult.total_usage`.
133
+ */
134
+ export interface AggregatedDiscussion {
135
+ themes: readonly { name: string; summary: string }[];
136
+ questions: readonly AggregatedQuestion[];
137
+ /** Output path. */
138
+ output_path: string;
139
+ /** Aggregator session usage. */
140
+ usage: { input_tokens: number; output_tokens: number; usd_cost: number };
141
+ }
142
+
143
+ /**
144
+ * Options for `run()` — the top-level orchestrator entry point.
145
+ *
146
+ * * `discussants` — omit to use `DEFAULT_DISCUSSANTS` (4 variants).
147
+ * * `budget` + `maxTurnsPerDiscussant` — applied per-discussant.
148
+ * * `aggregatorBudget` + `aggregatorMaxTurns` — applied to the
149
+ * aggregator session specifically.
150
+ * * `concurrency` — defaults to 4 (matches the default roster size).
151
+ * * `runOverride` — test injection for `session-runner.run()`. All
152
+ * discussants AND the aggregator receive the SAME override so one
153
+ * mock controls the entire run.
154
+ * * `aggregatorPrompt` — replace the default aggregator prompt
155
+ * (advanced / debug use).
156
+ */
157
+ export interface DiscussRunnerOptions {
158
+ /** Discussants to run. Default: the 4-variant roster. */
159
+ discussants?: readonly DiscussantSpec[];
160
+ budget: BudgetCap;
161
+ maxTurnsPerDiscussant: number;
162
+ aggregatorBudget: BudgetCap;
163
+ aggregatorMaxTurns: number;
164
+ concurrency?: number;
165
+ runOverride?: (opts: SessionRunnerOptions) => Promise<SessionResult>;
166
+ cwd?: string;
167
+ /** Custom aggregator prompt override. */
168
+ aggregatorPrompt?: string;
169
+ }
170
+
171
+ /**
172
+ * The final return value from `run()`.
173
+ *
174
+ * * `contributions` — one per discussant, in input spec order (NOT
175
+ * completion order). Failed discussants are included with
176
+ * `status !== 'completed'`.
177
+ * * `aggregated` — the aggregator's parsed output.
178
+ * * `total_usage` — sum of per-discussant + aggregator usage.
179
+ */
180
+ export interface DiscussRunnerResult {
181
+ contributions: readonly DiscussionContribution[];
182
+ aggregated: AggregatedDiscussion;
183
+ total_usage: { input_tokens: number; output_tokens: number; usd_cost: number };
184
+ }
@@ -61,7 +61,17 @@ let cachedHost: string | null = null;
61
61
  */
62
62
  export function getWriter(opts?: WriterOptions): EventWriter {
63
63
  if (defaultWriter === null) {
64
- defaultWriter = new EventWriter(opts ?? {});
64
+ // Honor GDD_EVENTS_PATH env var as the first-choice default path
65
+ // when the caller doesn't pass an explicit `opts.path`. Lets test
66
+ // harnesses and Plan 21-11's E2E subprocess steer the on-disk
67
+ // stream into a fixture-specific directory without chdir'ing the
68
+ // entire process. Explicit `opts.path` always wins.
69
+ const envPath: string | undefined = process.env['GDD_EVENTS_PATH'];
70
+ const finalOpts: WriterOptions =
71
+ opts?.path === undefined && envPath !== undefined && envPath.length > 0
72
+ ? { ...(opts ?? {}), path: envPath }
73
+ : (opts ?? {});
74
+ defaultWriter = new EventWriter(finalOpts);
65
75
  }
66
76
  return defaultWriter;
67
77
  }
@@ -0,0 +1,294 @@
1
+ // scripts/lib/explore-parallel-runner/index.ts — Plan 21-06 (SDK-18).
2
+ //
3
+ // Public surface:
4
+ //
5
+ // run(opts: ExploreRunnerOptions): Promise<ExploreRunnerResult>
6
+ // DEFAULT_MAPPERS — the locked Phase-21 4-mapper roster (frozen).
7
+ // isParallelismSafe, spawnMapper, spawnMappersParallel (from mappers.ts)
8
+ // synthesizeStreaming (from synthesizer.ts)
9
+ // Types (from types.ts) — MapperName, MapperSpec, MapperOutcome,
10
+ // ExploreRunnerOptions, ExploreRunnerResult.
11
+ //
12
+ // Algorithm:
13
+ // 1. specs = opts.mappers ?? DEFAULT_MAPPERS.
14
+ // 2. Partition by isParallelismSafe(spec.agentPath).
15
+ // 3. Run safe mappers via spawnMappersParallel(concurrency).
16
+ // 4. Run unsafe mappers sequentially (tail phase).
17
+ // 5. Run synthesizer via synthesizeStreaming.
18
+ // 6. Aggregate total_usage = sum mapper + synthesizer.
19
+ // 7. Emit logger + explore.runner.* lifecycle events.
20
+ // 8. Return ExploreRunnerResult.
21
+ //
22
+ // Empty specs short-circuits: no mappers spawned, synthesizer skipped,
23
+ // returns an all-zero result.
24
+
25
+ import { resolve as resolvePath } from 'node:path';
26
+
27
+ import { getLogger } from '../logger/index.ts';
28
+
29
+ import {
30
+ isParallelismSafe,
31
+ spawnMapper,
32
+ spawnMappersParallel,
33
+ } from './mappers.ts';
34
+ import { synthesizeStreaming } from './synthesizer.ts';
35
+ import type {
36
+ ExploreRunnerOptions,
37
+ ExploreRunnerResult,
38
+ MapperOutcome,
39
+ MapperSpec,
40
+ } from './types.ts';
41
+
42
+ // Re-exports.
43
+ export type {
44
+ MapperName,
45
+ MapperSpec,
46
+ MapperOutcome,
47
+ ExploreRunnerOptions,
48
+ ExploreRunnerResult,
49
+ } from './types.ts';
50
+ export {
51
+ isParallelismSafe,
52
+ spawnMapper,
53
+ spawnMappersParallel,
54
+ } from './mappers.ts';
55
+ export { synthesizeStreaming } from './synthesizer.ts';
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // DEFAULT_MAPPERS — locked Phase-21 roster
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Locked 4-mapper roster for the explore stage. Frozen end-to-end so
63
+ * consumers can't mutate entries; override via ExploreRunnerOptions.mappers.
64
+ *
65
+ * Agent paths use the exact filenames from `agents/` (as of Phase 21):
66
+ * token-mapper.md, component-taxonomy-mapper.md, a11y-mapper.md,
67
+ * visual-hierarchy-mapper.md.
68
+ *
69
+ * When an agent file is missing, session-runner scope computation
70
+ * gracefully falls through to the stage default (see mappers.ts).
71
+ */
72
+ export const DEFAULT_MAPPERS: readonly MapperSpec[] = Object.freeze([
73
+ Object.freeze({
74
+ name: 'token' as const,
75
+ agentPath: 'agents/token-mapper.md',
76
+ outputPath: '.design/map/token.md',
77
+ prompt:
78
+ 'Enumerate every design token found in the UI source: colors, typography, spacing, radii, shadows, motion durations. Output to .design/map/token.md as a canonical token inventory.',
79
+ }),
80
+ Object.freeze({
81
+ name: 'component-taxonomy' as const,
82
+ agentPath: 'agents/component-taxonomy-mapper.md',
83
+ outputPath: '.design/map/component-taxonomy.md',
84
+ prompt:
85
+ 'Enumerate component archetypes and their variants. Output to .design/map/component-taxonomy.md — one entry per archetype with variant list, slot inventory, and usage count.',
86
+ }),
87
+ Object.freeze({
88
+ name: 'a11y' as const,
89
+ agentPath: 'agents/a11y-mapper.md',
90
+ outputPath: '.design/map/a11y.md',
91
+ prompt:
92
+ 'WCAG-axis scan: contrast ratios, keyboard navigation, ARIA semantics, focus management, reduced-motion respect. Output to .design/map/a11y.md — one section per axis with findings.',
93
+ }),
94
+ Object.freeze({
95
+ name: 'visual-hierarchy' as const,
96
+ agentPath: 'agents/visual-hierarchy-mapper.md',
97
+ outputPath: '.design/map/visual-hierarchy.md',
98
+ prompt:
99
+ 'Describe z-order, focal points, and attention grammar. Output to .design/map/visual-hierarchy.md — one section per surface describing layering, emphasis, and scan path.',
100
+ }),
101
+ ]);
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // run — main orchestrator
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Spawn the 4 mapper sessions (parallel) + the synthesizer (sequential
109
+ * after mappers become stable), aggregate usage, emit lifecycle events,
110
+ * return terminal ExploreRunnerResult.
111
+ *
112
+ * Contract:
113
+ * * Never throws. All failure modes land as outcomes / synth status.
114
+ * * Individual mapper errors do NOT abort other mappers.
115
+ * * parallelism_safe: false mappers run serially AFTER the safe batch.
116
+ * * total_usage aggregates mappers + synthesizer.
117
+ */
118
+ export async function run(
119
+ opts: ExploreRunnerOptions,
120
+ ): Promise<ExploreRunnerResult> {
121
+ const specs: readonly MapperSpec[] = opts.mappers ?? DEFAULT_MAPPERS;
122
+ const cwd: string = opts.cwd ?? process.cwd();
123
+ const concurrency: number = opts.concurrency ?? 4;
124
+
125
+ const logger = getLogger().child('explore.runner');
126
+
127
+ const outputPath: string = resolvePath(cwd, '.design/DESIGN-PATTERNS.md');
128
+
129
+ logger.info('explore.runner.started', {
130
+ mapper_count: specs.length,
131
+ concurrency,
132
+ });
133
+
134
+ // Empty-spec short-circuit — no mappers, no synthesizer, zero usage.
135
+ if (specs.length === 0) {
136
+ logger.info('explore.runner.completed', {
137
+ parallel_count: 0,
138
+ serial_count: 0,
139
+ synthesizer_status: 'skipped',
140
+ total_usd_cost: 0,
141
+ });
142
+ return Object.freeze({
143
+ mappers: Object.freeze([]),
144
+ synthesizer: Object.freeze({
145
+ status: 'skipped' as const,
146
+ output_path: outputPath,
147
+ usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
148
+ files_fed: Object.freeze([]),
149
+ }),
150
+ parallel_count: 0,
151
+ serial_count: 0,
152
+ total_usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
153
+ });
154
+ }
155
+
156
+ // --- Partition specs by parallelism_safe frontmatter ---------------------
157
+ const safeSpecs: MapperSpec[] = [];
158
+ const serialSpecs: MapperSpec[] = [];
159
+ for (const spec of specs) {
160
+ const resolvedAgentPath: string = resolvePath(cwd, spec.agentPath);
161
+ if (isParallelismSafe(resolvedAgentPath)) {
162
+ safeSpecs.push(spec);
163
+ } else {
164
+ serialSpecs.push(spec);
165
+ }
166
+ }
167
+
168
+ // --- Parallel batch ------------------------------------------------------
169
+ const parallelOutcomes: readonly MapperOutcome[] =
170
+ safeSpecs.length > 0
171
+ ? await spawnMappersParallel(safeSpecs, {
172
+ concurrency,
173
+ budget: opts.budget,
174
+ maxTurns: opts.maxTurnsPerMapper,
175
+ cwd,
176
+ ...(opts.runOverride !== undefined ? { runOverride: opts.runOverride } : {}),
177
+ })
178
+ : Object.freeze([]);
179
+
180
+ for (const o of parallelOutcomes) {
181
+ logger.info('explore.runner.mapper_done', {
182
+ mapper: o.name,
183
+ status: o.status,
184
+ duration_ms: o.duration_ms,
185
+ output_exists: o.output_exists,
186
+ output_bytes: o.output_bytes,
187
+ mode: 'parallel',
188
+ });
189
+ }
190
+
191
+ // --- Serial tail --------------------------------------------------------
192
+ const serialOutcomes: MapperOutcome[] = [];
193
+ for (const spec of serialSpecs) {
194
+ const spawnOpts: Parameters<typeof spawnMapper>[1] = {
195
+ budget: opts.budget,
196
+ maxTurns: opts.maxTurnsPerMapper,
197
+ cwd,
198
+ ...(opts.runOverride !== undefined ? { runOverride: opts.runOverride } : {}),
199
+ };
200
+ const outcome = await spawnMapper(spec, spawnOpts);
201
+ serialOutcomes.push(outcome);
202
+ logger.info('explore.runner.mapper_done', {
203
+ mapper: outcome.name,
204
+ status: outcome.status,
205
+ duration_ms: outcome.duration_ms,
206
+ output_exists: outcome.output_exists,
207
+ output_bytes: outcome.output_bytes,
208
+ mode: 'serial',
209
+ });
210
+ }
211
+
212
+ // --- Merge outcomes in ORIGINAL spec order -------------------------------
213
+ //
214
+ // Callers rely on `.mappers[i]` pairing with `opts.mappers[i]` (or
215
+ // DEFAULT_MAPPERS[i]). We rebuild by indexing the name→outcome map.
216
+ const byName: Map<string, MapperOutcome> = new Map();
217
+ for (const o of parallelOutcomes) byName.set(o.name, o);
218
+ for (const o of serialOutcomes) byName.set(o.name, o);
219
+ const mergedOutcomes: MapperOutcome[] = specs.map((s) => {
220
+ const o = byName.get(s.name);
221
+ if (o === undefined) {
222
+ // Shouldn't happen unless partitioning dropped a spec. Surface
223
+ // as a synthetic error outcome rather than throwing.
224
+ return Object.freeze({
225
+ name: s.name,
226
+ status: 'error',
227
+ output_exists: false,
228
+ output_bytes: 0,
229
+ usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
230
+ duration_ms: 0,
231
+ error: Object.freeze({
232
+ code: 'PARTITION_LOST',
233
+ message: `mapper ${s.name} was not executed by either batch`,
234
+ }),
235
+ });
236
+ }
237
+ return o;
238
+ });
239
+
240
+ // --- Synthesizer --------------------------------------------------------
241
+ logger.info('explore.runner.synthesizer_started', {
242
+ mappers_ready: mergedOutcomes.filter((m) => m.output_exists).length,
243
+ mappers_total: mergedOutcomes.length,
244
+ });
245
+
246
+ const synthResult = await synthesizeStreaming({
247
+ mapperNames: specs.map((s) => s.name),
248
+ mapperOutputPaths: specs.map((s) => s.outputPath),
249
+ synthesizerPrompt: opts.synthesizerPrompt,
250
+ budget: opts.synthesizerBudget,
251
+ maxTurns: opts.synthesizerMaxTurns,
252
+ cwd,
253
+ ...(opts.runOverride !== undefined ? { runOverride: opts.runOverride } : {}),
254
+ ...(opts.pollIntervalMs !== undefined ? { pollIntervalMs: opts.pollIntervalMs } : {}),
255
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
256
+ });
257
+
258
+ // --- Aggregate usage ----------------------------------------------------
259
+ let totalInput = synthResult.usage.input_tokens;
260
+ let totalOutput = synthResult.usage.output_tokens;
261
+ let totalCost = synthResult.usage.usd_cost;
262
+ for (const m of mergedOutcomes) {
263
+ totalInput += m.usage.input_tokens;
264
+ totalOutput += m.usage.output_tokens;
265
+ totalCost += m.usage.usd_cost;
266
+ }
267
+
268
+ logger.info('explore.runner.completed', {
269
+ parallel_count: safeSpecs.length,
270
+ serial_count: serialSpecs.length,
271
+ synthesizer_status: synthResult.status,
272
+ total_usd_cost: totalCost,
273
+ total_input_tokens: totalInput,
274
+ total_output_tokens: totalOutput,
275
+ });
276
+
277
+ return Object.freeze({
278
+ mappers: Object.freeze(mergedOutcomes),
279
+ synthesizer: Object.freeze({
280
+ status: synthResult.status,
281
+ output_path: synthResult.output_path,
282
+ usage: synthResult.usage,
283
+ files_fed: synthResult.files_fed,
284
+ ...(synthResult.error !== undefined ? { error: synthResult.error } : {}),
285
+ }),
286
+ parallel_count: safeSpecs.length,
287
+ serial_count: serialSpecs.length,
288
+ total_usage: {
289
+ input_tokens: totalInput,
290
+ output_tokens: totalOutput,
291
+ usd_cost: totalCost,
292
+ },
293
+ });
294
+ }