@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,134 @@
1
+ // scripts/lib/pipeline-runner/human-gate.ts — Plan 21-05 Task 4.
2
+ //
3
+ // Human-gate extraction + dispatch. The pipeline recognizes HTML-comment
4
+ // markers of the form:
5
+ //
6
+ // <!-- AWAIT_USER_GATE: name="..." -->
7
+ //
8
+ // emitted by skills that want to pause mid-session. When the
9
+ // session-runner's `final_text` contains such a marker, the stage
10
+ // handler maps the session's terminal status to `halted-human-gate`
11
+ // and surfaces the gate name plus stdout tail.
12
+ //
13
+ // The pipeline driver (index.ts) then invokes `dispatchHumanGate`:
14
+ // * With `config.onHumanGate` → call it; its decision drives the
15
+ // driver.
16
+ // * Without a callback → default `{decision: 'stop'}` (safe default
17
+ // for headless operation — never proceed past a gate on autopilot).
18
+ // * Callback throws → caught; logged as warn; default `stop`.
19
+
20
+ import { getLogger } from '../logger/index.ts';
21
+ import type {
22
+ HumanGateDecision,
23
+ HumanGateInfo,
24
+ PipelineConfig,
25
+ } from './types.ts';
26
+
27
+ /**
28
+ * Regex for the canonical gate marker. Name is the first capture group;
29
+ * whitespace around `:` and inside the double quotes is tolerated.
30
+ *
31
+ * Intentionally LENIENT about surrounding whitespace (sanitizers may
32
+ * normalize around the comment), but STRICT about the core token
33
+ * shape so false positives (e.g., docs discussing AWAIT_USER_GATE)
34
+ * don't trip it — the marker must be inside an HTML comment AND
35
+ * carry a double-quoted `name`.
36
+ */
37
+ const GATE_MARKER_RE =
38
+ /<!--\s*AWAIT_USER_GATE\s*:\s*name\s*=\s*"([^"]+)"\s*-->/;
39
+
40
+ /**
41
+ * Extract the first `AWAIT_USER_GATE` marker from a session's stdout /
42
+ * final text. Returns `null` when no marker is present.
43
+ *
44
+ * Only the FIRST marker is returned — subsequent gates in the same
45
+ * session's output are ignored by design (one pause per stage).
46
+ */
47
+ export function extractGateMarker(
48
+ stdout: string,
49
+ ): { readonly name: string } | null {
50
+ if (typeof stdout !== 'string' || stdout.length === 0) return null;
51
+ const m = GATE_MARKER_RE.exec(stdout);
52
+ if (m === null) return null;
53
+ const name: string | undefined = m[1];
54
+ if (name === undefined || name === '') return null;
55
+ return { name };
56
+ }
57
+
58
+ /**
59
+ * Dispatch a single human gate. Calls `config.onHumanGate` when
60
+ * supplied; otherwise returns `{decision: 'stop'}`.
61
+ *
62
+ * Never throws — callback exceptions are caught and converted into a
63
+ * `stop` decision, with a warn-level log entry for observability.
64
+ */
65
+ export async function dispatchHumanGate(
66
+ info: HumanGateInfo,
67
+ config: PipelineConfig,
68
+ ): Promise<HumanGateDecision> {
69
+ if (config.onHumanGate === undefined) {
70
+ // No callback — default stop. We log this at debug (not warn)
71
+ // because it's a normal headless flow: the operator wanted to
72
+ // pause, and the orchestrator will resume via a fresh `run()`
73
+ // invocation with `resumeFrom` set.
74
+ try {
75
+ getLogger().debug('human-gate: no callback; default stop', {
76
+ stage: info.stage,
77
+ gateName: info.gateName,
78
+ });
79
+ } catch {
80
+ // Logger failures must not propagate.
81
+ }
82
+ return { decision: 'stop' };
83
+ }
84
+
85
+ try {
86
+ const decision = await config.onHumanGate(info);
87
+ // Validate the decision shape — callbacks may return partial
88
+ // objects from user code. Fall back to `stop` on anything invalid.
89
+ if (
90
+ decision === null ||
91
+ decision === undefined ||
92
+ typeof decision !== 'object'
93
+ ) {
94
+ try {
95
+ getLogger().warn('human-gate: callback returned non-object; defaulting to stop', {
96
+ stage: info.stage,
97
+ gateName: info.gateName,
98
+ });
99
+ } catch {
100
+ // Logger failures must not propagate.
101
+ }
102
+ return { decision: 'stop' };
103
+ }
104
+ if (decision.decision !== 'resume' && decision.decision !== 'stop') {
105
+ try {
106
+ getLogger().warn('human-gate: callback returned unknown decision; defaulting to stop', {
107
+ stage: info.stage,
108
+ gateName: info.gateName,
109
+ received: String(decision.decision),
110
+ });
111
+ } catch {
112
+ // Logger failures must not propagate.
113
+ }
114
+ return { decision: 'stop' };
115
+ }
116
+ // The decision's `payload` is optional; pass it through verbatim
117
+ // when present.
118
+ if (decision.decision === 'resume' && decision.payload !== undefined) {
119
+ return { decision: 'resume', payload: decision.payload };
120
+ }
121
+ return { decision: decision.decision };
122
+ } catch (err) {
123
+ try {
124
+ getLogger().warn('human-gate: callback threw; defaulting to stop', {
125
+ stage: info.stage,
126
+ gateName: info.gateName,
127
+ error: err instanceof Error ? err.message : String(err),
128
+ });
129
+ } catch {
130
+ // Logger failures must not propagate.
131
+ }
132
+ return { decision: 'stop' };
133
+ }
134
+ }
@@ -0,0 +1,527 @@
1
+ // scripts/lib/pipeline-runner/index.ts — Plan 21-05 Task 5 (SDK-17).
2
+ //
3
+ // The public surface of the pipeline runner. Re-exports every type
4
+ // and helper callers need, plus the `run()` driver.
5
+ //
6
+ // `run(config)` orchestrates the 5-stage design pipeline end to end:
7
+ //
8
+ // 1. Resolve the stage order from config (stages / resumeFrom /
9
+ // stopAfter / skipStages).
10
+ // 2. For each stage in order:
11
+ // a. Ask the gdd-state transition gate whether we may advance.
12
+ // On veto, halt with `halted-gate-veto` and surface blockers.
13
+ // b. Emit `stage.entered`.
14
+ // c. Invoke the stage via `invokeStage` (retry-once inside).
15
+ // d. Accumulate usage; push outcome.
16
+ // e. On `halted-human-gate` → dispatch the callback. `resume`
17
+ // re-invokes the stage with an optional payload suffix;
18
+ // `stop` halts pipeline with `awaiting-gate`.
19
+ // f. On any other `halted-*` → halt pipeline with `halted`.
20
+ // g. On `stage === config.stopAfter` → halt with `stopped-after`.
21
+ // h. Emit `stage.exited` (outcome mirrors stage status).
22
+ // 3. Emit `pipeline.started` at entry + `pipeline.completed` at exit.
23
+ //
24
+ // NEVER throws — every failure becomes a `PipelineResult`.
25
+
26
+ import { appendEvent } from '../event-stream/index.ts';
27
+ import type { BaseEvent } from '../event-stream/index.ts';
28
+ import { getLogger } from '../logger/index.ts';
29
+ import { transition as defaultTransition, TransitionGateFailed } from '../gdd-state/index.ts';
30
+ import { ValidationError } from '../gdd-errors/index.ts';
31
+
32
+ import {
33
+ STAGE_ORDER,
34
+ nextStage,
35
+ resolveStageOrder,
36
+ stageIndex,
37
+ } from './state-machine.ts';
38
+ import { invokeStage, type InvokeStageOverrides } from './stage-handlers.ts';
39
+ import { dispatchHumanGate, extractGateMarker } from './human-gate.ts';
40
+ import type {
41
+ HumanGateInfo,
42
+ PipelineConfig,
43
+ PipelineResult,
44
+ PipelineStatus,
45
+ Stage,
46
+ StageOutcome,
47
+ StageStatus,
48
+ } from './types.ts';
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Re-exports.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export type {
55
+ AgentsByStage,
56
+ BudgetCap,
57
+ HumanGateDecision,
58
+ HumanGateInfo,
59
+ PipelineConfig,
60
+ PipelineResult,
61
+ PipelineStatus,
62
+ Stage,
63
+ StageOutcome,
64
+ StageStatus,
65
+ } from './types.ts';
66
+ export {
67
+ STAGE_ORDER,
68
+ nextStage,
69
+ stageIndex,
70
+ resolveStageOrder,
71
+ } from './state-machine.ts';
72
+ export type { InvokeStageArgs, InvokeStageOverrides } from './stage-handlers.ts';
73
+ export { invokeStage } from './stage-handlers.ts';
74
+ export { dispatchHumanGate, extractGateMarker } from './human-gate.ts';
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Driver — `run()`.
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Result of the transition gate check for a single stage. `ok: false`
82
+ * surfaces blockers that the `halted-gate-veto` outcome carries.
83
+ */
84
+ export interface TransitionResult {
85
+ readonly ok: boolean;
86
+ readonly blockers?: readonly string[];
87
+ }
88
+
89
+ /**
90
+ * Test + integration overrides for `run()`. Omitted fields fall back
91
+ * to the real implementations (session-runner, context-engine,
92
+ * tool-scoping, gdd-state transition).
93
+ */
94
+ export interface RunOverrides extends InvokeStageOverrides {
95
+ /**
96
+ * Override the gdd-state transition gate. Defaults to a shim that
97
+ * invokes `gdd-state.transition(path, to)`. In test mode, returns
98
+ * `{ ok: true }` or `{ ok: false, blockers }`.
99
+ */
100
+ readonly transitionStageOverride?: (to: Stage) => Promise<TransitionResult>;
101
+ /**
102
+ * Override the state file path used by the default transition shim.
103
+ * Defaults to `.design/STATE.md` resolved against `config.cwd`.
104
+ */
105
+ readonly statePathOverride?: string;
106
+ }
107
+
108
+ /**
109
+ * Default transition-stage shim. Calls `gdd-state.transition(path, to)`
110
+ * against the working directory's `.design/STATE.md`. Maps
111
+ * `TransitionGateFailed` to `{ok: false, blockers}`; propagates other
112
+ * errors as `{ok: false, blockers: [message]}` so the pipeline never
113
+ * crashes on state-file hiccups.
114
+ *
115
+ * Wave C (Plan 21-09) may replace this with a direct MCP tool handler
116
+ * import. Until then, the shim calls the module directly.
117
+ */
118
+ async function defaultTransitionShim(
119
+ to: Stage,
120
+ statePath: string,
121
+ ): Promise<TransitionResult> {
122
+ try {
123
+ await defaultTransition(statePath, to);
124
+ return { ok: true };
125
+ } catch (err) {
126
+ if (err instanceof TransitionGateFailed) {
127
+ return { ok: false, blockers: err.blockers };
128
+ }
129
+ // Any other error (lock contention, parse error, etc.) — surface
130
+ // its message as a single blocker so the pipeline can halt
131
+ // gracefully with `halted-gate-veto`.
132
+ const msg: string =
133
+ err instanceof Error ? err.message : String(err);
134
+ return {
135
+ ok: false,
136
+ blockers: Object.freeze([`transition failed: ${msg}`]),
137
+ };
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Resolve the state-path used by the default transition shim.
143
+ */
144
+ function defaultStatePath(cwd: string | undefined, override: string | undefined): string {
145
+ if (override !== undefined) return override;
146
+ const root = cwd ?? process.cwd();
147
+ // .design/STATE.md is the canonical Phase-20 location.
148
+ const sep = root.endsWith('/') || root.endsWith('\\') ? '' : '/';
149
+ return `${root}${sep}.design/STATE.md`;
150
+ }
151
+
152
+ /**
153
+ * Validate `config` shape before entering the driver loop. Catches
154
+ * missing required fields early so the first stage's run doesn't
155
+ * proceed on a malformed config.
156
+ */
157
+ function validateConfig(config: PipelineConfig, order: readonly Stage[]): void {
158
+ if (
159
+ config.prompts === null ||
160
+ config.prompts === undefined ||
161
+ typeof config.prompts !== 'object'
162
+ ) {
163
+ throw new ValidationError(
164
+ 'PipelineConfig.prompts is required',
165
+ 'MISSING_PROMPTS',
166
+ );
167
+ }
168
+ for (const s of order) {
169
+ const p: string | undefined = config.prompts[s];
170
+ if (p === undefined || p === null) {
171
+ throw new ValidationError(
172
+ `PipelineConfig.prompts["${s}"] is required (stage is in run order)`,
173
+ 'MISSING_STAGE_PROMPT',
174
+ { stage: s, order: [...order] },
175
+ );
176
+ }
177
+ }
178
+ if (!Number.isFinite(config.maxTurnsPerStage) || config.maxTurnsPerStage < 0) {
179
+ throw new ValidationError(
180
+ 'PipelineConfig.maxTurnsPerStage must be a non-negative finite number',
181
+ 'INVALID_MAX_TURNS',
182
+ { value: config.maxTurnsPerStage },
183
+ );
184
+ }
185
+ const retries = config.stageRetries ?? 1;
186
+ if (retries !== 0 && retries !== 1) {
187
+ throw new ValidationError(
188
+ 'PipelineConfig.stageRetries must be 0 or 1',
189
+ 'INVALID_STAGE_RETRIES',
190
+ { value: retries },
191
+ );
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Emit a pipeline-level event. Mirrors the session-runner's emit shim —
197
+ * persist-first, broadcast-second, silent on subscriber errors.
198
+ */
199
+ function emitPipelineEvent(
200
+ type: string,
201
+ payload: Record<string, unknown>,
202
+ stage?: Stage,
203
+ ): void {
204
+ const ev: BaseEvent = {
205
+ type,
206
+ timestamp: new Date().toISOString(),
207
+ sessionId: `gdd-pipeline-${process.pid}`,
208
+ payload,
209
+ };
210
+ if (stage !== undefined) ev.stage = stage;
211
+ try {
212
+ appendEvent(ev);
213
+ } catch {
214
+ // Subscriber errors are swallowed per event-stream contract.
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Accumulate `session.usage` onto the running total. Missing sessions
220
+ * (skipped stages) contribute zero.
221
+ */
222
+ function foldUsage(
223
+ total: { input_tokens: number; output_tokens: number; usd_cost: number },
224
+ outcome: StageOutcome,
225
+ ): void {
226
+ const u = outcome.session?.usage;
227
+ if (u === undefined) return;
228
+ total.input_tokens += u.input_tokens;
229
+ total.output_tokens += u.output_tokens;
230
+ total.usd_cost += u.usd_cost;
231
+ }
232
+
233
+ /**
234
+ * Drive the full or partial pipeline. Returns once a terminal state is
235
+ * reached: `completed`, `halted`, `stopped-after`, or `awaiting-gate`.
236
+ *
237
+ * Never throws — all failures land on `PipelineResult`.
238
+ */
239
+ export async function run(
240
+ config: PipelineConfig,
241
+ overrides: RunOverrides = {},
242
+ ): Promise<PipelineResult> {
243
+ const cycle_start: string = new Date().toISOString();
244
+ const total: { input_tokens: number; output_tokens: number; usd_cost: number } = {
245
+ input_tokens: 0,
246
+ output_tokens: 0,
247
+ usd_cost: 0,
248
+ };
249
+ const outcomes: StageOutcome[] = [];
250
+ let pipelineStatus: PipelineStatus = 'completed';
251
+ let halted_at: Stage | undefined;
252
+ let finalGate: HumanGateInfo | undefined;
253
+
254
+ // 1. Resolve the stage order (may throw on invalid config).
255
+ let order: readonly Stage[];
256
+ try {
257
+ order = resolveStageOrder({
258
+ ...(config.stages !== undefined ? { stages: config.stages } : {}),
259
+ ...(config.skipStages !== undefined ? { skipStages: config.skipStages } : {}),
260
+ ...(config.resumeFrom !== undefined ? { resumeFrom: config.resumeFrom } : {}),
261
+ ...(config.stopAfter !== undefined ? { stopAfter: config.stopAfter } : {}),
262
+ });
263
+ validateConfig(config, order);
264
+ } catch (err) {
265
+ // Convert to a halted pipeline with no outcomes — driver's
266
+ // no-throw contract.
267
+ try {
268
+ getLogger().error('pipeline.invalid_config', {
269
+ error: err instanceof Error ? err.message : String(err),
270
+ });
271
+ } catch {
272
+ // Ignore logger failures.
273
+ }
274
+ const cycle_end = new Date().toISOString();
275
+ return {
276
+ status: 'halted',
277
+ cycle_start,
278
+ cycle_end,
279
+ outcomes: [],
280
+ total_usage: { ...total },
281
+ };
282
+ }
283
+
284
+ // 2. Emit pipeline.started.
285
+ emitPipelineEvent('pipeline.started', {
286
+ stages: [...order],
287
+ budget: { ...config.budget },
288
+ maxTurnsPerStage: config.maxTurnsPerStage,
289
+ });
290
+ try {
291
+ getLogger().info('pipeline.started', {
292
+ stages: [...order],
293
+ stageRetries: config.stageRetries ?? 1,
294
+ });
295
+ } catch {
296
+ // Ignore logger failures.
297
+ }
298
+
299
+ const transitionImpl =
300
+ overrides.transitionStageOverride ??
301
+ ((to: Stage) =>
302
+ defaultTransitionShim(to, defaultStatePath(config.cwd, overrides.statePathOverride)));
303
+
304
+ // 3. Drive the stage loop.
305
+ stageLoop: for (const stage of order) {
306
+ // 3a. Transition gate.
307
+ let gate: TransitionResult;
308
+ try {
309
+ gate = await transitionImpl(stage);
310
+ } catch (err) {
311
+ // The override contract says "return, never throw"; if it does
312
+ // throw, treat it as an implicit veto with the message as the
313
+ // blocker.
314
+ const msg: string = err instanceof Error ? err.message : String(err);
315
+ gate = { ok: false, blockers: Object.freeze([msg]) };
316
+ }
317
+ if (!gate.ok) {
318
+ const blockers: readonly string[] = gate.blockers ?? [];
319
+ const outcome: StageOutcome = {
320
+ stage,
321
+ status: 'halted-gate-veto',
322
+ blockers,
323
+ started_at: new Date().toISOString(),
324
+ ended_at: new Date().toISOString(),
325
+ retries: 0,
326
+ };
327
+ outcomes.push(outcome);
328
+ pipelineStatus = 'halted';
329
+ halted_at = stage;
330
+ emitPipelineEvent('stage.entered', { stage }, stage);
331
+ emitPipelineEvent(
332
+ 'stage.exited',
333
+ {
334
+ stage,
335
+ duration_ms: 0,
336
+ outcome: 'halted',
337
+ },
338
+ stage,
339
+ );
340
+ try {
341
+ getLogger().warn('pipeline.halted', {
342
+ stage,
343
+ reason: 'gate-veto',
344
+ blockers: [...blockers],
345
+ });
346
+ } catch {
347
+ // Ignore logger failures.
348
+ }
349
+ break stageLoop;
350
+ }
351
+
352
+ // 3b. Emit stage.entered.
353
+ emitPipelineEvent('stage.entered', { stage }, stage);
354
+
355
+ // 3c. Run the stage.
356
+ let outcome: StageOutcome = await invokeStage({
357
+ stage,
358
+ config,
359
+ retries: config.stageRetries ?? 1,
360
+ ...(overrides.runOverride !== undefined ? { runOverride: overrides.runOverride } : {}),
361
+ ...(overrides.bundleOverride !== undefined
362
+ ? { bundleOverride: overrides.bundleOverride }
363
+ : {}),
364
+ ...(overrides.scopeOverride !== undefined
365
+ ? { scopeOverride: overrides.scopeOverride }
366
+ : {}),
367
+ });
368
+
369
+ // 3d. Human-gate resolution.
370
+ if (outcome.status === 'halted-human-gate') {
371
+ const gateInfo: HumanGateInfo = buildGateInfo(stage, outcome);
372
+ const decision = await dispatchHumanGate(gateInfo, config);
373
+ if (decision.decision === 'resume') {
374
+ // Re-invoke with the payload suffix — replaces the first outcome.
375
+ const resumed: StageOutcome = await invokeStage({
376
+ stage,
377
+ config,
378
+ retries: config.stageRetries ?? 1,
379
+ ...(decision.payload !== undefined ? { _promptSuffix: decision.payload } : {}),
380
+ ...(overrides.runOverride !== undefined ? { runOverride: overrides.runOverride } : {}),
381
+ ...(overrides.bundleOverride !== undefined
382
+ ? { bundleOverride: overrides.bundleOverride }
383
+ : {}),
384
+ ...(overrides.scopeOverride !== undefined
385
+ ? { scopeOverride: overrides.scopeOverride }
386
+ : {}),
387
+ });
388
+ outcome = resumed;
389
+ } else {
390
+ // Stop — record outcome, halt pipeline with awaiting-gate.
391
+ outcomes.push(outcome);
392
+ foldUsage(total, outcome);
393
+ pipelineStatus = 'awaiting-gate';
394
+ finalGate = gateInfo;
395
+ emitPipelineEvent(
396
+ 'stage.exited',
397
+ {
398
+ stage,
399
+ duration_ms: durationMs(outcome),
400
+ outcome: 'halted',
401
+ },
402
+ stage,
403
+ );
404
+ try {
405
+ getLogger().info('pipeline.awaiting_gate', {
406
+ stage,
407
+ gateName: gateInfo.gateName,
408
+ });
409
+ } catch {
410
+ // Ignore logger failures.
411
+ }
412
+ break stageLoop;
413
+ }
414
+ }
415
+
416
+ // 3e. Accumulate + record.
417
+ outcomes.push(outcome);
418
+ foldUsage(total, outcome);
419
+
420
+ // 3f. Emit stage.exited.
421
+ emitPipelineEvent(
422
+ 'stage.exited',
423
+ {
424
+ stage,
425
+ duration_ms: durationMs(outcome),
426
+ outcome: mapOutcomeLabel(outcome.status),
427
+ },
428
+ stage,
429
+ );
430
+
431
+ // 3g. Non-gate halt?
432
+ if (isHaltingStatus(outcome.status)) {
433
+ pipelineStatus = 'halted';
434
+ halted_at = stage;
435
+ try {
436
+ getLogger().warn('pipeline.halted', {
437
+ stage,
438
+ status: outcome.status,
439
+ });
440
+ } catch {
441
+ // Ignore logger failures.
442
+ }
443
+ break stageLoop;
444
+ }
445
+
446
+ // 3h. stopAfter boundary?
447
+ if (config.stopAfter !== undefined && stage === config.stopAfter) {
448
+ pipelineStatus = 'stopped-after';
449
+ break stageLoop;
450
+ }
451
+ }
452
+
453
+ const cycle_end: string = new Date().toISOString();
454
+
455
+ const result: PipelineResult = {
456
+ status: pipelineStatus,
457
+ cycle_start,
458
+ cycle_end,
459
+ outcomes: Object.freeze([...outcomes]),
460
+ total_usage: { ...total },
461
+ ...(halted_at !== undefined ? { halted_at } : {}),
462
+ ...(finalGate !== undefined ? { gate: finalGate } : {}),
463
+ };
464
+
465
+ emitPipelineEvent('pipeline.completed', {
466
+ status: result.status,
467
+ outcomes_count: outcomes.length,
468
+ total_usage: { ...total },
469
+ ...(halted_at !== undefined ? { halted_at } : {}),
470
+ });
471
+ try {
472
+ getLogger().info('pipeline.completed', {
473
+ status: result.status,
474
+ outcomes_count: outcomes.length,
475
+ total_cost_usd: total.usd_cost,
476
+ ...(halted_at !== undefined ? { halted_at } : {}),
477
+ });
478
+ } catch {
479
+ // Ignore logger failures.
480
+ }
481
+
482
+ return result;
483
+ }
484
+
485
+ /**
486
+ * Build a HumanGateInfo from a stage outcome whose status is
487
+ * `halted-human-gate`. Falls back to re-extracting the gate marker
488
+ * from `session.final_text` if `outcome.gate` is missing (defensive).
489
+ */
490
+ function buildGateInfo(stage: Stage, outcome: StageOutcome): HumanGateInfo {
491
+ if (outcome.gate !== undefined) return outcome.gate;
492
+ const finalText: string = outcome.session?.final_text ?? '';
493
+ const marker = extractGateMarker(finalText);
494
+ return {
495
+ stage,
496
+ gateName: marker?.name ?? 'unnamed',
497
+ stdoutTail: finalText,
498
+ };
499
+ }
500
+
501
+ /** Elapsed wall-clock ms between `started_at` and `ended_at`. */
502
+ function durationMs(outcome: StageOutcome): number {
503
+ if (outcome.started_at === undefined || outcome.ended_at === undefined) {
504
+ return 0;
505
+ }
506
+ const s = Date.parse(outcome.started_at);
507
+ const e = Date.parse(outcome.ended_at);
508
+ if (!Number.isFinite(s) || !Number.isFinite(e)) return 0;
509
+ return Math.max(0, e - s);
510
+ }
511
+
512
+ /** Map a stage status into the event-stream `StageExitedEvent` outcome label. */
513
+ function mapOutcomeLabel(status: StageStatus): 'pass' | 'fail' | 'halted' {
514
+ if (status === 'completed') return 'pass';
515
+ if (status === 'skipped') return 'pass';
516
+ return 'halted';
517
+ }
518
+
519
+ /** True when `status` is a terminal halt (non-human-gate, non-completed). */
520
+ function isHaltingStatus(status: StageStatus): boolean {
521
+ return (
522
+ status === 'halted-gate-veto' ||
523
+ status === 'halted-budget' ||
524
+ status === 'halted-turn-cap' ||
525
+ status === 'halted-error'
526
+ );
527
+ }