stageflow 0.9.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/agent/activity.d.ts +9 -0
- package/dist/agent/agentBackend.d.ts +32 -0
- package/dist/agent/agentBackend.js +38 -0
- package/dist/agent/claudeActivity.d.ts +15 -0
- package/dist/agent/claudeActivity.js +58 -0
- package/dist/agent/claudeAdapter.d.ts +5 -0
- package/dist/agent/claudeAdapter.js +331 -0
- package/dist/agent/claudeSession.d.ts +18 -0
- package/dist/agent/claudeSession.js +54 -0
- package/dist/agent/claudeTools.d.ts +89 -0
- package/dist/agent/claudeTools.js +135 -0
- package/dist/agent/fakeAgent.js +24 -4
- package/dist/agent/piAdapter.d.ts +12 -7
- package/dist/agent/piAdapter.js +194 -44
- package/dist/agent/piIsolatedMcp.d.ts +23 -0
- package/dist/agent/piIsolatedMcp.js +223 -0
- package/dist/agent/piIsolatedMcpProbe.d.ts +15 -0
- package/dist/agent/piIsolatedMcpProbe.js +292 -0
- package/dist/agent/port.d.ts +52 -1
- package/dist/agent/port.js +1 -1
- package/dist/agent/resolveAgentPort.d.ts +26 -0
- package/dist/agent/resolveAgentPort.js +39 -0
- package/dist/cli/runCommand.js +2 -2
- package/dist/cli/runsCommand.d.ts +1 -1
- package/dist/cli/runsCommand.js +184 -2
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +11 -5
- package/dist/config/browseCatalog.js +1 -1
- package/dist/config/createPipeline.d.ts +2 -1
- package/dist/config/createPipeline.js +34 -9
- package/dist/config/createStage.js +14 -9
- package/dist/config/loadPipeline.js +11 -1
- package/dist/config/loadStage.d.ts +1 -0
- package/dist/config/loadStage.js +207 -2
- package/dist/config/loadStageflowManifest.js +14 -1
- package/dist/config/mergePipelineIncludes.d.ts +1 -0
- package/dist/config/mergePipelineIncludes.js +14 -1
- package/dist/config/normalizePipelineStageEntry.d.ts +7 -2
- package/dist/config/normalizePipelineStageEntry.js +56 -7
- package/dist/config/parseCompletionContract.d.ts +8 -0
- package/dist/config/parseCompletionContract.js +218 -0
- package/dist/config/parsePreEmitChecks.d.ts +3 -0
- package/dist/config/parsePreEmitChecks.js +65 -0
- package/dist/config/pipelineNeeds.d.ts +19 -0
- package/dist/config/pipelineNeeds.js +151 -0
- package/dist/config/pipelineStageKeys.js +17 -1
- package/dist/config/resolveForkEmitContext.d.ts +6 -2
- package/dist/config/resolveForkEmitContext.js +9 -2
- package/dist/config/resolvePipelineDag.d.ts +2 -1
- package/dist/config/resolvePipelineDag.js +182 -39
- package/dist/config/resolveStageMcpServers.d.ts +35 -0
- package/dist/config/resolveStageMcpServers.js +207 -0
- package/dist/config/validateCatalog.d.ts +1 -1
- package/dist/config/validateCatalog.js +51 -0
- package/dist/config/validateCompletionContract.d.ts +4 -0
- package/dist/config/validateCompletionContract.js +52 -0
- package/dist/envelope/check.d.ts +1 -1
- package/dist/envelope/check.js +86 -13
- package/dist/envelope/cloneForks.d.ts +1 -1
- package/dist/envelope/cloneForks.js +24 -9
- package/dist/envelope/feedbackLoop.d.ts +4 -0
- package/dist/envelope/feedbackLoop.js +31 -0
- package/dist/envelope/payloadSchema.d.ts +7 -1
- package/dist/envelope/payloadSchema.js +170 -13
- package/dist/envelope/preEmitChecks.d.ts +29 -0
- package/dist/envelope/preEmitChecks.js +57 -0
- package/dist/hitl/qaTrail.d.ts +25 -0
- package/dist/hitl/qaTrail.js +51 -0
- package/dist/mcp/catalogTools.js +4 -2
- package/dist/mcp/controlTools.js +72 -0
- package/dist/mcp/readArtifact.d.ts +2 -0
- package/dist/mcp/readArtifact.js +25 -1
- package/dist/mcp/waitingGates.d.ts +2 -1
- package/dist/mcp/waitingGates.js +17 -1
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/projection/projectRun.d.ts +4 -1
- package/dist/projection/projectRun.js +7 -0
- package/dist/prompt/feedbackLoopContext.d.ts +2 -0
- package/dist/prompt/feedbackLoopContext.js +17 -0
- package/dist/prompt/priorEnvelope.d.ts +2 -2
- package/dist/prompt/priorEnvelope.js +4 -1
- package/dist/runstore/pipelineDagSnapshot.d.ts +1 -0
- package/dist/runstore/pipelineDagSnapshot.js +51 -11
- package/dist/runstore/port.d.ts +185 -3
- package/dist/runstore/port.js +42 -3
- package/dist/runstore/runProjection.d.ts +7 -3
- package/dist/runstore/runProjection.js +30 -15
- package/dist/runstore/sqlite/SqliteRunStore.d.ts +25 -1
- package/dist/runstore/sqlite/SqliteRunStore.js +626 -8
- package/dist/runstore/sqlite/schema.d.ts +1 -1
- package/dist/runstore/sqlite/schema.js +99 -0
- package/dist/runstore/stageInstanceId.d.ts +1 -1
- package/dist/runstore/stageInstanceId.js +3 -2
- package/dist/runstore/trackProjection.js +59 -27
- package/dist/runstore/verificationHistory.d.ts +19 -0
- package/dist/runstore/verificationHistory.js +30 -0
- package/dist/runtime/cloneSchedule.d.ts +40 -4
- package/dist/runtime/cloneSchedule.js +212 -28
- package/dist/runtime/completionCheckRunner.d.ts +137 -0
- package/dist/runtime/completionCheckRunner.js +365 -0
- package/dist/runtime/completionCheckoutBaseline.d.ts +3 -0
- package/dist/runtime/completionCheckoutBaseline.js +44 -0
- package/dist/runtime/envelopeRouting.d.ts +10 -3
- package/dist/runtime/envelopeRouting.js +146 -5
- package/dist/runtime/feedbackLoopCoordinator.d.ts +74 -0
- package/dist/runtime/feedbackLoopCoordinator.js +446 -0
- package/dist/runtime/feedbackLoopDecision.d.ts +44 -0
- package/dist/runtime/feedbackLoopDecision.js +207 -0
- package/dist/runtime/feedbackLoopRoute.d.ts +5 -0
- package/dist/runtime/feedbackLoopRoute.js +13 -0
- package/dist/runtime/feedbackLoopSchedule.d.ts +38 -0
- package/dist/runtime/feedbackLoopSchedule.js +72 -0
- package/dist/runtime/forkGeneration.d.ts +72 -0
- package/dist/runtime/forkGeneration.js +295 -0
- package/dist/runtime/gitCheckoutCapability.d.ts +7 -0
- package/dist/runtime/gitCheckoutCapability.js +97 -0
- package/dist/runtime/manualRecoveryState.d.ts +40 -0
- package/dist/runtime/manualRecoveryState.js +73 -0
- package/dist/runtime/pipelineScheduler.d.ts +13 -1
- package/dist/runtime/pipelineScheduler.js +328 -45
- package/dist/runtime/replayLifecycle.d.ts +107 -0
- package/dist/runtime/replayLifecycle.js +168 -0
- package/dist/runtime/runManager.d.ts +54 -1
- package/dist/runtime/runManager.js +471 -6
- package/dist/runtime/runRetryCoordinator.d.ts +6 -1
- package/dist/runtime/runRetryCoordinator.js +43 -9
- package/dist/runtime/stageAttemptBootstrap.d.ts +3 -1
- package/dist/runtime/stageAttemptBootstrap.js +117 -3
- package/dist/runtime/stageProcessLauncher.d.ts +1 -1
- package/dist/runtime/stageRecovery.js +1 -1
- package/dist/runtime/stageRunner.d.ts +4 -1
- package/dist/runtime/stageRunner.js +29 -3
- package/dist/runtime/stageWorker.js +69 -2
- package/dist/runtime/stageWorkerProtocol.d.ts +1 -1
- package/dist/runtime/verifiedStageExecution.d.ts +31 -0
- package/dist/runtime/verifiedStageExecution.js +127 -0
- package/dist/server/bootstrap.js +1 -0
- package/dist/server/http.js +137 -4
- package/dist/server/operatorResults.js +2 -0
- package/dist/server/projectMcpRoutes.d.ts +6 -0
- package/dist/server/projectMcpRoutes.js +38 -0
- package/dist/tools/askOperator.d.ts +161 -5
- package/dist/tools/askOperator.js +58 -25
- package/dist/tools/emitStageEnvelope.d.ts +12 -12
- package/dist/tools/emitStageEnvelope.js +112 -24
- package/dist/types/completion.d.ts +43 -0
- package/dist/types/completion.js +1 -0
- package/dist/types/envelope.d.ts +18 -0
- package/dist/types/forkChoice.d.ts +9 -4
- package/dist/types/forkChoice.js +1 -1
- package/dist/types/pipeline.d.ts +41 -2
- package/dist/types/preEmitCheck.d.ts +18 -0
- package/dist/types/preEmitCheck.js +1 -0
- package/dist/types/stage.d.ts +14 -0
- package/dist/types/stageflowManifest.d.ts +2 -0
- package/dist/ui/assets/index-Yzh42vhf.js +118 -0
- package/dist/ui/assets/{index-C3N7MXAC.css → index-uVHiae8s.css} +1 -1
- package/dist/ui/index.html +2 -2
- package/package.json +5 -1
- package/skills/stageflow-author/SKILL.md +20 -2
- package/skills/stageflow-author/references/catalog-mapping.md +88 -3
- package/skills/stageflow-author/references/catalog-write-conventions.md +1 -1
- package/skills/stageflow-author/references/stage-prompt-template.md +63 -4
- package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -1
- 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
|
|
@@ -192,6 +193,8 @@ Host MCP via `sf ui` or `sf mcp` at `http://127.0.0.1:3847/mcp` (URL printed on
|
|
|
192
193
|
|
|
193
194
|
HITL-aware tools include `wait_run`, `answer_gate`, and `list_waiting`. Full tool list: [docs/mcp.md](docs/mcp.md).
|
|
194
195
|
|
|
196
|
+
Stages use project `.mcp.json` plus stage `mcp` names. See [YAML catalog — Stage MCP](docs/yaml-catalog.md#stage-mcp).
|
|
197
|
+
|
|
195
198
|
## Stageflow vs Conductor
|
|
196
199
|
|
|
197
200
|
Both projects address multi-step agent workflows. They differ in orchestration model and runtime.
|
|
@@ -217,6 +220,9 @@ If you want deterministic YAML routing across many agents, look at [Conductor](h
|
|
|
217
220
|
| [plan-review](examples/plan-review/) | Multi-stage with operator gate — SDLC-style **example** |
|
|
218
221
|
| [conditional-fork](examples/conditional-fork/) | Exclusive fork routing with operator branch choice |
|
|
219
222
|
| [clonable-fanout](examples/clonable-fanout/) | Clone one successor N times, then join |
|
|
223
|
+
| [stage-mcp](examples/stage-mcp/) | Stage MCP via project `.mcp.json` and a local echo fixture |
|
|
224
|
+
| [playwright-mcp](examples/playwright-mcp/) | One stage: open a page and save a PNG screenshot (Playwright MCP) |
|
|
225
|
+
| [context7-mcp](examples/context7-mcp/) | Three stages: resolve a library, fetch docs, write a brief (Context7 MCP) |
|
|
220
226
|
| [github-release](examples/github-release/) | Dogfood: draft + publish GitHub Release |
|
|
221
227
|
| [ci-validate](examples/ci-validate/) | Strict validate in CI |
|
|
222
228
|
|
|
@@ -240,7 +246,6 @@ Full docs: **[tejasghutukade.github.io/stageflow](https://tejasghutukade.github.
|
|
|
240
246
|
| [docs/providers.md](docs/providers.md) | Pi providers, `sf providers` |
|
|
241
247
|
| [docs/operator-console.md](docs/operator-console.md) | Console IA and settings |
|
|
242
248
|
| [docs/skills-suite.md](docs/skills-suite.md) | Harness skills — router + jobs for Cursor, Claude Code, Codex, Pi, OpenCode |
|
|
243
|
-
| [docs/compare-conductor.md](docs/compare-conductor.md) | Positioning deep dive |
|
|
244
249
|
|
|
245
250
|
## Develop from source
|
|
246
251
|
|
package/dist/agent/activity.d.ts
CHANGED
|
@@ -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,331 @@
|
|
|
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
|
+
import { StageMcpError } from "../config/resolveStageMcpServers.js";
|
|
50
|
+
/** Built-in tool allowlist, mirroring Pi's sealed-session base set (read/bash/write/edit). */
|
|
51
|
+
const CLAUDE_BUILTIN_TOOLS = ["Read", "Write", "Edit", "Bash"];
|
|
52
|
+
function resolveClaudeModel(stageModel) {
|
|
53
|
+
const slash = stageModel.indexOf("/");
|
|
54
|
+
if (slash < 0) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
reason: `stage.model "${stageModel}" must be "anthropic/<model>" to run on the claude agent backend`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const provider = stageModel.slice(0, slash);
|
|
61
|
+
const model = stageModel.slice(slash + 1);
|
|
62
|
+
if (provider !== "anthropic" || model.length === 0) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: `stage.model "${stageModel}" is not an anthropic model; the claude agent backend only runs anthropic/* models`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, model };
|
|
69
|
+
}
|
|
70
|
+
function preflight(input) {
|
|
71
|
+
if (input.stage.skill !== undefined) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
reason: `stage declares skill "${input.stage.skill}"; the claude agent backend does not support skills yet`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return resolveClaudeModel(input.stage.model);
|
|
78
|
+
}
|
|
79
|
+
function resultFromCapture(capture) {
|
|
80
|
+
if (capture.error && !capture.envelope) {
|
|
81
|
+
return { ok: false, reason: capture.error };
|
|
82
|
+
}
|
|
83
|
+
if (!capture.envelope) {
|
|
84
|
+
return { ok: false, reason: "missing emit_stage_envelope" };
|
|
85
|
+
}
|
|
86
|
+
if (!isAdvancingEnvelope(capture.envelope)) {
|
|
87
|
+
return { ok: false, reason: "status: failure", envelope: capture.envelope };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, envelope: capture.envelope };
|
|
90
|
+
}
|
|
91
|
+
function failedPassedMcpConnect(resolved, listed) {
|
|
92
|
+
const names = Object.keys(resolved ?? {});
|
|
93
|
+
if (names.length === 0)
|
|
94
|
+
return undefined;
|
|
95
|
+
const byName = new Map(listed.map((entry) => [entry.name, entry]));
|
|
96
|
+
for (const name of names) {
|
|
97
|
+
const entry = byName.get(name);
|
|
98
|
+
if (entry?.status === "connected")
|
|
99
|
+
continue;
|
|
100
|
+
const statusOrError = entry?.error ?? entry?.status ?? "missing";
|
|
101
|
+
return new StageMcpError(`MCP server "${name}" failed to connect (status: ${statusOrError})`, "connect_failed");
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
async function* singleUserMessage(text) {
|
|
106
|
+
yield {
|
|
107
|
+
type: "user",
|
|
108
|
+
message: { role: "user", content: text },
|
|
109
|
+
parent_tool_use_id: null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** One `query()` call — either the stage's initial turn or a resume turn. */
|
|
113
|
+
async function runTurn(input, model, markerPath, promptText, resumeSessionId) {
|
|
114
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
117
|
+
const activityMapper = createClaudeActivityMapper();
|
|
118
|
+
const emitCapture = {};
|
|
119
|
+
const askCapture = {};
|
|
120
|
+
const gateKinds = input.stage.gate_kinds;
|
|
121
|
+
const hitlAllowed = gateKinds === undefined || gateKinds.length > 0;
|
|
122
|
+
input.onActivity?.({ event: "agent_start" });
|
|
123
|
+
input.onActivity?.({ event: "turn_start" });
|
|
124
|
+
let sessionId = resumeSessionId;
|
|
125
|
+
try {
|
|
126
|
+
const mcpServer = buildStageflowMcpServer({
|
|
127
|
+
capture: emitCapture,
|
|
128
|
+
payloadSchema: input.stage.payload_schema,
|
|
129
|
+
forkEmitContext: input.forkEmitContext,
|
|
130
|
+
cloneEmitContext: input.cloneEmitContext,
|
|
131
|
+
preEmitCheckOptions: {
|
|
132
|
+
checks: input.stage.pre_emit_checks,
|
|
133
|
+
readQaTrail: input.readQaTrail,
|
|
134
|
+
},
|
|
135
|
+
writeStageArtifact: {
|
|
136
|
+
runWorkspaceDir: input.roots.runWorkspaceDir,
|
|
137
|
+
stageId: runtimeStageId(input),
|
|
138
|
+
attempt: input.roots.attempt ?? 1,
|
|
139
|
+
},
|
|
140
|
+
...(hitlAllowed
|
|
141
|
+
? {
|
|
142
|
+
askOperator: {
|
|
143
|
+
capture: askCapture,
|
|
144
|
+
...(gateKinds !== undefined ? { allowedKinds: gateKinds } : {}),
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
: {}),
|
|
148
|
+
});
|
|
149
|
+
const passedServers = Object.fromEntries(Object.entries(input.resolvedMcpServers ?? {}).map(([name, config]) => [
|
|
150
|
+
name,
|
|
151
|
+
{ ...config, alwaysLoad: true },
|
|
152
|
+
]));
|
|
153
|
+
const stream = query({
|
|
154
|
+
prompt: singleUserMessage(promptText),
|
|
155
|
+
options: {
|
|
156
|
+
abortController: controller,
|
|
157
|
+
cwd: input.roots.cwd,
|
|
158
|
+
model,
|
|
159
|
+
systemPrompt: { type: "custom", prompt: input.stage.system_prompt },
|
|
160
|
+
settingSources: [],
|
|
161
|
+
tools: CLAUDE_BUILTIN_TOOLS,
|
|
162
|
+
mcpServers: { [STAGEFLOW_MCP_SERVER_NAME]: mcpServer, ...passedServers },
|
|
163
|
+
permissionMode: "bypassPermissions",
|
|
164
|
+
allowDangerouslySkipPermissions: true,
|
|
165
|
+
persistSession: true,
|
|
166
|
+
strictMcpConfig: true,
|
|
167
|
+
...(resumeSessionId !== undefined ? { resume: resumeSessionId } : {}),
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
for await (const message of stream) {
|
|
171
|
+
for (const event of activityMapper.map(message)) {
|
|
172
|
+
input.onActivity?.(event);
|
|
173
|
+
}
|
|
174
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
175
|
+
sessionId = message.session_id;
|
|
176
|
+
await writeClaudeSessionMarker(markerPath, { sessionId: message.session_id });
|
|
177
|
+
const connectError = failedPassedMcpConnect(input.resolvedMcpServers, message.mcp_servers);
|
|
178
|
+
if (connectError) {
|
|
179
|
+
try {
|
|
180
|
+
await stream.interrupt();
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// best-effort backstop; a natural stop may have already ended the turn
|
|
184
|
+
}
|
|
185
|
+
return { kind: "completed", result: { ok: false, reason: connectError.message } };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (message.type === "user" &&
|
|
189
|
+
(emitCapture.envelope !== undefined || askCapture.prompt !== undefined)) {
|
|
190
|
+
try {
|
|
191
|
+
await stream.interrupt();
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// best-effort backstop; a natural stop may have already ended the turn
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
if (message.type === "result") {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (askCapture.prompt !== undefined) {
|
|
203
|
+
const resolvedSessionId = sessionId;
|
|
204
|
+
if (resolvedSessionId === undefined) {
|
|
205
|
+
return {
|
|
206
|
+
kind: "completed",
|
|
207
|
+
result: { ok: false, reason: "ask_operator called but no session_id was observed" },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
await writeClaudeSessionMarker(markerPath, {
|
|
211
|
+
sessionId: resolvedSessionId,
|
|
212
|
+
prompt: askCapture.prompt,
|
|
213
|
+
});
|
|
214
|
+
return { kind: "waiting", sessionId: resolvedSessionId, request: askCapture.prompt };
|
|
215
|
+
}
|
|
216
|
+
await clearClaudeSessionMarker(markerPath);
|
|
217
|
+
return { kind: "completed", result: resultFromCapture(emitCapture) };
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
if (askCapture.prompt !== undefined && sessionId !== undefined) {
|
|
221
|
+
await writeClaudeSessionMarker(markerPath, { sessionId, prompt: askCapture.prompt });
|
|
222
|
+
return { kind: "waiting", sessionId, request: askCapture.prompt };
|
|
223
|
+
}
|
|
224
|
+
if (emitCapture.envelope && isAdvancingEnvelope(emitCapture.envelope)) {
|
|
225
|
+
await clearClaudeSessionMarker(markerPath);
|
|
226
|
+
return { kind: "completed", result: { ok: true, envelope: emitCapture.envelope } };
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
kind: "completed",
|
|
230
|
+
result: {
|
|
231
|
+
ok: false,
|
|
232
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
233
|
+
envelope: emitCapture.envelope,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
input.onActivity?.({ event: "agent_end" });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function resumePromptText(answer) {
|
|
243
|
+
return [
|
|
244
|
+
summarizeAnswer(answer),
|
|
245
|
+
"",
|
|
246
|
+
`Continue the stage. Call ${EMIT_STAGE_ENVELOPE_TOOL_NAME} when finished, or ${ASK_OPERATOR_TOOL_NAME} again if you need to ask something else.`,
|
|
247
|
+
].join("\n");
|
|
248
|
+
}
|
|
249
|
+
export class ClaudeAgentAdapter {
|
|
250
|
+
openStage(input) {
|
|
251
|
+
const stageId = runtimeStageId(input);
|
|
252
|
+
const markerPath = claudeSessionMarkerPath(input);
|
|
253
|
+
let closed = false;
|
|
254
|
+
let bootstrapped = false;
|
|
255
|
+
let waiting;
|
|
256
|
+
let pendingAnswer;
|
|
257
|
+
const applyOutcome = (outcome) => {
|
|
258
|
+
if (outcome.kind === "waiting") {
|
|
259
|
+
waiting = { sessionId: outcome.sessionId, prompt: outcome.request };
|
|
260
|
+
pendingAnswer = undefined;
|
|
261
|
+
return { status: "waiting_for_input", request: outcome.request };
|
|
262
|
+
}
|
|
263
|
+
waiting = undefined;
|
|
264
|
+
return { status: "completed", result: outcome.result };
|
|
265
|
+
};
|
|
266
|
+
return {
|
|
267
|
+
stageId,
|
|
268
|
+
async next() {
|
|
269
|
+
if (closed) {
|
|
270
|
+
return { status: "completed", result: { ok: false, reason: "stage handle closed" } };
|
|
271
|
+
}
|
|
272
|
+
if (!bootstrapped) {
|
|
273
|
+
bootstrapped = true;
|
|
274
|
+
const marker = await readClaudeSessionMarker(markerPath);
|
|
275
|
+
if (marker?.prompt !== undefined) {
|
|
276
|
+
waiting = { sessionId: marker.sessionId, prompt: marker.prompt };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (waiting) {
|
|
280
|
+
if (pendingAnswer === undefined) {
|
|
281
|
+
return { status: "waiting_for_input", request: waiting.prompt };
|
|
282
|
+
}
|
|
283
|
+
const rawAnswer = pendingAnswer;
|
|
284
|
+
pendingAnswer = undefined;
|
|
285
|
+
const prompt = waiting.prompt;
|
|
286
|
+
const sessionId = waiting.sessionId;
|
|
287
|
+
let parsedAnswer;
|
|
288
|
+
try {
|
|
289
|
+
parsedAnswer = parseAskOperatorAnswer(rawAnswer);
|
|
290
|
+
assertAnswerMatchesPrompt(prompt, parsedAnswer);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
294
|
+
waiting = undefined;
|
|
295
|
+
await clearClaudeSessionMarker(markerPath);
|
|
296
|
+
return { status: "completed", result: { ok: false, reason: `invalid operator answer: ${message}` } };
|
|
297
|
+
}
|
|
298
|
+
const pre = preflight(input);
|
|
299
|
+
if (!pre.ok) {
|
|
300
|
+
waiting = undefined;
|
|
301
|
+
await clearClaudeSessionMarker(markerPath);
|
|
302
|
+
return { status: "completed", result: { ok: false, reason: pre.reason } };
|
|
303
|
+
}
|
|
304
|
+
const outcome = await runTurn(input, pre.model, markerPath, resumePromptText(parsedAnswer), sessionId);
|
|
305
|
+
return applyOutcome(outcome);
|
|
306
|
+
}
|
|
307
|
+
const pre = preflight(input);
|
|
308
|
+
if (!pre.ok) {
|
|
309
|
+
return { status: "completed", result: { ok: false, reason: pre.reason } };
|
|
310
|
+
}
|
|
311
|
+
const outcome = await runTurn(input, pre.model, markerPath, composeStageUserPrompt(input, EMIT_STAGE_ENVELOPE_TOOL_NAME, undefined, WRITE_STAGE_ARTIFACT_TOOL_NAME), undefined);
|
|
312
|
+
return applyOutcome(outcome);
|
|
313
|
+
},
|
|
314
|
+
deliverAnswer(answer) {
|
|
315
|
+
pendingAnswer = answer;
|
|
316
|
+
},
|
|
317
|
+
async close(closeOptions) {
|
|
318
|
+
closed = true;
|
|
319
|
+
if (closeOptions?.park) {
|
|
320
|
+
// Nothing to flush or kill — by the time a turn reports "waiting",
|
|
321
|
+
// its Claude CLI subprocess has already exited on its own and the
|
|
322
|
+
// marker file is already durably written.
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
async runStage(input) {
|
|
329
|
+
return runStageViaOpen(this, input);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -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
|
+
}
|