stageflow 0.12.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.
Files changed (39) hide show
  1. package/README.md +5 -1
  2. package/dist/agent/claudeAdapter.js +31 -1
  3. package/dist/agent/piAdapter.d.ts +6 -4
  4. package/dist/agent/piAdapter.js +66 -24
  5. package/dist/agent/piIsolatedMcp.d.ts +23 -0
  6. package/dist/agent/piIsolatedMcp.js +223 -0
  7. package/dist/agent/piIsolatedMcpProbe.d.ts +15 -0
  8. package/dist/agent/piIsolatedMcpProbe.js +292 -0
  9. package/dist/agent/port.d.ts +2 -0
  10. package/dist/agent/port.js +1 -1
  11. package/dist/config/loadPipeline.js +1 -0
  12. package/dist/config/loadStage.d.ts +1 -0
  13. package/dist/config/loadStage.js +69 -0
  14. package/dist/config/normalizePipelineStageEntry.js +7 -1
  15. package/dist/config/pipelineStageKeys.js +1 -0
  16. package/dist/config/resolveStageMcpServers.d.ts +35 -0
  17. package/dist/config/resolveStageMcpServers.js +207 -0
  18. package/dist/config/validateCatalog.d.ts +1 -1
  19. package/dist/config/validateCatalog.js +51 -0
  20. package/dist/mcp/readArtifact.d.ts +2 -0
  21. package/dist/mcp/readArtifact.js +25 -1
  22. package/dist/package-meta.d.ts +1 -1
  23. package/dist/package-meta.js +1 -1
  24. package/dist/runtime/stageAttemptBootstrap.js +38 -2
  25. package/dist/runtime/verifiedStageExecution.d.ts +2 -0
  26. package/dist/runtime/verifiedStageExecution.js +10 -2
  27. package/dist/server/http.js +23 -4
  28. package/dist/server/projectMcpRoutes.d.ts +6 -0
  29. package/dist/server/projectMcpRoutes.js +38 -0
  30. package/dist/types/pipeline.d.ts +2 -0
  31. package/dist/types/stage.d.ts +2 -1
  32. package/dist/ui/assets/index-Yzh42vhf.js +118 -0
  33. package/dist/ui/index.html +1 -1
  34. package/package.json +4 -1
  35. package/skills/stageflow-author/SKILL.md +20 -2
  36. package/skills/stageflow-author/references/catalog-mapping.md +63 -2
  37. package/skills/stageflow-author/references/stage-prompt-template.md +63 -4
  38. package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -1
  39. package/dist/ui/assets/index-CXGz4nL7.js +0 -118
@@ -0,0 +1,292 @@
1
+ import { StageMcpError, resolveStageMcpServers, } from "../config/resolveStageMcpServers.js";
2
+ import * as piIsolatedMcp from "./piIsolatedMcp.js";
3
+ export const PROJECT_MCP_PROBE_TIMEOUT_MS = 30_000;
4
+ const MCP_STATUS_EVENT = "pi-mcp-adapter/status/v1";
5
+ function scrub(message, secrets) {
6
+ let out = message;
7
+ for (const secret of secrets) {
8
+ if (secret.length === 0)
9
+ continue;
10
+ out = out.split(secret).join("[redacted]");
11
+ }
12
+ return out;
13
+ }
14
+ function collectInterpolatedSecrets(entry) {
15
+ const secrets = [];
16
+ for (const field of ["env", "headers"]) {
17
+ const value = entry[field];
18
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
19
+ continue;
20
+ }
21
+ for (const item of Object.values(value)) {
22
+ if (typeof item === "string" && item.length > 0) {
23
+ secrets.push(item);
24
+ }
25
+ }
26
+ }
27
+ return secrets;
28
+ }
29
+ function catalogErrorResult(name, err) {
30
+ if (!(err instanceof StageMcpError))
31
+ return undefined;
32
+ if (err.code === "unresolved_var") {
33
+ return { name, status: "unresolved_var", error: err.message };
34
+ }
35
+ if (err.code === "missing_catalog") {
36
+ return { name, status: "missing_catalog" };
37
+ }
38
+ if (err.code === "invalid_config" ||
39
+ err.code === "reserved_name" ||
40
+ err.code === "unknown_server") {
41
+ return { name, status: "invalid_config" };
42
+ }
43
+ return undefined;
44
+ }
45
+ function asStatusServers(data) {
46
+ if (data === null || typeof data !== "object")
47
+ return undefined;
48
+ const servers = data.servers;
49
+ if (!Array.isArray(servers))
50
+ return undefined;
51
+ const parsed = [];
52
+ for (const entry of servers) {
53
+ if (entry === null || typeof entry !== "object")
54
+ continue;
55
+ const name = entry.name;
56
+ const status = entry.status;
57
+ if (typeof name === "string" && typeof status === "string") {
58
+ parsed.push({ name, status });
59
+ }
60
+ }
61
+ return parsed;
62
+ }
63
+ function mapRuntimeStatus(status) {
64
+ if (status === "connected" || status === "cached")
65
+ return "connected";
66
+ if (status === "needs-auth")
67
+ return "needs_auth";
68
+ if (status === "failed" || status === "disabled")
69
+ return "connect_failed";
70
+ return undefined;
71
+ }
72
+ function createProbeExtensionApi(eventBus) {
73
+ const handlers = new Map();
74
+ const api = {
75
+ events: eventBus,
76
+ on(event, handler) {
77
+ const list = handlers.get(event) ?? [];
78
+ list.push(handler);
79
+ handlers.set(event, list);
80
+ },
81
+ registerTool() { },
82
+ registerCommand() { },
83
+ registerShortcut() { },
84
+ registerFlag() { },
85
+ getFlag() {
86
+ return undefined;
87
+ },
88
+ registerMessageRenderer() { },
89
+ registerMarkdownTransformer() { },
90
+ registerEntryRenderer() { },
91
+ sendMessage() { },
92
+ sendUserMessage() { },
93
+ appendEntry() { },
94
+ setSessionName() { },
95
+ getSessionName() {
96
+ return undefined;
97
+ },
98
+ setLabel() { },
99
+ async exec() {
100
+ return { code: 0, stdout: "", stderr: "" };
101
+ },
102
+ getActiveTools() {
103
+ return [];
104
+ },
105
+ setActiveTools() { },
106
+ getAllTools() {
107
+ return [];
108
+ },
109
+ };
110
+ async function emit(event, ...args) {
111
+ for (const handler of handlers.get(event) ?? []) {
112
+ await handler(...args);
113
+ }
114
+ }
115
+ return {
116
+ api: api,
117
+ emitSessionStart(cwd) {
118
+ void emit("session_start", {}, { mode: "print", hasUI: false, cwd });
119
+ },
120
+ emitSessionShutdown() {
121
+ return emit("session_shutdown", {});
122
+ },
123
+ };
124
+ }
125
+ function instantiateProbeFactories(attached, projectRoot) {
126
+ const factories = attached.extensionFactories ?? [];
127
+ const eventBus = attached.eventBus;
128
+ if (factories.length === 0 || eventBus === undefined) {
129
+ return async () => { };
130
+ }
131
+ const probeApi = createProbeExtensionApi(eventBus);
132
+ for (const ext of factories) {
133
+ const factory = typeof ext === "function" ? ext : ext.factory;
134
+ factory(probeApi.api);
135
+ }
136
+ probeApi.emitSessionStart(projectRoot);
137
+ return () => probeApi.emitSessionShutdown();
138
+ }
139
+ function mapConnectingError(err, aborted) {
140
+ const message = err instanceof Error ? err.message : String(err);
141
+ if (aborted || /cancelled/i.test(message)) {
142
+ return { kind: "cancelled" };
143
+ }
144
+ if (/needs-auth/.test(message)) {
145
+ return { kind: "mapped", status: "needs_auth" };
146
+ }
147
+ return {
148
+ kind: "mapped",
149
+ status: "connect_failed",
150
+ error: message.length > 0 ? message : undefined,
151
+ };
152
+ }
153
+ async function waitForProbeStatus(eventBus, serverName, connecting, options) {
154
+ if (options.signal?.aborted) {
155
+ return { kind: "cancelled" };
156
+ }
157
+ return new Promise((resolve) => {
158
+ let settled = false;
159
+ let unsubscribe;
160
+ let timer;
161
+ const finish = (result) => {
162
+ if (settled)
163
+ return;
164
+ settled = true;
165
+ unsubscribe?.();
166
+ if (timer !== undefined)
167
+ clearTimeout(timer);
168
+ options.signal?.removeEventListener("abort", onAbort);
169
+ resolve(result);
170
+ };
171
+ const onAbort = () => finish({ kind: "cancelled" });
172
+ const consider = (data) => {
173
+ const servers = asStatusServers(data);
174
+ if (servers === undefined)
175
+ return;
176
+ const entry = servers.find((server) => server.name === serverName);
177
+ if (entry === undefined)
178
+ return;
179
+ const mapped = mapRuntimeStatus(entry.status);
180
+ if (mapped === undefined)
181
+ return;
182
+ if (mapped === "connect_failed") {
183
+ finish({
184
+ kind: "mapped",
185
+ status: "connect_failed",
186
+ error: `MCP server "${serverName}" failed to connect (status: ${entry.status})`,
187
+ });
188
+ return;
189
+ }
190
+ finish({ kind: "mapped", status: mapped });
191
+ };
192
+ const handler = (data) => consider(data);
193
+ const maybeUnsub = eventBus.on(MCP_STATUS_EVENT, handler);
194
+ unsubscribe = typeof maybeUnsub === "function" ? maybeUnsub : undefined;
195
+ options.signal?.addEventListener("abort", onAbort, { once: true });
196
+ void (connecting ?? Promise.resolve()).then(() => finish({ kind: "mapped", status: "connected" }), (err) => finish(mapConnectingError(err, options.signal?.aborted === true)));
197
+ timer = setTimeout(() => {
198
+ finish({
199
+ kind: "mapped",
200
+ status: "connect_failed",
201
+ error: `MCP server "${serverName}" failed to connect (status: timed out)`,
202
+ });
203
+ }, options.timeoutMs);
204
+ });
205
+ }
206
+ export async function probeProjectMcpServer(options) {
207
+ const name = options.name;
208
+ const timeoutMs = options.timeoutMs ?? PROJECT_MCP_PROBE_TIMEOUT_MS;
209
+ const env = options.env ?? process.env;
210
+ if (options.signal?.aborted) {
211
+ return { name, status: "cancelled" };
212
+ }
213
+ let snapshot;
214
+ try {
215
+ snapshot = await resolveStageMcpServers({
216
+ projectRoot: options.projectRoot,
217
+ allowlist: [name],
218
+ env,
219
+ });
220
+ }
221
+ catch (err) {
222
+ return (catalogErrorResult(name, err) ?? {
223
+ name,
224
+ status: "invalid_config",
225
+ });
226
+ }
227
+ const resolved = snapshot[name];
228
+ if (resolved === undefined) {
229
+ return { name, status: "invalid_config" };
230
+ }
231
+ const secrets = collectInterpolatedSecrets(resolved);
232
+ let attached;
233
+ let shutdownFactories;
234
+ const dispose = async () => {
235
+ if (attached?.connecting !== undefined) {
236
+ void attached.connecting.then(() => undefined, () => undefined);
237
+ }
238
+ attached?.cancel?.();
239
+ if (shutdownFactories !== undefined) {
240
+ const stop = shutdownFactories;
241
+ shutdownFactories = undefined;
242
+ await stop();
243
+ }
244
+ };
245
+ try {
246
+ if (options.signal?.aborted) {
247
+ return { name, status: "cancelled" };
248
+ }
249
+ attached = await piIsolatedMcp.attachIsolatedMcp(snapshot, {
250
+ timeoutMs,
251
+ lifecycle: "eager",
252
+ });
253
+ if (attached.eventBus === undefined) {
254
+ return { name, status: "invalid_config" };
255
+ }
256
+ shutdownFactories = instantiateProbeFactories(attached, options.projectRoot);
257
+ const waited = await waitForProbeStatus(attached.eventBus, name, attached.connecting, { timeoutMs, signal: options.signal });
258
+ if (waited.kind === "cancelled") {
259
+ return { name, status: "cancelled" };
260
+ }
261
+ if (waited.status === "connect_failed") {
262
+ return {
263
+ name,
264
+ status: "connect_failed",
265
+ ...(waited.error !== undefined
266
+ ? { error: scrub(waited.error, secrets) }
267
+ : {}),
268
+ };
269
+ }
270
+ return { name, status: waited.status };
271
+ }
272
+ catch (err) {
273
+ if (options.signal?.aborted) {
274
+ return { name, status: "cancelled" };
275
+ }
276
+ const catalog = catalogErrorResult(name, err);
277
+ if (catalog)
278
+ return catalog;
279
+ const message = err instanceof Error ? err.message : String(err);
280
+ if (/cancelled/i.test(message)) {
281
+ return { name, status: "cancelled" };
282
+ }
283
+ return {
284
+ name,
285
+ status: "connect_failed",
286
+ error: scrub(message, secrets),
287
+ };
288
+ }
289
+ finally {
290
+ await dispose();
291
+ }
292
+ }
@@ -5,6 +5,7 @@ import type { StageConfig } from "../types/stage.js";
5
5
  import type { FeedbackLoopConfig } from "../types/pipeline.js";
6
6
  import type { TaskFile } from "../types/task.js";
7
7
  import type { StageActivityEvent } from "./activity.js";
8
+ import type { ResolvedMcpServers } from "../config/resolveStageMcpServers.js";
8
9
  import type { StageRoots } from "../runtime/stageRoots.js";
9
10
  import type { QaExchange } from "../hitl/qaTrail.js";
10
11
  /** Opaque to runtime; adapters interpret. */
@@ -52,6 +53,7 @@ export type StageRunInput = {
52
53
  priorEnvelopes?: StageEnvelope[];
53
54
  priorEnvelopesByStage?: Record<string, TerminalEnvelope | TerminalEnvelope[]>;
54
55
  timeoutMs?: number;
56
+ resolvedMcpServers?: ResolvedMcpServers;
55
57
  resumeToken?: StageResumeToken;
56
58
  /** Launch/session mode; omitted is treated as `"fresh"`. */
57
59
  sessionMode?: StageSessionMode;
@@ -46,4 +46,4 @@ export function createCompletedOnlyStageHandle(options) {
46
46
  },
47
47
  };
48
48
  }
49
- export const DEFAULT_STAGE_TIMEOUT_MS = 15 * 60 * 1000;
49
+ export const DEFAULT_STAGE_TIMEOUT_MS = 60 * 60 * 1000;
@@ -100,6 +100,7 @@ async function loadPipelineFromPath(pipelinePath) {
100
100
  const stage = {
101
101
  ...stageOutcome.value,
102
102
  ...(entry.skill !== undefined ? { skill: entry.skill } : {}),
103
+ ...(entry.mcp !== undefined ? { mcp: entry.mcp } : {}),
103
104
  };
104
105
  stages.push(stage);
105
106
  stageSources[stageId] = { kind: "file", path: entry.body.absolutePath };
@@ -1,5 +1,6 @@
1
1
  import { type StageConfig } from "../types/stage.js";
2
2
  import { type LoadOutcome } from "./loadOutcome.js";
3
+ export declare function parseStageMcp(raw: unknown, label: string, stageId?: string): LoadOutcome<string[] | undefined>;
3
4
  export declare function loadStageFromObjectOutcome(raw: Record<string, unknown>, ctx: {
4
5
  entryId: string;
5
6
  declaringPath: string;
@@ -1,4 +1,5 @@
1
1
  import { parseAgentField } from "../agent/agentBackend.js";
2
+ import { STAGEFLOW_MCP_SERVER_NAME } from "../agent/claudeTools.js";
2
3
  import { CLONE_ACTIONS } from "../types/forkChoice.js";
3
4
  import { STAGE_GATE_KINDS, } from "../types/stage.js";
4
5
  import { compilePayloadSchema } from "../envelope/payloadSchema.js";
@@ -91,6 +92,68 @@ function parseGateKinds(raw, label) {
91
92
  }
92
93
  return loadSuccess(kinds);
93
94
  }
95
+ export function parseStageMcp(raw, label, stageId) {
96
+ if (raw === undefined)
97
+ return loadSuccess(undefined);
98
+ if (!Array.isArray(raw)) {
99
+ return loadFailure([
100
+ {
101
+ code: "stage.invalid_mcp",
102
+ message: `Invalid stage ${label}: mcp must be an array of server names`,
103
+ category: "stage",
104
+ stageId,
105
+ },
106
+ ]);
107
+ }
108
+ const names = [];
109
+ const seen = new Set();
110
+ for (const item of raw) {
111
+ if (typeof item !== "string") {
112
+ return loadFailure([
113
+ {
114
+ code: "stage.invalid_mcp",
115
+ message: `Invalid stage ${label}: mcp entries must be strings`,
116
+ category: "stage",
117
+ stageId,
118
+ },
119
+ ]);
120
+ }
121
+ const name = item.trim();
122
+ if (name === "") {
123
+ return loadFailure([
124
+ {
125
+ code: "stage.invalid_mcp",
126
+ message: `Invalid stage ${label}: mcp entries must be non-empty strings`,
127
+ category: "stage",
128
+ stageId,
129
+ },
130
+ ]);
131
+ }
132
+ if (name === STAGEFLOW_MCP_SERVER_NAME) {
133
+ return loadFailure([
134
+ {
135
+ code: "stage.invalid_mcp",
136
+ message: `Invalid stage ${label}: mcp must not include reserved name "${STAGEFLOW_MCP_SERVER_NAME}"`,
137
+ category: "stage",
138
+ stageId,
139
+ },
140
+ ]);
141
+ }
142
+ if (seen.has(name)) {
143
+ return loadFailure([
144
+ {
145
+ code: "stage.invalid_mcp",
146
+ message: `Invalid stage ${label}: mcp contains duplicate name "${name}"`,
147
+ category: "stage",
148
+ stageId,
149
+ },
150
+ ]);
151
+ }
152
+ seen.add(name);
153
+ names.push(name);
154
+ }
155
+ return loadSuccess(names);
156
+ }
94
157
  function parseStageFields(raw, label, entryId) {
95
158
  if (typeof raw.system_prompt !== "string" ||
96
159
  typeof raw.model !== "string") {
@@ -223,6 +286,12 @@ function parseStageFields(raw, label, entryId) {
223
286
  }
224
287
  stage.skill = raw.skill.trim();
225
288
  }
289
+ const mcpOutcome = parseStageMcp(raw.mcp, label, entryId);
290
+ if (!mcpOutcome.ok)
291
+ return mcpOutcome;
292
+ if (mcpOutcome.value !== undefined) {
293
+ stage.mcp = mcpOutcome.value;
294
+ }
226
295
  const agentField = parseAgentField(raw.agent);
227
296
  if (!agentField.ok) {
228
297
  return loadFailure([
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { loadFailure, loadSuccess } from "./loadOutcome.js";
3
+ import { parseStageMcp } from "./loadStage.js";
3
4
  import { BODY_KEYS, isAllowedPipelineStageEntryKey, } from "./pipelineStageKeys.js";
4
5
  import { parseExecutionPolicy } from "./parseCompletionContract.js";
5
6
  import { parsePipelineNeeds } from "./pipelineNeeds.js";
@@ -31,7 +32,7 @@ function extractBodyRaw(raw) {
31
32
  return body;
32
33
  }
33
34
  function hasBodyKey(raw) {
34
- return Object.keys(raw).some((key) => BODY_KEYS.has(key) && key !== "skill");
35
+ return Object.keys(raw).some((key) => BODY_KEYS.has(key) && key !== "skill" && key !== "mcp");
35
36
  }
36
37
  function readSkill(raw) {
37
38
  if (raw.skill === undefined)
@@ -96,6 +97,10 @@ export function normalizePipelineStageEntries(rawEntries, ctx) {
96
97
  return skillOutcome;
97
98
  }
98
99
  const skill = skillOutcome?.ok ? skillOutcome.value : undefined;
100
+ const mcpOutcome = parseStageMcp(raw.mcp, `entry at index ${index} in ${declaringPath}`);
101
+ if (!mcpOutcome.ok)
102
+ return mcpOutcome;
103
+ const mcp = mcpOutcome.value;
99
104
  if (uses && hasBody) {
100
105
  return loadFailure([
101
106
  {
@@ -220,6 +225,7 @@ export function normalizePipelineStageEntries(rawEntries, ctx) {
220
225
  ? { replay_safe: raw.replay_safe }
221
226
  : {}),
222
227
  ...(skill !== undefined ? { skill } : {}),
228
+ ...(mcp !== undefined ? { mcp } : {}),
223
229
  };
224
230
  const priorPath = normalized.find((e) => e.id === id)?.declaringPath;
225
231
  if (priorPath) {
@@ -20,6 +20,7 @@ export const BODY_KEYS = new Set([
20
20
  "clone_actions",
21
21
  "timeout_ms",
22
22
  "skill",
23
+ "mcp",
23
24
  ]);
24
25
  export function isPipelineStageBodyKey(key) {
25
26
  return BODY_KEYS.has(key);
@@ -0,0 +1,35 @@
1
+ export type StageMcpErrorCode = "missing_catalog" | "unknown_server" | "reserved_name" | "unresolved_var" | "invalid_config" | "connect_failed";
2
+ export declare class StageMcpError extends Error {
3
+ readonly code: StageMcpErrorCode;
4
+ constructor(message: string, code: StageMcpErrorCode);
5
+ }
6
+ export type ResolvedMcpServerConfig = Record<string, unknown>;
7
+ export type ResolvedMcpServers = Record<string, ResolvedMcpServerConfig>;
8
+ export declare const MCP_CATALOG_FILENAME = ".mcp.json";
9
+ export declare const STAGEFLOW_STAGE_ARTIFACTS_DIR_ENV = "STAGEFLOW_STAGE_ARTIFACTS_DIR";
10
+ export declare function stampStagePromptArtifactsDir(prompt: string, artifactsDir: string): string;
11
+ export type McpCatalogServers = Record<string, Record<string, unknown>>;
12
+ export type InspectedMcpCatalog = {
13
+ path: string;
14
+ servers: McpCatalogServers;
15
+ };
16
+ export declare function mcpCatalogPath(projectRoot: string): string;
17
+ export declare function parseMcpCatalog(raw: string, catalogPath?: string): McpCatalogServers;
18
+ export declare function loadMcpCatalog(projectRoot: string): Promise<InspectedMcpCatalog>;
19
+ export type ProjectMcpCatalogTransport = "stdio" | "http";
20
+ export type ProjectMcpCatalogEntry = {
21
+ name: string;
22
+ transport: ProjectMcpCatalogTransport;
23
+ };
24
+ export type ProjectMcpCatalogListStatus = "ok" | "missing_catalog" | "invalid_config";
25
+ export type ProjectMcpCatalogList = {
26
+ status: ProjectMcpCatalogListStatus;
27
+ servers: ProjectMcpCatalogEntry[];
28
+ };
29
+ export declare function listProjectMcpCatalog(projectRoot: string): Promise<ProjectMcpCatalogList>;
30
+ export declare function assertMcpAllowlistKnown(servers: McpCatalogServers, allowlist: readonly string[]): void;
31
+ export declare function resolveStageMcpServers(options: {
32
+ projectRoot: string;
33
+ allowlist?: readonly string[];
34
+ env: NodeJS.ProcessEnv;
35
+ }): Promise<ResolvedMcpServers>;