stageflow 0.9.0 → 0.12.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 (151) hide show
  1. package/README.md +1 -0
  2. package/dist/agent/activity.d.ts +9 -0
  3. package/dist/agent/agentBackend.d.ts +32 -0
  4. package/dist/agent/agentBackend.js +38 -0
  5. package/dist/agent/claudeActivity.d.ts +15 -0
  6. package/dist/agent/claudeActivity.js +58 -0
  7. package/dist/agent/claudeAdapter.d.ts +5 -0
  8. package/dist/agent/claudeAdapter.js +301 -0
  9. package/dist/agent/claudeSession.d.ts +18 -0
  10. package/dist/agent/claudeSession.js +54 -0
  11. package/dist/agent/claudeTools.d.ts +89 -0
  12. package/dist/agent/claudeTools.js +135 -0
  13. package/dist/agent/fakeAgent.js +24 -4
  14. package/dist/agent/piAdapter.d.ts +6 -3
  15. package/dist/agent/piAdapter.js +129 -21
  16. package/dist/agent/port.d.ts +50 -1
  17. package/dist/agent/resolveAgentPort.d.ts +26 -0
  18. package/dist/agent/resolveAgentPort.js +39 -0
  19. package/dist/cli/runCommand.js +2 -2
  20. package/dist/cli/runsCommand.d.ts +1 -1
  21. package/dist/cli/runsCommand.js +184 -2
  22. package/dist/cli.d.ts +1 -1
  23. package/dist/cli.js +11 -5
  24. package/dist/config/browseCatalog.js +1 -1
  25. package/dist/config/createPipeline.d.ts +2 -1
  26. package/dist/config/createPipeline.js +34 -9
  27. package/dist/config/createStage.js +14 -9
  28. package/dist/config/loadPipeline.js +10 -1
  29. package/dist/config/loadStage.js +138 -2
  30. package/dist/config/loadStageflowManifest.js +14 -1
  31. package/dist/config/mergePipelineIncludes.d.ts +1 -0
  32. package/dist/config/mergePipelineIncludes.js +14 -1
  33. package/dist/config/normalizePipelineStageEntry.d.ts +7 -2
  34. package/dist/config/normalizePipelineStageEntry.js +49 -6
  35. package/dist/config/parseCompletionContract.d.ts +8 -0
  36. package/dist/config/parseCompletionContract.js +218 -0
  37. package/dist/config/parsePreEmitChecks.d.ts +3 -0
  38. package/dist/config/parsePreEmitChecks.js +65 -0
  39. package/dist/config/pipelineNeeds.d.ts +19 -0
  40. package/dist/config/pipelineNeeds.js +151 -0
  41. package/dist/config/pipelineStageKeys.js +16 -1
  42. package/dist/config/resolveForkEmitContext.d.ts +6 -2
  43. package/dist/config/resolveForkEmitContext.js +9 -2
  44. package/dist/config/resolvePipelineDag.d.ts +2 -1
  45. package/dist/config/resolvePipelineDag.js +182 -39
  46. package/dist/config/validateCatalog.d.ts +1 -1
  47. package/dist/config/validateCompletionContract.d.ts +4 -0
  48. package/dist/config/validateCompletionContract.js +52 -0
  49. package/dist/envelope/check.d.ts +1 -1
  50. package/dist/envelope/check.js +86 -13
  51. package/dist/envelope/cloneForks.d.ts +1 -1
  52. package/dist/envelope/cloneForks.js +24 -9
  53. package/dist/envelope/feedbackLoop.d.ts +4 -0
  54. package/dist/envelope/feedbackLoop.js +31 -0
  55. package/dist/envelope/payloadSchema.d.ts +7 -1
  56. package/dist/envelope/payloadSchema.js +170 -13
  57. package/dist/envelope/preEmitChecks.d.ts +29 -0
  58. package/dist/envelope/preEmitChecks.js +57 -0
  59. package/dist/hitl/qaTrail.d.ts +25 -0
  60. package/dist/hitl/qaTrail.js +51 -0
  61. package/dist/mcp/catalogTools.js +4 -2
  62. package/dist/mcp/controlTools.js +72 -0
  63. package/dist/mcp/waitingGates.d.ts +2 -1
  64. package/dist/mcp/waitingGates.js +17 -1
  65. package/dist/package-meta.d.ts +1 -1
  66. package/dist/package-meta.js +1 -1
  67. package/dist/projection/projectRun.d.ts +4 -1
  68. package/dist/projection/projectRun.js +7 -0
  69. package/dist/prompt/feedbackLoopContext.d.ts +2 -0
  70. package/dist/prompt/feedbackLoopContext.js +17 -0
  71. package/dist/prompt/priorEnvelope.d.ts +2 -2
  72. package/dist/prompt/priorEnvelope.js +4 -1
  73. package/dist/runstore/pipelineDagSnapshot.d.ts +1 -0
  74. package/dist/runstore/pipelineDagSnapshot.js +51 -11
  75. package/dist/runstore/port.d.ts +185 -3
  76. package/dist/runstore/port.js +42 -3
  77. package/dist/runstore/runProjection.d.ts +7 -3
  78. package/dist/runstore/runProjection.js +30 -15
  79. package/dist/runstore/sqlite/SqliteRunStore.d.ts +25 -1
  80. package/dist/runstore/sqlite/SqliteRunStore.js +626 -8
  81. package/dist/runstore/sqlite/schema.d.ts +1 -1
  82. package/dist/runstore/sqlite/schema.js +99 -0
  83. package/dist/runstore/stageInstanceId.d.ts +1 -1
  84. package/dist/runstore/stageInstanceId.js +3 -2
  85. package/dist/runstore/trackProjection.js +59 -27
  86. package/dist/runstore/verificationHistory.d.ts +19 -0
  87. package/dist/runstore/verificationHistory.js +30 -0
  88. package/dist/runtime/cloneSchedule.d.ts +40 -4
  89. package/dist/runtime/cloneSchedule.js +212 -28
  90. package/dist/runtime/completionCheckRunner.d.ts +137 -0
  91. package/dist/runtime/completionCheckRunner.js +365 -0
  92. package/dist/runtime/completionCheckoutBaseline.d.ts +3 -0
  93. package/dist/runtime/completionCheckoutBaseline.js +44 -0
  94. package/dist/runtime/envelopeRouting.d.ts +10 -3
  95. package/dist/runtime/envelopeRouting.js +146 -5
  96. package/dist/runtime/feedbackLoopCoordinator.d.ts +74 -0
  97. package/dist/runtime/feedbackLoopCoordinator.js +446 -0
  98. package/dist/runtime/feedbackLoopDecision.d.ts +44 -0
  99. package/dist/runtime/feedbackLoopDecision.js +207 -0
  100. package/dist/runtime/feedbackLoopRoute.d.ts +5 -0
  101. package/dist/runtime/feedbackLoopRoute.js +13 -0
  102. package/dist/runtime/feedbackLoopSchedule.d.ts +38 -0
  103. package/dist/runtime/feedbackLoopSchedule.js +72 -0
  104. package/dist/runtime/forkGeneration.d.ts +72 -0
  105. package/dist/runtime/forkGeneration.js +295 -0
  106. package/dist/runtime/gitCheckoutCapability.d.ts +7 -0
  107. package/dist/runtime/gitCheckoutCapability.js +97 -0
  108. package/dist/runtime/manualRecoveryState.d.ts +40 -0
  109. package/dist/runtime/manualRecoveryState.js +73 -0
  110. package/dist/runtime/pipelineScheduler.d.ts +13 -1
  111. package/dist/runtime/pipelineScheduler.js +328 -45
  112. package/dist/runtime/replayLifecycle.d.ts +107 -0
  113. package/dist/runtime/replayLifecycle.js +168 -0
  114. package/dist/runtime/runManager.d.ts +54 -1
  115. package/dist/runtime/runManager.js +471 -6
  116. package/dist/runtime/runRetryCoordinator.d.ts +6 -1
  117. package/dist/runtime/runRetryCoordinator.js +43 -9
  118. package/dist/runtime/stageAttemptBootstrap.d.ts +3 -1
  119. package/dist/runtime/stageAttemptBootstrap.js +79 -1
  120. package/dist/runtime/stageProcessLauncher.d.ts +1 -1
  121. package/dist/runtime/stageRecovery.js +1 -1
  122. package/dist/runtime/stageRunner.d.ts +4 -1
  123. package/dist/runtime/stageRunner.js +29 -3
  124. package/dist/runtime/stageWorker.js +69 -2
  125. package/dist/runtime/stageWorkerProtocol.d.ts +1 -1
  126. package/dist/runtime/verifiedStageExecution.d.ts +29 -0
  127. package/dist/runtime/verifiedStageExecution.js +119 -0
  128. package/dist/server/bootstrap.js +1 -0
  129. package/dist/server/http.js +114 -0
  130. package/dist/server/operatorResults.js +2 -0
  131. package/dist/tools/askOperator.d.ts +161 -5
  132. package/dist/tools/askOperator.js +58 -25
  133. package/dist/tools/emitStageEnvelope.d.ts +12 -12
  134. package/dist/tools/emitStageEnvelope.js +112 -24
  135. package/dist/types/completion.d.ts +43 -0
  136. package/dist/types/completion.js +1 -0
  137. package/dist/types/envelope.d.ts +18 -0
  138. package/dist/types/forkChoice.d.ts +9 -4
  139. package/dist/types/forkChoice.js +1 -1
  140. package/dist/types/pipeline.d.ts +39 -2
  141. package/dist/types/preEmitCheck.d.ts +18 -0
  142. package/dist/types/preEmitCheck.js +1 -0
  143. package/dist/types/stage.d.ts +13 -0
  144. package/dist/types/stageflowManifest.d.ts +2 -0
  145. package/dist/ui/assets/index-CXGz4nL7.js +118 -0
  146. package/dist/ui/assets/{index-C3N7MXAC.css → index-uVHiae8s.css} +1 -1
  147. package/dist/ui/index.html +2 -2
  148. package/package.json +2 -1
  149. package/skills/stageflow-author/references/catalog-mapping.md +26 -2
  150. package/skills/stageflow-author/references/catalog-write-conventions.md +1 -1
  151. package/dist/ui/assets/index-CSoGDA3A.js +0 -118
package/README.md CHANGED
@@ -31,6 +31,7 @@ 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** — pipeline-owned completion checks with durable evidence, automatic repair, and manual recovery ([guide](docs/verified-stage-execution.md))
34
35
  - **HITL gates** — operator questions in the console; CI exits `2` when a run is waiting
35
36
  - **Operator console** — triage runs, connect providers, answer gates, inspect transcripts at `http://127.0.0.1:3847`
36
37
  - **MCP endpoint** — Streamable HTTP at `/mcp` when `sf ui` or `sf mcp` is running
@@ -70,6 +70,15 @@ export type StageLifecycleEvent = {
70
70
  reason: string;
71
71
  } | {
72
72
  event: "skipped";
73
+ }
74
+ /** Operator explicitly authorized a new attempt after verified failure. */
75
+ | {
76
+ event: "manual_recovery_requested";
77
+ guidance?: string;
78
+ }
79
+ /** Operator chose to leave the verified failure terminal. */
80
+ | {
81
+ event: "manual_recovery_stopped";
73
82
  };
74
83
  export type StageLogLine = StageActivityEvent | StageLifecycleEvent;
75
84
  export declare function truncateActivityText(value: unknown, limit?: number): string | undefined;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Known AgentPort backend ids, and the config-hierarchy gate.
3
+ *
4
+ * Kept dependency-free (no adapter imports) so config loaders can validate
5
+ * `agent:` fields without pulling in the actual SDKs.
6
+ */
7
+ export declare const AGENT_BACKENDS: readonly ["pi", "claude"];
8
+ export type AgentBackendId = (typeof AGENT_BACKENDS)[number];
9
+ export declare function isAgentBackendId(value: string): value is AgentBackendId;
10
+ /** Narrows an already-validated config field; returns undefined for anything else. */
11
+ export declare function asAgentBackendId(value: string | undefined): AgentBackendId | undefined;
12
+ export type ParsedAgentField = {
13
+ ok: true;
14
+ value: AgentBackendId | undefined;
15
+ } | {
16
+ ok: false;
17
+ message: string;
18
+ };
19
+ /**
20
+ * Validates a raw `agent:` config value (stage, pipeline, or manifest YAML).
21
+ * Callers own their own error shape (code/category/label differ per site) —
22
+ * this only owns the one rule that must stay identical everywhere: absent is
23
+ * fine, present must be a known backend id.
24
+ */
25
+ export declare function parseAgentField(raw: unknown): ParsedAgentField;
26
+ /**
27
+ * Stage-level `agent` overrides are parsed and stored on StageConfig, but
28
+ * intentionally not consulted by resolveAgentBackend while this is false.
29
+ * Flip to true once per-stage selection is ready to ship; no other change
30
+ * needed to light it up.
31
+ */
32
+ export declare const STAGE_LEVEL_AGENT_OVERRIDE_ENABLED = false;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Known AgentPort backend ids, and the config-hierarchy gate.
3
+ *
4
+ * Kept dependency-free (no adapter imports) so config loaders can validate
5
+ * `agent:` fields without pulling in the actual SDKs.
6
+ */
7
+ export const AGENT_BACKENDS = ["pi", "claude"];
8
+ export function isAgentBackendId(value) {
9
+ return AGENT_BACKENDS.includes(value);
10
+ }
11
+ /** Narrows an already-validated config field; returns undefined for anything else. */
12
+ export function asAgentBackendId(value) {
13
+ return value !== undefined && isAgentBackendId(value) ? value : undefined;
14
+ }
15
+ /**
16
+ * Validates a raw `agent:` config value (stage, pipeline, or manifest YAML).
17
+ * Callers own their own error shape (code/category/label differ per site) —
18
+ * this only owns the one rule that must stay identical everywhere: absent is
19
+ * fine, present must be a known backend id.
20
+ */
21
+ export function parseAgentField(raw) {
22
+ if (raw === undefined)
23
+ return { ok: true, value: undefined };
24
+ if (typeof raw !== "string" || !isAgentBackendId(raw)) {
25
+ return {
26
+ ok: false,
27
+ message: `agent must be one of ${AGENT_BACKENDS.join(", ")}`,
28
+ };
29
+ }
30
+ return { ok: true, value: raw };
31
+ }
32
+ /**
33
+ * Stage-level `agent` overrides are parsed and stored on StageConfig, but
34
+ * intentionally not consulted by resolveAgentBackend while this is false.
35
+ * Flip to true once per-stage selection is ready to ship; no other change
36
+ * needed to light it up.
37
+ */
38
+ export const STAGE_LEVEL_AGENT_OVERRIDE_ENABLED = false;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Claude Agent SDK message → Pi-agnostic StageActivityEvent mapping.
3
+ *
4
+ * `tool_result` blocks in the Anthropic Messages API carry only a
5
+ * `tool_use_id`, not the tool name, so this mapper is stateful: it remembers
6
+ * the name for each in-flight tool_use id (set on `tool_start`, consumed on
7
+ * `tool_end`) the same way Pi's own session already tracks call/response
8
+ * pairing internally.
9
+ */
10
+ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
11
+ import { type StageActivityEvent } from "./activity.js";
12
+ export type ClaudeActivityMapper = {
13
+ map(message: SDKMessage): StageActivityEvent[];
14
+ };
15
+ export declare function createClaudeActivityMapper(): ClaudeActivityMapper;
@@ -0,0 +1,58 @@
1
+ import { truncateActivityText } from "./activity.js";
2
+ function contentBlocks(content) {
3
+ if (!Array.isArray(content))
4
+ return [];
5
+ return content.filter((block) => block !== null && typeof block === "object");
6
+ }
7
+ export function createClaudeActivityMapper() {
8
+ const toolNameById = new Map();
9
+ function map(message) {
10
+ if (message.type === "assistant") {
11
+ const events = [];
12
+ for (const block of contentBlocks(message.message.content)) {
13
+ if (block.type === "text" && typeof block.text === "string") {
14
+ events.push({
15
+ event: "message",
16
+ role: "assistant",
17
+ text: truncateActivityText(block.text),
18
+ });
19
+ }
20
+ else if (block.type === "tool_use" && typeof block.name === "string") {
21
+ const toolCallId = typeof block.id === "string" ? block.id : undefined;
22
+ if (toolCallId) {
23
+ toolNameById.set(toolCallId, block.name);
24
+ }
25
+ events.push({
26
+ event: "tool_start",
27
+ toolName: block.name,
28
+ toolCallId,
29
+ argsPreview: truncateActivityText(block.input),
30
+ });
31
+ }
32
+ }
33
+ return events;
34
+ }
35
+ if (message.type === "user") {
36
+ const events = [];
37
+ for (const block of contentBlocks(message.message.content)) {
38
+ if (block.type !== "tool_result")
39
+ continue;
40
+ const toolCallId = typeof block.tool_use_id === "string" ? block.tool_use_id : undefined;
41
+ const toolName = (toolCallId ? toolNameById.get(toolCallId) : undefined) ?? "unknown";
42
+ if (toolCallId) {
43
+ toolNameById.delete(toolCallId);
44
+ }
45
+ events.push({
46
+ event: "tool_end",
47
+ toolName,
48
+ toolCallId,
49
+ isError: Boolean(block.is_error),
50
+ resultPreview: truncateActivityText(block.content),
51
+ });
52
+ }
53
+ return events;
54
+ }
55
+ return [];
56
+ }
57
+ return { map };
58
+ }
@@ -0,0 +1,5 @@
1
+ import { type AgentPort, type StageHandle, type StageRunInput, type StageRunResult } from "./port.js";
2
+ export declare class ClaudeAgentAdapter implements AgentPort {
3
+ openStage(input: StageRunInput): StageHandle;
4
+ runStage(input: StageRunInput): Promise<StageRunResult>;
5
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Claude Agent SDK implementation of AgentPort.
3
+ *
4
+ * One turn = one streaming-input `query()` call, spawned as a subprocess by
5
+ * the SDK. The session is sealed the same way Pi's sealed stage session is:
6
+ * a custom (not preset) system prompt with nothing appended, no filesystem
7
+ * settings sources, and a fixed built-in tool allowlist — plus one
8
+ * in-process MCP server (`buildStageflowMcpServer`) carrying
9
+ * `emit_stage_envelope`, `write_stage_artifact`, and (when the stage's
10
+ * `gate_kinds` allow it) `ask_operator`. Permission prompts are bypassed
11
+ * outright: there is no human attached to a stage session to answer them,
12
+ * exactly like Pi's sealed session never surfaces an interactive approval
13
+ * loop either.
14
+ *
15
+ * HITL — "never let it go dangling" (confirmed by spike, see the adapter's
16
+ * plan doc): `ask_operator`'s handler never blocks. It records the prompt
17
+ * and returns an immediate placeholder result; the tool description tells
18
+ * the model to stop, and this adapter calls `query.interrupt()` right after
19
+ * observing that result as a backstop that doesn't depend on the model
20
+ * cooperating. By the time a stage reports `waiting_for_input`, the Claude
21
+ * CLI subprocess has already exited on its own — `close({park: true})` has
22
+ * nothing to flush or kill. The only state that needs to survive a process
23
+ * restart is the small JSON marker in `claudeSession.ts` (session id + the
24
+ * exact prompt), not a session file to repair.
25
+ *
26
+ * Answering resumes the *same* session (`resume: sessionId`, no
27
+ * `forkSession`) with a new user turn stating the operator's answer — the
28
+ * real prior conversation reloads (confirmed by spike), but this is a new
29
+ * turn on that history, not Pi's mid-thought `continue()`. That gap is
30
+ * permanent and documented, not a bug to chase.
31
+ *
32
+ * Explicitly NOT supported yet, rejected up front with a clear reason
33
+ * rather than silently misbehaving: `stage.skill` (Pi's `/skill:`
34
+ * convention has no Claude equivalent wired up yet) and any `stage.model`
35
+ * outside `anthropic/*` (this backend only runs Claude models).
36
+ *
37
+ * Credentials: delegated entirely to the spawned `claude` CLI's own
38
+ * environment/auth (ANTHROPIC_API_KEY or a logged-in session) — the same
39
+ * story as running `claude` by hand.
40
+ */
41
+ import { query } from "@anthropic-ai/claude-agent-sdk";
42
+ import { isAdvancingEnvelope } from "../envelope/check.js";
43
+ import { assertAnswerMatchesPrompt, parseAskOperatorAnswer, summarizeAnswer, } from "../tools/askOperator.js";
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
+ import { createClaudeActivityMapper } from "./claudeActivity.js";
46
+ import { composeStageUserPrompt } from "./piAdapter.js";
47
+ import { claudeSessionMarkerPath, clearClaudeSessionMarker, readClaudeSessionMarker, writeClaudeSessionMarker, } from "./claudeSession.js";
48
+ import { DEFAULT_STAGE_TIMEOUT_MS, runStageViaOpen, runtimeStageId, } from "./port.js";
49
+ /** Built-in tool allowlist, mirroring Pi's sealed-session base set (read/bash/write/edit). */
50
+ const CLAUDE_BUILTIN_TOOLS = ["Read", "Write", "Edit", "Bash"];
51
+ function resolveClaudeModel(stageModel) {
52
+ const slash = stageModel.indexOf("/");
53
+ if (slash < 0) {
54
+ return {
55
+ ok: false,
56
+ reason: `stage.model "${stageModel}" must be "anthropic/<model>" to run on the claude agent backend`,
57
+ };
58
+ }
59
+ const provider = stageModel.slice(0, slash);
60
+ const model = stageModel.slice(slash + 1);
61
+ if (provider !== "anthropic" || model.length === 0) {
62
+ return {
63
+ ok: false,
64
+ reason: `stage.model "${stageModel}" is not an anthropic model; the claude agent backend only runs anthropic/* models`,
65
+ };
66
+ }
67
+ return { ok: true, model };
68
+ }
69
+ function preflight(input) {
70
+ if (input.stage.skill !== undefined) {
71
+ return {
72
+ ok: false,
73
+ reason: `stage declares skill "${input.stage.skill}"; the claude agent backend does not support skills yet`,
74
+ };
75
+ }
76
+ return resolveClaudeModel(input.stage.model);
77
+ }
78
+ function resultFromCapture(capture) {
79
+ if (capture.error && !capture.envelope) {
80
+ return { ok: false, reason: capture.error };
81
+ }
82
+ if (!capture.envelope) {
83
+ return { ok: false, reason: "missing emit_stage_envelope" };
84
+ }
85
+ if (!isAdvancingEnvelope(capture.envelope)) {
86
+ return { ok: false, reason: "status: failure", envelope: capture.envelope };
87
+ }
88
+ return { ok: true, envelope: capture.envelope };
89
+ }
90
+ async function* singleUserMessage(text) {
91
+ yield {
92
+ type: "user",
93
+ message: { role: "user", content: text },
94
+ parent_tool_use_id: null,
95
+ };
96
+ }
97
+ /** One `query()` call — either the stage's initial turn or a resume turn. */
98
+ async function runTurn(input, model, markerPath, promptText, resumeSessionId) {
99
+ const timeoutMs = input.timeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
100
+ const controller = new AbortController();
101
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
102
+ const activityMapper = createClaudeActivityMapper();
103
+ const emitCapture = {};
104
+ const askCapture = {};
105
+ const gateKinds = input.stage.gate_kinds;
106
+ const hitlAllowed = gateKinds === undefined || gateKinds.length > 0;
107
+ input.onActivity?.({ event: "agent_start" });
108
+ input.onActivity?.({ event: "turn_start" });
109
+ let sessionId = resumeSessionId;
110
+ try {
111
+ const mcpServer = buildStageflowMcpServer({
112
+ capture: emitCapture,
113
+ payloadSchema: input.stage.payload_schema,
114
+ forkEmitContext: input.forkEmitContext,
115
+ cloneEmitContext: input.cloneEmitContext,
116
+ preEmitCheckOptions: {
117
+ checks: input.stage.pre_emit_checks,
118
+ readQaTrail: input.readQaTrail,
119
+ },
120
+ writeStageArtifact: {
121
+ runWorkspaceDir: input.roots.runWorkspaceDir,
122
+ stageId: runtimeStageId(input),
123
+ attempt: input.roots.attempt ?? 1,
124
+ },
125
+ ...(hitlAllowed
126
+ ? {
127
+ askOperator: {
128
+ capture: askCapture,
129
+ ...(gateKinds !== undefined ? { allowedKinds: gateKinds } : {}),
130
+ },
131
+ }
132
+ : {}),
133
+ });
134
+ const stream = query({
135
+ prompt: singleUserMessage(promptText),
136
+ options: {
137
+ abortController: controller,
138
+ cwd: input.roots.cwd,
139
+ model,
140
+ systemPrompt: { type: "custom", prompt: input.stage.system_prompt },
141
+ settingSources: [],
142
+ tools: CLAUDE_BUILTIN_TOOLS,
143
+ mcpServers: { [STAGEFLOW_MCP_SERVER_NAME]: mcpServer },
144
+ permissionMode: "bypassPermissions",
145
+ allowDangerouslySkipPermissions: true,
146
+ persistSession: true,
147
+ ...(resumeSessionId !== undefined ? { resume: resumeSessionId } : {}),
148
+ },
149
+ });
150
+ for await (const message of stream) {
151
+ for (const event of activityMapper.map(message)) {
152
+ input.onActivity?.(event);
153
+ }
154
+ if (message.type === "system" && message.subtype === "init") {
155
+ sessionId = message.session_id;
156
+ await writeClaudeSessionMarker(markerPath, { sessionId: message.session_id });
157
+ }
158
+ if (message.type === "user" &&
159
+ (emitCapture.envelope !== undefined || askCapture.prompt !== undefined)) {
160
+ try {
161
+ await stream.interrupt();
162
+ }
163
+ catch {
164
+ // best-effort backstop; a natural stop may have already ended the turn
165
+ }
166
+ break;
167
+ }
168
+ if (message.type === "result") {
169
+ break;
170
+ }
171
+ }
172
+ if (askCapture.prompt !== undefined) {
173
+ const resolvedSessionId = sessionId;
174
+ if (resolvedSessionId === undefined) {
175
+ return {
176
+ kind: "completed",
177
+ result: { ok: false, reason: "ask_operator called but no session_id was observed" },
178
+ };
179
+ }
180
+ await writeClaudeSessionMarker(markerPath, {
181
+ sessionId: resolvedSessionId,
182
+ prompt: askCapture.prompt,
183
+ });
184
+ return { kind: "waiting", sessionId: resolvedSessionId, request: askCapture.prompt };
185
+ }
186
+ await clearClaudeSessionMarker(markerPath);
187
+ return { kind: "completed", result: resultFromCapture(emitCapture) };
188
+ }
189
+ catch (err) {
190
+ if (askCapture.prompt !== undefined && sessionId !== undefined) {
191
+ await writeClaudeSessionMarker(markerPath, { sessionId, prompt: askCapture.prompt });
192
+ return { kind: "waiting", sessionId, request: askCapture.prompt };
193
+ }
194
+ if (emitCapture.envelope && isAdvancingEnvelope(emitCapture.envelope)) {
195
+ await clearClaudeSessionMarker(markerPath);
196
+ return { kind: "completed", result: { ok: true, envelope: emitCapture.envelope } };
197
+ }
198
+ return {
199
+ kind: "completed",
200
+ result: {
201
+ ok: false,
202
+ reason: err instanceof Error ? err.message : String(err),
203
+ envelope: emitCapture.envelope,
204
+ },
205
+ };
206
+ }
207
+ finally {
208
+ clearTimeout(timer);
209
+ input.onActivity?.({ event: "agent_end" });
210
+ }
211
+ }
212
+ function resumePromptText(answer) {
213
+ return [
214
+ summarizeAnswer(answer),
215
+ "",
216
+ `Continue the stage. Call ${EMIT_STAGE_ENVELOPE_TOOL_NAME} when finished, or ${ASK_OPERATOR_TOOL_NAME} again if you need to ask something else.`,
217
+ ].join("\n");
218
+ }
219
+ export class ClaudeAgentAdapter {
220
+ openStage(input) {
221
+ const stageId = runtimeStageId(input);
222
+ const markerPath = claudeSessionMarkerPath(input);
223
+ let closed = false;
224
+ let bootstrapped = false;
225
+ let waiting;
226
+ let pendingAnswer;
227
+ const applyOutcome = (outcome) => {
228
+ if (outcome.kind === "waiting") {
229
+ waiting = { sessionId: outcome.sessionId, prompt: outcome.request };
230
+ pendingAnswer = undefined;
231
+ return { status: "waiting_for_input", request: outcome.request };
232
+ }
233
+ waiting = undefined;
234
+ return { status: "completed", result: outcome.result };
235
+ };
236
+ return {
237
+ stageId,
238
+ async next() {
239
+ if (closed) {
240
+ return { status: "completed", result: { ok: false, reason: "stage handle closed" } };
241
+ }
242
+ if (!bootstrapped) {
243
+ bootstrapped = true;
244
+ const marker = await readClaudeSessionMarker(markerPath);
245
+ if (marker?.prompt !== undefined) {
246
+ waiting = { sessionId: marker.sessionId, prompt: marker.prompt };
247
+ }
248
+ }
249
+ if (waiting) {
250
+ if (pendingAnswer === undefined) {
251
+ return { status: "waiting_for_input", request: waiting.prompt };
252
+ }
253
+ const rawAnswer = pendingAnswer;
254
+ pendingAnswer = undefined;
255
+ const prompt = waiting.prompt;
256
+ const sessionId = waiting.sessionId;
257
+ let parsedAnswer;
258
+ try {
259
+ parsedAnswer = parseAskOperatorAnswer(rawAnswer);
260
+ assertAnswerMatchesPrompt(prompt, parsedAnswer);
261
+ }
262
+ catch (err) {
263
+ const message = err instanceof Error ? err.message : String(err);
264
+ waiting = undefined;
265
+ await clearClaudeSessionMarker(markerPath);
266
+ return { status: "completed", result: { ok: false, reason: `invalid operator answer: ${message}` } };
267
+ }
268
+ const pre = preflight(input);
269
+ if (!pre.ok) {
270
+ waiting = undefined;
271
+ await clearClaudeSessionMarker(markerPath);
272
+ return { status: "completed", result: { ok: false, reason: pre.reason } };
273
+ }
274
+ const outcome = await runTurn(input, pre.model, markerPath, resumePromptText(parsedAnswer), sessionId);
275
+ return applyOutcome(outcome);
276
+ }
277
+ const pre = preflight(input);
278
+ if (!pre.ok) {
279
+ return { status: "completed", result: { ok: false, reason: pre.reason } };
280
+ }
281
+ const outcome = await runTurn(input, pre.model, markerPath, composeStageUserPrompt(input, EMIT_STAGE_ENVELOPE_TOOL_NAME, undefined, WRITE_STAGE_ARTIFACT_TOOL_NAME), undefined);
282
+ return applyOutcome(outcome);
283
+ },
284
+ deliverAnswer(answer) {
285
+ pendingAnswer = answer;
286
+ },
287
+ async close(closeOptions) {
288
+ closed = true;
289
+ if (closeOptions?.park) {
290
+ // Nothing to flush or kill — by the time a turn reports "waiting",
291
+ // its Claude CLI subprocess has already exited on its own and the
292
+ // marker file is already durably written.
293
+ return;
294
+ }
295
+ },
296
+ };
297
+ }
298
+ async runStage(input) {
299
+ return runStageViaOpen(this, input);
300
+ }
301
+ }
@@ -0,0 +1,18 @@
1
+ import type { AskOperatorPrompt } from "../tools/askOperator.js";
2
+ import type { StageRunInput } from "./port.js";
3
+ export type ClaudeSessionMarker = {
4
+ sessionId: string;
5
+ prompt?: AskOperatorPrompt;
6
+ };
7
+ /**
8
+ * Derives a stable, per-attempt path from `input.resumeToken` — the same
9
+ * value the runtime always recomputes identically for a given
10
+ * (workspace, stage, attempt), so a later process asking for this stage's
11
+ * marker lands on the same file. `resumeToken` defaults to Pi's own session
12
+ * path (`.../pi-session.jsonl`); this adapter writes a sibling file instead
13
+ * of reusing that path directly, so the two backends never share a file.
14
+ */
15
+ export declare function claudeSessionMarkerPath(input: StageRunInput): string;
16
+ export declare function readClaudeSessionMarker(markerPath: string): Promise<ClaudeSessionMarker | undefined>;
17
+ export declare function writeClaudeSessionMarker(markerPath: string, marker: ClaudeSessionMarker): Promise<void>;
18
+ export declare function clearClaudeSessionMarker(markerPath: string): Promise<void>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Claude session continuity marker — the adapter's equivalent of Pi's
3
+ * session JSONL file, minus the "reconstruct a dangling tool call" problem.
4
+ *
5
+ * Under "never let it go dangling" (see claudeAdapter.ts), by the time a
6
+ * stage is waiting on an operator answer, the Claude CLI subprocess has
7
+ * already exited cleanly on its own — there is nothing left to reopen or
8
+ * repair. The only state that needs to survive a process restart is small
9
+ * enough to write as one JSON file: the Claude `session_id` to resume, and
10
+ * the exact operator prompt so a later `deliverAnswer()` can be validated
11
+ * against it (and re-reported unchanged if `next()` is called again before
12
+ * an answer arrives).
13
+ */
14
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
15
+ import path from "node:path";
16
+ import { runtimeStageId } from "./port.js";
17
+ /**
18
+ * Derives a stable, per-attempt path from `input.resumeToken` — the same
19
+ * value the runtime always recomputes identically for a given
20
+ * (workspace, stage, attempt), so a later process asking for this stage's
21
+ * marker lands on the same file. `resumeToken` defaults to Pi's own session
22
+ * path (`.../pi-session.jsonl`); this adapter writes a sibling file instead
23
+ * of reusing that path directly, so the two backends never share a file.
24
+ */
25
+ export function claudeSessionMarkerPath(input) {
26
+ if (input.resumeToken !== undefined && input.resumeToken.trim() !== "") {
27
+ return path.join(path.dirname(input.resumeToken), "claude-session.json");
28
+ }
29
+ return path.join(input.roots.runWorkspaceDir, "stages", runtimeStageId(input), "claude-session.json");
30
+ }
31
+ export async function readClaudeSessionMarker(markerPath) {
32
+ try {
33
+ const raw = await readFile(markerPath, "utf8");
34
+ const parsed = JSON.parse(raw);
35
+ if (parsed !== null &&
36
+ typeof parsed === "object" &&
37
+ typeof parsed.sessionId === "string") {
38
+ return parsed;
39
+ }
40
+ return undefined;
41
+ }
42
+ catch (err) {
43
+ if (err.code === "ENOENT")
44
+ return undefined;
45
+ throw err;
46
+ }
47
+ }
48
+ export async function writeClaudeSessionMarker(markerPath, marker) {
49
+ await mkdir(path.dirname(markerPath), { recursive: true });
50
+ await writeFile(markerPath, JSON.stringify(marker), "utf8");
51
+ }
52
+ export async function clearClaudeSessionMarker(markerPath) {
53
+ await rm(markerPath, { force: true });
54
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Claude Agent SDK tool wrappers for the stage tool set.
3
+ *
4
+ * The actual envelope/artifact business logic lives in
5
+ * `tools/emitStageEnvelope.ts` / `tools/writeStageArtifact.ts` and is fully
6
+ * SDK-agnostic (typed `params: unknown`, no Pi imports) — this file reuses
7
+ * those `execute` functions as-is and only translates the *declaration*
8
+ * layer (typebox → Zod) and the *result* layer (drops Pi-only `terminate`/
9
+ * `details` fields, keeping just the MCP `CallToolResult` shape).
10
+ *
11
+ * `ask_operator` is different: `tools/askOperator.ts`'s own `execute` blocks
12
+ * on an injected wait bridge (Pi's in-process mechanism), which is exactly
13
+ * what Phase 3's "never let it go dangling" design avoids. This file reuses
14
+ * only the pure parsing/validation helpers from that module
15
+ * (`parseAskOperatorParams`, `normalizePromptIds`) and writes its own
16
+ * non-blocking handler: record the prompt on `capture`, return an immediate
17
+ * placeholder result, and rely on the adapter's `interrupt()` backstop
18
+ * (called right after this result lands) to end the turn — see
19
+ * claudeAdapter.ts.
20
+ *
21
+ * Registered under one in-process MCP server named "stageflow", so the model
22
+ * sees these as `mcp__stageflow__emit_stage_envelope`,
23
+ * `mcp__stageflow__write_stage_artifact`, and `mcp__stageflow__ask_operator`
24
+ * — those exact strings are what `composeStageUserPrompt`'s hints and the
25
+ * adapter's resume prompts must reference.
26
+ */
27
+ import { type McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
28
+ import { z } from "zod";
29
+ import { type EmitCapture } from "../tools/emitStageEnvelope.js";
30
+ import { type WriteStageArtifactOptions } from "../tools/writeStageArtifact.js";
31
+ import { type AskOperatorKind, type AskOperatorPrompt } from "../tools/askOperator.js";
32
+ import type { CloneEmitContext, ForkEmitContext } from "../types/forkChoice.js";
33
+ import type { PreEmitCheckOptions } from "../envelope/preEmitChecks.js";
34
+ export declare const STAGEFLOW_MCP_SERVER_NAME = "stageflow";
35
+ export declare const EMIT_STAGE_ENVELOPE_TOOL_NAME = "mcp__stageflow__emit_stage_envelope";
36
+ export declare const WRITE_STAGE_ARTIFACT_TOOL_NAME = "mcp__stageflow__write_stage_artifact";
37
+ export declare const ASK_OPERATOR_TOOL_NAME = "mcp__stageflow__ask_operator";
38
+ export type AskOperatorCapture = {
39
+ prompt?: AskOperatorPrompt;
40
+ };
41
+ /**
42
+ * Exported so a parity test can assert this stays field-for-field aligned
43
+ * with `tools/emitStageEnvelope.ts`'s typebox schema — the two are hand
44
+ * written against the same envelope shape with no shared source, so
45
+ * nothing else catches one drifting from the other.
46
+ *
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
+ * `fork_choice` is required (not just present) when the stage has a fork
50
+ * to route. Building this statically would silently offer the model a
51
+ * `clone_forks` field on stages that have nowhere to route one.
52
+ */
53
+ export declare function buildEmitStageEnvelopeShape(options: {
54
+ forkEmitContext?: ForkEmitContext;
55
+ cloneEmitContext?: CloneEmitContext;
56
+ }): {
57
+ status: z.ZodEnum<{
58
+ failure: "failure";
59
+ success: "success";
60
+ }>;
61
+ summary: z.ZodString;
62
+ artifacts: z.ZodArray<z.ZodString>;
63
+ payload: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
64
+ 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
+ checklist_attestations: z.ZodOptional<z.ZodArray<z.ZodObject<{
67
+ check_id: z.ZodString;
68
+ items: z.ZodArray<z.ZodString>;
69
+ }, z.core.$strip>>>;
70
+ stage_id: z.ZodOptional<z.ZodString>;
71
+ notes: z.ZodOptional<z.ZodString>;
72
+ };
73
+ /** Exported for the same parity-test reason as `emitStageEnvelopeShape` above. */
74
+ export declare const writeStageArtifactShape: {
75
+ path: z.ZodString;
76
+ content: z.ZodString;
77
+ };
78
+ export declare function buildStageflowMcpServer(options: {
79
+ capture: EmitCapture;
80
+ payloadSchema?: unknown;
81
+ forkEmitContext?: ForkEmitContext;
82
+ cloneEmitContext?: CloneEmitContext;
83
+ preEmitCheckOptions?: PreEmitCheckOptions;
84
+ writeStageArtifact: WriteStageArtifactOptions;
85
+ askOperator?: {
86
+ capture: AskOperatorCapture;
87
+ allowedKinds?: readonly AskOperatorKind[];
88
+ };
89
+ }): McpServerConfig;