@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
@@ -112,6 +112,112 @@ export type ErrorEvent = BaseEvent & {
112
112
  payload: { code: string; message: string; kind: string };
113
113
  };
114
114
 
115
+ // ---------------------------------------------------------------------------
116
+ // Phase 22 — pre-registered subtypes expansion (Plan 22-01)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /** Wave orchestration — Plan 21 parallel-mapper / wave execution. */
120
+ export type WaveStartedEvent = BaseEvent & {
121
+ type: 'wave.started';
122
+ payload: { wave: string; plan_count: number };
123
+ };
124
+ export type WaveCompletedEvent = BaseEvent & {
125
+ type: 'wave.completed';
126
+ payload: { wave: string; duration_ms: number; outcome: 'pass' | 'fail' };
127
+ };
128
+
129
+ /** STATE.md mutation lifecycle (Plan 20-03). */
130
+ export type BlockerAddedEvent = BaseEvent & {
131
+ type: 'blocker.added';
132
+ payload: { id: string; summary: string; source: string };
133
+ };
134
+ export type DecisionAddedEvent = BaseEvent & {
135
+ type: 'decision.added';
136
+ payload: { id: string; summary: string; source: string };
137
+ };
138
+ export type MustHaveAddedEvent = BaseEvent & {
139
+ type: 'must_have.added';
140
+ payload: { id: string; summary: string; source: string };
141
+ };
142
+
143
+ /** Parallelism decision engine output — Plan 21 explore-parallel-runner. */
144
+ export type ParallelismVerdictEvent = BaseEvent & {
145
+ type: 'parallelism.verdict';
146
+ payload: { task_ids: string[]; verdict: 'parallel' | 'sequential'; reason: string };
147
+ };
148
+
149
+ /** Phase 10.1 cost-telemetry event-stream sink. */
150
+ export type CostUpdateEvent = BaseEvent & {
151
+ type: 'cost.update';
152
+ payload: { agent: string; tier: string; usd: number; tokens_in: number; tokens_out: number };
153
+ };
154
+
155
+ /** Rate-guard / backoff stream (Plan 20-10, 20-11). */
156
+ export type RateLimitEvent = BaseEvent & {
157
+ type: 'rate_limit';
158
+ payload: { provider: string; reset_at: string; remaining: number };
159
+ };
160
+ export type ApiRetryEvent = BaseEvent & {
161
+ type: 'api.retry';
162
+ payload: { provider: string; attempt: number; delay_ms: number; reason: string };
163
+ };
164
+
165
+ /** Context-window churn; emitted by `hooks/context-exhaustion.ts`. */
166
+ export type CompactBoundaryEvent = BaseEvent & {
167
+ type: 'compact.boundary';
168
+ payload: { tokens_before: number; tokens_after: number };
169
+ };
170
+
171
+ /** MCP liveness probe from connection-probe primitive (Plan 22-08). */
172
+ export type McpProbeEvent = BaseEvent & {
173
+ type: 'mcp.probe';
174
+ payload: { name: string; status: 'ok' | 'degraded' | 'down'; latency_ms?: number };
175
+ };
176
+
177
+ /** Reflector proposal (Phase 11 post-cycle reflector → event stream). */
178
+ export type ReflectionProposedEvent = BaseEvent & {
179
+ type: 'reflection.proposed';
180
+ payload: { kind: string; target_file: string; summary: string };
181
+ };
182
+
183
+ /** Connection state transitions emitted by `connection-probe` (Plan 22-08). */
184
+ export type ConnectionStatusChangeEvent = BaseEvent & {
185
+ type: 'connection.status_change';
186
+ payload: { name: string; from: string; to: string };
187
+ };
188
+
189
+ /** Per-tool-call trajectory (Plan 22-03). */
190
+ export type ToolCallStartedEvent = BaseEvent & {
191
+ type: 'tool_call.started';
192
+ payload: { tool: string; args_hash: string };
193
+ };
194
+ export type ToolCallCompletedEvent = BaseEvent & {
195
+ type: 'tool_call.completed';
196
+ payload: {
197
+ tool: string;
198
+ args_hash: string;
199
+ result_hash: string;
200
+ latency_ms: number;
201
+ status: 'ok' | 'error';
202
+ };
203
+ };
204
+
205
+ /** Agent-level lifecycle (Plan 21 pipeline-runner / subagent spawn). */
206
+ export type AgentSpawnEvent = BaseEvent & {
207
+ type: 'agent.spawn';
208
+ payload: { agent: string; task_id?: string; tier?: string };
209
+ };
210
+ export type AgentOutcomeEvent = BaseEvent & {
211
+ type: 'agent.outcome';
212
+ payload: {
213
+ agent: string;
214
+ task_id?: string;
215
+ outcome: 'pass' | 'fail' | 'halted';
216
+ duration_ms: number;
217
+ cost_usd?: number;
218
+ };
219
+ };
220
+
115
221
  /**
116
222
  * Union of all pre-registered event types. Not a closed enum at the
117
223
  * envelope level — callers can emit unknown types — but downstream
@@ -124,4 +230,52 @@ export type KnownEvent =
124
230
  | StageEnteredEvent
125
231
  | StageExitedEvent
126
232
  | HookFiredEvent
127
- | ErrorEvent;
233
+ | ErrorEvent
234
+ | WaveStartedEvent
235
+ | WaveCompletedEvent
236
+ | BlockerAddedEvent
237
+ | DecisionAddedEvent
238
+ | MustHaveAddedEvent
239
+ | ParallelismVerdictEvent
240
+ | CostUpdateEvent
241
+ | RateLimitEvent
242
+ | ApiRetryEvent
243
+ | CompactBoundaryEvent
244
+ | McpProbeEvent
245
+ | ReflectionProposedEvent
246
+ | ConnectionStatusChangeEvent
247
+ | ToolCallStartedEvent
248
+ | ToolCallCompletedEvent
249
+ | AgentSpawnEvent
250
+ | AgentOutcomeEvent;
251
+
252
+ /**
253
+ * Runtime list of all pre-registered event `type` strings. Used by the
254
+ * Phase 22 baseline test and the CLI transport's `--list-types`
255
+ * subcommand.
256
+ */
257
+ export const KNOWN_EVENT_TYPES: readonly string[] = [
258
+ 'state.mutation',
259
+ 'state.transition',
260
+ 'stage.entered',
261
+ 'stage.exited',
262
+ 'hook.fired',
263
+ 'error',
264
+ 'wave.started',
265
+ 'wave.completed',
266
+ 'blocker.added',
267
+ 'decision.added',
268
+ 'must_have.added',
269
+ 'parallelism.verdict',
270
+ 'cost.update',
271
+ 'rate_limit',
272
+ 'api.retry',
273
+ 'compact.boundary',
274
+ 'mcp.probe',
275
+ 'reflection.proposed',
276
+ 'connection.status_change',
277
+ 'tool_call.started',
278
+ 'tool_call.completed',
279
+ 'agent.spawn',
280
+ 'agent.outcome',
281
+ ] as const;
@@ -21,11 +21,64 @@
21
21
  // `payload` with `{ _truncated_placeholder: true }`, then re-serialize
22
22
  // and stamp `_truncated: true` on the line.
23
23
 
24
- import { appendFileSync, mkdirSync } from 'node:fs';
24
+ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
25
25
  import { dirname, resolve, isAbsolute, join } from 'node:path';
26
+ import { createRequire } from 'node:module';
26
27
 
27
28
  import type { BaseEvent } from './types.ts';
28
29
 
30
+ // Phase 22 Plan 22-02: write-time secret scrubbing. `redact()` deep-walks
31
+ // the event and replaces secret-shaped strings with `[REDACTED:<type>]`
32
+ // placeholders before serialization. Loaded via createRequire so the
33
+ // CommonJS `redact.cjs` interops cleanly. We avoid `import.meta.url` —
34
+ // tsc's Node16 module mode classifies this .ts file as CJS output for
35
+ // typecheck purposes (even though it runs as ESM under
36
+ // `--experimental-strip-types`), and `import.meta` is forbidden in CJS
37
+ // output. Mirror the pattern from `scripts/lib/session-runner/errors.ts`:
38
+ // anchor createRequire on the repo-root package.json discovered by
39
+ // walking up from `process.cwd()`.
40
+ function _findRepoRoot(): string {
41
+ let dir = process.cwd();
42
+ for (let i = 0; i < 8; i++) {
43
+ if (existsSync(join(dir, 'package.json'))) return dir;
44
+ const parent = dirname(dir);
45
+ if (parent === dir) break;
46
+ dir = parent;
47
+ }
48
+ return process.cwd();
49
+ }
50
+
51
+ // Soft load: if redact.cjs is unreachable from the runtime cwd (e.g. a
52
+ // hook subprocess running in a temp test dir three directories above
53
+ // the plugin root), fall through to the identity function. The writer
54
+ // keeps working — events just aren't scrubbed in that environment.
55
+ // Production callers always run from inside the plugin tree.
56
+ let _redact: (v: unknown) => unknown;
57
+ try {
58
+ const _root = _findRepoRoot();
59
+ const _candidate = resolve(_root, 'scripts/lib/redact.cjs');
60
+ if (existsSync(_candidate)) {
61
+ const _redactRequire = createRequire(join(_root, 'package.json'));
62
+ const _mod = _redactRequire(_candidate) as { redact: (v: unknown) => unknown };
63
+ _redact = _mod.redact;
64
+ } else {
65
+ // Fallback: also try walking up from this source file's logical
66
+ // position (3 dirs above writer.ts → repo root).
67
+ const _altRoot = resolve(_root, '..', '..');
68
+ const _altCandidate = resolve(_altRoot, 'scripts/lib/redact.cjs');
69
+ if (existsSync(_altCandidate)) {
70
+ const _altRequire = createRequire(join(_altRoot, 'package.json'));
71
+ const _altMod = _altRequire(_altCandidate) as { redact: (v: unknown) => unknown };
72
+ _redact = _altMod.redact;
73
+ } else {
74
+ _redact = (v) => v;
75
+ }
76
+ }
77
+ } catch {
78
+ _redact = (v) => v;
79
+ }
80
+ const redact = _redact;
81
+
29
82
  /** Default relative path for the persisted event stream. */
30
83
  export const DEFAULT_EVENTS_PATH = '.design/telemetry/events.jsonl';
31
84
 
@@ -113,22 +166,26 @@ export class EventWriter {
113
166
  * {@link append}.
114
167
  */
115
168
  serialize(ev: BaseEvent): string {
116
- const raw = JSON.stringify(ev) + '\n';
169
+ // Phase 22 Plan 22-02: scrub secrets from the entire event (envelope +
170
+ // payload) before serialization. Redaction is non-mutating and runs
171
+ // exactly once per event, here at the write boundary.
172
+ const scrubbed = redact(ev) as BaseEvent;
173
+ const raw = JSON.stringify(scrubbed) + '\n';
117
174
  if (Buffer.byteLength(raw, 'utf8') <= this.maxLineBytes) {
118
175
  return raw;
119
176
  }
120
177
 
121
178
  // Truncate: keep envelope fields, drop payload content.
122
179
  const truncated: BaseEvent = {
123
- type: ev.type,
124
- timestamp: ev.timestamp,
125
- sessionId: ev.sessionId,
180
+ type: scrubbed.type,
181
+ timestamp: scrubbed.timestamp,
182
+ sessionId: scrubbed.sessionId,
126
183
  payload: { _truncated_placeholder: true },
127
184
  _truncated: true,
128
185
  };
129
- if (ev.stage !== undefined) truncated.stage = ev.stage;
130
- if (ev.cycle !== undefined) truncated.cycle = ev.cycle;
131
- if (ev._meta !== undefined) truncated._meta = ev._meta;
186
+ if (scrubbed.stage !== undefined) truncated.stage = scrubbed.stage;
187
+ if (scrubbed.cycle !== undefined) truncated.cycle = scrubbed.cycle;
188
+ if (scrubbed._meta !== undefined) truncated._meta = scrubbed._meta;
132
189
  return JSON.stringify(truncated) + '\n';
133
190
  }
134
191
 
@@ -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
+ }