stageflow 0.8.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # <img src="ui/public/stageflow-icon.svg" alt="" width="44" height="44" valign="middle"> Stageflow
2
2
 
3
- CLI pipeline runtime for **configurable stages** on [Pi](https://github.com/badlogic/pi-mono), with a local operator console.
3
+ Open-source runtime for **configurable multi-stage agent workflows** with typed handoffs, DAG execution, human gates, MCP, CI, and a local operator console.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/stageflow)](https://www.npmjs.com/package/stageflow)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/stageflow)](https://www.npmjs.com/package/stageflow)
@@ -9,7 +9,7 @@ CLI pipeline runtime for **configurable stages** on [Pi](https://github.com/badl
9
9
  [![GitHub issues](https://img.shields.io/github/issues/tejasghutukade/stageflow)](https://github.com/tejasghutukade/stageflow/issues)
10
10
  [![CI](https://img.shields.io/github/actions/workflow/status/tejasghutukade/stageflow/ci.yml?branch=main)](https://github.com/tejasghutukade/stageflow/actions/workflows/ci.yml)
11
11
 
12
- Author pipeline-owned YAML in your project. Stageflow runs each stage in a fresh Pi agent session, writes a structured handoff envelope, and keeps run state under `.stageflow/`. Bins: **`sf`** and **`stageflow`**.
12
+ Author pipeline-owned YAML in your project. Stageflow schedules each stage in a fresh agent session, moves context through explicit envelopes and artifacts, and persists run state under `.stageflow/`. [Pi](https://github.com/badlogic/pi-mono) is the current agent execution backend. Bins: **`sf`** and **`stageflow`**.
13
13
 
14
14
  ## Why Stageflow?
15
15
 
@@ -21,7 +21,7 @@ The same pipeline runs three ways without rewriting anything:
21
21
 
22
22
  - **Locally** — `sf ui` for triage, provider setup, and gate replies
23
23
  - **Headless / CI** — `sf validate` and `sf run --json` with predictable exit codes
24
- - **Via MCP** — Streamable HTTP tools when the console is running
24
+ - **Via MCP** — Streamable HTTP tools when `sf ui` or `sf mcp` is running
25
25
 
26
26
  **Stageflow is not an SDLC tool.** Software delivery is a popular pattern in fixtures and dogfood flows, but stages are user-authored and domain-agnostic. If you can express a multi-step workflow in YAML, Stageflow can run it on Pi.
27
27
 
@@ -33,12 +33,20 @@ The same pipeline runs three ways without rewriting anything:
33
33
  - **Envelope handoffs** — typed stage payloads and artifacts via `write_stage_artifact` / `emit_stage_envelope`
34
34
  - **HITL gates** — operator questions in the console; CI exits `2` when a run is waiting
35
35
  - **Operator console** — triage runs, connect providers, answer gates, inspect transcripts at `http://127.0.0.1:3847`
36
- - **MCP endpoint** — Streamable HTTP at `/mcp` when `sf ui` is running
36
+ - **MCP endpoint** — Streamable HTTP at `/mcp` when `sf ui` or `sf mcp` is running
37
37
  - **CI / headless** — `sf validate --strict --json`, `sf run --json` with exit codes `0` / `1` / `2`
38
38
  - **Parallel stages** — pipeline DAG with fan-out and join (see [YAML catalog](docs/yaml-catalog.md))
39
39
  - **Clonable fan-out** — clone one successor N times at completion, then join (see [YAML catalog](docs/yaml-catalog.md#clonable-successors))
40
40
  - **SQLite run store** — `<git-root>/.stageflow/` state plus per-run workspaces under `.stageflow/runs/`
41
41
 
42
+ ## Architecture at a glance
43
+
44
+ ![Stageflow architecture: pipeline definitions and operator interfaces drive the orchestration runtime, which coordinates agent execution and persisted state](docs/img/stageflow-architecture.svg)
45
+
46
+ The scheduler owns orchestration semantics: DAG readiness, bounded parallelism, fan-out/join, conditional routing, retries, skipped branches, and resumable human gates. Agent execution sits behind `AgentPort`; Pi supplies the current coding-agent session, while Stageflow owns pipeline state, stage workspaces, handoff validation, and the interfaces used by the CLI, console, MCP, and CI.
47
+
48
+ See [Architecture](docs/architecture.md) for component boundaries, execution flow, persistence, recovery behavior, and the tradeoffs behind fresh sessions and explicit handoffs.
49
+
42
50
  ## Installation
43
51
 
44
52
  Requires **Node.js ≥ 20**.
@@ -117,7 +125,7 @@ sf providers detect
117
125
  sf providers source set pi_home # or sf_owned
118
126
  ```
119
127
 
120
- Stageflow is a thin Pi shell you do not need Pi CLI `/login` as a hard prerequisite if you configure providers via the console or `sf providers`.
128
+ Pi is Stageflow's current agent execution backend. Stageflow owns the workflow layer around it, and you do not need Pi CLI `/login` as a prerequisite when providers are configured through the console or `sf providers`.
121
129
 
122
130
  Full reference: [docs/providers.md](docs/providers.md)
123
131
 
@@ -126,12 +134,12 @@ Full reference: [docs/providers.md](docs/providers.md)
126
134
  Start the console with `sf ui` (default `http://127.0.0.1:3847`).
127
135
 
128
136
  - **Runs** — active and recent pipeline runs, capacity, and status at a glance
129
- - **Run detail** — stage timeline, transcripts, envelope payloads, and artifact paths
137
+ - **Run detail** — spatial stage map; select a stage for the gated workspace (logs, files, envelopes, HITL). Created runs show **not started** with a **Start run** action until history exists
130
138
  - **HITL reply** — answer operator gates (`ask_operator`) without leaving the browser
131
139
  - **Pipelines** — browse manifest-declared pipeline definitions
132
140
  - **Settings → Providers** — connect model providers (`pi_home` or `sf_owned` credential storage)
133
141
 
134
- *Screenshot coming soon capture after console polish lands (see `docs/img/`).*
142
+ Brand assets live under `docs/img/` (`stageflow-og.svg`, `stageflow-icon.svg`).
135
143
 
136
144
  ## Headless / CI
137
145
 
@@ -141,7 +149,7 @@ The guest actor is the CLI (`sf` / `stageflow`). `sf ui` and MCP are not require
141
149
  sf validate --strict --json
142
150
  ```
143
151
 
144
- Validate exits `0` or `1` only (no waiting / `2`). It checks pipeline and stage YAML only; it does not prove provider auth, Task, or checkout.
152
+ Validate exits `0` or `1` only (no waiting / `2`). With no flags, `sf validate` checks pipelines and tasks in the manifest (plus stages). `--pipeline` validates that pipeline and its stages only; `--task` validates that task file. It never proves provider auth or checkout paths.
145
153
 
146
154
  ```bash
147
155
  sf providers login <providerId> --api-key-env <VAR>
@@ -180,11 +188,9 @@ Runtime state lives in **`<git-root>/.stageflow/`** when inside a git repository
180
188
 
181
189
  ## MCP
182
190
 
183
- `sf ui` also serves a Streamable HTTP MCP endpoint at `http://127.0.0.1:3847/mcp` (URL printed on boot). Point a Cursor (or other) MCP client at that URL.
184
-
185
- Available tools: `list_pipelines`, `list_tasks`, `list_runs`, `get_health`, `start_run`, `get_run`, `read_artifact`.
191
+ Host MCP via `sf ui` or `sf mcp` at `http://127.0.0.1:3847/mcp` (URL printed on boot). Sessions are the default. Point a Cursor (or other) MCP client at that URL. Do not run both hosts against the same store.
186
192
 
187
- Full reference: [docs/mcp.md](docs/mcp.md)
193
+ HITL-aware tools include `wait_run`, `answer_gate`, and `list_waiting`. Full tool list: [docs/mcp.md](docs/mcp.md).
188
194
 
189
195
  ## Stageflow vs Conductor
190
196
 
@@ -196,7 +202,7 @@ Both projects address multi-step agent workflows. They differ in orchestration m
196
202
  | **Orchestration** | Pipeline DAG + stage worker | Jinja routing, no LLM in router |
197
203
  | **Unit of work** | Task → Pipeline → Stage attempts | Workflow → Agents |
198
204
  | **Handoff** | Typed **envelope** + artifacts | Agent output → context |
199
- | **Human gates** | Operator console + MCP | Dashboard + TUI fleet |
205
+ | **Human gates** | Console + MCP + harness native question UI | Dashboard + TUI fleet |
200
206
  | **Runtime** | Node.js, Pi coding agent | Python, Copilot/Claude SDKs |
201
207
  | **Best for** | Personal/team **multi-stage Pi workflows** you define (releases, research, SDLC, …) | Enterprise multi-agent workflows |
202
208
 
@@ -210,6 +216,7 @@ If you want deterministic YAML routing across many agents, look at [Conductor](h
210
216
  | [hello-world](examples/hello-world/) | Single stage, domain-neutral |
211
217
  | [plan-review](examples/plan-review/) | Multi-stage with operator gate — SDLC-style **example** |
212
218
  | [conditional-fork](examples/conditional-fork/) | Exclusive fork routing with operator branch choice |
219
+ | [clonable-fanout](examples/clonable-fanout/) | Clone one successor N times, then join |
213
220
  | [github-release](examples/github-release/) | Dogfood: draft + publish GitHub Release |
214
221
  | [ci-validate](examples/ci-validate/) | Strict validate in CI |
215
222
 
@@ -222,15 +229,17 @@ Full docs: **[tejasghutukade.github.io/stageflow](https://tejasghutukade.github.
222
229
  | Doc | Description |
223
230
  |-----|-------------|
224
231
  | [docs/README.md](docs/README.md) | Documentation index |
232
+ | [docs/architecture.md](docs/architecture.md) | Runtime components, execution flow, persistence, and design decisions |
225
233
  | [docs/quickstart.md](docs/quickstart.md) | Expanded quick start |
226
234
  | [docs/yaml-catalog.md](docs/yaml-catalog.md) | Pipelines, stages, tasks schema |
227
- | [docs/cli-reference.md](docs/cli-reference.md) | `sf run`, `sf envelope`, `sf skills`, `sf validate`, `sf ui`, `sf providers` |
235
+ | [docs/cli-reference.md](docs/cli-reference.md) | `sf init`, `sf run`, `sf validate`, `sf ui`, `sf mcp`, `sf envelope`, `sf export-run`, `sf artifact`, `sf skills`, `sf providers` |
228
236
  | [docs/envelopes.md](docs/envelopes.md) | Handoff envelope contract |
229
237
  | [docs/hitl.md](docs/hitl.md) | Gate kinds, `--skip-gates`, exit code `2` |
230
238
  | [docs/ci.md](docs/ci.md) | `--json`, env vars, GitHub Actions |
231
239
  | [docs/mcp.md](docs/mcp.md) | MCP tool reference |
232
240
  | [docs/providers.md](docs/providers.md) | Pi providers, `sf providers` |
233
241
  | [docs/operator-console.md](docs/operator-console.md) | Console IA and settings |
242
+ | [docs/skills-suite.md](docs/skills-suite.md) | Harness skills — router + jobs for Cursor, Claude Code, Codex, Pi, OpenCode |
234
243
  | [docs/compare-conductor.md](docs/compare-conductor.md) | Positioning deep dive |
235
244
 
236
245
  ## Develop from source
@@ -0,0 +1,22 @@
1
+ import type { RunStore } from "../runstore/port.js";
2
+ import { RunManager } from "../runtime/runManager.js";
3
+ export declare const RUNS_USAGE = "Usage:\n sf runs list [--status created|running|succeeded|failed] [--since <iso>] [--pipeline <id-or-path>] [--json]\n sf runs show --run <runId> [--from <sf-run.json>] [--json]\n sf runs waiting [--run <runId>] [--json]\n sf runs wait --run <runId> [--from <sf-run.json>] [--until any|waiting|terminal] [--timeout-ms <n>] [--json]\n sf runs answer --run <runId> --stage <stageId> [--answer '<json>'] [--json]\n sf runs retry --run <runId> --stage <stageId> [--json]\n sf runs abandon --run <runId> --stage <stageId> [--json]\n sf runs rerun --run <runId> [--json]";
4
+ export type RunsCommandIo = {
5
+ log: (line: string) => void;
6
+ error: (line: string) => void;
7
+ };
8
+ export type HostProbeResult = "up" | "down";
9
+ export declare function defaultProbeHost(): Promise<HostProbeResult>;
10
+ export declare function runRunsCommand(args: string[], options?: {
11
+ cwd?: string;
12
+ projectRoot?: string;
13
+ isGitProject?: boolean;
14
+ io?: Partial<RunsCommandIo>;
15
+ store?: RunStore;
16
+ probeHost?: () => Promise<HostProbeResult>;
17
+ stdinIsTTY?: boolean;
18
+ readStdin?: () => string;
19
+ waitSignal?: AbortSignal;
20
+ env?: NodeJS.ProcessEnv;
21
+ createManager?: (store: RunStore) => RunManager;
22
+ }): Promise<number>;
@@ -0,0 +1,591 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { PiAgentAdapter } from "../agent/piAdapter.js";
4
+ import { projectRun } from "../projection/projectRun.js";
5
+ import { waitRun } from "../mcp/waitRun.js";
6
+ import { projectWaitingGates } from "../mcp/waitingGates.js";
7
+ import { resolveOperatorCatalog } from "./operatorCatalog.js";
8
+ import { completeCliRun } from "./runCommand.js";
9
+ import { reportCliRun } from "./runOutput.js";
10
+ import { createRunStore } from "../runstore/createStore.js";
11
+ import { RunManager } from "../runtime/runManager.js";
12
+ import { readStageExecutionMode } from "../runtime/stageConcurrency.js";
13
+ import { DEFAULT_PORT } from "../server/createHttpHost.js";
14
+ import { mapRetryStageFailure } from "../server/operatorResults.js";
15
+ import { AskOperatorError, parseAskOperatorAnswer, } from "../tools/askOperator.js";
16
+ export const RUNS_USAGE = `Usage:
17
+ sf runs list [--status created|running|succeeded|failed] [--since <iso>] [--pipeline <id-or-path>] [--json]
18
+ sf runs show --run <runId> [--from <sf-run.json>] [--json]
19
+ sf runs waiting [--run <runId>] [--json]
20
+ sf runs wait --run <runId> [--from <sf-run.json>] [--until any|waiting|terminal] [--timeout-ms <n>] [--json]
21
+ sf runs answer --run <runId> --stage <stageId> [--answer '<json>'] [--json]
22
+ sf runs retry --run <runId> --stage <stageId> [--json]
23
+ sf runs abandon --run <runId> --stage <stageId> [--json]
24
+ sf runs rerun --run <runId> [--json]`;
25
+ const defaultIo = {
26
+ log: (line) => console.log(line),
27
+ error: (line) => console.error(line),
28
+ };
29
+ const RUN_STATUSES = [
30
+ "created",
31
+ "running",
32
+ "succeeded",
33
+ "failed",
34
+ ];
35
+ const LIST_FLAGS = new Set([
36
+ "--status",
37
+ "--since",
38
+ "--pipeline",
39
+ "--json",
40
+ "--help",
41
+ "-h",
42
+ ]);
43
+ const SHOW_FLAGS = new Set(["--run", "--from", "--json", "--help", "-h"]);
44
+ const WAITING_FLAGS = new Set(["--run", "--json", "--help", "-h"]);
45
+ const WAIT_FLAGS = new Set([
46
+ "--run",
47
+ "--from",
48
+ "--until",
49
+ "--timeout-ms",
50
+ "--json",
51
+ "--help",
52
+ "-h",
53
+ ]);
54
+ const ANSWER_FLAGS = new Set([
55
+ "--run",
56
+ "--stage",
57
+ "--answer",
58
+ "--json",
59
+ "--help",
60
+ "-h",
61
+ ]);
62
+ const RETRY_FLAGS = new Set(["--run", "--stage", "--json", "--help", "-h"]);
63
+ const ABANDON_FLAGS = new Set(["--run", "--stage", "--json", "--help", "-h"]);
64
+ const RERUN_FLAGS = new Set(["--run", "--json", "--help", "-h"]);
65
+ const VALUE_FLAGS = new Set([
66
+ "--status",
67
+ "--since",
68
+ "--pipeline",
69
+ "--run",
70
+ "--from",
71
+ "--until",
72
+ "--timeout-ms",
73
+ "--stage",
74
+ "--answer",
75
+ ]);
76
+ function flagsFor(subcommand) {
77
+ switch (subcommand) {
78
+ case "list":
79
+ return LIST_FLAGS;
80
+ case "show":
81
+ return SHOW_FLAGS;
82
+ case "waiting":
83
+ return WAITING_FLAGS;
84
+ case "wait":
85
+ return WAIT_FLAGS;
86
+ case "answer":
87
+ return ANSWER_FLAGS;
88
+ case "retry":
89
+ return RETRY_FLAGS;
90
+ case "abandon":
91
+ return ABANDON_FLAGS;
92
+ case "rerun":
93
+ return RERUN_FLAGS;
94
+ default:
95
+ return undefined;
96
+ }
97
+ }
98
+ function parseRunsArgs(args) {
99
+ if (args.length === 0) {
100
+ return { help: false, json: false };
101
+ }
102
+ if (args[0] === "--help" || args[0] === "-h") {
103
+ return { help: true, json: false };
104
+ }
105
+ const subcommand = args[0];
106
+ const allowed = flagsFor(subcommand);
107
+ if (allowed === undefined) {
108
+ return { subcommand, help: false, json: false };
109
+ }
110
+ let help = false;
111
+ let json = false;
112
+ let status;
113
+ let since;
114
+ let pipeline;
115
+ let runId;
116
+ let fromPath;
117
+ let until;
118
+ let timeoutMs;
119
+ let stageId;
120
+ let answer;
121
+ for (let i = 1; i < args.length; i++) {
122
+ const arg = args[i];
123
+ if (arg === "--help" || arg === "-h") {
124
+ help = true;
125
+ }
126
+ else if (arg === "--json") {
127
+ json = true;
128
+ }
129
+ else if (VALUE_FLAGS.has(arg)) {
130
+ if (!allowed.has(arg)) {
131
+ throw new Error(`Unknown flag: ${arg}`);
132
+ }
133
+ const value = args[++i];
134
+ if (value === undefined || value.length === 0) {
135
+ throw new Error(`Missing value for ${arg}`);
136
+ }
137
+ if (arg === "--status")
138
+ status = value;
139
+ else if (arg === "--since")
140
+ since = value;
141
+ else if (arg === "--pipeline")
142
+ pipeline = value;
143
+ else if (arg === "--run")
144
+ runId = value;
145
+ else if (arg === "--from")
146
+ fromPath = value;
147
+ else if (arg === "--until")
148
+ until = value;
149
+ else if (arg === "--timeout-ms")
150
+ timeoutMs = Number(value);
151
+ else if (arg === "--stage")
152
+ stageId = value;
153
+ else if (arg === "--answer")
154
+ answer = value;
155
+ }
156
+ else if (arg.startsWith("-")) {
157
+ throw new Error(`Unknown flag: ${arg}`);
158
+ }
159
+ else {
160
+ throw new Error(`Unexpected argument: ${arg}`);
161
+ }
162
+ }
163
+ return {
164
+ help,
165
+ json,
166
+ subcommand,
167
+ status,
168
+ since,
169
+ pipeline,
170
+ runId,
171
+ fromPath,
172
+ until,
173
+ timeoutMs,
174
+ stageId,
175
+ answer,
176
+ };
177
+ }
178
+ function resolveRunIdFromFile(fromPath, cwd) {
179
+ const resolved = path.resolve(cwd, fromPath);
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(readFileSync(resolved, "utf8"));
183
+ }
184
+ catch (err) {
185
+ const message = err instanceof Error ? err.message : String(err);
186
+ throw new Error(`Failed to read --from file: ${message}`);
187
+ }
188
+ if (!parsed || typeof parsed !== "object") {
189
+ throw new Error("--from file must contain a JSON object with runId");
190
+ }
191
+ const runId = parsed.runId;
192
+ if (typeof runId !== "string" || runId.length === 0) {
193
+ throw new Error("--from file must contain a JSON object with runId");
194
+ }
195
+ return runId;
196
+ }
197
+ function hostBaseUrl() {
198
+ return `http://127.0.0.1:${DEFAULT_PORT}`;
199
+ }
200
+ export async function defaultProbeHost() {
201
+ const ac = new AbortController();
202
+ const timer = setTimeout(() => ac.abort(), 1500);
203
+ try {
204
+ const res = await fetch(`${hostBaseUrl()}/api/health`, {
205
+ signal: ac.signal,
206
+ });
207
+ if (res.status !== 200)
208
+ return "down";
209
+ JSON.parse(await res.text());
210
+ return "up";
211
+ }
212
+ catch {
213
+ return "down";
214
+ }
215
+ finally {
216
+ clearTimeout(timer);
217
+ }
218
+ }
219
+ function printJson(io, payload) {
220
+ io.log(JSON.stringify(payload, null, 2));
221
+ }
222
+ function isRunStatus(value) {
223
+ return RUN_STATUSES.includes(value);
224
+ }
225
+ function isWaitUntil(value) {
226
+ return value === "any" || value === "waiting" || value === "terminal";
227
+ }
228
+ function usageError(io, message) {
229
+ if (message !== undefined)
230
+ io.error(message);
231
+ io.error(RUNS_USAGE);
232
+ return 1;
233
+ }
234
+ async function resolveShowWaitRunId(parsed, cwd, io) {
235
+ if (parsed.fromPath !== undefined) {
236
+ try {
237
+ return resolveRunIdFromFile(parsed.fromPath, cwd);
238
+ }
239
+ catch (err) {
240
+ io.error(err instanceof Error ? err.message : String(err));
241
+ return undefined;
242
+ }
243
+ }
244
+ return parsed.runId;
245
+ }
246
+ export async function runRunsCommand(args, options = {}) {
247
+ const cwd = options.cwd ?? process.cwd();
248
+ const projectRoot = options.projectRoot ?? cwd;
249
+ const isGitProject = options.isGitProject ?? false;
250
+ const out = { ...defaultIo, ...options.io };
251
+ const env = options.env ?? process.env;
252
+ const stdinIsTTY = options.stdinIsTTY ?? process.stdin.isTTY === true;
253
+ const readStdin = options.readStdin ?? (() => readFileSync(0, "utf8"));
254
+ const probeHost = options.probeHost ?? defaultProbeHost;
255
+ let parsed;
256
+ try {
257
+ parsed = parseRunsArgs(args);
258
+ }
259
+ catch (err) {
260
+ const message = err instanceof Error ? err.message : String(err);
261
+ return usageError(out, message);
262
+ }
263
+ if (parsed.help) {
264
+ out.error(RUNS_USAGE);
265
+ return 0;
266
+ }
267
+ if (!parsed.subcommand) {
268
+ return usageError(out);
269
+ }
270
+ let resolvedStore = options.store;
271
+ const getStore = () => {
272
+ if (resolvedStore === undefined) {
273
+ resolvedStore = createRunStore({ rootDir: projectRoot });
274
+ }
275
+ return resolvedStore;
276
+ };
277
+ const mutatingIo = out;
278
+ const buildManager = () => {
279
+ const store = getStore();
280
+ if (options.createManager)
281
+ return options.createManager(store);
282
+ const operatorCatalog = resolveOperatorCatalog({
283
+ flags: {},
284
+ env,
285
+ defaultCwd: cwd,
286
+ });
287
+ return new RunManager({
288
+ agent: new PiAgentAdapter(),
289
+ store,
290
+ cwd,
291
+ projectRoot,
292
+ isGitProject,
293
+ operatorCatalog,
294
+ executionMode: readStageExecutionMode(env, "process"),
295
+ });
296
+ };
297
+ const guardHost = async () => {
298
+ const status = await probeHost();
299
+ if (status === "up") {
300
+ out.error(`A Stageflow host is already running at ${hostBaseUrl()}. Use the operator console or MCP instead of mutating the store from the CLI.`);
301
+ return 1;
302
+ }
303
+ return undefined;
304
+ };
305
+ switch (parsed.subcommand) {
306
+ case "list": {
307
+ if (parsed.status === "waiting") {
308
+ out.error("waiting is not a run status; parked HITL runs stay running. Use sf runs waiting.");
309
+ return 1;
310
+ }
311
+ if (parsed.status !== undefined && !isRunStatus(parsed.status)) {
312
+ out.error("--status must be created, running, succeeded, or failed");
313
+ return 1;
314
+ }
315
+ if (parsed.since !== undefined && !Number.isFinite(Date.parse(parsed.since))) {
316
+ out.error("since must be a valid date");
317
+ return 1;
318
+ }
319
+ const filter = {};
320
+ if (parsed.status !== undefined)
321
+ filter.status = parsed.status;
322
+ if (parsed.since !== undefined)
323
+ filter.since = parsed.since;
324
+ if (parsed.pipeline !== undefined)
325
+ filter.pipeline = parsed.pipeline;
326
+ const runs = await getStore().listRuns(Object.keys(filter).length > 0 ? filter : undefined);
327
+ if (parsed.json) {
328
+ printJson(out, { runs });
329
+ }
330
+ else {
331
+ for (const run of runs) {
332
+ out.log(`${run.run_id}\t${run.status}`);
333
+ }
334
+ }
335
+ return 0;
336
+ }
337
+ case "show": {
338
+ const runId = await resolveShowWaitRunId(parsed, cwd, out);
339
+ if (parsed.fromPath !== undefined && runId === undefined) {
340
+ return 1;
341
+ }
342
+ if (!runId) {
343
+ return usageError(out, "Missing --run (or --from <sf-run.json>)");
344
+ }
345
+ try {
346
+ const detail = await getStore().readRun(runId);
347
+ const projection = projectRun(detail);
348
+ if (parsed.json) {
349
+ printJson(out, projection);
350
+ }
351
+ else {
352
+ out.log(`${projection.run_id}\t${projection.status}`);
353
+ }
354
+ return 0;
355
+ }
356
+ catch (err) {
357
+ const message = err instanceof Error ? err.message : String(err);
358
+ out.error(message);
359
+ return 1;
360
+ }
361
+ }
362
+ case "waiting": {
363
+ const waiting = await projectWaitingGates(getStore(), {
364
+ runId: parsed.runId,
365
+ });
366
+ if (parsed.json) {
367
+ printJson(out, { waiting });
368
+ }
369
+ else {
370
+ for (const item of waiting) {
371
+ const runId = typeof item.runId === "string" ? item.runId : "";
372
+ const stageId = typeof item.stageId === "string" ? item.stageId : "";
373
+ out.log(`${runId}\t${stageId}`);
374
+ }
375
+ }
376
+ return 0;
377
+ }
378
+ case "wait": {
379
+ const runId = await resolveShowWaitRunId(parsed, cwd, out);
380
+ if (parsed.fromPath !== undefined && runId === undefined) {
381
+ return 1;
382
+ }
383
+ if (!runId) {
384
+ return usageError(out, "Missing --run (or --from <sf-run.json>)");
385
+ }
386
+ const untilRaw = parsed.until ?? "any";
387
+ if (!isWaitUntil(untilRaw)) {
388
+ return usageError(out, "--until must be any, waiting, or terminal");
389
+ }
390
+ let signal = options.waitSignal;
391
+ let onSigInt;
392
+ if (signal === undefined) {
393
+ const controller = new AbortController();
394
+ signal = controller.signal;
395
+ onSigInt = () => controller.abort();
396
+ process.once("SIGINT", onSigInt);
397
+ }
398
+ try {
399
+ const result = await waitRun({
400
+ store: getStore(),
401
+ runId,
402
+ timeoutMs: parsed.timeoutMs,
403
+ until: untilRaw,
404
+ signal,
405
+ });
406
+ if (!result.ok) {
407
+ if (result.code === "aborted") {
408
+ if (parsed.json) {
409
+ printJson(out, { error: result.error, code: "aborted" });
410
+ }
411
+ else {
412
+ out.error(result.error);
413
+ }
414
+ return 130;
415
+ }
416
+ if (parsed.json) {
417
+ const payload = { error: result.error };
418
+ if (result.status !== undefined)
419
+ payload.status = result.status;
420
+ printJson(out, payload);
421
+ }
422
+ else {
423
+ out.error(result.error);
424
+ }
425
+ return 1;
426
+ }
427
+ if (parsed.json) {
428
+ printJson(out, result);
429
+ }
430
+ else {
431
+ out.log(`${result.reason}\t${result.run.status}`);
432
+ }
433
+ return 0;
434
+ }
435
+ finally {
436
+ if (onSigInt !== undefined) {
437
+ process.removeListener("SIGINT", onSigInt);
438
+ }
439
+ }
440
+ }
441
+ case "answer": {
442
+ const blocked = await guardHost();
443
+ if (blocked !== undefined)
444
+ return blocked;
445
+ if (!parsed.runId) {
446
+ return usageError(out, "Missing --run");
447
+ }
448
+ if (!parsed.stageId) {
449
+ return usageError(out, "Missing --stage");
450
+ }
451
+ let answerRaw = parsed.answer;
452
+ if (answerRaw === undefined) {
453
+ if (stdinIsTTY) {
454
+ return usageError(out, "Missing --answer (pass --answer '<json>' or pipe JSON on stdin)");
455
+ }
456
+ answerRaw = readStdin().trim();
457
+ if (answerRaw.length === 0) {
458
+ return usageError(out, "Missing --answer (pass --answer '<json>' or pipe JSON on stdin)");
459
+ }
460
+ }
461
+ let parsedJson;
462
+ try {
463
+ parsedJson = JSON.parse(answerRaw);
464
+ }
465
+ catch {
466
+ const message = "--answer must be valid JSON";
467
+ if (parsed.json) {
468
+ printJson(out, { error: message, status: 400 });
469
+ }
470
+ else {
471
+ out.error(message);
472
+ }
473
+ return 1;
474
+ }
475
+ let answer;
476
+ try {
477
+ answer = parseAskOperatorAnswer(parsedJson);
478
+ }
479
+ catch (err) {
480
+ const message = err instanceof Error ? err.message : String(err);
481
+ if (err instanceof AskOperatorError) {
482
+ if (parsed.json) {
483
+ printJson(out, { error: message, status: 400 });
484
+ }
485
+ else {
486
+ out.error(message);
487
+ }
488
+ return 1;
489
+ }
490
+ out.error(message);
491
+ return 1;
492
+ }
493
+ const manager = buildManager();
494
+ const result = await manager.deliverAnswer(parsed.runId, parsed.stageId, answer);
495
+ if (!result.ok) {
496
+ const payload = { error: result.reason };
497
+ if (result.status !== undefined)
498
+ payload.status = result.status;
499
+ if (parsed.json) {
500
+ printJson(out, payload);
501
+ }
502
+ else {
503
+ out.error(result.reason);
504
+ }
505
+ return 1;
506
+ }
507
+ if (parsed.json) {
508
+ printJson(out, { ok: true });
509
+ }
510
+ else {
511
+ out.log("ok");
512
+ }
513
+ return 0;
514
+ }
515
+ case "retry": {
516
+ const blocked = await guardHost();
517
+ if (blocked !== undefined)
518
+ return blocked;
519
+ if (!parsed.runId || !parsed.stageId) {
520
+ return usageError(out, "Missing --run and/or --stage");
521
+ }
522
+ const manager = buildManager();
523
+ const result = await manager.retryStageUntilStop(parsed.runId, parsed.stageId);
524
+ if (!result.ok) {
525
+ if (parsed.json) {
526
+ printJson(out, {
527
+ ...mapRetryStageFailure(result),
528
+ status: result.status,
529
+ });
530
+ }
531
+ else {
532
+ out.error(result.reason);
533
+ }
534
+ return 1;
535
+ }
536
+ return reportCliRun({ kind: "completion", result: result.pipeline }, { json: parsed.json, io: mutatingIo });
537
+ }
538
+ case "abandon": {
539
+ const blocked = await guardHost();
540
+ if (blocked !== undefined)
541
+ return blocked;
542
+ if (!parsed.runId || !parsed.stageId) {
543
+ return usageError(out, "Missing --run and/or --stage");
544
+ }
545
+ const manager = buildManager();
546
+ const result = await manager.abandonStage(parsed.runId, parsed.stageId);
547
+ if (!result.ok) {
548
+ const payload = { error: result.reason };
549
+ if (result.status !== undefined)
550
+ payload.status = result.status;
551
+ if (parsed.json) {
552
+ printJson(out, payload);
553
+ }
554
+ else {
555
+ out.error(result.reason);
556
+ }
557
+ return 1;
558
+ }
559
+ if (parsed.json) {
560
+ printJson(out, {
561
+ ok: true,
562
+ runId: result.runId,
563
+ stageId: result.stageId,
564
+ });
565
+ }
566
+ else {
567
+ out.log(`${result.runId}\t${result.stageId}`);
568
+ }
569
+ return 0;
570
+ }
571
+ case "rerun": {
572
+ const blocked = await guardHost();
573
+ if (blocked !== undefined)
574
+ return blocked;
575
+ if (!parsed.runId) {
576
+ return usageError(out, "Missing --run");
577
+ }
578
+ const manager = buildManager();
579
+ const started = await manager.rerun(parsed.runId);
580
+ if (!started.ok) {
581
+ return reportCliRun({ kind: "start-failure", started }, { json: parsed.json, io: mutatingIo });
582
+ }
583
+ return completeCliRun(started, mutatingIo, {
584
+ json: parsed.json,
585
+ store: getStore(),
586
+ });
587
+ }
588
+ default:
589
+ return usageError(out, `Unknown runs subcommand: ${parsed.subcommand}`);
590
+ }
591
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ValidationResult } from "../config/validateCatalog.js";
2
- export declare const VALIDATION_SCOPE_LINE = "Scope: pipeline and stage YAML only (task, checkout, providers not checked).";
3
- export declare const VALIDATION_CHECKS = "pipeline and stage YAML only";
2
+ export declare const VALIDATION_SCOPE_LINE = "Scope: catalog YAML (pipelines, stages, and tasks as selected by flags). Provider auth and checkout paths are not checked.";
3
+ export declare const VALIDATION_CHECKS = "catalog YAML (pipelines, stages, tasks as selected by flags)";
4
4
  export declare function formatValidationHuman(result: ValidationResult, options?: {
5
5
  strict?: boolean;
6
6
  }): string;
@@ -1,6 +1,6 @@
1
1
  import { effectiveSeverity } from "../config/validateCatalog.js";
2
- export const VALIDATION_SCOPE_LINE = "Scope: pipeline and stage YAML only (task, checkout, providers not checked).";
3
- export const VALIDATION_CHECKS = "pipeline and stage YAML only";
2
+ export const VALIDATION_SCOPE_LINE = "Scope: catalog YAML (pipelines, stages, and tasks as selected by flags). Provider auth and checkout paths are not checked.";
3
+ export const VALIDATION_CHECKS = "catalog YAML (pipelines, stages, tasks as selected by flags)";
4
4
  function effectiveSeverityRank(finding, strict) {
5
5
  return effectiveSeverity(finding, strict) === "error" ? 0 : 1;
6
6
  }
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import { EXPORT_RUN_USAGE, runExportRunCommand, } from "./cli/exportRunCommand.j
10
10
  import { INIT_USAGE, runInitCommand } from "./cli/initCommand.js";
11
11
  import { PROVIDERS_USAGE, runProvidersCommand } from "./cli/providersCommand.js";
12
12
  import { RUN_USAGE, runRunCommand } from "./cli/runCommand.js";
13
+ import { RUNS_USAGE, runRunsCommand } from "./cli/runsCommand.js";
13
14
  import { resolveOperatorCatalog } from "./cli/operatorCatalog.js";
14
15
  import { SKILLS_USAGE, runSkillsCommand } from "./cli/skillsCommand.js";
15
16
  import { VALIDATE_USAGE, runValidateCommand } from "./cli/validateCommand.js";
@@ -20,6 +21,7 @@ import { SF_STAGE_WORKER } from "./runtime/stageWorkerProtocol.js";
20
21
  import { DEFAULT_PORT, startUiServer } from "./server/http.js";
21
22
  import { startMcpServer } from "./server/mcpHost.js";
22
23
  import { resolveMcpStateless } from "./mcp/server.js";
24
+ import { PACKAGE_VERSION } from "./package-meta.js";
23
25
  const USAGE = `Usage:
24
26
  sf init
25
27
  sf run --task <path> --pipeline <path> [--checkout <path>] [--json] [--include stages] [--skip-gates] [--git-sha <sha>] [--ci-pr-url <url>] [--ci-job-url <url>] [--operator-cwd <path>] [--operator-agent-dir <path>]
@@ -27,6 +29,14 @@ const USAGE = `Usage:
27
29
  sf artifact read --run <runId> --path <relPath> [--out <file>]
28
30
  sf envelope get --run <runId> --stage <stageId> [--json] [--from <sf-run.json>] [--detect-stage <id>] [--format envelope|handoff]
29
31
  sf export-run --run <runId> [--from <sf-run.json>] [--out <file>]
32
+ sf runs list [--status created|running|succeeded|failed] [--since <iso>] [--pipeline <id-or-path>] [--json]
33
+ sf runs show --run <runId> [--from <sf-run.json>] [--json]
34
+ sf runs waiting [--run <runId>] [--json]
35
+ sf runs wait --run <runId> [--from <sf-run.json>] [--until any|waiting|terminal] [--timeout-ms <n>] [--json]
36
+ sf runs answer --run <runId> --stage <stageId> [--answer '<json>'] [--json]
37
+ sf runs retry --run <runId> --stage <stageId> [--json]
38
+ sf runs abandon --run <runId> --stage <stageId> [--json]
39
+ sf runs rerun --run <runId> [--json]
30
40
  sf ui [--port ${DEFAULT_PORT}] [--mcp-stateless]
31
41
  sf mcp [--port ${DEFAULT_PORT}] [--mcp-stateless]
32
42
  sf providers list
@@ -38,6 +48,8 @@ const USAGE = `Usage:
38
48
  sf skills list
39
49
  sf skills install --from-path <dir> [--skill-name <name>]
40
50
  sf skills install --from-zip <url-or-path> [--skill-name <name>] [--checksum sha256:<hex>]
51
+ sf --version
52
+ sf -V
41
53
  sf --help
42
54
 
43
55
  Stageflow (sf) runs YAML-defined automatic stage pipelines.
@@ -56,6 +68,8 @@ ${ENVELOPE_USAGE}
56
68
 
57
69
  ${EXPORT_RUN_USAGE}
58
70
 
71
+ ${RUNS_USAGE}
72
+
59
73
  ${PROVIDERS_USAGE}
60
74
 
61
75
  ${SKILLS_USAGE}`;
@@ -68,6 +82,12 @@ function parseArgs(argv) {
68
82
  args.length === 1) {
69
83
  return { help: true };
70
84
  }
85
+ if (args[0] === "--version" || args[0] === "-V") {
86
+ if (args.length > 1) {
87
+ throw new Error(`Unexpected argument: ${args[1]}`);
88
+ }
89
+ return { help: false, version: true };
90
+ }
71
91
  const command = args[0];
72
92
  if (command === "providers" ||
73
93
  command === "validate" ||
@@ -76,6 +96,7 @@ function parseArgs(argv) {
76
96
  command === "artifact" ||
77
97
  command === "envelope" ||
78
98
  command === "export-run" ||
99
+ command === "runs" ||
79
100
  command === "skills") {
80
101
  return { help: false, command };
81
102
  }
@@ -240,6 +261,10 @@ async function main(argv) {
240
261
  console.log(USAGE);
241
262
  return argv.slice(2).length === 0 ? 1 : 0;
242
263
  }
264
+ if (parsed.version) {
265
+ console.log(PACKAGE_VERSION);
266
+ return 0;
267
+ }
243
268
  const ctx = await resolveStageflowContext(process.cwd());
244
269
  if (parsed.command === "init") {
245
270
  return runInitCommand(argv.slice(3), { cwd: ctx.invocationCwd });
@@ -272,6 +297,13 @@ async function main(argv) {
272
297
  projectRoot: ctx.projectRoot,
273
298
  });
274
299
  }
300
+ if (parsed.command === "runs") {
301
+ return runRunsCommand(argv.slice(3), {
302
+ cwd: ctx.invocationCwd,
303
+ projectRoot: ctx.projectRoot,
304
+ isGitProject: ctx.isGitProject,
305
+ });
306
+ }
275
307
  if (parsed.command === "providers") {
276
308
  return runProvidersCommand(argv.slice(3), ctx.invocationCwd);
277
309
  }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { findProjectRoot, clearFindProjectRootCacheForTests, } from "./project/f
2
2
  export { globalStageflowHome, ensureGlobalHome, } from "./project/globalHome.js";
3
3
  export { resolveProjectContext, legacyProjectContext, type ProjectContext, } from "./project/resolveProjectContext.js";
4
4
  export { resolveStageflowContext, projectContextFromStageflow, type StageflowContext, type CatalogManifestStatus, } from "./project/resolveStageflowContext.js";
5
- export { PACKAGE_NAME } from "./package-meta.js";
5
+ export { PACKAGE_NAME, PACKAGE_VERSION } from "./package-meta.js";
6
6
  export type { StageEnvelope } from "./types/envelope.js";
7
7
  export { assertRequiredEnvelope, isAdvancingEnvelope, } from "./envelope/check.js";
8
8
  export type { AgentPort } from "./agent/port.js";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ export { findProjectRoot, clearFindProjectRootCacheForTests, } from "./project/f
2
2
  export { globalStageflowHome, ensureGlobalHome, } from "./project/globalHome.js";
3
3
  export { resolveProjectContext, legacyProjectContext, } from "./project/resolveProjectContext.js";
4
4
  export { resolveStageflowContext, projectContextFromStageflow, } from "./project/resolveStageflowContext.js";
5
- export { PACKAGE_NAME } from "./package-meta.js";
5
+ export { PACKAGE_NAME, PACKAGE_VERSION } from "./package-meta.js";
6
6
  export { assertRequiredEnvelope, isAdvancingEnvelope, } from "./envelope/check.js";
7
7
  export { runPipeline, startPipeline } from "./runtime/pipelineRunner.js";
8
8
  export { createRunStore } from "./runstore/createStore.js";
@@ -1 +1,2 @@
1
1
  export declare const PACKAGE_NAME = "stageflow";
2
+ export declare const PACKAGE_VERSION = "0.9.0";
@@ -1 +1,2 @@
1
1
  export const PACKAGE_NAME = "stageflow";
2
+ export const PACKAGE_VERSION = "0.9.0";
@@ -111,6 +111,13 @@ export declare class RunManager {
111
111
  }): Promise<StartRunResult>;
112
112
  rerun(runId: string): Promise<StartRunResult>;
113
113
  retryStage(runId: string, stageId: string): Promise<RetryStageResult>;
114
+ retryStageUntilStop(runId: string, stageId: string): Promise<{
115
+ ok: true;
116
+ pipeline: PipelineRunResult;
117
+ } | Extract<RetryStageResult, {
118
+ ok: false;
119
+ }>>;
120
+ private retryStageInternal;
114
121
  /**
115
122
  * Deliver an operator answer for `(runId, stageId)` (KTD2 / KTD6).
116
123
  * Same-process: unparks the live yield loop. After restart: reconstructs
@@ -389,6 +389,29 @@ export class RunManager {
389
389
  return this.reserveAndStartPipeline(taskYaml, pipeline, `run ${runId} task`, rerunCwd, undefined, undefined, undefined, rerunProjectRoot);
390
390
  }
391
391
  async retryStage(runId, stageId) {
392
+ return this.retryStageInternal(runId, stageId, true);
393
+ }
394
+ async retryStageUntilStop(runId, stageId) {
395
+ const retryKey = waitKey(runId, stageId);
396
+ const retried = await this.retryStageInternal(runId, stageId, false);
397
+ if (!retried.ok)
398
+ return retried;
399
+ try {
400
+ if (retried.done === undefined) {
401
+ return {
402
+ ok: false,
403
+ reason: `Retry orchestration did not start for run ${runId} stage ${stageId}`,
404
+ status: 500,
405
+ };
406
+ }
407
+ const pipeline = await retried.done;
408
+ return { ok: true, pipeline };
409
+ }
410
+ finally {
411
+ this.retryInFlight.delete(retryKey);
412
+ }
413
+ }
414
+ async retryStageInternal(runId, stageId, awaitRoot) {
392
415
  const retryKey = waitKey(runId, stageId);
393
416
  if (this.retryInFlight.has(retryKey)) {
394
417
  return {
@@ -401,7 +424,7 @@ export class RunManager {
401
424
  try {
402
425
  const orchestrationConflict = this.active.has(runId) &&
403
426
  !this.attachedWaiting.has(waitKey(runId, stageId));
404
- return await this.retryCoordinator.retryStage({
427
+ const result = await this.retryCoordinator.retryStage({
405
428
  runId,
406
429
  stageId,
407
430
  store: this.options.store,
@@ -414,10 +437,16 @@ export class RunManager {
414
437
  hitl: this.hitl,
415
438
  orchestrationConflict,
416
439
  tracking: this.retryTracking,
440
+ awaitRoot,
417
441
  });
442
+ if (!result.ok || awaitRoot !== false) {
443
+ this.retryInFlight.delete(retryKey);
444
+ }
445
+ return result;
418
446
  }
419
- finally {
447
+ catch (err) {
420
448
  this.retryInFlight.delete(retryKey);
449
+ throw err;
421
450
  }
422
451
  }
423
452
  /**
@@ -29,6 +29,7 @@ export type RetryStageResult = {
29
29
  runId: string;
30
30
  stageId: string;
31
31
  attemptIndex: number;
32
+ done?: Promise<PipelineRunResult>;
32
33
  } | {
33
34
  ok: false;
34
35
  reason: string;
@@ -62,6 +63,7 @@ export type RetryStageRequest = {
62
63
  hitl: StageHitlController;
63
64
  orchestrationConflict: boolean;
64
65
  tracking: RetryTrackingPort;
66
+ awaitRoot?: boolean;
65
67
  };
66
68
  export declare function assertStageRetryEligible(detail: RunDetail, stageId: string, opts?: {
67
69
  recoveryActive?: boolean;
@@ -315,12 +315,15 @@ export class RunRetryCoordinator {
315
315
  tracking.onOrchestrationStarted(runId, orchestrationPromise);
316
316
  }
317
317
  }
318
- await this.waitForRoot(runId, stageId, store);
318
+ if (req.awaitRoot !== false) {
319
+ await this.waitForRoot(runId, stageId, store);
320
+ }
319
321
  return {
320
322
  ok: true,
321
323
  runId,
322
324
  stageId,
323
325
  attemptIndex: execution.attempt,
326
+ done: this.getOrchestrationPromise(runId),
324
327
  };
325
328
  }
326
329
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stageflow",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Stageflow — CLI pipeline runtime for configurable stages on Pi",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@ node ../scripts/detect-host.mjs
11
11
  node ../scripts/detect-host.mjs --base-url http://127.0.0.1:3847
12
12
  ```
13
13
 
14
- The script `GET`s `{baseUrl}/api/health` (default `http://127.0.0.1:3847`, 1500 ms timeout). **up** means HTTP 200 and parseable JSON. Non-200, non-JSON, or timeout is **down**.
14
+ The script `GET`s `{baseUrl}/api/health` (default `http://127.0.0.1:3847`, 1500 ms timeout). **up** means HTTP 200 and parseable JSON. Non-200, non-JSON, or timeout is **down**. This probe is Stageflow host up/down only — it does not detect a coding-agent question UI. Gate presentation lives in [`../../stageflow-run/references/native-question-ui.md`](../../stageflow-run/references/native-question-ui.md).
15
15
 
16
16
  Stdout is one line: `up <baseUrl>` or `down <baseUrl>`. Exit `0` when up, `1` when down, `2` on usage error.
17
17
 
@@ -23,4 +23,6 @@ Use MCP tools over the Streamable HTTP endpoint at `{baseUrl}/mcp`. Tool names a
23
23
 
24
24
  ## When the host is down
25
25
 
26
- Use the `sf` CLI. Command names and flags live in [docs/cli-reference.md](../../../docs/cli-reference.md). Typical talking-job commands: `sf run`, `sf validate`, `sf envelope get`, `sf artifact read`, `sf providers`.
26
+ Use the `sf` CLI. Command names and flags live in [docs/cli-reference.md](../../../docs/cli-reference.md). Typical talking-job commands: `sf run`, `sf runs waiting`, `sf runs answer`, `sf runs wait`, `sf validate`, `sf envelope get`, `sf artifact read`, `sf providers`.
27
+
28
+ Probe before each mutating `sf runs` verb. If the probe is **up** or the command refuses because a host is up, continue that gate via MCP — do not start a second mutating writer, and do not start `sf mcp` as a disposable bridge.
@@ -2,19 +2,20 @@
2
2
  name: stageflow-run
3
3
  description: >-
4
4
  Starts a catalog pipeline from the harness, reports progress, and answers
5
- every HITL gate in this chat. Triggers: run a pipeline, start <pipeline>,
6
- check on my run, answer the pending question.
5
+ every HITL gate on the host native question UI when it can, otherwise in
6
+ this chat. Triggers: run a pipeline, start <pipeline>, check on my run,
7
+ answer the pending question.
7
8
  compatibility: Requires the sf CLI on PATH. An MCP host (sf ui or sf mcp) is optional.
8
9
  disable-model-invocation: true
9
10
  ---
10
11
 
11
12
  # Stageflow run
12
13
 
13
- Start a catalog pipeline, keep every HITL gate in this chat, and report the outcome. Author and session-capture own catalog YAML; this job owns the run and any throwaway `*.task.yaml`.
14
+ Start a catalog pipeline, present HITL on the host native question UI when it can, and report the outcome. Author and session-capture own catalog YAML; this job owns the run and any throwaway `*.task.yaml`.
14
15
 
15
16
  Talking jobs cite [`../stageflow/references/control-surface.md`](../stageflow/references/control-surface.md). Probe with [`../stageflow/scripts/detect-host.mjs`](../stageflow/scripts/detect-host.mjs) only. Do not write a second probe.
16
17
 
17
- MCP tool shapes: [`docs/mcp.md`](../../docs/mcp.md). CLI flags and exit codes: [`docs/cli-reference.md`](../../docs/cli-reference.md). Direct tool calls: [`references/mcp-call.md`](references/mcp-call.md). Selection: [`references/task-and-pipeline-selection.md`](references/task-and-pipeline-selection.md).
18
+ MCP tool shapes: [`docs/mcp.md`](../../docs/mcp.md). CLI flags and exit codes: [`docs/cli-reference.md`](../../docs/cli-reference.md). Direct tool calls: [`references/mcp-call.md`](references/mcp-call.md). Selection: [`references/task-and-pipeline-selection.md`](references/task-and-pipeline-selection.md). Gate presentation: [`references/native-question-ui.md`](references/native-question-ui.md).
18
19
 
19
20
  ## Preconditions
20
21
 
@@ -38,15 +39,15 @@ node ../stageflow/scripts/detect-host.mjs --base-url http://127.0.0.1:3847
38
39
 
39
40
  Read [`references/task-and-pipeline-selection.md`](references/task-and-pipeline-selection.md). Follow it until you have a pipeline filesystem path and a task (catalog path, MCP inline object, or CLI throwaway file).
40
41
 
41
- If this chat already has a `runId` and the request is check, answer, or continue: skip start. Host up → [Wait](#wait). Host down and the human wants to answer → [Bridge](#bridge).
42
+ If this chat already has a `runId` and the request is check, answer, or continue: skip start. Host up → [Wait](#wait). Host down → [CLI wait](#cli-wait).
42
43
 
43
44
  **Done when** the target is named, or a live `runId` is in hand.
44
45
 
45
46
  ## MCP tools
46
47
 
47
- Prefer this harness's native Stageflow MCP tools when their names are already in the tool list. When they are not, call [`scripts/mcp-call.mjs`](scripts/mcp-call.mjs) — see [`references/mcp-call.md`](references/mcp-call.md). Always use `mcp-call.mjs --stateless` for a bridge this skill started.
48
+ Prefer this harness's native Stageflow MCP tools when their names are already in the tool list. When they are not, call [`scripts/mcp-call.mjs`](scripts/mcp-call.mjs) — see [`references/mcp-call.md`](references/mcp-call.md). Use `--stateless` only when the **user** started the host with `--mcp-stateless`. Do not start `sf mcp` from this skill.
48
49
 
49
- Call only `list_pipelines`, `list_tasks`, `start_run`, `get_run`, `wait_run`, `list_waiting`, `answer_gate`, `get_health`.
50
+ Call only these Stageflow MCP tools: `list_pipelines`, `list_tasks`, `start_run`, `get_run`, `wait_run`, `list_waiting`, `answer_gate`, `get_health`. Host question tools already in this harness's tool list (`AskQuestion`, `AskUserQuestion`, `ask_user`) are for [Gate](#gate) presentation, not Stageflow MCP.
50
51
 
51
52
  ## MCP path
52
53
 
@@ -78,9 +79,14 @@ Call only `list_pipelines`, `list_tasks`, `start_run`, `get_run`, `wait_run`, `l
78
79
 
79
80
  ### Gate
80
81
 
82
+ Read [`references/native-question-ui.md`](references/native-question-ui.md) before presenting a pending prompt.
83
+
81
84
  1. `list_waiting` with `{ "runId" }`.
82
85
  2. Print the pending prompt text **verbatim** (and artifacts / sub-questions when present).
83
- 3. Collect the human's reply in this chat. Map it to the prompt `kind`:
86
+ 3. Present the decision as that reference directs:
87
+ - If a host question tool is already in this harness's tool list and the gate is representable, invoke that picker. For `multi_question`, one picker call with one question per sub-item when **every** sub-question is representable, then one `answer_gate`; otherwise the **whole** gate in this chat.
88
+ - Otherwise collect the reply in this chat.
89
+ 4. Map the reply to the prompt `kind` (picker Accept/Reject → `accept`/`reject`):
84
90
 
85
91
  | kind | `answer` |
86
92
  |---|---|
@@ -89,12 +95,12 @@ Call only `list_pipelines`, `list_tasks`, `start_run`, `get_run`, `wait_run`, `l
89
95
  | `artifact_backed` | `{ "promptId", "kind": "artifact_backed", "decision": "accept" \| "reject" }` |
90
96
  | `multi_question` | `{ "promptId", "kind": "multi_question", "answers": { "<id>": { "kind", "text" \| "decision" } } }` |
91
97
 
92
- `promptId` is `pending_prompt.id` or `waiting_prompt_id`. Map yes/y/accept/approve to `accept`; no/n/reject/deny to `reject`. Ask once when the kind is `multi_question` and any sub-question is unanswered, or when confirm/artifact_backed is not a clear decision.
98
+ `promptId` is `pending_prompt.id` or `waiting_prompt_id`. Map yes/y/accept/approve/Accept to `accept`; no/n/reject/deny/Reject to `reject`. Each `multi_question` sub-answer keeps that sub's `kind` (`confirm` `decision`, `free_text` `text`). Collect every sub-answer before `answer_gate`.
93
99
 
94
- 4. `answer_gate` with `{ "runId", "stageId", "answer" }`.
95
- 5. Return to [Wait](#wait).
100
+ 5. `answer_gate` with `{ "runId", "stageId", "answer" }`.
101
+ 6. Return to [Wait](#wait).
96
102
 
97
- **Done when** `answer_gate` returns `{ "ok": true }` and Wait is re-entered. On `isError` (400 / 404 / 409), print the payload and ask for a corrected reply do not open another surface.
103
+ **Done when** `answer_gate` returns `{ "ok": true }` and Wait is re-entered. On `isError` (400 / 404 / 409), print the payload and collect a corrected reply the same way (picker if still representable; otherwise this chat).
98
104
 
99
105
  ## CLI path
100
106
 
@@ -113,24 +119,49 @@ Parse the single JSON document.
113
119
  | `0` | `succeeded` | [Report](#report) |
114
120
  | `1` | `failed` | [Report](#report) `reason` verbatim |
115
121
  | `1` | `busy` | [Report](#report) `busy_capacity` / `busy_checkout` and the included fields. Stop. |
116
- | `2` | `waiting` | [Bridge](#bridge) with this `runId` / `runDir` |
122
+ | `2` | `waiting` | [CLI wait](#cli-wait) with this `runId` / `runDir` |
123
+
124
+ **Done when** the outcome is reported, or a waiting exit has handed `runId` to CLI wait.
125
+
126
+ ## CLI wait
127
+
128
+ Host down after `sf run` exit `2`, or when this chat already has a `runId` and the host is down. Do not start `sf mcp`. Presentation follows [`references/native-question-ui.md`](references/native-question-ui.md). Submit with `sf runs answer --json`.
129
+
130
+ 1. Probe again with [`../stageflow/scripts/detect-host.mjs`](../stageflow/scripts/detect-host.mjs) (the host may have appeared). Probe before each `sf runs answer`.
131
+ 2. **Up:** use that host — native tools if present, otherwise `mcp-call.mjs` (omit `--stateless` unless the user started the host with `--mcp-stateless`). Continue at [Gate](#gate).
132
+ 3. **Down:**
117
133
 
118
- **Done when** the outcome is reported, or a waiting exit has handed `runId` and `runDir` to Bridge.
134
+ ```
135
+ sf runs waiting --run <runId> --json
136
+ ```
137
+
138
+ Print the pending prompt **verbatim**. Present it as native-question-ui directs (picker or chat). Map the reply with the [Gate](#gate) kind table.
119
139
 
120
- ## Bridge
140
+ ```
141
+ sf runs answer --run <runId> --stage <stageId> --answer '<json>' --json
142
+ ```
143
+
144
+ Success is `{ "ok": true }` exit `0` even if the run parks again. Do not treat answer as terminal.
145
+
146
+ ```
147
+ sf runs wait --run <runId> --json --until any
148
+ ```
149
+
150
+ A shorter `--timeout-ms` is fine when the harness timeout is tight. Default `60000`, max `240000`. Branch on JSON `reason`, not wait exit `0`:
151
+
152
+ | `reason` | next |
153
+ |---|---|
154
+ | `waiting` or `already` with a waiting snapshot | `sf runs waiting` then answer |
155
+ | `terminal` | [Report](#report) |
156
+ | `timeout` | call `sf runs wait` again |
121
157
 
122
- A CLI waiting exit has no answer command. Keep the gate in this chat by joining a host on the same project root (git top-level when `git rev-parse --show-toplevel` succeeds, otherwise the current directory).
158
+ Wait abort (exit `130`, `{ "error", "code": "aborted" }`) [Report](#report): the run continues, resumable later. Stop. Do not reuse `sf run` exit `2` for wait.
123
159
 
124
- 1. Probe again with [`../stageflow/scripts/detect-host.mjs`](../stageflow/scripts/detect-host.mjs) (the host may have appeared).
125
- 2. **Up:** use that host — native tools if present, otherwise `mcp-call.mjs` (omit `--stateless`). Continue at [Gate](#gate).
126
- 3. **Down:** start `sf mcp --mcp-stateless` as a background process on that same project root. This is a disposable HITL bridge, not setup auto-start.
127
- 4. Probe until stdout is `up <baseUrl>`, at most 8 times. **Done when** the host is up. If it stays down, [Report](#report) the parked `runId` / `runDir` and the bind/stderr text. Stop.
128
- 5. Drive the rest with `mcp-call.mjs --stateless` only: [Gate](#gate) → [Wait](#wait) until terminal. A second wait on the same run reuses this process.
129
- 6. When the run is terminal, stop the bridge process.
160
+ 4. If `sf runs answer` refuses because the host came up, continue that gate via MCP [Gate](#gate). Do not dual-write.
130
161
 
131
162
  If this chat ends before terminal, say the run stays in `.stageflow/` and to continue by re-invoking this skill (or starting a host and using MCP).
132
163
 
133
- **Done when** the run is terminal and the bridge this skill started is stopped, or a parked-run failure is reported.
164
+ **Done when** the run is terminal, aborted, or a hard error is printed.
134
165
 
135
166
  ## Report
136
167
 
@@ -142,10 +173,10 @@ Print one shape on every path:
142
173
  4. Run id.
143
174
  5. Run folder (`runDir` / `.stageflow/`).
144
175
 
145
- MCP `get_run` / `wait_run` carry live stage state. `sf run --json` reports start and finish only — say that in chat when the CLI path ran without a host. A succeeded report names id and folder and does not keep waiting language. A failure names `reason` in this same shape.
176
+ MCP `get_run` / `wait_run` and `sf runs wait` / `sf runs show` `--json` carry live stage state. `sf run --json` reports start and finish only — say that in chat when the CLI path ran without a later wait/show. A succeeded report names id and folder and does not keep waiting language. A failure names `reason` in this same shape.
146
177
 
147
178
  **Done when** that five-part report is printed.
148
179
 
149
180
  ## Non-goals
150
181
 
151
- This job starts, watches, and answers runs. It does not require `sf ui`. The operator console is not the answer path. It does not register or invent an MCP tool.
182
+ This job starts, watches, and answers runs. It does not require `sf ui`. The operator console is not the answer path. It does not register or invent an MCP tool. It does not start `sf mcp`. It does not retry, abandon, or rerun stages.
@@ -1,6 +1,6 @@
1
1
  # mcp-call
2
2
 
3
- Use [`../scripts/mcp-call.mjs`](../scripts/mcp-call.mjs) when this harness has no native Stageflow MCP tools, and always for a self-started `sf mcp --mcp-stateless` bridge.
3
+ Use [`../scripts/mcp-call.mjs`](../scripts/mcp-call.mjs) when the Stageflow host is up and this harness has no native Stageflow MCP tools. Do not start `sf mcp` from this skill.
4
4
 
5
5
  ```
6
6
  node scripts/mcp-call.mjs --base-url <url> --tool <name> --args '<json>' [--stateless]
@@ -8,7 +8,7 @@ node scripts/mcp-call.mjs --base-url <url> --tool <name> --args '<json>' [--stat
8
8
 
9
9
  Default `--base-url` is `http://127.0.0.1:3847`. `--args` defaults to `{}`.
10
10
 
11
- `--stateless` sends one `tools/call` and no session header. Use it for hosts started with `sf mcp --mcp-stateless`.
11
+ `--stateless` sends one `tools/call` and no session header. Use it for hosts the **user** started with `sf mcp --mcp-stateless`, not a skill-started process.
12
12
 
13
13
  Without `--stateless`: `initialize`, capture `Mcp-Session-Id`, reuse it for `tools/call`.
14
14
 
@@ -0,0 +1,78 @@
1
+ # Native question UI
2
+
3
+ Present a waiting HITL gate on the coding-agent host's structured question tool when that tool can represent the gate. Otherwise collect the reply in this chat. Submit with `answer_gate` when the Stageflow host is up, or `sf runs answer --json` when it is down. This file is presentation only — it does not change MCP tools, `detect-host.mjs`, or the operator console.
4
+
5
+ Read `pending_prompt` from `list_waiting` or `sf runs waiting --json` (not only `waiting_questions` strings).
6
+
7
+ ## Detect a picker
8
+
9
+ Inspect **this harness's current tool list**. A picker is a tool that collects a structured choice (question text plus labeled options), not a free-form chat reply.
10
+
11
+ Do not run `detect-host.mjs` for this. That script is Stageflow host up/down (MCP vs CLI). It does not detect a question UI.
12
+
13
+ | If this name is in the tool list | Use it |
14
+ |---|---|
15
+ | `AskQuestion` | Cursor question cards |
16
+ | `AskUserQuestion` | Claude Code questions |
17
+ | `ask_user` | Pi questions |
18
+
19
+ If none of those (or another options-based question tool already in the list) is present, collect in chat. Do not invent a tool name or call shape. Codex and OpenCode stay in chat until an options-based question tool appears in the list.
20
+
21
+ Pass the pending message as the question text and the mapped labels as that tool's options. One question per call except `multi_question`: one call with one question per representable sub-item when the tool accepts a questions array (Cursor `AskQuestion` does). Do not invent placeholder options such as Type in Other — host Other is the custom-text escape. If the picker returns a custom or Other value, map that text the same as a chat reply.
22
+
23
+ **Done when** you know picker or chat for this gate.
24
+
25
+ ## Representable vs chat
26
+
27
+ | Gate | Picker | Chat |
28
+ |---|---|---|
29
+ | `confirm` | Accept and Reject | Host has no picker |
30
+ | `artifact_backed` | Accept and Reject (decision only) | Host has no picker |
31
+ | `free_text` with a harvested closed set | Those names as options | Open-ended, or harvest is ambiguous |
32
+ | `free_text` with no closed set | — | Always |
33
+ | `multi_question` | One picker call with one question per sub-item when **every** sub-question is representable; then one submit | Any sub-question is open or unmappable — the **whole** gate in chat |
34
+
35
+ Map picker Accept / yes / approve → `accept`. Reject / no / deny → `reject`. Always include `promptId` (`pending_prompt.id` or `waiting_prompt_id`).
36
+
37
+ | Item | Submit |
38
+ |---|---|
39
+ | Top-level or sub `confirm` | `{ "kind": "confirm", "decision": "accept" \| "reject" }` |
40
+ | Top-level `artifact_backed` | `{ "kind": "artifact_backed", "decision": "accept" \| "reject" }` plus optional `text` after a reject follow-up |
41
+ | Top-level or sub `free_text` (including harvested) | `{ "kind": "free_text", "text": "<selected label>" }` |
42
+
43
+ Never submit a `confirm` item as `free_text`. For `multi_question`, wrap sub-answers in the Gate `answers` object and submit once (`answer_gate` or `sf runs answer --json`).
44
+
45
+ When in doubt, chat.
46
+
47
+ **Done when** the path (picker or chat) is chosen from this table.
48
+
49
+ ## Harvest
50
+
51
+ Harvest only from `pending_prompt.message` (or a `multi_question` item `message`) when it names a **hard closed set**:
52
+
53
+ - `reply with exactly one of:`
54
+ - `choose one of:`
55
+ - `exactly one of`
56
+
57
+ Split the names after that directive (commas, `or`, newlines). Those literal names are the picker options.
58
+
59
+ Do **not** harvest loose example lists (`you might try`, `e.g.`, `for example` without `exactly one of`). A listed example set that is not a hard closed directive stays in chat.
60
+
61
+ Submit the selected label as `text` for `free_text` items. If the prompt also lists aliases for the same choice (for example `minimal` and `prototype~1`), keep every listed name on the card; do not rewrite the label at submit — the stage maps synonyms.
62
+
63
+ **Done when** the option list is the prompt's literal closed set, or the gate stayed in chat.
64
+
65
+ ## Artifact-backed
66
+
67
+ 1. Print the pending message and artifact paths (from `list_waiting`) in this chat. Do not put artifact bodies on the card. Do not call `read_artifact` unless the human asked to open a file.
68
+ 2. Open the picker with Accept and Reject.
69
+ 3. Accept → submit immediately (`decision: "accept"`).
70
+ 4. Reject → one chat follow-up for optional notes, then submit `decision: "reject"` and `text` when they typed notes.
71
+
72
+ **Done when** the operator has seen the paths in chat and the decision is on the picker (or chat if there is no picker).
73
+
74
+ ## Errors
75
+
76
+ On MCP `answer_gate` `isError` (400 / 404 / 409) or CLI `sf runs answer` exit `1`, print the payload. Collect a corrected reply on the picker if the gate is still representable; otherwise chat. Then submit again.
77
+
78
+ **Done when** `{ "ok": true }` or the error payload has been printed and a corrected reply is in hand.