stageflow 0.3.0 → 0.9.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 (164) hide show
  1. package/README.md +44 -14
  2. package/dist/agent/activity.d.ts +2 -0
  3. package/dist/agent/cursorProvider.d.ts +1 -10
  4. package/dist/agent/cursorProvider.js +18 -10
  5. package/dist/agent/fakeAgent.js +11 -7
  6. package/dist/agent/piAdapter.d.ts +2 -1
  7. package/dist/agent/piAdapter.js +28 -25
  8. package/dist/agent/port.d.ts +6 -1
  9. package/dist/agent/port.js +3 -0
  10. package/dist/cli/artifactCommand.d.ts +10 -0
  11. package/dist/cli/artifactCommand.js +120 -0
  12. package/dist/cli/envelopeCommand.d.ts +32 -0
  13. package/dist/cli/envelopeCommand.js +285 -0
  14. package/dist/cli/exportRunCommand.d.ts +10 -0
  15. package/dist/cli/exportRunCommand.js +150 -0
  16. package/dist/cli/handoffFormat.d.ts +22 -0
  17. package/dist/cli/handoffFormat.js +61 -0
  18. package/dist/cli/runCommand.d.ts +5 -1
  19. package/dist/cli/runCommand.js +36 -5
  20. package/dist/cli/runOutput.d.ts +4 -1
  21. package/dist/cli/runOutput.js +36 -8
  22. package/dist/cli/runsCommand.d.ts +22 -0
  23. package/dist/cli/runsCommand.js +591 -0
  24. package/dist/cli/skillsCommand.d.ts +16 -0
  25. package/dist/cli/skillsCommand.js +459 -0
  26. package/dist/cli/validateOutput.d.ts +2 -2
  27. package/dist/cli/validateOutput.js +2 -2
  28. package/dist/cli.js +110 -5
  29. package/dist/config/loadPipeline.js +5 -1
  30. package/dist/config/normalizePipelineStageEntry.d.ts +2 -0
  31. package/dist/config/normalizePipelineStageEntry.js +25 -1
  32. package/dist/config/pipelineStageKeys.js +1 -1
  33. package/dist/config/resolveForkEmitContext.d.ts +2 -1
  34. package/dist/config/resolveForkEmitContext.js +23 -1
  35. package/dist/config/resolvePipelineDag.js +36 -4
  36. package/dist/envelope/check.d.ts +2 -0
  37. package/dist/envelope/check.js +51 -0
  38. package/dist/envelope/cloneForks.d.ts +2 -0
  39. package/dist/envelope/cloneForks.js +62 -0
  40. package/dist/envelope/forkChoice.js +1 -1
  41. package/dist/index.d.ts +3 -1
  42. package/dist/index.js +3 -1
  43. package/dist/mcp/catalogTools.d.ts +3 -0
  44. package/dist/mcp/catalogTools.js +187 -0
  45. package/dist/mcp/controlTools.d.ts +3 -0
  46. package/dist/mcp/controlTools.js +147 -0
  47. package/dist/mcp/deps.d.ts +11 -0
  48. package/dist/mcp/deps.js +1 -0
  49. package/dist/mcp/projectRun.d.ts +1 -40
  50. package/dist/mcp/projectRun.js +1 -64
  51. package/dist/mcp/resources.d.ts +5 -0
  52. package/dist/mcp/resources.js +45 -0
  53. package/dist/mcp/server.d.ts +16 -3
  54. package/dist/mcp/server.js +159 -3
  55. package/dist/mcp/toolResults.d.ts +7 -0
  56. package/dist/mcp/toolResults.js +6 -0
  57. package/dist/mcp/tools.d.ts +2 -7
  58. package/dist/mcp/tools.js +52 -103
  59. package/dist/mcp/waitRun.d.ts +46 -0
  60. package/dist/mcp/waitRun.js +146 -0
  61. package/dist/mcp/waitingGates.d.ts +12 -0
  62. package/dist/mcp/waitingGates.js +83 -0
  63. package/dist/package-meta.d.ts +1 -0
  64. package/dist/package-meta.js +1 -0
  65. package/dist/projection/projectRun.d.ts +44 -0
  66. package/dist/projection/projectRun.js +71 -0
  67. package/dist/prompt/priorEnvelope.d.ts +1 -1
  68. package/dist/prompt/priorEnvelope.js +4 -1
  69. package/dist/runstore/paths.js +2 -0
  70. package/dist/runstore/pipelineDagSnapshot.d.ts +9 -0
  71. package/dist/runstore/pipelineDagSnapshot.js +90 -1
  72. package/dist/runstore/port.d.ts +13 -2
  73. package/dist/runstore/port.js +4 -1
  74. package/dist/runstore/runProjection.d.ts +1 -1
  75. package/dist/runstore/runProjection.js +14 -3
  76. package/dist/runstore/sqlite/SqliteRunStore.d.ts +3 -2
  77. package/dist/runstore/sqlite/SqliteRunStore.js +35 -3
  78. package/dist/runstore/stageInstanceId.d.ts +4 -0
  79. package/dist/runstore/stageInstanceId.js +23 -0
  80. package/dist/runstore/syntheticStageSnapshot.d.ts +1 -1
  81. package/dist/runstore/syntheticStageSnapshot.js +2 -1
  82. package/dist/runstore/trackProjection.js +28 -12
  83. package/dist/runstore/workspaceLayout.d.ts +1 -0
  84. package/dist/runstore/workspaceLayout.js +1 -1
  85. package/dist/runtime/cloneSchedule.d.ts +18 -0
  86. package/dist/runtime/cloneSchedule.js +233 -0
  87. package/dist/runtime/envelopeRouting.d.ts +2 -1
  88. package/dist/runtime/envelopeRouting.js +80 -13
  89. package/dist/runtime/pipelineScheduler.d.ts +6 -2
  90. package/dist/runtime/pipelineScheduler.js +166 -30
  91. package/dist/runtime/resumeReconstruct.js +8 -3
  92. package/dist/runtime/runChangeBus.d.ts +18 -0
  93. package/dist/runtime/runChangeBus.js +83 -0
  94. package/dist/runtime/runManager.d.ts +7 -0
  95. package/dist/runtime/runManager.js +31 -2
  96. package/dist/runtime/runRetryCoordinator.d.ts +2 -0
  97. package/dist/runtime/runRetryCoordinator.js +4 -1
  98. package/dist/runtime/stageAttemptBootstrap.d.ts +1 -0
  99. package/dist/runtime/stageAttemptBootstrap.js +17 -9
  100. package/dist/runtime/stageRunner.d.ts +1 -0
  101. package/dist/runtime/stageRunner.js +17 -15
  102. package/dist/runtime/stageWorker.js +11 -4
  103. package/dist/server/bootstrap.d.ts +32 -0
  104. package/dist/server/bootstrap.js +56 -0
  105. package/dist/server/createHttpHost.d.ts +34 -0
  106. package/dist/server/createHttpHost.js +70 -0
  107. package/dist/server/http.d.ts +6 -13
  108. package/dist/server/http.js +295 -374
  109. package/dist/server/mcpHost.d.ts +22 -0
  110. package/dist/server/mcpHost.js +21 -0
  111. package/dist/server/operatorResults.d.ts +13 -0
  112. package/dist/server/operatorResults.js +22 -0
  113. package/dist/tools/emitStageEnvelope.d.ts +11 -2
  114. package/dist/tools/emitStageEnvelope.js +22 -6
  115. package/dist/types/envelope.d.ts +2 -0
  116. package/dist/types/forkChoice.d.ts +22 -0
  117. package/dist/types/pipeline.d.ts +8 -0
  118. package/dist/ui/assets/{index-DefBlEvN.css → index-C3N7MXAC.css} +1 -1
  119. package/dist/ui/assets/index-CSoGDA3A.js +118 -0
  120. package/dist/ui/index.html +2 -2
  121. package/package.json +2 -1
  122. package/skills/install-suite.sh +107 -0
  123. package/skills/stageflow/SKILL.md +26 -0
  124. package/skills/stageflow/references/control-surface.md +28 -0
  125. package/skills/stageflow/scripts/detect-host.mjs +54 -0
  126. package/skills/stageflow/scripts/detect-host.test.mjs +97 -0
  127. package/skills/stageflow-author/SKILL.md +58 -0
  128. package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +10 -0
  129. package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +12 -0
  130. package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +13 -0
  131. package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +10 -0
  132. package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +9 -0
  133. package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +10 -0
  134. package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +10 -0
  135. package/skills/stageflow-author/assets/examples/linear-review/review.yaml +15 -0
  136. package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +9 -0
  137. package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +10 -0
  138. package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +10 -0
  139. package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +9 -0
  140. package/skills/stageflow-author/references/catalog-mapping.md +131 -0
  141. package/skills/stageflow-author/references/catalog-write-conventions.md +29 -0
  142. package/skills/stageflow-author/references/stage-prompt-template.md +55 -0
  143. package/skills/stageflow-author/references/validate-and-report.md +38 -0
  144. package/skills/stageflow-delegate/SKILL.md +18 -0
  145. package/skills/stageflow-delegate/references/authoring-or-run.md +15 -0
  146. package/skills/stageflow-delegate/references/example-walkthrough.md +30 -0
  147. package/skills/stageflow-delegate/references/pattern-detection.md +44 -0
  148. package/skills/stageflow-run/SKILL.md +182 -0
  149. package/skills/stageflow-run/references/mcp-call.md +17 -0
  150. package/skills/stageflow-run/references/native-question-ui.md +78 -0
  151. package/skills/stageflow-run/references/task-and-pipeline-selection.md +52 -0
  152. package/skills/stageflow-run/scripts/mcp-call.mjs +299 -0
  153. package/skills/stageflow-run/scripts/mcp-call.test.mjs +271 -0
  154. package/skills/stageflow-session-capture/SKILL.md +94 -0
  155. package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +7 -0
  156. package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +8 -0
  157. package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +8 -0
  158. package/skills/stageflow-session-capture/references/catalog-authoring.md +40 -0
  159. package/skills/stageflow-session-capture/references/transcript-sources.md +25 -0
  160. package/skills/stageflow-session-capture/scripts/check-provider-gate.mjs +43 -0
  161. package/skills/stageflow-session-capture/scripts/locate-session-transcript.mjs +141 -0
  162. package/skills/stageflow-session-capture/scripts/resolve-catalog-id.mjs +119 -0
  163. package/skills/stageflow-setup/SKILL.md +110 -0
  164. package/dist/ui/assets/index-CFSzDZje.js +0 -118
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+
3
+ const DEFAULT_BASE_URL = "http://127.0.0.1:3847";
4
+ const ALLOWED_TOOLS = new Set([
5
+ "list_pipelines",
6
+ "list_tasks",
7
+ "start_run",
8
+ "get_run",
9
+ "wait_run",
10
+ "list_waiting",
11
+ "answer_gate",
12
+ "get_health",
13
+ ]);
14
+ const PROTOCOL_VERSION = "2025-03-26";
15
+ const INIT_TIMEOUT_MS = 10_000;
16
+ const DEFAULT_TOOL_TIMEOUT_MS = 30_000;
17
+ const WAIT_RUN_DEFAULT_MS = 60_000;
18
+ const WAIT_RUN_BUFFER_MS = 5_000;
19
+ const WAIT_RUN_CAP_MS = 250_000;
20
+
21
+ function usage() {
22
+ return "Usage: mcp-call.mjs --base-url URL --tool NAME --args JSON [--stateless]";
23
+ }
24
+
25
+ function parseArgs(argv) {
26
+ let baseUrl = DEFAULT_BASE_URL;
27
+ let tool;
28
+ let argsText = "{}";
29
+ let stateless = false;
30
+ for (let i = 0; i < argv.length; i += 1) {
31
+ const arg = argv[i];
32
+ if (arg === "-h" || arg === "--help") {
33
+ console.log(usage());
34
+ process.exit(0);
35
+ }
36
+ if (arg === "--stateless") {
37
+ stateless = true;
38
+ continue;
39
+ }
40
+ if (arg === "--base-url") {
41
+ const value = argv[i + 1];
42
+ if (!value) {
43
+ console.error("mcp-call: --base-url requires a URL");
44
+ console.error(usage());
45
+ process.exit(2);
46
+ }
47
+ baseUrl = value.replace(/\/$/, "");
48
+ i += 1;
49
+ continue;
50
+ }
51
+ if (arg === "--tool") {
52
+ const value = argv[i + 1];
53
+ if (!value) {
54
+ console.error("mcp-call: --tool requires a name");
55
+ console.error(usage());
56
+ process.exit(2);
57
+ }
58
+ tool = value;
59
+ i += 1;
60
+ continue;
61
+ }
62
+ if (arg === "--args") {
63
+ const value = argv[i + 1];
64
+ if (value === undefined) {
65
+ console.error("mcp-call: --args requires JSON");
66
+ console.error(usage());
67
+ process.exit(2);
68
+ }
69
+ argsText = value;
70
+ i += 1;
71
+ continue;
72
+ }
73
+ console.error(`mcp-call: unknown argument: ${arg}`);
74
+ console.error(usage());
75
+ process.exit(2);
76
+ }
77
+ return { baseUrl, tool, argsText, stateless };
78
+ }
79
+
80
+ function parseToolArgs(argsText) {
81
+ try {
82
+ const value = JSON.parse(argsText);
83
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
84
+ console.error("mcp-call: --args must be a JSON object");
85
+ process.exit(2);
86
+ }
87
+ return value;
88
+ } catch {
89
+ console.error("mcp-call: --args is not valid JSON");
90
+ process.exit(2);
91
+ }
92
+ }
93
+
94
+ function toolTimeoutMs(tool, toolArgs) {
95
+ if (tool !== "wait_run") return DEFAULT_TOOL_TIMEOUT_MS;
96
+ const budget =
97
+ typeof toolArgs.timeout_ms === "number" && toolArgs.timeout_ms > 0
98
+ ? toolArgs.timeout_ms
99
+ : WAIT_RUN_DEFAULT_MS;
100
+ return Math.min(budget + WAIT_RUN_BUFFER_MS, WAIT_RUN_CAP_MS);
101
+ }
102
+
103
+ function parseMcpMessage(text) {
104
+ const trimmed = text.trim();
105
+ if (!trimmed) return undefined;
106
+ const looksSse =
107
+ /(?:^|\n)(?:event:|data:)/.test(trimmed) || trimmed.startsWith("event:") || trimmed.startsWith("data:");
108
+ if (looksSse) {
109
+ const payloads = [];
110
+ for (const line of trimmed.split(/\r?\n/)) {
111
+ if (!line.startsWith("data:")) continue;
112
+ const raw = line.slice(line.startsWith("data: ") ? 6 : 5).trim();
113
+ if (!raw || raw === "[DONE]") continue;
114
+ try {
115
+ payloads.push(JSON.parse(raw));
116
+ } catch {}
117
+ }
118
+ return (
119
+ payloads.find((item) => item && (item.result !== undefined || item.error !== undefined)) ??
120
+ payloads.at(-1)
121
+ );
122
+ }
123
+ return JSON.parse(trimmed);
124
+ }
125
+
126
+ function extractToolPayload(message) {
127
+ if (message.error) {
128
+ return { payload: message.error, isError: true };
129
+ }
130
+ const result = message.result;
131
+ if (!result || typeof result !== "object") {
132
+ return { payload: message, isError: true };
133
+ }
134
+ const text = result.content?.[0]?.text;
135
+ let payload;
136
+ if (typeof text === "string" && text.length > 0) {
137
+ try {
138
+ payload = JSON.parse(text);
139
+ } catch {
140
+ payload = { text };
141
+ }
142
+ } else if (result.structuredContent !== undefined) {
143
+ payload = result.structuredContent;
144
+ } else {
145
+ payload = result;
146
+ }
147
+ return { payload, isError: result.isError === true };
148
+ }
149
+
150
+ async function postMcp(mcpUrl, { body, sessionId, timeoutMs }) {
151
+ const ac = new AbortController();
152
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
153
+ try {
154
+ const headers = {
155
+ "Content-Type": "application/json",
156
+ Accept: "application/json, text/event-stream",
157
+ };
158
+ if (sessionId) headers["mcp-session-id"] = sessionId;
159
+ const res = await fetch(mcpUrl, {
160
+ method: "POST",
161
+ headers,
162
+ body: JSON.stringify(body),
163
+ signal: ac.signal,
164
+ });
165
+ const text = await res.text();
166
+ return { res, text };
167
+ } finally {
168
+ clearTimeout(timer);
169
+ }
170
+ }
171
+
172
+ function failTransport(detail, extra) {
173
+ if (extra !== undefined) {
174
+ process.stdout.write(`${JSON.stringify(extra)}\n`);
175
+ }
176
+ console.error(`mcp-call: ${detail}`);
177
+ }
178
+
179
+ async function initializeSession(mcpUrl) {
180
+ const { res, text } = await postMcp(mcpUrl, {
181
+ body: {
182
+ jsonrpc: "2.0",
183
+ id: 0,
184
+ method: "initialize",
185
+ params: {
186
+ protocolVersion: PROTOCOL_VERSION,
187
+ capabilities: {},
188
+ clientInfo: { name: "stageflow-run", version: "1.0.0" },
189
+ },
190
+ },
191
+ timeoutMs: INIT_TIMEOUT_MS,
192
+ });
193
+ if (res.status < 200 || res.status >= 300) {
194
+ let extra;
195
+ try {
196
+ extra = parseMcpMessage(text) ?? { status: res.status, body: text };
197
+ } catch {
198
+ extra = { status: res.status, body: text };
199
+ }
200
+ failTransport(`initialize failed (${res.status})`, extra.error ?? extra);
201
+ throw new Error("initialize failed");
202
+ }
203
+ const sessionId = res.headers.get("mcp-session-id") ?? undefined;
204
+ try {
205
+ await postMcp(mcpUrl, {
206
+ body: { jsonrpc: "2.0", method: "notifications/initialized" },
207
+ sessionId,
208
+ timeoutMs: INIT_TIMEOUT_MS,
209
+ });
210
+ } catch {}
211
+ return sessionId;
212
+ }
213
+
214
+ async function closeSession(mcpUrl, sessionId) {
215
+ if (!sessionId) return;
216
+ const ac = new AbortController();
217
+ const timer = setTimeout(() => ac.abort(), INIT_TIMEOUT_MS);
218
+ try {
219
+ await fetch(mcpUrl, {
220
+ method: "DELETE",
221
+ headers: { "mcp-session-id": sessionId },
222
+ signal: ac.signal,
223
+ });
224
+ } catch {
225
+ } finally {
226
+ clearTimeout(timer);
227
+ }
228
+ }
229
+
230
+ function handleToolResponse(res, text) {
231
+ let message;
232
+ try {
233
+ message = parseMcpMessage(text);
234
+ } catch {
235
+ failTransport(`unreadable response (${res.status})`, { status: res.status, body: text });
236
+ return 1;
237
+ }
238
+ if (res.status < 200 || res.status >= 300) {
239
+ failTransport(`HTTP ${res.status}`, message?.error ?? message ?? { status: res.status, body: text });
240
+ return 1;
241
+ }
242
+ if (!message) {
243
+ failTransport(`empty response (${res.status})`, { status: res.status });
244
+ return 1;
245
+ }
246
+ const { payload, isError } = extractToolPayload(message);
247
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
248
+ return isError ? 1 : 0;
249
+ }
250
+
251
+ const { baseUrl, tool, argsText, stateless } = parseArgs(process.argv.slice(2));
252
+
253
+ if (!tool) {
254
+ console.error("mcp-call: --tool is required");
255
+ console.error(usage());
256
+ process.exit(2);
257
+ }
258
+
259
+ if (!ALLOWED_TOOLS.has(tool)) {
260
+ console.error(`mcp-call: unknown tool: ${tool}`);
261
+ console.error(`allowed: ${[...ALLOWED_TOOLS].join(", ")}`);
262
+ process.exit(2);
263
+ }
264
+
265
+ const toolArgs = parseToolArgs(argsText);
266
+ const mcpUrl = `${baseUrl}/mcp`;
267
+ const timeoutMs = toolTimeoutMs(tool, toolArgs);
268
+
269
+ let sessionId;
270
+ let exitCode = 1;
271
+ try {
272
+ if (!stateless) {
273
+ sessionId = await initializeSession(mcpUrl);
274
+ }
275
+ const { res, text } = await postMcp(mcpUrl, {
276
+ body: {
277
+ jsonrpc: "2.0",
278
+ id: 1,
279
+ method: "tools/call",
280
+ params: { name: tool, arguments: toolArgs },
281
+ },
282
+ sessionId,
283
+ timeoutMs,
284
+ });
285
+ exitCode = handleToolResponse(res, text);
286
+ } catch (err) {
287
+ if (err instanceof Error && err.message === "initialize failed") {
288
+ exitCode = 1;
289
+ } else {
290
+ const aborted = err && typeof err === "object" && err.name === "AbortError";
291
+ failTransport(aborted ? "request timed out" : err instanceof Error ? err.message : String(err));
292
+ exitCode = 1;
293
+ }
294
+ } finally {
295
+ if (sessionId) {
296
+ await closeSession(mcpUrl, sessionId);
297
+ }
298
+ }
299
+ process.exit(exitCode);
@@ -0,0 +1,271 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { createServer } from "node:http";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { test } from "node:test";
7
+
8
+ const script = path.join(path.dirname(fileURLToPath(import.meta.url)), "mcp-call.mjs");
9
+
10
+ function run(args, opts = {}) {
11
+ return new Promise((resolve) => {
12
+ const child = spawn(process.execPath, [script, ...args], {
13
+ stdio: ["ignore", "pipe", "pipe"],
14
+ });
15
+ let stdout = "";
16
+ let stderr = "";
17
+ child.stdout.setEncoding("utf8");
18
+ child.stderr.setEncoding("utf8");
19
+ child.stdout.on("data", (chunk) => {
20
+ stdout += chunk;
21
+ });
22
+ child.stderr.on("data", (chunk) => {
23
+ stderr += chunk;
24
+ });
25
+ const timer = setTimeout(() => {
26
+ child.kill("SIGKILL");
27
+ resolve({ status: -1, stdout, stderr: `${stderr}\ntimed out` });
28
+ }, opts.timeoutMs ?? 8000);
29
+ child.on("close", (status) => {
30
+ clearTimeout(timer);
31
+ resolve({ status, stdout, stderr });
32
+ });
33
+ });
34
+ }
35
+
36
+ function listen(handler) {
37
+ return new Promise((resolve) => {
38
+ const server = createServer(handler);
39
+ server.listen(0, "127.0.0.1", () => {
40
+ const addr = server.address();
41
+ resolve({ server, baseUrl: `http://127.0.0.1:${addr.port}` });
42
+ });
43
+ });
44
+ }
45
+
46
+ function readJsonBody(req) {
47
+ return new Promise((resolve, reject) => {
48
+ const chunks = [];
49
+ req.on("data", (chunk) => {
50
+ chunks.push(chunk);
51
+ });
52
+ req.on("end", () => {
53
+ if (chunks.length === 0) {
54
+ resolve(undefined);
55
+ return;
56
+ }
57
+ try {
58
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
59
+ } catch (err) {
60
+ reject(err);
61
+ }
62
+ });
63
+ req.on("error", reject);
64
+ });
65
+ }
66
+
67
+ function writeToolResult(res, id, payload, isError = false) {
68
+ const message = {
69
+ jsonrpc: "2.0",
70
+ id,
71
+ result: {
72
+ content: [{ type: "text", text: JSON.stringify(payload) }],
73
+ ...(isError ? { isError: true } : {}),
74
+ },
75
+ };
76
+ const data = `event: message\ndata: ${JSON.stringify(message)}\n\n`;
77
+ res.writeHead(200, { "content-type": "text/event-stream" });
78
+ res.end(data);
79
+ }
80
+
81
+ const HEALTH = {
82
+ ok: true,
83
+ activeRunIds: [],
84
+ activeCount: 0,
85
+ maxConcurrent: 3,
86
+ slotsAvailable: 3,
87
+ activeStageProcesses: 0,
88
+ maxActiveStageProcesses: null,
89
+ };
90
+
91
+ test("get_health against a mock stateless host returns health JSON, exit 0", async () => {
92
+ const { server, baseUrl } = await listen(async (req, res) => {
93
+ if (req.method !== "POST" || req.url !== "/mcp") {
94
+ res.writeHead(404);
95
+ res.end();
96
+ return;
97
+ }
98
+ const body = await readJsonBody(req);
99
+ if (body?.method !== "tools/call" || body?.params?.name !== "get_health") {
100
+ res.writeHead(400, { "content-type": "application/json" });
101
+ res.end(JSON.stringify({ error: "unexpected call" }));
102
+ return;
103
+ }
104
+ writeToolResult(res, body.id, HEALTH);
105
+ });
106
+ try {
107
+ const result = await run([
108
+ "--base-url",
109
+ baseUrl,
110
+ "--tool",
111
+ "get_health",
112
+ "--args",
113
+ "{}",
114
+ "--stateless",
115
+ ]);
116
+ assert.equal(result.status, 0);
117
+ assert.deepEqual(JSON.parse(result.stdout), HEALTH);
118
+ } finally {
119
+ server.close();
120
+ }
121
+ });
122
+
123
+ test("answer_gate with a well-formed free_text answer returns { ok: true }", async () => {
124
+ const { server, baseUrl } = await listen(async (req, res) => {
125
+ const body = await readJsonBody(req);
126
+ const args = body?.params?.arguments ?? {};
127
+ const answer = args.answer ?? {};
128
+ if (
129
+ body?.params?.name !== "answer_gate" ||
130
+ args.runId !== "run-1" ||
131
+ args.stageId !== "clarify" ||
132
+ answer.kind !== "free_text" ||
133
+ answer.text !== "payments"
134
+ ) {
135
+ writeToolResult(res, body?.id ?? 1, { error: "bad answer", status: 400 }, true);
136
+ return;
137
+ }
138
+ writeToolResult(res, body.id, { ok: true });
139
+ });
140
+ try {
141
+ const result = await run([
142
+ "--base-url",
143
+ baseUrl,
144
+ "--tool",
145
+ "answer_gate",
146
+ "--args",
147
+ JSON.stringify({
148
+ runId: "run-1",
149
+ stageId: "clarify",
150
+ answer: { promptId: "prompt-1", kind: "free_text", text: "payments" },
151
+ }),
152
+ "--stateless",
153
+ ]);
154
+ assert.equal(result.status, 0);
155
+ assert.deepEqual(JSON.parse(result.stdout), { ok: true });
156
+ } finally {
157
+ server.close();
158
+ }
159
+ });
160
+
161
+ test("wait_run timeout_ms returns reason timeout with exit 0", async () => {
162
+ const { server, baseUrl } = await listen(async (req, res) => {
163
+ const body = await readJsonBody(req);
164
+ writeToolResult(res, body.id, {
165
+ reason: "timeout",
166
+ elapsed_ms: 1,
167
+ until: "any",
168
+ run: { id: "run-1", status: "running" },
169
+ });
170
+ });
171
+ try {
172
+ const result = await run([
173
+ "--base-url",
174
+ baseUrl,
175
+ "--tool",
176
+ "wait_run",
177
+ "--args",
178
+ JSON.stringify({ runId: "run-1", timeout_ms: 1, until: "any" }),
179
+ "--stateless",
180
+ ]);
181
+ assert.equal(result.status, 0);
182
+ assert.equal(JSON.parse(result.stdout).reason, "timeout");
183
+ } finally {
184
+ server.close();
185
+ }
186
+ });
187
+
188
+ test("malformed JSON args fail before any network call", async () => {
189
+ let hits = 0;
190
+ const { server, baseUrl } = await listen((_req, res) => {
191
+ hits += 1;
192
+ res.writeHead(204);
193
+ res.end();
194
+ });
195
+ try {
196
+ const result = await run([
197
+ "--base-url",
198
+ baseUrl,
199
+ "--tool",
200
+ "get_health",
201
+ "--args",
202
+ "{not-json",
203
+ "--stateless",
204
+ ]);
205
+ assert.equal(result.status, 2);
206
+ assert.match(result.stderr, /json|parse|args/i);
207
+ assert.equal(hits, 0);
208
+ } finally {
209
+ server.close();
210
+ }
211
+ });
212
+
213
+ test("isError true exits non-zero and prints the payload", async () => {
214
+ const payload = { error: "stage not waiting", status: 409 };
215
+ const { server, baseUrl } = await listen(async (req, res) => {
216
+ const body = await readJsonBody(req);
217
+ writeToolResult(res, body.id, payload, true);
218
+ });
219
+ try {
220
+ const result = await run([
221
+ "--base-url",
222
+ baseUrl,
223
+ "--tool",
224
+ "answer_gate",
225
+ "--args",
226
+ JSON.stringify({
227
+ runId: "run-1",
228
+ stageId: "clarify",
229
+ answer: { promptId: "p", kind: "free_text", text: "x" },
230
+ }),
231
+ "--stateless",
232
+ ]);
233
+ assert.notEqual(result.status, 0);
234
+ assert.notEqual(result.status, 2);
235
+ assert.deepEqual(JSON.parse(result.stdout), payload);
236
+ } finally {
237
+ server.close();
238
+ }
239
+ });
240
+
241
+ test("--stateless against a session-mode-only mock errors without hanging", async () => {
242
+ const { server, baseUrl } = await listen((_req, res) => {
243
+ res.writeHead(400, { "content-type": "application/json" });
244
+ res.end(
245
+ JSON.stringify({
246
+ jsonrpc: "2.0",
247
+ error: { code: -32000, message: "Bad Request: Session ID required" },
248
+ id: null,
249
+ }),
250
+ );
251
+ });
252
+ try {
253
+ const result = await run(
254
+ [
255
+ "--base-url",
256
+ baseUrl,
257
+ "--tool",
258
+ "get_health",
259
+ "--args",
260
+ "{}",
261
+ "--stateless",
262
+ ],
263
+ { timeoutMs: 4000 },
264
+ );
265
+ assert.notEqual(result.status, 0);
266
+ assert.notEqual(result.status, -1);
267
+ assert.match(`${result.stdout}\n${result.stderr}`, /session|required|400/i);
268
+ } finally {
269
+ server.close();
270
+ }
271
+ });
@@ -0,0 +1,94 @@
1
+ ---
2
+ name: stageflow-session-capture
3
+ description: >-
4
+ Turns a coding session or pasted transcript into reusable Stageflow stages
5
+ and a pipeline in the project catalog. Use when the user wants to capture
6
+ this chat or a past session so the same loop can be rerun later.
7
+ compatibility: Requires Node.js >= 20 and the sf CLI on PATH
8
+ disable-model-invocation: true
9
+ ---
10
+
11
+ # Stageflow session capture
12
+
13
+ Extract what happened and what worked from the current session, an explicit file, or a pasted transcript. Write stages and a pipeline into the catalog. `stageflow-run` owns throwaway tasks and starting a run.
14
+
15
+ This job talks to Stageflow through the `sf` CLI. Talking jobs cite [`../stageflow/references/control-surface.md`](../stageflow/references/control-surface.md) for MCP-vs-CLI; do not run the host probe in that file from this job.
16
+
17
+ Shape reference: [`assets/example-pipeline/example.pipeline.yaml`](assets/example-pipeline/example.pipeline.yaml), [`assets/example-pipeline/research.yaml`](assets/example-pipeline/research.yaml), [`assets/example-pipeline/implement.yaml`](assets/example-pipeline/implement.yaml).
18
+
19
+ ## Preconditions
20
+
21
+ **Done when** `sf` is on PATH and `stageflow.yaml` exists at the catalog root (git top-level when `git rev-parse --show-toplevel` succeeds, otherwise the current directory).
22
+
23
+ If either is missing, stop and name `stageflow-setup`. Do not scaffold the manifest or install the CLI here.
24
+
25
+ ## Session source
26
+
27
+ Pick one mode. **Done when** you have the history you will extract from.
28
+
29
+ 1. **Live context (default).** The work is already in this conversation. Draft from context. Do not read a transcript file.
30
+ 2. **Explicit pointer.** The human passed `--path`, a file path, or pasted text. Use that directly. For a path, run:
31
+
32
+ `node scripts/locate-session-transcript.mjs --path <file>`
33
+
34
+ `{"ok":true,"source":"explicit"}` → read that path. `{"ok":false,"reason":"unreadable"}` → ask for another path or a paste.
35
+ 3. **Past session, no pointer.** Run one lookup:
36
+
37
+ `node scripts/locate-session-transcript.mjs`
38
+
39
+ `{"ok":true}` → show `path` to the human, then extract from that file. `{"ok":false,"reason":"not_found"}` → ask the human to point at a file or paste the history.
40
+
41
+ Stores, encoding, and the point-or-paste fallback: [`references/transcript-sources.md`](references/transcript-sources.md). Do not open Cursor or OpenCode SQLite databases.
42
+
43
+ ## Provider gate
44
+
45
+ Run before any catalog write:
46
+
47
+ ```
48
+ node scripts/check-provider-gate.mjs
49
+ ```
50
+
51
+ `--sf-bin <path>` defaults to `sf`. Stdout is JSON.
52
+
53
+ | stdout | next |
54
+ |---|---|
55
+ | `{"ok":true}` | continue |
56
+ | `{"ok":false,"reason":"no_provider_configured"}` | stop; name `stageflow-setup` |
57
+ | `{"ok":false,"reason":"sf_not_found"}` | stop; name `stageflow-setup` |
58
+
59
+ Do not run `sf providers login`.
60
+
61
+ ## Extract
62
+
63
+ From the chosen source, list the phases that happened and what worked in each. Drop one-off paths, secrets, and chat noise. Keep a human-review checkpoint as prose in that stage's approach, not as a HITL gate.
64
+
65
+ **Done when** you have an ordered phase list and a one-line pipeline purpose.
66
+
67
+ ## Author
68
+
69
+ Read [`references/catalog-authoring.md`](references/catalog-authoring.md) before writing.
70
+
71
+ 1. Open `stageflow.yaml`. Write under a `catalog.pipelines` root (a new subdirectory is fine). If the manifest is missing or unreadable, stop and name `stageflow-setup`.
72
+ 2. For the pipeline and each stage, resolve an id:
73
+
74
+ `node scripts/resolve-catalog-id.mjs --text "<phrase>" --dir <write-dir> --kind pipeline|stage`
75
+
76
+ Use the printed `{"id":"..."}`.
77
+ 3. Write one external stage YAML per phase (`id`, `system_prompt`, `model`). End every `system_prompt` with an `emit_stage_envelope` footer. Omit `gate_kinds`. Filename stem must match `id`.
78
+ 4. Write one pipeline YAML that wires the stages with `uses:` and `needs:` in dependency order.
79
+
80
+ Do not write a `*.task.yaml`.
81
+
82
+ **Done when** the pipeline file and every stage file exist on disk.
83
+
84
+ ## Validate
85
+
86
+ ```
87
+ sf validate --pipeline <pipeline-path> --strict
88
+ ```
89
+
90
+ **Done when** the command exits 0. If it fails, delete every file this invocation wrote, report the finding, and stop.
91
+
92
+ ## Report
93
+
94
+ Print the pipeline path and each stage path. Name `stageflow-run` if the human wants to execute the pipeline.
@@ -0,0 +1,7 @@
1
+ id: example
2
+ stages:
3
+ - id: research
4
+ uses: ./research.yaml
5
+ - id: implement
6
+ uses: ./implement.yaml
7
+ needs: research
@@ -0,0 +1,8 @@
1
+ id: implement
2
+ system_prompt: |
3
+ Take the prior stage envelope and produce the concrete deliverable described
4
+ there. Prefer what already worked in the source session.
5
+
6
+ When finished, call emit_stage_envelope once with status, summary, artifacts,
7
+ and a payload describing what you produced.
8
+ model: anthropic/claude-sonnet-4-5
@@ -0,0 +1,8 @@
1
+ id: research
2
+ system_prompt: |
3
+ Review the incoming goal and prior notes. Identify what is known, what is
4
+ unknown, and the next useful work.
5
+
6
+ When finished, call emit_stage_envelope once with status, summary, artifacts,
7
+ and a payload the next stage can use.
8
+ model: anthropic/claude-sonnet-4-5