@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,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
|
+
}
|