stageflow 0.13.0 → 0.16.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 (113) hide show
  1. package/dist/agent/activity.d.ts +3 -0
  2. package/dist/agent/claudeAdapter.js +84 -7
  3. package/dist/agent/claudeSession.d.ts +3 -0
  4. package/dist/agent/piAdapter.d.ts +2 -0
  5. package/dist/agent/piAdapter.js +34 -7
  6. package/dist/agent/port.d.ts +5 -2
  7. package/dist/cli/initTemplates.d.ts +1 -1
  8. package/dist/cli/initTemplates.js +1 -1
  9. package/dist/cli/migrateYamlCommand.d.ts +18 -0
  10. package/dist/cli/migrateYamlCommand.js +180 -0
  11. package/dist/cli/runOutput.js +58 -26
  12. package/dist/cli/validateCommand.d.ts +1 -0
  13. package/dist/cli/validateCommand.js +2 -0
  14. package/dist/cli.js +15 -1
  15. package/dist/config/browseCatalog.js +1 -1
  16. package/dist/config/createPipeline.d.ts +1 -1
  17. package/dist/config/createPipeline.js +23 -11
  18. package/dist/config/createStage.d.ts +1 -1
  19. package/dist/config/createStage.js +10 -5
  20. package/dist/config/legacyYaml.d.ts +28 -0
  21. package/dist/config/legacyYaml.js +98 -0
  22. package/dist/config/loadOutcome.d.ts +2 -1
  23. package/dist/config/loadOutcome.js +4 -1
  24. package/dist/config/loadPipeline.d.ts +2 -0
  25. package/dist/config/loadPipeline.js +150 -8
  26. package/dist/config/loadStage.d.ts +8 -1
  27. package/dist/config/loadStage.js +138 -29
  28. package/dist/config/loadStageflowManifest.js +10 -0
  29. package/dist/config/loadTask.js +14 -0
  30. package/dist/config/materializeStageModels.d.ts +7 -0
  31. package/dist/config/materializeStageModels.js +35 -0
  32. package/dist/config/mergePipelineIncludes.d.ts +5 -1
  33. package/dist/config/mergePipelineIncludes.js +82 -4
  34. package/dist/config/migrateYaml.d.ts +19 -0
  35. package/dist/config/migrateYaml.js +627 -0
  36. package/dist/config/modelField.d.ts +13 -0
  37. package/dist/config/modelField.js +16 -0
  38. package/dist/config/normalizePipelineStageEntry.js +41 -2
  39. package/dist/config/parseCompletionContract.d.ts +5 -2
  40. package/dist/config/parseCompletionContract.js +16 -3
  41. package/dist/config/parsePreEmitChecks.js +4 -0
  42. package/dist/config/pipelineNeeds.js +3 -3
  43. package/dist/config/pipelineStageKeys.d.ts +11 -0
  44. package/dist/config/pipelineStageKeys.js +14 -0
  45. package/dist/config/printTargetYaml.d.ts +28 -0
  46. package/dist/config/printTargetYaml.js +243 -0
  47. package/dist/config/resolveModel.d.ts +20 -0
  48. package/dist/config/resolveModel.js +35 -0
  49. package/dist/config/validateCatalog.d.ts +5 -1
  50. package/dist/config/validateCatalog.js +36 -6
  51. package/dist/config/validateCompletionContract.js +5 -0
  52. package/dist/config/yamlDialect.d.ts +29 -0
  53. package/dist/config/yamlDialect.js +327 -0
  54. package/dist/envelope/payloadSchema.d.ts +37 -5
  55. package/dist/envelope/payloadSchema.js +272 -22
  56. package/dist/mcp/catalogTools.js +1 -0
  57. package/dist/package-meta.d.ts +1 -1
  58. package/dist/package-meta.js +1 -1
  59. package/dist/runstore/pipelineDagSnapshot.js +2 -0
  60. package/dist/runstore/port.d.ts +11 -0
  61. package/dist/runstore/runProjection.js +9 -0
  62. package/dist/runstore/sqlite/SqliteRunStore.js +27 -4
  63. package/dist/runstore/sqlite/schema.d.ts +1 -1
  64. package/dist/runstore/sqlite/schema.js +2 -0
  65. package/dist/runstore/stageExecution.d.ts +3 -0
  66. package/dist/runstore/stageExecution.js +6 -2
  67. package/dist/runstore/stageSnapshot.js +11 -0
  68. package/dist/runtime/completionCheckRunner.d.ts +3 -2
  69. package/dist/runtime/completionCheckRunner.js +3 -2
  70. package/dist/runtime/envelopeRouting.d.ts +2 -2
  71. package/dist/runtime/pipelineRunner.d.ts +3 -0
  72. package/dist/runtime/pipelineRunner.js +23 -3
  73. package/dist/runtime/runManager.js +3 -2
  74. package/dist/runtime/stageAttemptBootstrap.d.ts +2 -2
  75. package/dist/runtime/stageRunner.d.ts +2 -2
  76. package/dist/runtime/stageRunner.js +2 -2
  77. package/dist/runtime/taskInput.d.ts +7 -0
  78. package/dist/runtime/taskInput.js +60 -0
  79. package/dist/runtime/verifiedStageExecution.js +3 -0
  80. package/dist/types/completion.d.ts +6 -0
  81. package/dist/types/pipeline.d.ts +21 -2
  82. package/dist/types/preEmitCheck.d.ts +4 -6
  83. package/dist/types/stage.d.ts +22 -3
  84. package/dist/types/stageflowManifest.d.ts +2 -0
  85. package/dist/types/task.d.ts +1 -0
  86. package/dist/types/usage.d.ts +16 -0
  87. package/dist/types/usage.js +24 -0
  88. package/dist/ui/assets/{index-uVHiae8s.css → index-D2Wt8hwH.css} +1 -1
  89. package/dist/ui/assets/index-XWJovnp7.js +118 -0
  90. package/dist/ui/index.html +2 -2
  91. package/package.json +1 -1
  92. package/skills/stageflow-author/SKILL.md +7 -7
  93. package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +9 -1
  94. package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +6 -2
  95. package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +9 -1
  96. package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +16 -1
  97. package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +12 -2
  98. package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +16 -1
  99. package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +11 -2
  100. package/skills/stageflow-author/assets/examples/linear-review/review.yaml +19 -1
  101. package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +9 -1
  102. package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +6 -2
  103. package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +16 -1
  104. package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +9 -1
  105. package/skills/stageflow-author/references/catalog-mapping.md +30 -28
  106. package/skills/stageflow-author/references/catalog-write-conventions.md +2 -2
  107. package/skills/stageflow-author/references/stage-prompt-template.md +5 -5
  108. package/skills/stageflow-session-capture/SKILL.md +1 -1
  109. package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +2 -1
  110. package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +13 -2
  111. package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +18 -2
  112. package/skills/stageflow-session-capture/references/catalog-authoring.md +2 -2
  113. package/dist/ui/assets/index-Yzh42vhf.js +0 -118
@@ -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
@@ -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
@@ -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 () => {
@@ -390,10 +411,10 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
390
411
  if (successor.cloneInputSchema === undefined) {
391
412
  return header;
392
413
  }
393
- return `${header}\n Assignment schema (clone_input_schema):\n${JSON.stringify(successor.cloneInputSchema, null, 2)}`;
414
+ return `${header}\n Assignment schema (io.input.schema):\n${JSON.stringify(successor.cloneInputSchema, null, 2)}`;
394
415
  });
395
416
  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). clone_input_schema fields belong in envelope.payload, not at the top level of the clone_forks item.`;
417
+ 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
418
  emitHint += `\nAllowed clone actions: ${allowedActions.join(", ")}`;
398
419
  }
399
420
  if (input.feedbackLoopEmitContext !== undefined) {
@@ -431,6 +452,9 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
431
452
  `Goal: ${input.task.goal}`,
432
453
  input.task.context ? `Context: ${input.task.context}` : "",
433
454
  input.task.constraints ? `Constraints: ${input.task.constraints}` : "",
455
+ input.task.input !== undefined
456
+ ? `Input: ${JSON.stringify(input.task.input, null, 2)}`
457
+ : "",
434
458
  formatPriorEnvelope(input.priorEnvelope, input.priorEnvelopes, input.priorEnvelopesByStage),
435
459
  input.feedbackLoopContext !== undefined
436
460
  ? formatFeedbackLoopContext(input.feedbackLoopContext, input.sessionMode)
@@ -749,6 +773,7 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
749
773
  const { roots } = input;
750
774
  const provider = findProviderSupport(input.stage.model);
751
775
  const capture = {};
776
+ const usage = emptyStageUsage();
752
777
  const askWaitChannel = existingAskWaitChannel ?? new AskOperatorWaitChannel();
753
778
  const emitDef = createEmitStageEnvelopeTool(capture, input.stage.payload_schema, input.forkEmitContext, input.cloneEmitContext, {
754
779
  checks: input.stage.pre_emit_checks,
@@ -861,6 +886,7 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
861
886
  providerEmitHint: provider?.emitToolHint?.(emitDef.name),
862
887
  restoreProvider,
863
888
  capture,
889
+ usage,
864
890
  askWaitChannel,
865
891
  connecting: attached.connecting,
866
892
  cancelConnecting: attached.cancel,
@@ -1087,7 +1113,7 @@ export class PiAgentAdapter {
1087
1113
  return;
1088
1114
  }
1089
1115
  session = bound;
1090
- unsubscribeProgress = attachStageProgress(session, input.onActivity);
1116
+ unsubscribeProgress = attachStageProgress(session, input.onActivity, wiring.usage);
1091
1117
  })();
1092
1118
  const runWithTimeout = async (work) => {
1093
1119
  await preparePromise;
@@ -1106,16 +1132,17 @@ export class PiAgentAdapter {
1106
1132
  });
1107
1133
  });
1108
1134
  await Promise.race([workPromise, abortPromise]);
1109
- return resultFromCapture(wiring.capture);
1135
+ return { ...resultFromCapture(wiring.capture), usage: wiring.usage };
1110
1136
  }
1111
1137
  catch (err) {
1112
1138
  if (wiring.capture.envelope && isAdvancingEnvelope(wiring.capture.envelope)) {
1113
- return { ok: true, envelope: wiring.capture.envelope };
1139
+ return { ok: true, envelope: wiring.capture.envelope, usage: wiring.usage };
1114
1140
  }
1115
1141
  return {
1116
1142
  ok: false,
1117
1143
  reason: err instanceof Error ? err.message : String(err),
1118
1144
  envelope: wiring.capture.envelope,
1145
+ usage: wiring.usage,
1119
1146
  };
1120
1147
  }
1121
1148
  finally {
@@ -1,13 +1,14 @@
1
1
  import type { StageEnvelope, TerminalEnvelope } from "../types/envelope.js";
2
2
  import type { CompletionContract } from "../types/completion.js";
3
3
  import type { CloneEmitContext, ForkEmitContext } from "../types/forkChoice.js";
4
- import type { StageConfig } from "../types/stage.js";
4
+ import type { LoadedStageConfig } from "../types/stage.js";
5
5
  import type { FeedbackLoopConfig } from "../types/pipeline.js";
6
6
  import type { TaskFile } from "../types/task.js";
7
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";
@@ -45,7 +46,7 @@ export type FeedbackLoopContext = {
45
46
  };
46
47
  export type StageRunInput = {
47
48
  roots: StageRoots;
48
- stage: StageConfig;
49
+ stage: LoadedStageConfig;
49
50
  /** Runtime instance id (`work~2`). Defaults to `stage.id` when omitted. */
50
51
  stageId?: string;
51
52
  task: TaskFile;
@@ -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;
@@ -1,3 +1,3 @@
1
1
  export declare const STAGEFLOW_YAML = "version: 1\ncatalog:\n pipelines:\n - pipelines\n tasks:\n - tasks\n patterns:\n pipeline: \"*.pipeline.yaml\"\n task: \"*.task.yaml\"\n";
2
- export declare const HELLO_PIPELINE_YAML = "id: hello\nstages:\n - id: hello\n system_prompt: Say hello and emit a success envelope.\n model: anthropic/claude-sonnet-4-5\n";
2
+ export declare const HELLO_PIPELINE_YAML = "id: hello\nmodel: anthropic/claude-sonnet-4-5\nstages:\n - id: hello\n system_prompt: Say hello and emit a success envelope.\n";
3
3
  export declare const HELLO_TASK_YAML = "id: hello\ngoal: Run the hello pipeline scaffold.\n";
@@ -9,10 +9,10 @@ catalog:
9
9
  task: "*.task.yaml"
10
10
  `;
11
11
  export const HELLO_PIPELINE_YAML = `id: hello
12
+ model: anthropic/claude-sonnet-4-5
12
13
  stages:
13
14
  - id: hello
14
15
  system_prompt: Say hello and emit a success envelope.
15
- model: anthropic/claude-sonnet-4-5
16
16
  `;
17
17
  export const HELLO_TASK_YAML = `id: hello
18
18
  goal: Run the hello pipeline scaffold.
@@ -0,0 +1,18 @@
1
+ export declare const MIGRATE_YAML_USAGE = "Usage:\n sf migrate-yaml [path] [--root <path>] [--write] [--json] [--force]\n\n Convert legacy catalog YAML to target YAML (io / verify / on_verify_fail).\n Dry-run is the default. --write applies changes. Path may be a pipeline,\n stage, task, or catalog root. Does not rewrite .stageflow snapshots.";
2
+ export type MigrateYamlCommandIo = {
3
+ log: (line: string) => void;
4
+ error: (line: string) => void;
5
+ };
6
+ export type MigrateYamlJson = {
7
+ ok: boolean;
8
+ write: boolean;
9
+ planned: string[];
10
+ written: string[];
11
+ skipped: string[];
12
+ errors: string[];
13
+ };
14
+ export declare function runMigrateYamlCommand(args: string[], options?: {
15
+ cwd?: string;
16
+ projectRoot?: string;
17
+ io?: Partial<MigrateYamlCommandIo>;
18
+ }): Promise<number>;
@@ -0,0 +1,180 @@
1
+ import path from "node:path";
2
+ import { applyMigrateYamlPlan, fileDirtyVsHead, planMigrateYaml, } from "../config/migrateYaml.js";
3
+ export const MIGRATE_YAML_USAGE = `Usage:
4
+ sf migrate-yaml [path] [--root <path>] [--write] [--json] [--force]
5
+
6
+ Convert legacy catalog YAML to target YAML (io / verify / on_verify_fail).
7
+ Dry-run is the default. --write applies changes. Path may be a pipeline,
8
+ stage, task, or catalog root. Does not rewrite .stageflow snapshots.`;
9
+ const defaultIo = {
10
+ log: (line) => console.log(line),
11
+ error: (line) => console.error(line),
12
+ };
13
+ function parseMigrateYamlArgs(args) {
14
+ if (args.length === 0) {
15
+ return { help: false, write: false, json: false, force: false };
16
+ }
17
+ if (args[0] === "--help" || args[0] === "-h") {
18
+ return { help: true, write: false, json: false, force: false };
19
+ }
20
+ let target;
21
+ let root;
22
+ let write = false;
23
+ let json = false;
24
+ let force = false;
25
+ let help = false;
26
+ for (let i = 0; i < args.length; i++) {
27
+ const arg = args[i];
28
+ if (arg === "--help" || arg === "-h") {
29
+ help = true;
30
+ }
31
+ else if (arg === "--write") {
32
+ write = true;
33
+ }
34
+ else if (arg === "--json") {
35
+ json = true;
36
+ }
37
+ else if (arg === "--force") {
38
+ force = true;
39
+ }
40
+ else if (arg === "--root") {
41
+ const value = args[++i];
42
+ if (value === undefined || value.length === 0) {
43
+ throw new Error("Missing value for --root");
44
+ }
45
+ root = value;
46
+ }
47
+ else if (arg.startsWith("-")) {
48
+ throw new Error(`Unknown flag: ${arg}`);
49
+ }
50
+ else if (target !== undefined) {
51
+ throw new Error(`Unexpected argument: ${arg}`);
52
+ }
53
+ else {
54
+ target = arg;
55
+ }
56
+ }
57
+ if (target !== undefined && root !== undefined) {
58
+ throw new Error("Use at most one of a positional path or --root");
59
+ }
60
+ return { help, target: target ?? root, write, json, force };
61
+ }
62
+ function planToJson(plan, options) {
63
+ return {
64
+ ok: options.ok,
65
+ write: options.write,
66
+ planned: plan.writes.map((item) => item.file),
67
+ written: options.written,
68
+ skipped: plan.skipped,
69
+ errors: [...plan.errors, ...(options.extraErrors ?? [])],
70
+ };
71
+ }
72
+ function formatHuman(plan, write, extraErrors) {
73
+ const lines = [];
74
+ const errors = [...plan.errors, ...extraErrors];
75
+ if (errors.length > 0) {
76
+ lines.push("Migration failed:");
77
+ for (const error of errors)
78
+ lines.push(` ${error}`);
79
+ return lines.join("\n");
80
+ }
81
+ if (write) {
82
+ if (plan.writes.length === 0) {
83
+ lines.push("Nothing to write.");
84
+ }
85
+ else {
86
+ lines.push("Wrote:");
87
+ for (const item of plan.writes)
88
+ lines.push(` ${item.file}`);
89
+ }
90
+ }
91
+ else if (plan.writes.length === 0) {
92
+ lines.push("Nothing to migrate.");
93
+ }
94
+ else {
95
+ lines.push("Would write:");
96
+ for (const item of plan.writes)
97
+ lines.push(` ${item.file}`);
98
+ lines.push("");
99
+ lines.push("Dry-run; pass --write to apply.");
100
+ }
101
+ if (plan.skipped.length > 0) {
102
+ lines.push("");
103
+ lines.push("Skipped:");
104
+ for (const file of plan.skipped)
105
+ lines.push(` ${file}`);
106
+ }
107
+ return lines.join("\n");
108
+ }
109
+ export async function runMigrateYamlCommand(args, options = {}) {
110
+ const cwd = options.cwd ?? process.cwd();
111
+ const projectRoot = options.projectRoot ?? cwd;
112
+ const out = { ...defaultIo, ...options.io };
113
+ try {
114
+ const parsed = parseMigrateYamlArgs(args);
115
+ if (parsed.help) {
116
+ out.error(MIGRATE_YAML_USAGE);
117
+ return 0;
118
+ }
119
+ const target = path.resolve(cwd, parsed.target ?? ".");
120
+ const plan = await planMigrateYaml(target, { cwd, projectRoot });
121
+ const extraErrors = [];
122
+ if (plan.errors.length > 0) {
123
+ if (parsed.json) {
124
+ out.log(JSON.stringify(planToJson(plan, { write: parsed.write, written: [], ok: false }), null, 2));
125
+ }
126
+ else {
127
+ out.error(formatHuman(plan, parsed.write, extraErrors));
128
+ out.error(MIGRATE_YAML_USAGE);
129
+ }
130
+ return 1;
131
+ }
132
+ if (parsed.write && !parsed.force) {
133
+ for (const item of plan.writes) {
134
+ if (fileDirtyVsHead(item.absPath)) {
135
+ extraErrors.push(`Refusing to overwrite uncommitted changes in ${item.file} (pass --force)`);
136
+ }
137
+ }
138
+ if (extraErrors.length > 0) {
139
+ if (parsed.json) {
140
+ out.log(JSON.stringify(planToJson(plan, {
141
+ write: true,
142
+ written: [],
143
+ ok: false,
144
+ extraErrors,
145
+ }), null, 2));
146
+ }
147
+ else {
148
+ out.error(formatHuman(plan, parsed.write, extraErrors));
149
+ }
150
+ return 1;
151
+ }
152
+ }
153
+ let written = [];
154
+ if (parsed.write) {
155
+ await applyMigrateYamlPlan(plan);
156
+ written = plan.writes.map((item) => item.file);
157
+ }
158
+ if (parsed.json) {
159
+ out.log(JSON.stringify(planToJson(plan, {
160
+ write: parsed.write,
161
+ written,
162
+ ok: true,
163
+ }), null, 2));
164
+ }
165
+ else {
166
+ out.log(formatHuman(plan, parsed.write, extraErrors));
167
+ }
168
+ return 0;
169
+ }
170
+ catch (err) {
171
+ const message = err instanceof Error
172
+ ? err.message
173
+ : typeof err === "string"
174
+ ? err
175
+ : "Migrate-yaml command failed";
176
+ out.error(message);
177
+ out.error(MIGRATE_YAML_USAGE);
178
+ return 1;
179
+ }
180
+ }