stageflow 0.15.0 → 0.18.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/README.md +2 -4
- package/dist/agent/activity.d.ts +3 -0
- package/dist/agent/claudeAdapter.js +84 -7
- package/dist/agent/claudeSession.d.ts +3 -0
- package/dist/agent/claudeTools.d.ts +1 -4
- package/dist/agent/claudeTools.js +1 -6
- package/dist/agent/fakeAgent.js +4 -5
- package/dist/agent/piAdapter.d.ts +2 -0
- package/dist/agent/piAdapter.js +30 -19
- package/dist/agent/port.d.ts +3 -0
- package/dist/cli/runOutput.js +32 -8
- package/dist/config/cloneChain.d.ts +12 -0
- package/dist/config/cloneChain.js +322 -0
- package/dist/config/createPipeline.js +101 -21
- package/dist/config/createStage.js +7 -0
- package/dist/config/loadOutcome.d.ts +1 -0
- package/dist/config/loadPipeline.d.ts +2 -0
- package/dist/config/loadPipeline.js +34 -22
- package/dist/config/loadStage.d.ts +2 -0
- package/dist/config/loadStage.js +135 -125
- package/dist/config/migrateYaml.js +14 -3
- package/dist/config/normalizePipelineStageEntry.d.ts +5 -9
- package/dist/config/normalizePipelineStageEntry.js +133 -60
- package/dist/config/parsePreEmitChecks.js +2 -2
- package/dist/config/pipelineNeeds.d.ts +15 -2
- package/dist/config/pipelineNeeds.js +86 -4
- package/dist/config/pipelineRoute.d.ts +46 -0
- package/dist/config/pipelineRoute.js +215 -0
- package/dist/config/pipelineStageKeys.d.ts +2 -0
- package/dist/config/pipelineStageKeys.js +15 -4
- package/dist/config/printTargetYaml.js +18 -12
- package/dist/config/resolveForkEmitContext.d.ts +1 -1
- package/dist/config/resolveForkEmitContext.js +3 -26
- package/dist/config/resolvePipelineDag.d.ts +1 -3
- package/dist/config/resolvePipelineDag.js +143 -186
- package/dist/config/routeIf.d.ts +24 -0
- package/dist/config/routeIf.js +247 -0
- package/dist/config/validateCatalog.d.ts +1 -1
- package/dist/config/validateCatalog.js +5 -3
- package/dist/config/yamlDialect.d.ts +5 -8
- package/dist/config/yamlDialect.js +37 -29
- package/dist/envelope/check.d.ts +0 -2
- package/dist/envelope/check.js +1 -57
- package/dist/envelope/feedbackLoop.js +0 -3
- package/dist/envelope/forkChoice.js +1 -1
- package/dist/envelope/payloadSchema.d.ts +14 -0
- package/dist/envelope/payloadSchema.js +87 -2
- package/dist/mcp/catalogTools.js +1 -2
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/projection/projectRun.d.ts +0 -2
- package/dist/projection/projectRun.js +0 -3
- package/dist/prompt/priorEnvelope.js +1 -1
- package/dist/runstore/port.d.ts +10 -0
- package/dist/runstore/runProjection.js +9 -0
- package/dist/runstore/sqlite/SqliteRunStore.js +27 -4
- package/dist/runstore/sqlite/schema.d.ts +1 -1
- package/dist/runstore/sqlite/schema.js +2 -0
- package/dist/runstore/stageExecution.d.ts +3 -0
- package/dist/runstore/stageExecution.js +6 -2
- package/dist/runstore/stageInstanceId.d.ts +1 -0
- package/dist/runstore/stageInstanceId.js +9 -0
- package/dist/runstore/stageSnapshot.js +11 -0
- package/dist/runtime/cloneSchedule.d.ts +16 -33
- package/dist/runtime/cloneSchedule.js +155 -236
- package/dist/runtime/envelopeRouting.js +43 -94
- package/dist/runtime/forkGeneration.d.ts +2 -2
- package/dist/runtime/forkGeneration.js +29 -15
- package/dist/runtime/joinReadiness.d.ts +16 -0
- package/dist/runtime/joinReadiness.js +156 -0
- package/dist/runtime/pipelineScheduler.d.ts +0 -3
- package/dist/runtime/pipelineScheduler.js +81 -65
- package/dist/runtime/routeIfEval.d.ts +3 -0
- package/dist/runtime/routeIfEval.js +94 -0
- package/dist/runtime/runRetryCoordinator.js +0 -4
- package/dist/runtime/stageAttemptBootstrap.js +60 -11
- package/dist/runtime/stageRunner.js +2 -2
- package/dist/runtime/taskInput.js +1 -13
- package/dist/tools/emitStageEnvelope.d.ts +2 -3
- package/dist/tools/emitStageEnvelope.js +1 -65
- package/dist/types/envelope.d.ts +0 -2
- package/dist/types/forkChoice.d.ts +0 -16
- package/dist/types/pipeline.d.ts +86 -27
- package/dist/types/stage.d.ts +11 -7
- package/dist/types/usage.d.ts +16 -0
- package/dist/types/usage.js +24 -0
- package/dist/ui/assets/{index-uVHiae8s.css → index-DsgVjLUS.css} +1 -1
- package/dist/ui/assets/index-DwKGaQvQ.js +119 -0
- package/dist/ui/index.html +2 -2
- package/package.json +2 -1
- package/skills/stageflow-author/SKILL.md +4 -4
- package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +10 -2
- package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +12 -4
- package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +6 -4
- package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +9 -1
- package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +3 -0
- package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +8 -0
- package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +5 -2
- package/skills/stageflow-author/assets/examples/linear-review/review.yaml +8 -0
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +3 -0
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +5 -2
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +8 -0
- package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +8 -0
- package/skills/stageflow-author/references/catalog-mapping.md +159 -50
- package/skills/stageflow-author/references/catalog-write-conventions.md +3 -3
- package/skills/stageflow-author/references/stage-prompt-template.md +57 -17
- package/skills/stageflow-session-capture/SKILL.md +2 -2
- package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +3 -1
- package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +15 -0
- package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +3 -0
- package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -2
- package/dist/envelope/cloneForks.d.ts +0 -2
- package/dist/envelope/cloneForks.js +0 -77
- package/dist/ui/assets/index-GtyiWiZg.js +0 -118
package/README.md
CHANGED
|
@@ -31,13 +31,12 @@ The same pipeline runs three ways without rewriting anything:
|
|
|
31
31
|
- **Path-based CLI** — `--pipeline` and `--task` take filesystem paths (no bare-id lookup)
|
|
32
32
|
- **Pi-native** — runs on `@earendil-works/pi-coding-agent`; reuse an existing Pi login (`pi_home`) or store credentials in `~/.stageflow/agent/auth.json` (`sf_owned`)
|
|
33
33
|
- **Envelope handoffs** — typed stage payloads and artifacts via `write_stage_artifact` / `emit_stage_envelope`
|
|
34
|
-
- **Verified stage execution** —
|
|
34
|
+
- **Verified stage execution** — after-phase `verify` checks with durable evidence, plus `on_verify_fail` repair or manual operator recovery ([guide](docs/verified-stage-execution.md))
|
|
35
35
|
- **HITL gates** — operator questions in the console; CI exits `2` when a run is waiting
|
|
36
36
|
- **Operator console** — triage runs, connect providers, answer gates, inspect transcripts at `http://127.0.0.1:3847`
|
|
37
37
|
- **MCP endpoint** — Streamable HTTP at `/mcp` when `sf ui` or `sf mcp` is running
|
|
38
38
|
- **CI / headless** — `sf validate --strict --json`, `sf run --json` with exit codes `0` / `1` / `2`
|
|
39
|
-
- **Parallel stages** — pipeline DAG with fan-out and
|
|
40
|
-
- **Clonable fan-out** — clone one successor N times at completion, then join (see [YAML catalog](docs/yaml-catalog.md#clonable-successors))
|
|
39
|
+
- **Parallel stages** — pipeline DAG with fan-out, join, and Clone Chains (one Clone Instance per Clone Array element; see [YAML catalog](docs/yaml-catalog.md#clone-chain))
|
|
41
40
|
- **SQLite run store** — `<git-root>/.stageflow/` state plus per-run workspaces under `.stageflow/runs/`
|
|
42
41
|
|
|
43
42
|
## Architecture at a glance
|
|
@@ -219,7 +218,6 @@ If you want deterministic YAML routing across many agents, look at [Conductor](h
|
|
|
219
218
|
| [hello-world](examples/hello-world/) | Single stage, domain-neutral |
|
|
220
219
|
| [plan-review](examples/plan-review/) | Multi-stage with operator gate — SDLC-style **example** |
|
|
221
220
|
| [conditional-fork](examples/conditional-fork/) | Exclusive fork routing with operator branch choice |
|
|
222
|
-
| [clonable-fanout](examples/clonable-fanout/) | Clone one successor N times, then join |
|
|
223
221
|
| [stage-mcp](examples/stage-mcp/) | Stage MCP via project `.mcp.json` and a local echo fixture |
|
|
224
222
|
| [playwright-mcp](examples/playwright-mcp/) | One stage: open a page and save a PNG screenshot (Playwright MCP) |
|
|
225
223
|
| [context7-mcp](examples/context7-mcp/) | Three stages: resolve a library, fetch docs, write a brief (Context7 MCP) |
|
package/dist/agent/activity.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* those Q&A bodies.
|
|
12
12
|
*/
|
|
13
13
|
import type { AskOperatorAnswer, AskOperatorPrompt } from "../tools/askOperator.js";
|
|
14
|
+
import type { StageUsage } from "../types/usage.js";
|
|
14
15
|
export declare const ACTIVITY_TEXT_LIMIT = 2000;
|
|
15
16
|
export declare const ACTIVITY_TEXT_LIMIT_ENV = "STAGEFLOW_ACTIVITY_TEXT_LIMIT";
|
|
16
17
|
export declare const ACTIVITY_VERBOSE_ENV = "STAGEFLOW_ACTIVITY_VERBOSE";
|
|
@@ -65,9 +66,11 @@ export type StageLifecycleEvent = {
|
|
|
65
66
|
event: "resumed";
|
|
66
67
|
} | {
|
|
67
68
|
event: "succeeded";
|
|
69
|
+
usage?: StageUsage;
|
|
68
70
|
} | {
|
|
69
71
|
event: "failed";
|
|
70
72
|
reason: string;
|
|
73
|
+
usage?: StageUsage;
|
|
71
74
|
} | {
|
|
72
75
|
event: "skipped";
|
|
73
76
|
}
|
|
@@ -43,10 +43,12 @@ import { isAdvancingEnvelope } from "../envelope/check.js";
|
|
|
43
43
|
import { assertAnswerMatchesPrompt, parseAskOperatorAnswer, summarizeAnswer, } from "../tools/askOperator.js";
|
|
44
44
|
import { buildStageflowMcpServer, EMIT_STAGE_ENVELOPE_TOOL_NAME, STAGEFLOW_MCP_SERVER_NAME, WRITE_STAGE_ARTIFACT_TOOL_NAME, ASK_OPERATOR_TOOL_NAME, } from "./claudeTools.js";
|
|
45
45
|
import { createClaudeActivityMapper } from "./claudeActivity.js";
|
|
46
|
+
import { readActivityVerbose } from "./activity.js";
|
|
46
47
|
import { composeStageUserPrompt } from "./piAdapter.js";
|
|
47
48
|
import { claudeSessionMarkerPath, clearClaudeSessionMarker, readClaudeSessionMarker, writeClaudeSessionMarker, } from "./claudeSession.js";
|
|
48
49
|
import { DEFAULT_STAGE_TIMEOUT_MS, runStageViaOpen, runtimeStageId, } from "./port.js";
|
|
49
50
|
import { StageMcpError } from "../config/resolveStageMcpServers.js";
|
|
51
|
+
import { addModelUsage, emptyStageUsage } from "../types/usage.js";
|
|
50
52
|
/** Built-in tool allowlist, mirroring Pi's sealed-session base set (read/bash/write/edit). */
|
|
51
53
|
const CLAUDE_BUILTIN_TOOLS = ["Read", "Write", "Edit", "Bash"];
|
|
52
54
|
function resolveClaudeModel(stageModel) {
|
|
@@ -102,6 +104,65 @@ function failedPassedMcpConnect(resolved, listed) {
|
|
|
102
104
|
}
|
|
103
105
|
return undefined;
|
|
104
106
|
}
|
|
107
|
+
/** Merge one query() call's per-model totals (this call's own turns only) into the stage-level accumulator. */
|
|
108
|
+
function mergeClaudeModelUsage(usage, modelUsage) {
|
|
109
|
+
if (!modelUsage)
|
|
110
|
+
return;
|
|
111
|
+
for (const [key, entry] of Object.entries(modelUsage)) {
|
|
112
|
+
addModelUsage(usage, entry.canonicalModel ?? key, {
|
|
113
|
+
inputTokens: entry.inputTokens,
|
|
114
|
+
outputTokens: entry.outputTokens,
|
|
115
|
+
cacheReadInputTokens: entry.cacheReadInputTokens,
|
|
116
|
+
cacheCreationInputTokens: entry.cacheCreationInputTokens,
|
|
117
|
+
costUsd: entry.costUSD,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const TRAILING_RESULT_WAIT_MS = 2000;
|
|
122
|
+
/**
|
|
123
|
+
* emit_stage_envelope's tool_result lands on a "user" message, which the
|
|
124
|
+
* caller interrupts and breaks on immediately (never-let-it-go-dangling) —
|
|
125
|
+
* well before the SDK's own "result" message (the one carrying cost/token
|
|
126
|
+
* totals) would naturally arrive. There can be more than one trailing frame
|
|
127
|
+
* after the interrupted tool-result (observed: another "user" frame before
|
|
128
|
+
* "result"), so this drains messages in a loop, bounded by one overall time
|
|
129
|
+
* budget rather than a single peek. By the docstring above, `interrupt()`
|
|
130
|
+
* resolving means the CLI subprocess has already exited, so its trailing
|
|
131
|
+
* frames are normally already queued or arrive within milliseconds — this
|
|
132
|
+
* claims them without risking a real hang.
|
|
133
|
+
*/
|
|
134
|
+
async function drainTrailingResultUsage(stream, usage) {
|
|
135
|
+
const debug = readActivityVerbose();
|
|
136
|
+
const deadline = Date.now() + TRAILING_RESULT_WAIT_MS;
|
|
137
|
+
try {
|
|
138
|
+
while (Date.now() < deadline) {
|
|
139
|
+
const next = await Promise.race([
|
|
140
|
+
stream.next(),
|
|
141
|
+
new Promise((resolve) => setTimeout(() => resolve({ done: true, value: undefined }), deadline - Date.now())),
|
|
142
|
+
]);
|
|
143
|
+
if (debug) {
|
|
144
|
+
console.error(`[claudeAdapter] drainTrailingResultUsage: done=${next.done} type=${!next.done ? next.value?.type : "n/a"}`);
|
|
145
|
+
}
|
|
146
|
+
if (next.done)
|
|
147
|
+
return;
|
|
148
|
+
const message = next.value;
|
|
149
|
+
if (message.type === "result") {
|
|
150
|
+
if (debug) {
|
|
151
|
+
console.error(`[claudeAdapter] drainTrailingResultUsage: modelUsage=${JSON.stringify(message.modelUsage)}`);
|
|
152
|
+
}
|
|
153
|
+
mergeClaudeModelUsage(usage, message.modelUsage);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// not "result" yet (e.g. another "user"/"system" frame) — keep draining within the budget
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (debug) {
|
|
161
|
+
console.error(`[claudeAdapter] drainTrailingResultUsage: threw ${err instanceof Error ? err.message : String(err)}`);
|
|
162
|
+
}
|
|
163
|
+
// best-effort; a natural stop may already have ended the turn
|
|
164
|
+
}
|
|
165
|
+
}
|
|
105
166
|
async function* singleUserMessage(text) {
|
|
106
167
|
yield {
|
|
107
168
|
type: "user",
|
|
@@ -110,7 +171,7 @@ async function* singleUserMessage(text) {
|
|
|
110
171
|
};
|
|
111
172
|
}
|
|
112
173
|
/** One `query()` call — either the stage's initial turn or a resume turn. */
|
|
113
|
-
async function runTurn(input, model, markerPath, promptText, resumeSessionId) {
|
|
174
|
+
async function runTurn(input, model, markerPath, promptText, resumeSessionId, usage) {
|
|
114
175
|
const timeoutMs = input.timeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
|
|
115
176
|
const controller = new AbortController();
|
|
116
177
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -187,15 +248,23 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId) {
|
|
|
187
248
|
}
|
|
188
249
|
if (message.type === "user" &&
|
|
189
250
|
(emitCapture.envelope !== undefined || askCapture.prompt !== undefined)) {
|
|
251
|
+
if (readActivityVerbose()) {
|
|
252
|
+
console.error(`[claudeAdapter] interrupting on user tool-result frame`);
|
|
253
|
+
}
|
|
190
254
|
try {
|
|
191
255
|
await stream.interrupt();
|
|
192
256
|
}
|
|
193
257
|
catch {
|
|
194
258
|
// best-effort backstop; a natural stop may have already ended the turn
|
|
195
259
|
}
|
|
260
|
+
await drainTrailingResultUsage(stream, usage);
|
|
196
261
|
break;
|
|
197
262
|
}
|
|
198
263
|
if (message.type === "result") {
|
|
264
|
+
if (readActivityVerbose()) {
|
|
265
|
+
console.error(`[claudeAdapter] main loop saw result: modelUsage=${JSON.stringify(message.modelUsage)}`);
|
|
266
|
+
}
|
|
267
|
+
mergeClaudeModelUsage(usage, message.modelUsage);
|
|
199
268
|
break;
|
|
200
269
|
}
|
|
201
270
|
}
|
|
@@ -204,26 +273,27 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId) {
|
|
|
204
273
|
if (resolvedSessionId === undefined) {
|
|
205
274
|
return {
|
|
206
275
|
kind: "completed",
|
|
207
|
-
result: { ok: false, reason: "ask_operator called but no session_id was observed" },
|
|
276
|
+
result: { ok: false, reason: "ask_operator called but no session_id was observed", usage },
|
|
208
277
|
};
|
|
209
278
|
}
|
|
210
279
|
await writeClaudeSessionMarker(markerPath, {
|
|
211
280
|
sessionId: resolvedSessionId,
|
|
212
281
|
prompt: askCapture.prompt,
|
|
282
|
+
usage,
|
|
213
283
|
});
|
|
214
284
|
return { kind: "waiting", sessionId: resolvedSessionId, request: askCapture.prompt };
|
|
215
285
|
}
|
|
216
286
|
await clearClaudeSessionMarker(markerPath);
|
|
217
|
-
return { kind: "completed", result: resultFromCapture(emitCapture) };
|
|
287
|
+
return { kind: "completed", result: { ...resultFromCapture(emitCapture), usage } };
|
|
218
288
|
}
|
|
219
289
|
catch (err) {
|
|
220
290
|
if (askCapture.prompt !== undefined && sessionId !== undefined) {
|
|
221
|
-
await writeClaudeSessionMarker(markerPath, { sessionId, prompt: askCapture.prompt });
|
|
291
|
+
await writeClaudeSessionMarker(markerPath, { sessionId, prompt: askCapture.prompt, usage });
|
|
222
292
|
return { kind: "waiting", sessionId, request: askCapture.prompt };
|
|
223
293
|
}
|
|
224
294
|
if (emitCapture.envelope && isAdvancingEnvelope(emitCapture.envelope)) {
|
|
225
295
|
await clearClaudeSessionMarker(markerPath);
|
|
226
|
-
return { kind: "completed", result: { ok: true, envelope: emitCapture.envelope } };
|
|
296
|
+
return { kind: "completed", result: { ok: true, envelope: emitCapture.envelope, usage } };
|
|
227
297
|
}
|
|
228
298
|
return {
|
|
229
299
|
kind: "completed",
|
|
@@ -231,6 +301,7 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId) {
|
|
|
231
301
|
ok: false,
|
|
232
302
|
reason: err instanceof Error ? err.message : String(err),
|
|
233
303
|
envelope: emitCapture.envelope,
|
|
304
|
+
usage,
|
|
234
305
|
},
|
|
235
306
|
};
|
|
236
307
|
}
|
|
@@ -254,6 +325,7 @@ export class ClaudeAgentAdapter {
|
|
|
254
325
|
let bootstrapped = false;
|
|
255
326
|
let waiting;
|
|
256
327
|
let pendingAnswer;
|
|
328
|
+
const usage = emptyStageUsage();
|
|
257
329
|
const applyOutcome = (outcome) => {
|
|
258
330
|
if (outcome.kind === "waiting") {
|
|
259
331
|
waiting = { sessionId: outcome.sessionId, prompt: outcome.request };
|
|
@@ -275,6 +347,11 @@ export class ClaudeAgentAdapter {
|
|
|
275
347
|
if (marker?.prompt !== undefined) {
|
|
276
348
|
waiting = { sessionId: marker.sessionId, prompt: marker.prompt };
|
|
277
349
|
}
|
|
350
|
+
if (marker?.usage) {
|
|
351
|
+
for (const [model, breakdown] of Object.entries(marker.usage.models)) {
|
|
352
|
+
addModelUsage(usage, model, breakdown);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
278
355
|
}
|
|
279
356
|
if (waiting) {
|
|
280
357
|
if (pendingAnswer === undefined) {
|
|
@@ -301,14 +378,14 @@ export class ClaudeAgentAdapter {
|
|
|
301
378
|
await clearClaudeSessionMarker(markerPath);
|
|
302
379
|
return { status: "completed", result: { ok: false, reason: pre.reason } };
|
|
303
380
|
}
|
|
304
|
-
const outcome = await runTurn(input, pre.model, markerPath, resumePromptText(parsedAnswer), sessionId);
|
|
381
|
+
const outcome = await runTurn(input, pre.model, markerPath, resumePromptText(parsedAnswer), sessionId, usage);
|
|
305
382
|
return applyOutcome(outcome);
|
|
306
383
|
}
|
|
307
384
|
const pre = preflight(input);
|
|
308
385
|
if (!pre.ok) {
|
|
309
386
|
return { status: "completed", result: { ok: false, reason: pre.reason } };
|
|
310
387
|
}
|
|
311
|
-
const outcome = await runTurn(input, pre.model, markerPath, composeStageUserPrompt(input, EMIT_STAGE_ENVELOPE_TOOL_NAME, undefined, WRITE_STAGE_ARTIFACT_TOOL_NAME), undefined);
|
|
388
|
+
const outcome = await runTurn(input, pre.model, markerPath, composeStageUserPrompt(input, EMIT_STAGE_ENVELOPE_TOOL_NAME, undefined, WRITE_STAGE_ARTIFACT_TOOL_NAME), undefined, usage);
|
|
312
389
|
return applyOutcome(outcome);
|
|
313
390
|
},
|
|
314
391
|
deliverAnswer(answer) {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { AskOperatorPrompt } from "../tools/askOperator.js";
|
|
2
2
|
import type { StageRunInput } from "./port.js";
|
|
3
|
+
import type { StageUsage } from "../types/usage.js";
|
|
3
4
|
export type ClaudeSessionMarker = {
|
|
4
5
|
sessionId: string;
|
|
5
6
|
prompt?: AskOperatorPrompt;
|
|
7
|
+
/** Cost/tokens accumulated by turns run so far, so a restart resuming this stage doesn't lose prior spend. */
|
|
8
|
+
usage?: StageUsage;
|
|
6
9
|
};
|
|
7
10
|
/**
|
|
8
11
|
* Derives a stable, per-attempt path from `input.resumeToken` — the same
|
|
@@ -45,10 +45,8 @@ export type AskOperatorCapture = {
|
|
|
45
45
|
* nothing else catches one drifting from the other.
|
|
46
46
|
*
|
|
47
47
|
* Field presence mirrors the typebox schema's own conditionality:
|
|
48
|
-
* `clone_forks` only exists when a stage can clone-fork at all, and
|
|
49
48
|
* `fork_choice` is required (not just present) when the stage has a fork
|
|
50
|
-
* to route.
|
|
51
|
-
* `clone_forks` field on stages that have nowhere to route one.
|
|
49
|
+
* to route.
|
|
52
50
|
*/
|
|
53
51
|
export declare function buildEmitStageEnvelopeShape(options: {
|
|
54
52
|
forkEmitContext?: ForkEmitContext;
|
|
@@ -62,7 +60,6 @@ export declare function buildEmitStageEnvelopeShape(options: {
|
|
|
62
60
|
artifacts: z.ZodArray<z.ZodString>;
|
|
63
61
|
payload: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
64
62
|
fork_choice: z.ZodArray<z.ZodString> | z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
65
|
-
clone_forks?: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>> | undefined;
|
|
66
63
|
checklist_attestations: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
67
64
|
check_id: z.ZodString;
|
|
68
65
|
items: z.ZodArray<z.ZodString>;
|
|
@@ -40,10 +40,8 @@ export const ASK_OPERATOR_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__ask_op
|
|
|
40
40
|
* nothing else catches one drifting from the other.
|
|
41
41
|
*
|
|
42
42
|
* Field presence mirrors the typebox schema's own conditionality:
|
|
43
|
-
* `clone_forks` only exists when a stage can clone-fork at all, and
|
|
44
43
|
* `fork_choice` is required (not just present) when the stage has a fork
|
|
45
|
-
* to route.
|
|
46
|
-
* `clone_forks` field on stages that have nowhere to route one.
|
|
44
|
+
* to route.
|
|
47
45
|
*/
|
|
48
46
|
export function buildEmitStageEnvelopeShape(options) {
|
|
49
47
|
return {
|
|
@@ -54,9 +52,6 @@ export function buildEmitStageEnvelopeShape(options) {
|
|
|
54
52
|
fork_choice: options.forkEmitContext !== undefined
|
|
55
53
|
? z.array(z.string())
|
|
56
54
|
: z.array(z.string()).optional(),
|
|
57
|
-
...(options.cloneEmitContext !== undefined
|
|
58
|
-
? { clone_forks: z.array(z.record(z.string(), z.unknown())) }
|
|
59
|
-
: {}),
|
|
60
55
|
checklist_attestations: z
|
|
61
56
|
.array(z.object({
|
|
62
57
|
check_id: z.string().min(1),
|
package/dist/agent/fakeAgent.js
CHANGED
|
@@ -3,7 +3,6 @@ import path from "node:path";
|
|
|
3
3
|
import { assertRequiredEnvelope, isAdvancingEnvelope, } from "../envelope/check.js";
|
|
4
4
|
import { assertFeedbackLoopAction } from "../envelope/feedbackLoop.js";
|
|
5
5
|
import { assertEnvelopePayload } from "../envelope/payloadSchema.js";
|
|
6
|
-
import { assertCloneForks } from "../envelope/cloneForks.js";
|
|
7
6
|
import { assertForkEnvelope } from "../envelope/forkChoice.js";
|
|
8
7
|
import { assertAnswerMatchesPrompt, parseAskOperatorAnswer, } from "../tools/askOperator.js";
|
|
9
8
|
import { tryParsePendingPrompt } from "../runtime/stageHitl.js";
|
|
@@ -154,15 +153,15 @@ export class FakeAgent {
|
|
|
154
153
|
? behavior.envelope
|
|
155
154
|
: undefined;
|
|
156
155
|
try {
|
|
157
|
-
|
|
156
|
+
let envelope = assertRequiredEnvelope(envelopeValue);
|
|
157
|
+
if (envelope.status === "success" && envelope.payload === undefined) {
|
|
158
|
+
envelope = { ...envelope, payload: {} };
|
|
159
|
+
}
|
|
158
160
|
assertFeedbackLoopAction(envelope, input.feedbackLoopEmitContext);
|
|
159
161
|
const isFeedbackSendBack = envelope.feedback_loop?.action === "send_back";
|
|
160
162
|
if (!isFeedbackSendBack && input.forkEmitContext !== undefined) {
|
|
161
163
|
assertForkEnvelope(envelope, input.forkEmitContext);
|
|
162
164
|
}
|
|
163
|
-
if (!isFeedbackSendBack && input.cloneEmitContext !== undefined) {
|
|
164
|
-
assertCloneForks(envelope, input.cloneEmitContext);
|
|
165
|
-
}
|
|
166
165
|
assertEnvelopePayload(envelope, input.stage.payload_schema);
|
|
167
166
|
if (!isAdvancingEnvelope(envelope)) {
|
|
168
167
|
input.onActivity?.({ event: "agent_end" });
|
|
@@ -5,6 +5,7 @@ import "./cursorProvider.js";
|
|
|
5
5
|
import { type StageActivityObserver } from "./activityObserver.js";
|
|
6
6
|
import type { AgentPort, OpaqueAnswer, StageHandle, StageHandleCloseOptions, StageRunInput, StageRunResult } from "./port.js";
|
|
7
7
|
import type { StageGateKind } from "../types/stage.js";
|
|
8
|
+
import { type StageUsage } from "../types/usage.js";
|
|
8
9
|
/**
|
|
9
10
|
* Stage tool allowlist for sealed Pi sessions.
|
|
10
11
|
* Includes `ask_operator` unless `gateKinds` is an empty list (R6).
|
|
@@ -56,6 +57,7 @@ export declare function extractPartialResultText(partialResult: unknown): string
|
|
|
56
57
|
export type RouteSessionEventToProgressOptions = {
|
|
57
58
|
observer: StageActivityObserver;
|
|
58
59
|
verbose: boolean;
|
|
60
|
+
usage?: StageUsage;
|
|
59
61
|
};
|
|
60
62
|
/**
|
|
61
63
|
* Adapter-edge routing: stream deltas to the observer, map milestones to
|
package/dist/agent/piAdapter.js
CHANGED
|
@@ -47,6 +47,7 @@ import { findProviderSupport } from "./providerSupport.js";
|
|
|
47
47
|
import { mapSessionEventToActivity, readActivityVerbose } from "./activity.js";
|
|
48
48
|
import { createStageActivityObserver, } from "./activityObserver.js";
|
|
49
49
|
import { DEFAULT_STAGE_TIMEOUT_MS, runtimeStageId, runStageViaOpen } from "./port.js";
|
|
50
|
+
import { addModelUsage, emptyStageUsage } from "../types/usage.js";
|
|
50
51
|
/**
|
|
51
52
|
* Stage tool allowlist for sealed Pi sessions.
|
|
52
53
|
* Includes `ask_operator` unless `gateKinds` is an empty list (R6).
|
|
@@ -302,13 +303,29 @@ export function extractPartialResultText(partialResult) {
|
|
|
302
303
|
}
|
|
303
304
|
return String(partialResult);
|
|
304
305
|
}
|
|
306
|
+
/** Merge one assistant message's usage/cost (as reported by pi-ai) into the stage-level accumulator. */
|
|
307
|
+
function mergePiAssistantUsage(usage, message) {
|
|
308
|
+
if (!message || typeof message !== "object")
|
|
309
|
+
return;
|
|
310
|
+
const msg = message;
|
|
311
|
+
if (msg.role !== "assistant" || typeof msg.model !== "string" || !msg.usage)
|
|
312
|
+
return;
|
|
313
|
+
const u = msg.usage;
|
|
314
|
+
addModelUsage(usage, msg.model, {
|
|
315
|
+
inputTokens: u.input,
|
|
316
|
+
outputTokens: u.output,
|
|
317
|
+
cacheReadInputTokens: u.cacheRead,
|
|
318
|
+
cacheCreationInputTokens: u.cacheWrite,
|
|
319
|
+
costUsd: u.cost?.total ?? 0,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
305
322
|
/**
|
|
306
323
|
* Adapter-edge routing: stream deltas to the observer, map milestones to
|
|
307
324
|
* StageActivityEvent. Verbose thinking / live tool partials go to observer
|
|
308
325
|
* hooks (stderr streams + coalesced/throttled persist via onActivity).
|
|
309
326
|
*/
|
|
310
327
|
export function routeSessionEventToProgress(event, options) {
|
|
311
|
-
const { observer, verbose } = options;
|
|
328
|
+
const { observer, verbose, usage } = options;
|
|
312
329
|
if (event.type === "message_update") {
|
|
313
330
|
const ame = event.assistantMessageEvent;
|
|
314
331
|
if (ame && typeof ame === "object") {
|
|
@@ -334,6 +351,9 @@ export function routeSessionEventToProgress(event, options) {
|
|
|
334
351
|
}
|
|
335
352
|
if (event.type === "message_end" || event.type === "agent_end") {
|
|
336
353
|
observer.onStreamBoundary();
|
|
354
|
+
if (usage && event.type === "message_end") {
|
|
355
|
+
mergePiAssistantUsage(usage, event.message);
|
|
356
|
+
}
|
|
337
357
|
}
|
|
338
358
|
if (event.type === "agent_start" ||
|
|
339
359
|
event.type === "turn_start" ||
|
|
@@ -350,13 +370,14 @@ export function routeSessionEventToProgress(event, options) {
|
|
|
350
370
|
* Map Pi session events to StageActivityEvent at the adapter edge, then
|
|
351
371
|
* forward to the activity observer (stderr + onActivity).
|
|
352
372
|
*/
|
|
353
|
-
function attachStageProgress(session, onActivity) {
|
|
373
|
+
function attachStageProgress(session, onActivity, usage) {
|
|
354
374
|
const observer = createStageActivityObserver({ onActivity, writeStderr: true });
|
|
355
375
|
const verbose = readActivityVerbose();
|
|
356
376
|
const unsubscribe = session.subscribe((event) => {
|
|
357
377
|
routeSessionEventToProgress(event, {
|
|
358
378
|
observer,
|
|
359
379
|
verbose,
|
|
380
|
+
usage,
|
|
360
381
|
});
|
|
361
382
|
});
|
|
362
383
|
return () => {
|
|
@@ -383,23 +404,10 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
|
|
|
383
404
|
emitHint += `\nFailed-check evidence from attempt ${input.repairContext.prior_attempt}:\n${JSON.stringify(input.repairContext.failed_checks, null, 2)}`;
|
|
384
405
|
}
|
|
385
406
|
}
|
|
386
|
-
if (input.cloneEmitContext !== undefined) {
|
|
387
|
-
const allowedActions = input.cloneEmitContext.allowedActions ?? ["skip", "once", "fanout"];
|
|
388
|
-
const successorLines = input.cloneEmitContext.clonableSuccessors.map((successor) => {
|
|
389
|
-
const header = `- ${successor.successorId} (clone_cap: ${successor.cloneCap})`;
|
|
390
|
-
if (successor.cloneInputSchema === undefined) {
|
|
391
|
-
return header;
|
|
392
|
-
}
|
|
393
|
-
return `${header}\n Assignment schema (io.input.schema):\n${JSON.stringify(successor.cloneInputSchema, null, 2)}`;
|
|
394
|
-
});
|
|
395
|
-
emitHint += `\nClonable successors — emit clone_forks covering each of these ids exactly once:\n${successorLines.join("\n")}`;
|
|
396
|
-
emitHint += `\nEach once or fanout envelope is a full StageEnvelope — it requires status ("success" or "failure"), summary (non-empty string), and artifacts (array of strings). io.input.schema fields belong in envelope.payload, not at the top level of the clone_forks item.`;
|
|
397
|
-
emitHint += `\nAllowed clone actions: ${allowedActions.join(", ")}`;
|
|
398
|
-
}
|
|
399
407
|
if (input.feedbackLoopEmitContext !== undefined) {
|
|
400
408
|
emitHint += `\nFeedback-loop decision: this stage is a feedback-loop source. On status=success, feedback_loop is required. Emit either {"action":"continue"} to proceed downstream or {"action":"send_back","target":"…"} to request another pass.`;
|
|
401
409
|
emitHint += `\nAllowed send_back target: ${input.feedbackLoopEmitContext.target}.`;
|
|
402
|
-
emitHint += `\nA send_back decision cannot be combined with fork_choice
|
|
410
|
+
emitHint += `\nA send_back decision cannot be combined with fork_choice.`;
|
|
403
411
|
}
|
|
404
412
|
const attempt = input.roots.attempt ?? 1;
|
|
405
413
|
const attemptArtifactsPath = `stages/${runtimeStageId(input)}/attempts/${attempt}/artifacts/`;
|
|
@@ -752,6 +760,7 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
|
|
|
752
760
|
const { roots } = input;
|
|
753
761
|
const provider = findProviderSupport(input.stage.model);
|
|
754
762
|
const capture = {};
|
|
763
|
+
const usage = emptyStageUsage();
|
|
755
764
|
const askWaitChannel = existingAskWaitChannel ?? new AskOperatorWaitChannel();
|
|
756
765
|
const emitDef = createEmitStageEnvelopeTool(capture, input.stage.payload_schema, input.forkEmitContext, input.cloneEmitContext, {
|
|
757
766
|
checks: input.stage.pre_emit_checks,
|
|
@@ -864,6 +873,7 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
|
|
|
864
873
|
providerEmitHint: provider?.emitToolHint?.(emitDef.name),
|
|
865
874
|
restoreProvider,
|
|
866
875
|
capture,
|
|
876
|
+
usage,
|
|
867
877
|
askWaitChannel,
|
|
868
878
|
connecting: attached.connecting,
|
|
869
879
|
cancelConnecting: attached.cancel,
|
|
@@ -1090,7 +1100,7 @@ export class PiAgentAdapter {
|
|
|
1090
1100
|
return;
|
|
1091
1101
|
}
|
|
1092
1102
|
session = bound;
|
|
1093
|
-
unsubscribeProgress = attachStageProgress(session, input.onActivity);
|
|
1103
|
+
unsubscribeProgress = attachStageProgress(session, input.onActivity, wiring.usage);
|
|
1094
1104
|
})();
|
|
1095
1105
|
const runWithTimeout = async (work) => {
|
|
1096
1106
|
await preparePromise;
|
|
@@ -1109,16 +1119,17 @@ export class PiAgentAdapter {
|
|
|
1109
1119
|
});
|
|
1110
1120
|
});
|
|
1111
1121
|
await Promise.race([workPromise, abortPromise]);
|
|
1112
|
-
return resultFromCapture(wiring.capture);
|
|
1122
|
+
return { ...resultFromCapture(wiring.capture), usage: wiring.usage };
|
|
1113
1123
|
}
|
|
1114
1124
|
catch (err) {
|
|
1115
1125
|
if (wiring.capture.envelope && isAdvancingEnvelope(wiring.capture.envelope)) {
|
|
1116
|
-
return { ok: true, envelope: wiring.capture.envelope };
|
|
1126
|
+
return { ok: true, envelope: wiring.capture.envelope, usage: wiring.usage };
|
|
1117
1127
|
}
|
|
1118
1128
|
return {
|
|
1119
1129
|
ok: false,
|
|
1120
1130
|
reason: err instanceof Error ? err.message : String(err),
|
|
1121
1131
|
envelope: wiring.capture.envelope,
|
|
1132
|
+
usage: wiring.usage,
|
|
1122
1133
|
};
|
|
1123
1134
|
}
|
|
1124
1135
|
finally {
|
package/dist/agent/port.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { StageActivityEvent } from "./activity.js";
|
|
|
8
8
|
import type { ResolvedMcpServers } from "../config/resolveStageMcpServers.js";
|
|
9
9
|
import type { StageRoots } from "../runtime/stageRoots.js";
|
|
10
10
|
import type { QaExchange } from "../hitl/qaTrail.js";
|
|
11
|
+
import type { StageUsage } from "../types/usage.js";
|
|
11
12
|
/** Opaque to runtime; adapters interpret. */
|
|
12
13
|
export type StageResumeToken = string;
|
|
13
14
|
export type StageSessionMode = "fresh" | "waiting_resume" | "feedback_resume" | "new_session";
|
|
@@ -77,10 +78,12 @@ export declare function runtimeStageId(input: Pick<StageRunInput, "stage" | "sta
|
|
|
77
78
|
export type StageRunResult = {
|
|
78
79
|
ok: true;
|
|
79
80
|
envelope: StageEnvelope;
|
|
81
|
+
usage?: StageUsage;
|
|
80
82
|
} | {
|
|
81
83
|
ok: false;
|
|
82
84
|
reason: string;
|
|
83
85
|
envelope?: StageEnvelope;
|
|
86
|
+
usage?: StageUsage;
|
|
84
87
|
};
|
|
85
88
|
/** Opaque wait-request blob; T2 owns concrete prompt shapes later. */
|
|
86
89
|
export type OpaqueWaitRequest = unknown;
|
package/dist/cli/runOutput.js
CHANGED
|
@@ -2,6 +2,21 @@ import { projectRun } from "../projection/projectRun.js";
|
|
|
2
2
|
function stringify(payload) {
|
|
3
3
|
return JSON.stringify(payload, null, 2);
|
|
4
4
|
}
|
|
5
|
+
function formatUsd(amount) {
|
|
6
|
+
return `$${amount.toFixed(4)}`;
|
|
7
|
+
}
|
|
8
|
+
/** Best-effort total cost for a run; undefined (not thrown) on any read failure or when no stage reported usage. */
|
|
9
|
+
async function tryReadTotalCostUsd(store, runId) {
|
|
10
|
+
if (!store)
|
|
11
|
+
return undefined;
|
|
12
|
+
try {
|
|
13
|
+
const detail = await store.readRun(runId);
|
|
14
|
+
return detail.total_cost_usd;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
5
20
|
function isBusyCode(code) {
|
|
6
21
|
return code === "busy_capacity" || code === "busy_checkout";
|
|
7
22
|
}
|
|
@@ -80,14 +95,19 @@ function baseRunCompletionPayload(result) {
|
|
|
80
95
|
}
|
|
81
96
|
return payload;
|
|
82
97
|
}
|
|
83
|
-
function
|
|
84
|
-
|
|
98
|
+
async function formatRunCompletionJsonWithCost(result, store) {
|
|
99
|
+
const totalCostUsd = await tryReadTotalCostUsd(store, result.runId);
|
|
100
|
+
return stringify({
|
|
101
|
+
...baseRunCompletionPayload(result),
|
|
102
|
+
...(totalCostUsd !== undefined ? { total_cost_usd: totalCostUsd } : {}),
|
|
103
|
+
});
|
|
85
104
|
}
|
|
86
105
|
async function formatRunCompletionJsonWithStages(result, store) {
|
|
87
106
|
const detail = await store.readRun(result.runId);
|
|
88
107
|
const projection = projectRun(detail);
|
|
89
108
|
return stringify({
|
|
90
109
|
...baseRunCompletionPayload(result),
|
|
110
|
+
...(detail.total_cost_usd !== undefined ? { total_cost_usd: detail.total_cost_usd } : {}),
|
|
91
111
|
stages: projection.stages,
|
|
92
112
|
});
|
|
93
113
|
}
|
|
@@ -101,18 +121,21 @@ function writeStartFailureHuman(started, io) {
|
|
|
101
121
|
io.error(`conflictingCheckout: ${started.conflictingCheckout}`);
|
|
102
122
|
}
|
|
103
123
|
}
|
|
104
|
-
function writeCompletionHuman(result, io) {
|
|
124
|
+
function writeCompletionHuman(result, io, totalCostUsd) {
|
|
105
125
|
switch (result.outcome) {
|
|
106
126
|
case "waiting":
|
|
107
127
|
io.error(`Pipeline waiting. Run folder: ${result.runDir} (${result.runId})`);
|
|
108
|
-
|
|
128
|
+
break;
|
|
109
129
|
case "failed":
|
|
110
130
|
io.error(`Pipeline failed: ${result.reason}`);
|
|
111
131
|
io.error(`Run folder: ${result.runDir}`);
|
|
112
|
-
|
|
132
|
+
break;
|
|
113
133
|
case "succeeded":
|
|
114
134
|
io.log(`Pipeline succeeded. Run folder: ${result.runDir}`);
|
|
115
|
-
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
if (totalCostUsd !== undefined) {
|
|
138
|
+
io.error(`Cost: ${formatUsd(totalCostUsd)}`);
|
|
116
139
|
}
|
|
117
140
|
}
|
|
118
141
|
function exitCodeForRunOutcome(outcome) {
|
|
@@ -151,11 +174,12 @@ export async function reportCliRun(event, options) {
|
|
|
151
174
|
}
|
|
152
175
|
}
|
|
153
176
|
else {
|
|
154
|
-
options.io.log(
|
|
177
|
+
options.io.log(await formatRunCompletionJsonWithCost(event.result, options.store));
|
|
155
178
|
}
|
|
156
179
|
}
|
|
157
180
|
else {
|
|
158
|
-
|
|
181
|
+
const totalCostUsd = await tryReadTotalCostUsd(options.store, event.result.runId);
|
|
182
|
+
writeCompletionHuman(event.result, options.io, totalCostUsd);
|
|
159
183
|
}
|
|
160
184
|
return exitCodeForRunOutcome(event.result.outcome);
|
|
161
185
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PipelineStageRef, ResolvedPipelineDag } from "../types/pipeline.js";
|
|
2
|
+
import type { StageConfig } from "../types/stage.js";
|
|
3
|
+
import { type LoadOutcome } from "./loadOutcome.js";
|
|
4
|
+
export type DetectedCloneChain = {
|
|
5
|
+
emitterId: string;
|
|
6
|
+
cloneChildId: string;
|
|
7
|
+
joinId: string;
|
|
8
|
+
arrayField: string;
|
|
9
|
+
ref: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function detectCloneChains(stages: StageConfig[], refs: PipelineStageRef[], pipelineId: string): LoadOutcome<DetectedCloneChain[]>;
|
|
12
|
+
export declare function applyCloneChains(stages: StageConfig[], refs: PipelineStageRef[], dag: ResolvedPipelineDag, pipelineId: string): LoadOutcome<void>;
|