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
@@ -0,0 +1,135 @@
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 { createSdkMcpServer, tool, } from "@anthropic-ai/claude-agent-sdk";
28
+ import { z } from "zod";
29
+ import { createEmitStageEnvelopeTool, } from "../tools/emitStageEnvelope.js";
30
+ import { createWriteStageArtifactTool, } from "../tools/writeStageArtifact.js";
31
+ import { ASK_OPERATOR_KINDS, AskOperatorError, normalizePromptIds, parseAskOperatorParams, } from "../tools/askOperator.js";
32
+ export const STAGEFLOW_MCP_SERVER_NAME = "stageflow";
33
+ export const EMIT_STAGE_ENVELOPE_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__emit_stage_envelope`;
34
+ export const WRITE_STAGE_ARTIFACT_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__write_stage_artifact`;
35
+ export const ASK_OPERATOR_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__ask_operator`;
36
+ /**
37
+ * Exported so a parity test can assert this stays field-for-field aligned
38
+ * with `tools/emitStageEnvelope.ts`'s typebox schema — the two are hand
39
+ * written against the same envelope shape with no shared source, so
40
+ * nothing else catches one drifting from the other.
41
+ *
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
+ * `fork_choice` is required (not just present) when the stage has a fork
45
+ * to route. Building this statically would silently offer the model a
46
+ * `clone_forks` field on stages that have nowhere to route one.
47
+ */
48
+ export function buildEmitStageEnvelopeShape(options) {
49
+ return {
50
+ status: z.enum(["success", "failure"]),
51
+ summary: z.string().min(1),
52
+ artifacts: z.array(z.string()),
53
+ payload: z.record(z.string(), z.unknown()).optional(),
54
+ fork_choice: options.forkEmitContext !== undefined
55
+ ? z.array(z.string())
56
+ : z.array(z.string()).optional(),
57
+ ...(options.cloneEmitContext !== undefined
58
+ ? { clone_forks: z.array(z.record(z.string(), z.unknown())) }
59
+ : {}),
60
+ checklist_attestations: z
61
+ .array(z.object({
62
+ check_id: z.string().min(1),
63
+ items: z.array(z.string()),
64
+ }))
65
+ .optional(),
66
+ stage_id: z.string().optional(),
67
+ notes: z.string().optional(),
68
+ };
69
+ }
70
+ /** Exported for the same parity-test reason as `emitStageEnvelopeShape` above. */
71
+ export const writeStageArtifactShape = {
72
+ path: z.string().min(1),
73
+ content: z.string(),
74
+ };
75
+ const askOperatorShape = {
76
+ kind: z.string(),
77
+ message: z.string().optional(),
78
+ id: z.string().optional(),
79
+ questions: z.array(z.record(z.string(), z.unknown())).optional(),
80
+ artifacts: z.array(z.string()).optional(),
81
+ };
82
+ /** Adapts a `{content, details?, isError?, terminate?}` tool result to plain MCP CallToolResult. */
83
+ function toCallToolResult(result) {
84
+ return {
85
+ content: result.content,
86
+ ...(result.isError ? { isError: true } : {}),
87
+ };
88
+ }
89
+ function buildAskOperatorTool(capture, allowedKinds) {
90
+ const allowed = allowedKinds && allowedKinds.length > 0
91
+ ? new Set(allowedKinds)
92
+ : null;
93
+ const kindList = allowed ? [...allowed].join(", ") : ASK_OPERATOR_KINDS.join(", ");
94
+ return tool("ask_operator", `Ask the operator a question and stop. Supports ${kindList} prompts. Calling this tool immediately ends your turn — after it returns, do not call any more tools and do not keep talking; you will be resumed in a new turn once the operator has answered. Does not complete the stage — call emit_stage_envelope when finished.`, askOperatorShape, async (args) => {
95
+ try {
96
+ const parsed = parseAskOperatorParams(args);
97
+ if (allowed && !allowed.has(parsed.kind)) {
98
+ throw new AskOperatorError(`unsupported prompt kind "${parsed.kind}" (allowed: ${kindList})`);
99
+ }
100
+ capture.prompt = normalizePromptIds(parsed);
101
+ }
102
+ catch (err) {
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ return {
105
+ content: [{ type: "text", text: `Invalid ask_operator params: ${message}` }],
106
+ isError: true,
107
+ };
108
+ }
109
+ return {
110
+ content: [
111
+ {
112
+ type: "text",
113
+ text: "Question recorded for the operator. Stop here — do not call any more tools or continue this turn; you will be resumed with the operator's answer in a new turn.",
114
+ },
115
+ ],
116
+ };
117
+ });
118
+ }
119
+ export function buildStageflowMcpServer(options) {
120
+ const emitDef = createEmitStageEnvelopeTool(options.capture, options.payloadSchema, options.forkEmitContext, options.cloneEmitContext, options.preEmitCheckOptions);
121
+ const artifactDef = createWriteStageArtifactTool(options.writeStageArtifact);
122
+ const emitTool = tool(emitDef.name, emitDef.description, buildEmitStageEnvelopeShape({
123
+ forkEmitContext: options.forkEmitContext,
124
+ cloneEmitContext: options.cloneEmitContext,
125
+ }), async (args) => toCallToolResult(await emitDef.execute("claude-tool-call", args)));
126
+ const artifactTool = tool(artifactDef.name, artifactDef.description, writeStageArtifactShape, async (args) => toCallToolResult(await artifactDef.execute("claude-tool-call", args)));
127
+ const tools = [emitTool, artifactTool];
128
+ if (options.askOperator) {
129
+ tools.push(buildAskOperatorTool(options.askOperator.capture, options.askOperator.allowedKinds));
130
+ }
131
+ return createSdkMcpServer({
132
+ name: STAGEFLOW_MCP_SERVER_NAME,
133
+ tools,
134
+ });
135
+ }
@@ -1,12 +1,13 @@
1
1
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { assertRequiredEnvelope, isAdvancingEnvelope, } from "../envelope/check.js";
4
+ import { assertFeedbackLoopAction } from "../envelope/feedbackLoop.js";
4
5
  import { assertEnvelopePayload } from "../envelope/payloadSchema.js";
5
6
  import { assertCloneForks } from "../envelope/cloneForks.js";
6
7
  import { assertForkEnvelope } from "../envelope/forkChoice.js";
7
8
  import { assertAnswerMatchesPrompt, parseAskOperatorAnswer, } from "../tools/askOperator.js";
8
9
  import { tryParsePendingPrompt } from "../runtime/stageHitl.js";
9
- import { runtimeStageId, runStageViaOpen } from "./port.js";
10
+ import { runtimeStageId, runStageViaOpen, createCompletedOnlyStageHandle, } from "./port.js";
10
11
  function opaqueEqual(a, b) {
11
12
  if (Object.is(a, b))
12
13
  return true;
@@ -68,6 +69,17 @@ export class FakeAgent {
68
69
  let answerPromise;
69
70
  let started = false;
70
71
  let resumeCorrupt = false;
72
+ const sessionMode = input.sessionMode ?? "fresh";
73
+ const skipHitlPark = sessionMode === "feedback_resume" || sessionMode === "new_session";
74
+ if (sessionMode === "feedback_resume" && input.feedbackLoopContext === undefined) {
75
+ return createCompletedOnlyStageHandle({
76
+ stageId: runtimeStageId(input),
77
+ run: async () => ({
78
+ ok: false,
79
+ reason: "feedback_resume requires feedbackLoopContext",
80
+ }),
81
+ });
82
+ }
71
83
  const armWait = () => {
72
84
  waiting = true;
73
85
  answerPromise = new Promise((resolve) => {
@@ -83,7 +95,7 @@ export class FakeAgent {
83
95
  answerPromise = undefined;
84
96
  return answer;
85
97
  };
86
- if (behavior.type === "wait_then_emit") {
98
+ if (behavior.type === "wait_then_emit" && !skipHitlPark) {
87
99
  const loaded = loadFakeHitlResume(input.roots, runtimeStageId(input));
88
100
  if (loaded === "corrupt") {
89
101
  resumeCorrupt = true;
@@ -99,6 +111,12 @@ export class FakeAgent {
99
111
  armWait();
100
112
  }
101
113
  }
114
+ if (sessionMode === "feedback_resume" && behavior.type === "wait_then_emit") {
115
+ behavior = {
116
+ type: "emit",
117
+ envelope: behavior.envelope,
118
+ };
119
+ }
102
120
  const emitStartActivity = () => {
103
121
  if (started)
104
122
  return;
@@ -137,10 +155,12 @@ export class FakeAgent {
137
155
  : undefined;
138
156
  try {
139
157
  const envelope = assertRequiredEnvelope(envelopeValue);
140
- if (input.forkEmitContext !== undefined) {
158
+ assertFeedbackLoopAction(envelope, input.feedbackLoopEmitContext);
159
+ const isFeedbackSendBack = envelope.feedback_loop?.action === "send_back";
160
+ if (!isFeedbackSendBack && input.forkEmitContext !== undefined) {
141
161
  assertForkEnvelope(envelope, input.forkEmitContext);
142
162
  }
143
- if (input.cloneEmitContext !== undefined) {
163
+ if (!isFeedbackSendBack && input.cloneEmitContext !== undefined) {
144
164
  assertCloneForks(envelope, input.cloneEmitContext);
145
165
  }
146
166
  assertEnvelopePayload(envelope, input.stage.payload_schema);
@@ -4,12 +4,14 @@ import { type AskOperatorPrompt, type AskOperatorWaitBridge } from "../tools/ask
4
4
  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
+ import type { StageGateKind } from "../types/stage.js";
7
8
  /**
8
9
  * Stage tool allowlist for sealed Pi sessions.
9
- * Always includes `ask_operator` (R7); `write_stage_artifact` when registered
10
- * (always, for bound and unbound — bound/unbound only gates cwd / env bind).
10
+ * Includes `ask_operator` unless `gateKinds` is an empty list (R6).
11
+ * `write_stage_artifact` when registered (always, for bound and unbound —
12
+ * bound/unbound only gates cwd / env bind).
11
13
  */
12
- export declare function resolveStageToolNames(emitToolName: string, artifactToolName?: string, askOperatorToolName?: string): string[];
14
+ export declare function resolveStageToolNames(emitToolName: string, artifactToolName?: string, askOperatorToolName?: string, gateKinds?: StageGateKind[]): string[];
13
15
  /**
14
16
  * In-process wait channel for `ask_operator` → StageHandle yield/deliver.
15
17
  * HITL `openStage` connects this via `setWaitHandler` + handle `deliverAnswer`.
@@ -62,6 +64,7 @@ export type RouteSessionEventToProgressOptions = {
62
64
  */
63
65
  export declare function routeSessionEventToProgress(event: Record<string, unknown>, options: RouteSessionEventToProgressOptions): void;
64
66
  export declare function composeStageUserPrompt(input: StageRunInput, emitToolName: string, emitHintOverride?: string, artifactToolName?: string): string;
67
+ export declare function composeFeedbackResumePrompt(input: StageRunInput): string;
65
68
  /**
66
69
  * DefaultResourceLoader with host/global discovery turned off.
67
70
  *
@@ -26,14 +26,15 @@
26
26
  * fails closed with `StageSessionReconstructError` (KTD7). Epic proving
27
27
  * vehicle: pipeline `plan-review-proving` (Pi live wait + console answers).
28
28
  *
29
- * `ask_operator` is always registered on sealed stage sessions (allowlist +
30
- * customTools).
29
+ * `ask_operator` is registered on sealed stage sessions unless the stage
30
+ * declares `gate_kinds: []`.
31
31
  */
32
32
  import { existsSync } from "node:fs";
33
33
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
34
34
  import path from "node:path";
35
35
  import { createAgentSession, DefaultResourceLoader, defineTool, ModelRuntime, resolveCliModel, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
36
36
  import { isAdvancingEnvelope } from "../envelope/check.js";
37
+ import { formatFeedbackLoopContext } from "../prompt/feedbackLoopContext.js";
37
38
  import { formatPriorEnvelope } from "../prompt/priorEnvelope.js";
38
39
  import { attemptContext, noAttemptContext, } from "../runtime/stageAttemptContext.js";
39
40
  import { createEmitStageEnvelopeTool, } from "../tools/emitStageEnvelope.js";
@@ -46,18 +47,21 @@ import { createStageActivityObserver, } from "./activityObserver.js";
46
47
  import { DEFAULT_STAGE_TIMEOUT_MS, runtimeStageId, runStageViaOpen } from "./port.js";
47
48
  /**
48
49
  * Stage tool allowlist for sealed Pi sessions.
49
- * Always includes `ask_operator` (R7); `write_stage_artifact` when registered
50
- * (always, for bound and unbound — bound/unbound only gates cwd / env bind).
50
+ * Includes `ask_operator` unless `gateKinds` is an empty list (R6).
51
+ * `write_stage_artifact` when registered (always, for bound and unbound —
52
+ * bound/unbound only gates cwd / env bind).
51
53
  */
52
- export function resolveStageToolNames(emitToolName, artifactToolName, askOperatorToolName = "ask_operator") {
54
+ export function resolveStageToolNames(emitToolName, artifactToolName, askOperatorToolName = "ask_operator", gateKinds) {
53
55
  const tools = [
54
56
  "read",
55
57
  "bash",
56
58
  "write",
57
59
  "edit",
58
60
  emitToolName,
59
- askOperatorToolName,
60
61
  ];
62
+ if (gateKinds === undefined || gateKinds.length > 0) {
63
+ tools.push(askOperatorToolName);
64
+ }
61
65
  if (artifactToolName) {
62
66
  tools.push(artifactToolName);
63
67
  }
@@ -364,6 +368,37 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
364
368
  if (input.stage.payload_schema !== undefined) {
365
369
  emitHint += `\nOn status=success, payload is required and must match this JSON Schema:\n${JSON.stringify(input.stage.payload_schema, null, 2)}`;
366
370
  }
371
+ const checklistChecks = input.completionContract?.checks.filter((check) => check.type === "checklist") ?? [];
372
+ if (checklistChecks.length > 0) {
373
+ emitHint += `\nBefore emitting success, self-review every required checklist item. Include checklist_attestations in the envelope with each check_id and its complete item list exactly as declared:\n${JSON.stringify(checklistChecks, null, 2)}`;
374
+ }
375
+ if (input.repairContext !== undefined) {
376
+ emitHint += `\nThis is recovery attempt ${input.roots.attempt ?? input.repairContext.prior_attempt + 1}. The previous candidate did not pass Stageflow verification. Address the failed checks before emitting success.`;
377
+ if (input.repairContext.operator_guidance) {
378
+ emitHint += `\nThe operator approved this retry with these instructions:\n${input.repairContext.operator_guidance}`;
379
+ }
380
+ if (input.repairContext.failed_checks?.length) {
381
+ emitHint += `\nFailed-check evidence from attempt ${input.repairContext.prior_attempt}:\n${JSON.stringify(input.repairContext.failed_checks, null, 2)}`;
382
+ }
383
+ }
384
+ if (input.cloneEmitContext !== undefined) {
385
+ const allowedActions = input.cloneEmitContext.allowedActions ?? ["skip", "once", "fanout"];
386
+ const successorLines = input.cloneEmitContext.clonableSuccessors.map((successor) => {
387
+ const header = `- ${successor.successorId} (clone_cap: ${successor.cloneCap})`;
388
+ if (successor.cloneInputSchema === undefined) {
389
+ return header;
390
+ }
391
+ return `${header}\n Assignment schema (clone_input_schema):\n${JSON.stringify(successor.cloneInputSchema, null, 2)}`;
392
+ });
393
+ emitHint += `\nClonable successors — emit clone_forks covering each of these ids exactly once:\n${successorLines.join("\n")}`;
394
+ 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.`;
395
+ emitHint += `\nAllowed clone actions: ${allowedActions.join(", ")}`;
396
+ }
397
+ if (input.feedbackLoopEmitContext !== undefined) {
398
+ 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.`;
399
+ emitHint += `\nAllowed send_back target: ${input.feedbackLoopEmitContext.target}.`;
400
+ emitHint += `\nA send_back decision cannot be combined with fork_choice or clone_forks.`;
401
+ }
367
402
  const attempt = input.roots.attempt ?? 1;
368
403
  const attemptArtifactsPath = `stages/${runtimeStageId(input)}/attempts/${attempt}/artifacts/`;
369
404
  const skillBaseDir = input.stage.skill !== undefined && input.skillFilePath !== undefined
@@ -394,7 +429,10 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
394
429
  `Goal: ${input.task.goal}`,
395
430
  input.task.context ? `Context: ${input.task.context}` : "",
396
431
  input.task.constraints ? `Constraints: ${input.task.constraints}` : "",
397
- formatPriorEnvelope(input.priorEnvelope, input.priorEnvelopes),
432
+ formatPriorEnvelope(input.priorEnvelope, input.priorEnvelopes, input.priorEnvelopesByStage),
433
+ input.feedbackLoopContext !== undefined
434
+ ? formatFeedbackLoopContext(input.feedbackLoopContext, input.sessionMode)
435
+ : "",
398
436
  "",
399
437
  artifactGuidance,
400
438
  emitHint,
@@ -409,6 +447,16 @@ export function composeStageUserPrompt(input, emitToolName, emitHintOverride, ar
409
447
  }
410
448
  return body;
411
449
  }
450
+ export function composeFeedbackResumePrompt(input) {
451
+ if (input.feedbackLoopContext === undefined) {
452
+ throw new Error("feedback_resume requires feedbackLoopContext");
453
+ }
454
+ return [
455
+ "Continue this stage after feedback-loop send-back.",
456
+ "Incorporate the operator/source feedback below, then complete the stage (emit an envelope when done).",
457
+ formatFeedbackLoopContext(input.feedbackLoopContext, input.sessionMode ?? "feedback_resume"),
458
+ ].join("\n\n");
459
+ }
412
460
  /**
413
461
  * DefaultResourceLoader with host/global discovery turned off.
414
462
  *
@@ -686,12 +734,20 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
686
734
  const provider = findProviderSupport(input.stage.model);
687
735
  const capture = {};
688
736
  const askWaitChannel = existingAskWaitChannel ?? new AskOperatorWaitChannel();
689
- const emitDef = createEmitStageEnvelopeTool(capture, input.stage.payload_schema, input.forkEmitContext, input.cloneEmitContext);
737
+ const emitDef = createEmitStageEnvelopeTool(capture, input.stage.payload_schema, input.forkEmitContext, input.cloneEmitContext, {
738
+ checks: input.stage.pre_emit_checks,
739
+ readQaTrail: input.readQaTrail,
740
+ }, input.feedbackLoopEmitContext);
690
741
  const emitTool = defineTool(emitDef);
691
- const askDef = createAskOperatorTool({
692
- requestWait: (prompt) => askWaitChannel.requestWait(prompt),
693
- });
694
- const askTool = defineTool(askDef);
742
+ const gateKinds = input.stage.gate_kinds;
743
+ const includeAsk = gateKinds === undefined || gateKinds.length > 0;
744
+ const askDef = includeAsk
745
+ ? createAskOperatorTool({
746
+ requestWait: (prompt) => askWaitChannel.requestWait(prompt),
747
+ ...(gateKinds !== undefined ? { allowedKinds: gateKinds } : {}),
748
+ })
749
+ : undefined;
750
+ const askTool = askDef ? defineTool(askDef) : undefined;
695
751
  const artifactDef = createWriteStageArtifactTool({
696
752
  runWorkspaceDir: roots.runWorkspaceDir,
697
753
  stageId: runtimeStageId(input),
@@ -715,7 +771,17 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
715
771
  restoreProvider = prepared.restore;
716
772
  }
717
773
  try {
718
- const modelRuntime = await ModelRuntime.create(roots.authPath ? { authPath: roots.authPath } : undefined);
774
+ const modelRuntime = await ModelRuntime.create(roots.authPath
775
+ ? {
776
+ authPath: roots.authPath,
777
+ // ModelRuntime otherwise resolves models.json from getAgentDir(),
778
+ // which bindPiAgentDirEnv seals to an empty per-attempt sandbox
779
+ // dir. Point it at the same durable agent dir as authPath so
780
+ // custom providers (e.g. a self-hosted OpenAI-compatible
781
+ // gateway) defined there stay usable in sealed stage sessions.
782
+ modelsPath: path.join(path.dirname(roots.authPath), "models.json"),
783
+ }
784
+ : undefined);
719
785
  const settingsManager = SettingsManager.inMemory({
720
786
  compaction: { enabled: false },
721
787
  });
@@ -760,16 +826,18 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
760
826
  reason: `Skill "${input.stage.skill}" is not installed`,
761
827
  };
762
828
  }
763
- const customTools = [emitTool, askTool, artifactTool];
829
+ const customTools = askTool
830
+ ? [emitTool, askTool, artifactTool]
831
+ : [emitTool, artifactTool];
764
832
  return {
765
833
  sessionManager,
766
834
  modelRuntime,
767
835
  settingsManager,
768
836
  loader,
769
- tools: resolveStageToolNames(emitDef.name, artifactDef.name, askDef.name),
837
+ tools: resolveStageToolNames(emitDef.name, artifactDef.name, askDef?.name ?? "ask_operator", gateKinds),
770
838
  customTools,
771
839
  emitDefName: emitDef.name,
772
- askOperatorDefName: askDef.name,
840
+ ...(askDef ? { askOperatorDefName: askDef.name } : {}),
773
841
  artifactDefName: artifactDef.name,
774
842
  providerEmitHint: provider?.emitToolHint?.(emitDef.name),
775
843
  restoreProvider,
@@ -901,30 +969,65 @@ export class PiAgentAdapter {
901
969
  const timeoutMs = input.timeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
902
970
  const askWaitChannel = new AskOperatorWaitChannel();
903
971
  const sessionFile = resolveStageSessionFile(input);
972
+ const sessionMode = input.sessionMode ?? "fresh";
904
973
  let sessionManager;
905
974
  let resumeWaiting = false;
906
- if (existsSync(sessionFile)) {
975
+ let feedbackResume = false;
976
+ let forceNewSession = sessionMode === "new_session";
977
+ if (sessionMode === "feedback_resume") {
978
+ if (input.feedbackLoopContext === undefined) {
979
+ throw new StageSessionReconstructError(`feedback_resume requires feedbackLoopContext: ${sessionFile}`, { stageId: runtimeStageId(input), sessionFile });
980
+ }
981
+ if (!existsSync(sessionFile)) {
982
+ throw new StageSessionReconstructError(`stage session file missing for feedback_resume: ${sessionFile}`, { stageId: runtimeStageId(input), sessionFile });
983
+ }
907
984
  try {
908
985
  sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), input.roots.cwd);
909
986
  }
910
987
  catch (err) {
911
- throw new StageSessionReconstructError(`stage session file corrupt or unreadable for resume: ${sessionFile}`, {
988
+ throw new StageSessionReconstructError(`stage session file corrupt or unreadable for feedback_resume: ${sessionFile}`, {
912
989
  stageId: runtimeStageId(input),
913
990
  sessionFile,
914
991
  cause: err,
915
992
  });
916
993
  }
917
994
  ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
918
- if (!findOpenToolCall(sessionManager)) {
919
- throw new StageSessionReconstructError(`stage session has no open tool call for resume: ${sessionFile}`, { stageId: runtimeStageId(input), sessionFile });
995
+ feedbackResume = true;
996
+ }
997
+ else if (sessionMode !== "new_session") {
998
+ if (sessionMode === "waiting_resume" || existsSync(sessionFile)) {
999
+ if (existsSync(sessionFile)) {
1000
+ try {
1001
+ sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), input.roots.cwd);
1002
+ }
1003
+ catch (err) {
1004
+ throw new StageSessionReconstructError(`stage session file corrupt or unreadable for resume: ${sessionFile}`, {
1005
+ stageId: runtimeStageId(input),
1006
+ sessionFile,
1007
+ cause: err,
1008
+ });
1009
+ }
1010
+ ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
1011
+ if (!findOpenToolCall(sessionManager)) {
1012
+ throw new StageSessionReconstructError(`stage session has no open tool call for resume: ${sessionFile}`, { stageId: runtimeStageId(input), sessionFile });
1013
+ }
1014
+ resumeWaiting = true;
1015
+ }
1016
+ else {
1017
+ throw new StageSessionReconstructError(`stage session file missing for resume: ${sessionFile}`, { stageId: runtimeStageId(input), sessionFile });
1018
+ }
920
1019
  }
921
- resumeWaiting = true;
922
1020
  }
923
1021
  let session;
924
1022
  let wiring;
925
1023
  let unsubscribeProgress;
926
1024
  let prepareError;
927
1025
  const preparePromise = (async () => {
1026
+ if (forceNewSession) {
1027
+ await mkdir(path.dirname(sessionFile), { recursive: true });
1028
+ await writeFile(sessionFile, "");
1029
+ sessionManager = undefined;
1030
+ }
928
1031
  if (!sessionManager) {
929
1032
  sessionManager = await createStageSessionManager(input.roots, runtimeStageId(input), sessionFile);
930
1033
  }
@@ -987,6 +1090,11 @@ export class PiAgentAdapter {
987
1090
  },
988
1091
  run: async () => {
989
1092
  return runWithTimeout(async () => {
1093
+ if (feedbackResume) {
1094
+ syncAgentMessagesFromSession(session, sessionManager);
1095
+ await session.prompt(composeFeedbackResumePrompt(input));
1096
+ return;
1097
+ }
990
1098
  const userPrompt = composeStageUserPrompt(input, wiring.emitDefName, wiring.providerEmitHint, wiring.artifactDefName);
991
1099
  await session.prompt(userPrompt);
992
1100
  });
@@ -1,11 +1,47 @@
1
- import type { StageEnvelope } from "../types/envelope.js";
1
+ import type { StageEnvelope, TerminalEnvelope } from "../types/envelope.js";
2
+ import type { CompletionContract } from "../types/completion.js";
2
3
  import type { CloneEmitContext, ForkEmitContext } from "../types/forkChoice.js";
3
4
  import type { StageConfig } from "../types/stage.js";
5
+ import type { FeedbackLoopConfig } from "../types/pipeline.js";
4
6
  import type { TaskFile } from "../types/task.js";
5
7
  import type { StageActivityEvent } from "./activity.js";
6
8
  import type { StageRoots } from "../runtime/stageRoots.js";
9
+ import type { QaExchange } from "../hitl/qaTrail.js";
7
10
  /** Opaque to runtime; adapters interpret. */
8
11
  export type StageResumeToken = string;
12
+ export type StageSessionMode = "fresh" | "waiting_resume" | "feedback_resume" | "new_session";
13
+ export type StageRepairContext = {
14
+ prior_attempt: number;
15
+ /** Operator-authored instructions for an explicitly approved manual retry. */
16
+ operator_guidance?: string;
17
+ failed_checks?: Array<{
18
+ id: string;
19
+ type: string;
20
+ evidence_preview?: string;
21
+ }>;
22
+ };
23
+ export type FeedbackLoopContext = {
24
+ loop_id: string;
25
+ replay_id: string;
26
+ source_stage_id: string;
27
+ target_stage_id: string;
28
+ /** Original send-back envelope from the source (feedback + artifacts). */
29
+ feedback_envelope: StageEnvelope;
30
+ /** One-based replay number for this send-back. */
31
+ replay_number: number;
32
+ max_replays: number;
33
+ remaining_replays: number;
34
+ is_final_replay: boolean;
35
+ replay_session: "resume" | "new_session";
36
+ /** Persistent stages on the active target→source route, forward order. */
37
+ route_stage_ids: string[];
38
+ /** Prior pass/session identity for THIS stage when resuming, if any. */
39
+ prior_stage_attempt?: number;
40
+ prior_pass_status?: string;
41
+ /** Active fork generation for this stage's parent fan-out, if any. */
42
+ active_fork_generation_id?: string;
43
+ active_fork_clone_stage_ids?: string[];
44
+ };
9
45
  export type StageRunInput = {
10
46
  roots: StageRoots;
11
47
  stage: StageConfig;
@@ -14,13 +50,26 @@ export type StageRunInput = {
14
50
  task: TaskFile;
15
51
  priorEnvelope: StageEnvelope | null;
16
52
  priorEnvelopes?: StageEnvelope[];
53
+ priorEnvelopesByStage?: Record<string, TerminalEnvelope | TerminalEnvelope[]>;
17
54
  timeoutMs?: number;
18
55
  resumeToken?: StageResumeToken;
56
+ /** Launch/session mode; omitted is treated as `"fresh"`. */
57
+ sessionMode?: StageSessionMode;
19
58
  /** Optional observe hook; HITL wait/answer uses openStage beside this. */
20
59
  onActivity?: (event: StageActivityEvent) => void;
21
60
  skillFilePath?: string;
22
61
  forkEmitContext?: ForkEmitContext;
23
62
  cloneEmitContext?: CloneEmitContext;
63
+ /** Declared source policy; successful emits must continue or send work back. */
64
+ feedbackLoopEmitContext?: FeedbackLoopConfig;
65
+ /** Replay context for stages being replayed (and typically the source on send-back). */
66
+ feedbackLoopContext?: FeedbackLoopContext;
67
+ /** Frozen pipeline-owned checks that must pass before Stageflow advances. */
68
+ completionContract?: CompletionContract;
69
+ /** Compact evidence from the failed verification that triggered this repair. */
70
+ repairContext?: StageRepairContext;
71
+ /** Live attempt-scoped QA trail; read at emit execute time, not bootstrap time. */
72
+ readQaTrail?: () => QaExchange[] | Promise<QaExchange[]>;
24
73
  };
25
74
  export declare function runtimeStageId(input: Pick<StageRunInput, "stage" | "stageId">): string;
26
75
  export type StageRunResult = {
@@ -0,0 +1,26 @@
1
+ import { type AgentBackendId } from "./agentBackend.js";
2
+ import type { AgentPort } from "./port.js";
3
+ import type { LoadedManifest } from "../types/stageflowManifest.js";
4
+ export type AgentSelection = {
5
+ /** stageflow.yaml top-level `agent`. */
6
+ global?: AgentBackendId;
7
+ /** Pipeline yaml top-level `agent`; overrides global. */
8
+ pipeline?: AgentBackendId;
9
+ /** Stage yaml `agent`; would override pipeline/global once enabled. */
10
+ stage?: AgentBackendId;
11
+ };
12
+ /** stage > pipeline > global > "pi", with stage gated off for now. */
13
+ export declare function resolveAgentBackend(selection?: AgentSelection): AgentBackendId;
14
+ export declare function resolveAgentPort(selection?: AgentSelection): AgentPort;
15
+ /** The global tier's backend id from an already-loaded manifest, or undefined for missing/invalid/unset. */
16
+ export declare function globalAgentBackendFromManifest(manifest: LoadedManifest | null): AgentBackendId | undefined;
17
+ /**
18
+ * Resolves an AgentPort using only the global (stageflow.yaml) tier — for
19
+ * the three call sites that construct an agent before any pipeline is
20
+ * known (`cli.ts`'s ui/mcp servers, `runCommand.ts`, `runsCommand.ts`).
21
+ * Named explicitly so it's clear these sites are scoped to the global tier
22
+ * on purpose, not by omission: production stage execution always goes
23
+ * through `stageWorker.ts`, which resolves the full stage > pipeline >
24
+ * global chain once it has loaded the specific pipeline and stage.
25
+ */
26
+ export declare function resolveGlobalOnlyAgentPort(projectRoot: string): Promise<AgentPort>;
@@ -0,0 +1,39 @@
1
+ import { asAgentBackendId, STAGE_LEVEL_AGENT_OVERRIDE_ENABLED, } from "./agentBackend.js";
2
+ import { PiAgentAdapter } from "./piAdapter.js";
3
+ import { ClaudeAgentAdapter } from "./claudeAdapter.js";
4
+ import { loadStageflowManifestOutcome } from "../config/loadStageflowManifest.js";
5
+ /** stage > pipeline > global > "pi", with stage gated off for now. */
6
+ export function resolveAgentBackend(selection = {}) {
7
+ if (STAGE_LEVEL_AGENT_OVERRIDE_ENABLED && selection.stage !== undefined) {
8
+ return selection.stage;
9
+ }
10
+ return selection.pipeline ?? selection.global ?? "pi";
11
+ }
12
+ export function resolveAgentPort(selection = {}) {
13
+ const backend = resolveAgentBackend(selection);
14
+ switch (backend) {
15
+ case "pi":
16
+ return new PiAgentAdapter();
17
+ case "claude":
18
+ return new ClaudeAgentAdapter();
19
+ }
20
+ }
21
+ /** The global tier's backend id from an already-loaded manifest, or undefined for missing/invalid/unset. */
22
+ export function globalAgentBackendFromManifest(manifest) {
23
+ return manifest ? asAgentBackendId(manifest.manifest.agent) : undefined;
24
+ }
25
+ /**
26
+ * Resolves an AgentPort using only the global (stageflow.yaml) tier — for
27
+ * the three call sites that construct an agent before any pipeline is
28
+ * known (`cli.ts`'s ui/mcp servers, `runCommand.ts`, `runsCommand.ts`).
29
+ * Named explicitly so it's clear these sites are scoped to the global tier
30
+ * on purpose, not by omission: production stage execution always goes
31
+ * through `stageWorker.ts`, which resolves the full stage > pipeline >
32
+ * global chain once it has loaded the specific pipeline and stage.
33
+ */
34
+ export async function resolveGlobalOnlyAgentPort(projectRoot) {
35
+ const outcome = await loadStageflowManifestOutcome(projectRoot);
36
+ return resolveAgentPort({
37
+ global: outcome.ok ? globalAgentBackendFromManifest(outcome.value) : undefined,
38
+ });
39
+ }
@@ -1,4 +1,4 @@
1
- import { PiAgentAdapter } from "../agent/piAdapter.js";
1
+ import { resolveGlobalOnlyAgentPort } from "../agent/resolveAgentPort.js";
2
2
  import { createRunStore } from "../runstore/createStore.js";
3
3
  import { PipelineValidationError } from "../runtime/pipelineRunner.js";
4
4
  import { RunManager } from "../runtime/runManager.js";
@@ -137,7 +137,7 @@ function defaultStartRun(cwd, projectRoot, isGitProject, operatorCatalog) {
137
137
  return async (input) => {
138
138
  const store = createRunStore({ rootDir: projectRoot });
139
139
  const manager = new RunManager({
140
- agent: new PiAgentAdapter(),
140
+ agent: await resolveGlobalOnlyAgentPort(projectRoot),
141
141
  store,
142
142
  cwd,
143
143
  projectRoot,