stageflow 0.18.0 → 0.23.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 (116) hide show
  1. package/README.md +2 -1
  2. package/dist/agent/activity.d.ts +9 -0
  3. package/dist/agent/activityObserver.d.ts +3 -0
  4. package/dist/agent/activityObserver.js +6 -1
  5. package/dist/agent/claudeAdapter.js +38 -0
  6. package/dist/agent/fakeAgent.js +3 -1
  7. package/dist/agent/piAdapter.js +40 -4
  8. package/dist/agent/port.d.ts +3 -1
  9. package/dist/agent/providerInspect.d.ts +19 -0
  10. package/dist/agent/providerInspect.js +35 -0
  11. package/dist/agent/stageTimeout.d.ts +9 -0
  12. package/dist/agent/stageTimeout.js +24 -0
  13. package/dist/agent/streamLogRedact.d.ts +2 -0
  14. package/dist/agent/streamLogRedact.js +13 -0
  15. package/dist/cli/artifactCommand.js +2 -2
  16. package/dist/cli/envelopeCommand.js +2 -2
  17. package/dist/cli/exportRunCommand.js +2 -2
  18. package/dist/cli/graphCommand.d.ts +15 -0
  19. package/dist/cli/graphCommand.js +79 -0
  20. package/dist/cli/graphRender.d.ts +2 -0
  21. package/dist/cli/graphRender.js +85 -0
  22. package/dist/cli/hostClient.d.ts +43 -0
  23. package/dist/cli/hostClient.js +182 -0
  24. package/dist/cli/providersCommand.js +5 -3
  25. package/dist/cli/runCommand.d.ts +6 -5
  26. package/dist/cli/runCommand.js +23 -31
  27. package/dist/cli/runOutput.d.ts +1 -1
  28. package/dist/cli/runsCommand.d.ts +4 -8
  29. package/dist/cli/runsCommand.js +65 -89
  30. package/dist/cli.js +13 -5
  31. package/dist/config/describePipeline.d.ts +26 -0
  32. package/dist/config/describePipeline.js +37 -0
  33. package/dist/config/loadPipeline.d.ts +13 -1
  34. package/dist/config/loadPipeline.js +34 -4
  35. package/dist/config/mergePipelineIncludes.d.ts +11 -0
  36. package/dist/config/mergePipelineIncludes.js +75 -0
  37. package/dist/config/validateCatalog.d.ts +2 -2
  38. package/dist/config/validateCatalog.js +24 -17
  39. package/dist/mcp/catalogTools.js +146 -52
  40. package/dist/mcp/controlTools.js +94 -19
  41. package/dist/mcp/deps.d.ts +3 -0
  42. package/dist/mcp/projectMcpTools.d.ts +3 -0
  43. package/dist/mcp/projectMcpTools.js +25 -0
  44. package/dist/mcp/providerTools.d.ts +3 -0
  45. package/dist/mcp/providerTools.js +17 -0
  46. package/dist/mcp/readArtifact.d.ts +9 -0
  47. package/dist/mcp/readArtifact.js +11 -0
  48. package/dist/mcp/server.js +9 -1
  49. package/dist/mcp/tailStreamLog.d.ts +8 -0
  50. package/dist/mcp/tailStreamLog.js +28 -0
  51. package/dist/mcp/toolResults.d.ts +17 -0
  52. package/dist/mcp/toolResults.js +15 -0
  53. package/dist/mcp/tools.js +4 -0
  54. package/dist/mcp/waitRun.js +4 -4
  55. package/dist/mcp/waitingGates.d.ts +1 -0
  56. package/dist/mcp/waitingGates.js +3 -2
  57. package/dist/package-meta.d.ts +1 -1
  58. package/dist/package-meta.js +1 -1
  59. package/dist/projection/projectRun.d.ts +3 -0
  60. package/dist/projection/projectRun.js +7 -0
  61. package/dist/runstore/port.d.ts +3 -1
  62. package/dist/runstore/port.js +2 -0
  63. package/dist/runstore/sqlite/SqliteRunStore.d.ts +2 -1
  64. package/dist/runstore/sqlite/SqliteRunStore.js +15 -1
  65. package/dist/runstore/workspaceLayout.d.ts +1 -0
  66. package/dist/runstore/workspaceLayout.js +3 -0
  67. package/dist/runtime/feedbackLoopCoordinator.d.ts +9 -1
  68. package/dist/runtime/feedbackLoopCoordinator.js +50 -20
  69. package/dist/runtime/feedbackLoopDecision.js +33 -1
  70. package/dist/runtime/forkGeneration.d.ts +4 -0
  71. package/dist/runtime/forkGeneration.js +16 -0
  72. package/dist/runtime/pipelineRunner.d.ts +3 -3
  73. package/dist/runtime/pipelineRunner.js +3 -1
  74. package/dist/runtime/pipelineScheduler.js +23 -1
  75. package/dist/runtime/reopenSkipped.d.ts +13 -0
  76. package/dist/runtime/reopenSkipped.js +56 -0
  77. package/dist/runtime/resumeReconstruct.js +2 -2
  78. package/dist/runtime/resumeTimedOut.d.ts +33 -0
  79. package/dist/runtime/resumeTimedOut.js +165 -0
  80. package/dist/runtime/runManager.d.ts +3 -1
  81. package/dist/runtime/runManager.js +110 -14
  82. package/dist/runtime/settingsFile.d.ts +2 -0
  83. package/dist/runtime/settingsFile.js +19 -0
  84. package/dist/runtime/stageAttemptBootstrap.d.ts +3 -0
  85. package/dist/runtime/stageAttemptBootstrap.js +8 -2
  86. package/dist/runtime/stageRecovery.js +2 -2
  87. package/dist/runtime/stageRunner.js +6 -2
  88. package/dist/runtime/stageStreamLog.d.ts +17 -0
  89. package/dist/runtime/stageStreamLog.js +89 -0
  90. package/dist/runtime/stageWorker.js +2 -1
  91. package/dist/server/bootstrap.js +9 -2
  92. package/dist/server/ensureGlobalService.d.ts +35 -0
  93. package/dist/server/ensureGlobalService.js +138 -0
  94. package/dist/server/http.d.ts +20 -1
  95. package/dist/server/http.js +452 -386
  96. package/dist/server/mcpHost.js +7 -8
  97. package/dist/server/operatorResults.d.ts +10 -0
  98. package/dist/server/operatorResults.js +24 -0
  99. package/dist/server/providerRoutes.d.ts +0 -6
  100. package/dist/server/providerRoutes.js +10 -18
  101. package/dist/tools/askOperator.d.ts +1 -0
  102. package/dist/tools/askOperator.js +14 -11
  103. package/dist/tools/emitStageEnvelope.d.ts +1 -0
  104. package/dist/tools/emitStageEnvelope.js +3 -1
  105. package/dist/tools/prepareToolArguments.d.ts +4 -0
  106. package/dist/tools/prepareToolArguments.js +79 -0
  107. package/dist/types/pipeline.d.ts +8 -0
  108. package/dist/ui/assets/{index-DsgVjLUS.css → index-CSsDued1.css} +1 -1
  109. package/dist/ui/assets/index-Da5EHU6M.js +119 -0
  110. package/dist/ui/index.html +2 -2
  111. package/package.json +1 -1
  112. package/skills/stageflow/references/control-surface.md +2 -2
  113. package/skills/stageflow-run/SKILL.md +27 -9
  114. package/skills/stageflow-run/references/mcp-call.md +1 -1
  115. package/skills/stageflow-run/scripts/mcp-call.mjs +9 -0
  116. package/dist/ui/assets/index-DwKGaQvQ.js +0 -119
package/README.md CHANGED
@@ -190,7 +190,7 @@ Runtime state lives in **`<git-root>/.stageflow/`** when inside a git repository
190
190
 
191
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.
192
192
 
193
- HITL-aware tools include `wait_run`, `answer_gate`, and `list_waiting`. Full tool list: [docs/mcp.md](docs/mcp.md).
193
+ HITL-aware tools include `wait_run`, `answer_gate`, `list_waiting`, and `decide_feedback_loop`. Inspect tools (`list_providers`, `list_models`, `describe_pipeline`, …) are read-only. Full tool list: [docs/mcp.md](docs/mcp.md).
194
194
 
195
195
  Stages use project `.mcp.json` plus stage `mcp` names. See [YAML catalog — Stage MCP](docs/yaml-catalog.md#stage-mcp).
196
196
 
@@ -222,6 +222,7 @@ If you want deterministic YAML routing across many agents, look at [Conductor](h
222
222
  | [playwright-mcp](examples/playwright-mcp/) | One stage: open a page and save a PNG screenshot (Playwright MCP) |
223
223
  | [context7-mcp](examples/context7-mcp/) | Three stages: resolve a library, fetch docs, write a brief (Context7 MCP) |
224
224
  | [github-release](examples/github-release/) | Dogfood: draft + publish GitHub Release |
225
+ | [mcp-hitl-tour](examples/mcp-hitl-tour/) | MCP-first HITL: gates, fan-out/join, `wait_for_human` decide |
225
226
  | [ci-validate](examples/ci-validate/) | Strict validate in CI |
226
227
 
227
228
  Index: [examples/README.md](examples/README.md)
@@ -74,6 +74,10 @@ export type StageLifecycleEvent = {
74
74
  } | {
75
75
  event: "skipped";
76
76
  }
77
+ /** A previously skipped stage is runnable again (parent succeeded). */
78
+ | {
79
+ event: "reopened";
80
+ }
77
81
  /** Operator explicitly authorized a new attempt after verified failure. */
78
82
  | {
79
83
  event: "manual_recovery_requested";
@@ -82,6 +86,11 @@ export type StageLifecycleEvent = {
82
86
  /** Operator chose to leave the verified failure terminal. */
83
87
  | {
84
88
  event: "manual_recovery_stopped";
89
+ } | {
90
+ event: "feedback_loop_decided";
91
+ decision: "extend" | "continue" | "abandon";
92
+ loopId: string;
93
+ reason?: string;
85
94
  };
86
95
  export type StageLogLine = StageActivityEvent | StageLifecycleEvent;
87
96
  export declare function truncateActivityText(value: unknown, limit?: number): string | undefined;
@@ -1,5 +1,6 @@
1
1
  import { type StageActivityEvent } from "./activity.js";
2
2
  export declare const TOOL_PROGRESS_THROTTLE_MS = 300;
3
+ export declare const STREAM_FLUSH_THROTTLE_MS = 300;
3
4
  export type StageActivityObserver = {
4
5
  onAssistantTextDelta(delta: string): void;
5
6
  onThinkingDelta(delta: string): void;
@@ -10,6 +11,8 @@ export type StageActivityObserver = {
10
11
  };
11
12
  export type CreateStageActivityObserverOptions = {
12
13
  onActivity?: (event: StageActivityEvent) => void;
14
+ /** Fired for every non-empty assistant text delta, regardless of writeStderr. */
15
+ onAssistantTextDelta?: (delta: string) => void;
13
16
  writeStderr?: boolean;
14
17
  toolProgressThrottleMs?: number;
15
18
  };
@@ -1,5 +1,6 @@
1
1
  import { truncateActivityText, } from "./activity.js";
2
2
  export const TOOL_PROGRESS_THROTTLE_MS = 300;
3
+ export const STREAM_FLUSH_THROTTLE_MS = 300;
3
4
  function writeIndentedPreview(preview) {
4
5
  for (const line of preview.split("\n")) {
5
6
  process.stderr.write(` ${line}\n`);
@@ -127,7 +128,11 @@ export function createStageActivityObserver(options = {}) {
127
128
  };
128
129
  return {
129
130
  onAssistantTextDelta(delta) {
130
- if (!writeStderr || !delta) {
131
+ if (!delta) {
132
+ return;
133
+ }
134
+ options.onAssistantTextDelta?.(delta);
135
+ if (!writeStderr) {
131
136
  return;
132
137
  }
133
138
  assistantStreamedForMessage = true;
@@ -47,6 +47,7 @@ import { readActivityVerbose } from "./activity.js";
47
47
  import { composeStageUserPrompt } from "./piAdapter.js";
48
48
  import { claudeSessionMarkerPath, clearClaudeSessionMarker, readClaudeSessionMarker, writeClaudeSessionMarker, } from "./claudeSession.js";
49
49
  import { DEFAULT_STAGE_TIMEOUT_MS, runStageViaOpen, runtimeStageId, } from "./port.js";
50
+ import { composeTimeoutResumePrompt, stageTimeoutReason, } from "./stageTimeout.js";
50
51
  import { StageMcpError } from "../config/resolveStageMcpServers.js";
51
52
  import { addModelUsage, emptyStageUsage } from "../types/usage.js";
52
53
  /** Built-in tool allowlist, mirroring Pi's sealed-session base set (read/bash/write/edit). */
@@ -295,6 +296,29 @@ async function runTurn(input, model, markerPath, promptText, resumeSessionId, us
295
296
  await clearClaudeSessionMarker(markerPath);
296
297
  return { kind: "completed", result: { ok: true, envelope: emitCapture.envelope, usage } };
297
298
  }
299
+ if (controller.signal.aborted && sessionId !== undefined) {
300
+ await writeClaudeSessionMarker(markerPath, { sessionId, usage });
301
+ return {
302
+ kind: "completed",
303
+ result: {
304
+ ok: false,
305
+ reason: stageTimeoutReason(timeoutMs),
306
+ envelope: emitCapture.envelope,
307
+ usage,
308
+ },
309
+ };
310
+ }
311
+ if (controller.signal.aborted) {
312
+ return {
313
+ kind: "completed",
314
+ result: {
315
+ ok: false,
316
+ reason: stageTimeoutReason(timeoutMs),
317
+ envelope: emitCapture.envelope,
318
+ usage,
319
+ },
320
+ };
321
+ }
298
322
  return {
299
323
  kind: "completed",
300
324
  result: {
@@ -385,6 +409,20 @@ export class ClaudeAgentAdapter {
385
409
  if (!pre.ok) {
386
410
  return { status: "completed", result: { ok: false, reason: pre.reason } };
387
411
  }
412
+ if (input.sessionMode === "timeout_resume") {
413
+ const marker = await readClaudeSessionMarker(markerPath);
414
+ if (marker?.sessionId === undefined) {
415
+ return {
416
+ status: "completed",
417
+ result: {
418
+ ok: false,
419
+ reason: "missing Claude session to resume; use retry to start a new attempt",
420
+ },
421
+ };
422
+ }
423
+ const outcome = await runTurn(input, pre.model, markerPath, composeTimeoutResumePrompt(), marker.sessionId, usage);
424
+ return applyOutcome(outcome);
425
+ }
388
426
  const outcome = await runTurn(input, pre.model, markerPath, composeStageUserPrompt(input, EMIT_STAGE_ENVELOPE_TOOL_NAME, undefined, WRITE_STAGE_ARTIFACT_TOOL_NAME), undefined, usage);
389
427
  return applyOutcome(outcome);
390
428
  },
@@ -69,7 +69,9 @@ export class FakeAgent {
69
69
  let started = false;
70
70
  let resumeCorrupt = false;
71
71
  const sessionMode = input.sessionMode ?? "fresh";
72
- const skipHitlPark = sessionMode === "feedback_resume" || sessionMode === "new_session";
72
+ const skipHitlPark = sessionMode === "feedback_resume" ||
73
+ sessionMode === "timeout_resume" ||
74
+ sessionMode === "new_session";
73
75
  if (sessionMode === "feedback_resume" && input.feedbackLoopContext === undefined) {
74
76
  return createCompletedOnlyStageHandle({
75
77
  stageId: runtimeStageId(input),
@@ -48,6 +48,7 @@ import { mapSessionEventToActivity, readActivityVerbose } from "./activity.js";
48
48
  import { createStageActivityObserver, } from "./activityObserver.js";
49
49
  import { DEFAULT_STAGE_TIMEOUT_MS, runtimeStageId, runStageViaOpen } from "./port.js";
50
50
  import { addModelUsage, emptyStageUsage } from "../types/usage.js";
51
+ import { TIMEOUT_ABORT_TOOL_RESULT, composeTimeoutResumePrompt, stageTimeoutReason, } from "./stageTimeout.js";
51
52
  /**
52
53
  * Stage tool allowlist for sealed Pi sessions.
53
54
  * Includes `ask_operator` unless `gateKinds` is an empty list (R6).
@@ -370,8 +371,12 @@ export function routeSessionEventToProgress(event, options) {
370
371
  * Map Pi session events to StageActivityEvent at the adapter edge, then
371
372
  * forward to the activity observer (stderr + onActivity).
372
373
  */
373
- function attachStageProgress(session, onActivity, usage) {
374
- const observer = createStageActivityObserver({ onActivity, writeStderr: true });
374
+ function attachStageProgress(session, onActivity, usage, onAssistantTextDelta) {
375
+ const observer = createStageActivityObserver({
376
+ onActivity,
377
+ onAssistantTextDelta,
378
+ writeStderr: true,
379
+ });
375
380
  const verbose = readActivityVerbose();
376
381
  const unsubscribe = session.subscribe((event) => {
377
382
  routeSessionEventToProgress(event, {
@@ -1028,6 +1033,7 @@ export class PiAgentAdapter {
1028
1033
  let sessionManager;
1029
1034
  let resumeWaiting = false;
1030
1035
  let feedbackResume = false;
1036
+ let timeoutResume = false;
1031
1037
  let forceNewSession = sessionMode === "new_session";
1032
1038
  if (sessionMode === "feedback_resume") {
1033
1039
  if (input.feedbackLoopContext === undefined) {
@@ -1049,6 +1055,23 @@ export class PiAgentAdapter {
1049
1055
  ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
1050
1056
  feedbackResume = true;
1051
1057
  }
1058
+ else if (sessionMode === "timeout_resume") {
1059
+ if (!existsSync(sessionFile)) {
1060
+ throw new StageSessionReconstructError(`stage session file missing for timeout_resume: ${sessionFile}`, { stageId: runtimeStageId(input), sessionFile });
1061
+ }
1062
+ try {
1063
+ sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), input.roots.cwd);
1064
+ }
1065
+ catch (err) {
1066
+ throw new StageSessionReconstructError(`stage session file corrupt or unreadable for timeout_resume: ${sessionFile}`, {
1067
+ stageId: runtimeStageId(input),
1068
+ sessionFile,
1069
+ cause: err,
1070
+ });
1071
+ }
1072
+ ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
1073
+ timeoutResume = true;
1074
+ }
1052
1075
  else if (sessionMode !== "new_session") {
1053
1076
  if (sessionMode === "waiting_resume" || existsSync(sessionFile)) {
1054
1077
  if (existsSync(sessionFile)) {
@@ -1100,7 +1123,7 @@ export class PiAgentAdapter {
1100
1123
  return;
1101
1124
  }
1102
1125
  session = bound;
1103
- unsubscribeProgress = attachStageProgress(session, input.onActivity, wiring.usage);
1126
+ unsubscribeProgress = attachStageProgress(session, input.onActivity, wiring.usage, input.onAssistantTextDelta);
1104
1127
  })();
1105
1128
  const runWithTimeout = async (work) => {
1106
1129
  await preparePromise;
@@ -1115,13 +1138,17 @@ export class PiAgentAdapter {
1115
1138
  const workPromise = work();
1116
1139
  const abortPromise = new Promise((_, reject) => {
1117
1140
  controller.signal.addEventListener("abort", () => {
1118
- reject(new Error(`stage timed out after ${timeoutMs}ms`));
1141
+ void session?.abort();
1142
+ reject(new Error(stageTimeoutReason(timeoutMs)));
1119
1143
  });
1120
1144
  });
1121
1145
  await Promise.race([workPromise, abortPromise]);
1122
1146
  return { ...resultFromCapture(wiring.capture), usage: wiring.usage };
1123
1147
  }
1124
1148
  catch (err) {
1149
+ if (sessionManager) {
1150
+ ensureStageSessionFlushed(sessionManager, runtimeStageId(input));
1151
+ }
1125
1152
  if (wiring.capture.envelope && isAdvancingEnvelope(wiring.capture.envelope)) {
1126
1153
  return { ok: true, envelope: wiring.capture.envelope, usage: wiring.usage };
1127
1154
  }
@@ -1151,6 +1178,15 @@ export class PiAgentAdapter {
1151
1178
  await session.prompt(composeFeedbackResumePrompt(input));
1152
1179
  return;
1153
1180
  }
1181
+ if (timeoutResume) {
1182
+ syncAgentMessagesFromSession(session, sessionManager);
1183
+ if (findOpenToolCall(sessionManager)) {
1184
+ injectOpaqueAnswerIntoSession(sessionManager, TIMEOUT_ABORT_TOOL_RESULT);
1185
+ syncAgentMessagesFromSession(session, sessionManager);
1186
+ }
1187
+ await session.prompt(composeTimeoutResumePrompt());
1188
+ return;
1189
+ }
1154
1190
  const userPrompt = composeStageUserPrompt(input, wiring.emitDefName, wiring.providerEmitHint, wiring.artifactDefName);
1155
1191
  await session.prompt(userPrompt);
1156
1192
  });
@@ -11,7 +11,7 @@ import type { QaExchange } from "../hitl/qaTrail.js";
11
11
  import type { StageUsage } from "../types/usage.js";
12
12
  /** Opaque to runtime; adapters interpret. */
13
13
  export type StageResumeToken = string;
14
- export type StageSessionMode = "fresh" | "waiting_resume" | "feedback_resume" | "new_session";
14
+ export type StageSessionMode = "fresh" | "waiting_resume" | "feedback_resume" | "timeout_resume" | "new_session";
15
15
  export type StageRepairContext = {
16
16
  prior_attempt: number;
17
17
  /** Operator-authored instructions for an explicitly approved manual retry. */
@@ -60,6 +60,8 @@ export type StageRunInput = {
60
60
  sessionMode?: StageSessionMode;
61
61
  /** Optional observe hook; HITL wait/answer uses openStage beside this. */
62
62
  onActivity?: (event: StageActivityEvent) => void;
63
+ /** Optional raw assistant-text-delta sink, fired alongside onActivity, not instead of it. */
64
+ onAssistantTextDelta?: (delta: string) => void;
63
65
  skillFilePath?: string;
64
66
  forkEmitContext?: ForkEmitContext;
65
67
  cloneEmitContext?: CloneEmitContext;
@@ -0,0 +1,19 @@
1
+ import { type PiHomeDetectResult, type ProviderAuthContext, type ProviderAuthStatus, type ProviderSummary, type ProvidersListResult } from "./providerAuth.js";
2
+ export type ProviderReadinessRow = ProviderSummary & {
3
+ configured: boolean;
4
+ authKind?: ProviderAuthStatus["authKind"];
5
+ source?: string;
6
+ };
7
+ export type ProviderReadinessInspect = {
8
+ authShell: ProvidersListResult["authShell"];
9
+ via: ProvidersListResult["via"];
10
+ detect: PiHomeDetectResult;
11
+ providers: ProviderReadinessRow[];
12
+ };
13
+ export declare function inspectProviderReadiness(cwd: string, ctx?: ProviderAuthContext): Promise<ProviderReadinessInspect>;
14
+ export declare function mapProviderAuthError(err: unknown): {
15
+ status: number;
16
+ body: {
17
+ error: string;
18
+ };
19
+ };
@@ -0,0 +1,35 @@
1
+ import { defaultContext, detectPiHome, getAuthStatus, listProviders, ProviderAuthError, } from "./providerAuth.js";
2
+ function statusList(statuses) {
3
+ return Array.isArray(statuses) ? statuses : [statuses];
4
+ }
5
+ export async function inspectProviderReadiness(cwd, ctx = defaultContext) {
6
+ const listed = await listProviders(cwd, ctx);
7
+ const statuses = statusList(await getAuthStatus(cwd, undefined, ctx));
8
+ const statusById = new Map(statuses.map((status) => [status.providerId, status]));
9
+ const detect = detectPiHome(cwd);
10
+ return {
11
+ authShell: listed.authShell,
12
+ via: listed.via,
13
+ detect,
14
+ providers: listed.providers.map((provider) => {
15
+ const status = statusById.get(provider.id);
16
+ return {
17
+ ...provider,
18
+ configured: status?.configured ?? false,
19
+ ...(status?.authKind !== undefined
20
+ ? { authKind: status.authKind }
21
+ : {}),
22
+ ...(status?.source !== undefined ? { source: status.source } : {}),
23
+ };
24
+ }),
25
+ };
26
+ }
27
+ export function mapProviderAuthError(err) {
28
+ if (err instanceof ProviderAuthError) {
29
+ return { status: err.status, body: { error: err.message } };
30
+ }
31
+ return {
32
+ status: 500,
33
+ body: { error: "Provider auth operation failed" },
34
+ };
35
+ }
@@ -0,0 +1,9 @@
1
+ export declare const STAGE_TIMEOUT_REASON_PREFIX = "stage timed out after ";
2
+ export declare function stageTimeoutReason(timeoutMs: number): string;
3
+ export declare function isStageTimeoutReason(reason: string | undefined): boolean;
4
+ export declare function lastFailedReason(events: ReadonlyArray<{
5
+ event: string;
6
+ reason?: string;
7
+ }>): string | undefined;
8
+ export declare const TIMEOUT_ABORT_TOOL_RESULT = "Command aborted: the stage timed out. Continue without a debugger. Do not use --inspect or --inspect-brk.";
9
+ export declare function composeTimeoutResumePrompt(): string;
@@ -0,0 +1,24 @@
1
+ export const STAGE_TIMEOUT_REASON_PREFIX = "stage timed out after ";
2
+ export function stageTimeoutReason(timeoutMs) {
3
+ return `${STAGE_TIMEOUT_REASON_PREFIX}${timeoutMs}ms`;
4
+ }
5
+ export function isStageTimeoutReason(reason) {
6
+ return (typeof reason === "string" && reason.startsWith(STAGE_TIMEOUT_REASON_PREFIX));
7
+ }
8
+ export function lastFailedReason(events) {
9
+ let reason;
10
+ for (const event of events) {
11
+ if (event.event === "failed")
12
+ reason = event.reason;
13
+ }
14
+ return reason;
15
+ }
16
+ export const TIMEOUT_ABORT_TOOL_RESULT = "Command aborted: the stage timed out. Continue without a debugger. Do not use --inspect or --inspect-brk.";
17
+ export function composeTimeoutResumePrompt() {
18
+ return [
19
+ "The previous turn was interrupted because the stage timed out.",
20
+ "Continue the same work from the conversation above. Do not start over.",
21
+ "Do not attach a debugger (no --inspect or --inspect-brk).",
22
+ "Finish the stage and emit the envelope when done.",
23
+ ].join(" ");
24
+ }
@@ -0,0 +1,2 @@
1
+ /** Scrub common secret-shaped substrings before text is persisted or returned. */
2
+ export declare function redactSecrets(text: string): string;
@@ -0,0 +1,13 @@
1
+ const SECRET_PATTERNS = [
2
+ /\bsk-[A-Za-z0-9_-]{16,}\b/g,
3
+ /\bghp_[A-Za-z0-9]{20,}\b/g,
4
+ /\b[A-Za-z_]*KEY[A-Za-z_]*\s*[:=]\s*['"]?[A-Za-z0-9+/_-]{16,}['"]?/gi,
5
+ ];
6
+ /** Scrub common secret-shaped substrings before text is persisted or returned. */
7
+ export function redactSecrets(text) {
8
+ let out = text;
9
+ for (const pattern of SECRET_PATTERNS) {
10
+ out = out.replace(pattern, "[redacted]");
11
+ }
12
+ return out;
13
+ }
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { readRunArtifact } from "../mcp/readArtifact.js";
4
+ import { globalStageflowHome } from "../project/globalHome.js";
4
5
  import { createRunStore } from "../runstore/createStore.js";
5
6
  import { isInsideDir } from "../runstore/workspaceLayout.js";
6
7
  export const ARTIFACT_USAGE = `Usage:
@@ -73,7 +74,6 @@ function resolveSafeOutPath(outPath, cwd) {
73
74
  }
74
75
  export async function runArtifactCommand(args, options = {}) {
75
76
  const cwd = options.cwd ?? process.cwd();
76
- const projectRoot = options.projectRoot ?? cwd;
77
77
  const out = { ...defaultIo, ...options.io };
78
78
  let parsed;
79
79
  try {
@@ -99,7 +99,7 @@ export async function runArtifactCommand(args, options = {}) {
99
99
  out.error(ARTIFACT_USAGE);
100
100
  return 1;
101
101
  }
102
- const store = createRunStore({ rootDir: projectRoot });
102
+ const store = createRunStore({ rootDir: globalStageflowHome() });
103
103
  try {
104
104
  const contents = await readRunArtifact(store, parsed.runId, parsed.artifactPath);
105
105
  if (parsed.outPath !== undefined) {
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { buildHandoffDeliverable, isHandoffSkipped, } from "./handoffFormat.js";
4
+ import { globalStageflowHome } from "../project/globalHome.js";
4
5
  import { createRunStore } from "../runstore/createStore.js";
5
6
  export const ENVELOPE_USAGE = `Usage:
6
7
  sf envelope get --run <runId> --stage <stageId> [--json] [--from <sf-run.json>] [--detect-stage <id>] [--format envelope|handoff]`;
@@ -198,7 +199,6 @@ function formatHandoffHuman(result) {
198
199
  }
199
200
  export async function runEnvelopeCommand(args, options = {}) {
200
201
  const cwd = options.cwd ?? process.cwd();
201
- const projectRoot = options.projectRoot ?? cwd;
202
202
  const out = { ...defaultIo, ...options.io };
203
203
  let parsed;
204
204
  try {
@@ -224,7 +224,7 @@ export async function runEnvelopeCommand(args, options = {}) {
224
224
  out.error(ENVELOPE_USAGE);
225
225
  return 1;
226
226
  }
227
- const store = createRunStore({ rootDir: projectRoot });
227
+ const store = createRunStore({ rootDir: globalStageflowHome() });
228
228
  let runContext;
229
229
  try {
230
230
  runContext = resolveRunContext(parsed, cwd, store);
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { projectRun } from "../projection/projectRun.js";
4
+ import { globalStageflowHome } from "../project/globalHome.js";
4
5
  import { createRunStore } from "../runstore/createStore.js";
5
6
  import { isInsideDir } from "../runstore/workspaceLayout.js";
6
7
  export const EXPORT_RUN_USAGE = `Usage:
@@ -94,7 +95,6 @@ function assertRunComplete(status, runId) {
94
95
  }
95
96
  export async function runExportRunCommand(args, options = {}) {
96
97
  const cwd = options.cwd ?? process.cwd();
97
- const projectRoot = options.projectRoot ?? cwd;
98
98
  const out = { ...defaultIo, ...options.io };
99
99
  let parsed;
100
100
  try {
@@ -126,7 +126,7 @@ export async function runExportRunCommand(args, options = {}) {
126
126
  out.error(EXPORT_RUN_USAGE);
127
127
  return 1;
128
128
  }
129
- const store = createRunStore({ rootDir: projectRoot });
129
+ const store = createRunStore({ rootDir: globalStageflowHome() });
130
130
  try {
131
131
  const detail = await store.readRun(runId);
132
132
  assertRunComplete(detail.status, runId);
@@ -0,0 +1,15 @@
1
+ import type { LoadedPipeline } from "../types/pipeline.js";
2
+ export declare const GRAPH_USAGE = "Usage:\n sf graph --pipeline <path> [--json]\n\n Prints a definition-time view of how a pipeline graph is wired, before any run.\n --pipeline loads and resolves the pipeline (same resolution as sf validate / sf run).\n --json dumps the resolved DAG as JSON instead of the terminal diagram.";
3
+ export type GraphCommandIo = {
4
+ log: (line: string) => void;
5
+ error: (line: string) => void;
6
+ };
7
+ export declare function runGraphCommand(args: string[], options?: {
8
+ cwd?: string;
9
+ projectRoot?: string;
10
+ io?: Partial<GraphCommandIo>;
11
+ loadPipeline?: (nameOrPath: string, opts: {
12
+ cwd?: string;
13
+ projectRoot?: string;
14
+ }) => Promise<LoadedPipeline>;
15
+ }): Promise<number>;
@@ -0,0 +1,79 @@
1
+ import { loadPipeline } from "../config/loadPipeline.js";
2
+ import { renderGraph } from "./graphRender.js";
3
+ export const GRAPH_USAGE = `Usage:
4
+ sf graph --pipeline <path> [--json]
5
+
6
+ Prints a definition-time view of how a pipeline graph is wired, before any run.
7
+ --pipeline loads and resolves the pipeline (same resolution as sf validate / sf run).
8
+ --json dumps the resolved DAG as JSON instead of the terminal diagram.`;
9
+ const defaultIo = {
10
+ log: (line) => console.log(line),
11
+ error: (line) => console.error(line),
12
+ };
13
+ function parseGraphArgs(args) {
14
+ if (args.length === 0) {
15
+ return { help: false, json: false };
16
+ }
17
+ if (args[0] === "--help" || args[0] === "-h") {
18
+ return { help: true, json: false };
19
+ }
20
+ let pipeline;
21
+ let json = false;
22
+ let help = false;
23
+ for (let i = 0; i < args.length; i++) {
24
+ const arg = args[i];
25
+ if (arg === "--help" || arg === "-h") {
26
+ help = true;
27
+ }
28
+ else if (arg === "--pipeline") {
29
+ const value = args[++i];
30
+ if (value === undefined || value.length === 0) {
31
+ throw new Error("Missing value for --pipeline");
32
+ }
33
+ pipeline = value;
34
+ }
35
+ else if (arg === "--json") {
36
+ json = true;
37
+ }
38
+ else if (arg.startsWith("-")) {
39
+ throw new Error(`Unknown flag: ${arg}`);
40
+ }
41
+ else {
42
+ throw new Error(`Unexpected argument: ${arg}`);
43
+ }
44
+ }
45
+ return { help, pipeline, json };
46
+ }
47
+ export async function runGraphCommand(args, options = {}) {
48
+ const cwd = options.cwd ?? process.cwd();
49
+ const projectRoot = options.projectRoot ?? cwd;
50
+ const out = { ...defaultIo, ...options.io };
51
+ const loadPipelineFn = options.loadPipeline ?? loadPipeline;
52
+ try {
53
+ const parsed = parseGraphArgs(args);
54
+ if (parsed.help) {
55
+ out.error(GRAPH_USAGE);
56
+ return 0;
57
+ }
58
+ if (parsed.pipeline === undefined) {
59
+ throw new Error("Missing value for --pipeline");
60
+ }
61
+ const loaded = await loadPipelineFn(parsed.pipeline, { cwd, projectRoot });
62
+ if (parsed.json) {
63
+ out.log(JSON.stringify(loaded.dag, null, 2));
64
+ return 0;
65
+ }
66
+ out.log(renderGraph(loaded.dag));
67
+ return 0;
68
+ }
69
+ catch (err) {
70
+ const message = err instanceof Error
71
+ ? err.message
72
+ : typeof err === "string"
73
+ ? err
74
+ : "Graph command failed";
75
+ out.error(message);
76
+ out.error(GRAPH_USAGE);
77
+ return 1;
78
+ }
79
+ }
@@ -0,0 +1,2 @@
1
+ import type { ResolvedPipelineDag } from "../types/pipeline.js";
2
+ export declare function renderGraph(dag: ResolvedPipelineDag): string;
@@ -0,0 +1,85 @@
1
+ const MAX_WIDTH = 80;
2
+ // Clip to MAX_WIDTH code points so the picture fits an 80-column terminal.
3
+ function clampWidth(text, max = MAX_WIDTH) {
4
+ const chars = [...text];
5
+ if (chars.length <= max)
6
+ return text;
7
+ return chars.slice(0, max - 1).join("") + "…";
8
+ }
9
+ function cloneModeSuffix(node) {
10
+ return node.clone_mode ? ` (${node.clone_mode})` : "";
11
+ }
12
+ function buildAnnotations(node) {
13
+ const parts = [];
14
+ if (node.entry)
15
+ parts.push("entry");
16
+ if (node.clone_cap !== undefined) {
17
+ parts.push(`clone ${node.clone_cap}${cloneModeSuffix(node)}`);
18
+ }
19
+ if (node.replay_safe === false) {
20
+ parts.push("replay_safe: false");
21
+ }
22
+ return parts.join(" · ");
23
+ }
24
+ // loadPipeline returns a topologically ordered DAG, so a DF walk over childrenOf yields a stable, dependency-ordered list.
25
+ function orderedNodeIds(dag) {
26
+ const visited = new Set();
27
+ const order = [];
28
+ const walk = (id) => {
29
+ if (visited.has(id))
30
+ return;
31
+ visited.add(id);
32
+ order.push(id);
33
+ for (const child of dag.childrenOf[id] ?? [])
34
+ walk(child);
35
+ };
36
+ for (const root of dag.roots)
37
+ walk(root);
38
+ for (const node of dag.nodes) {
39
+ if (!visited.has(node.id))
40
+ walk(node.id);
41
+ }
42
+ return order;
43
+ }
44
+ function cloneBandLabel(node) {
45
+ if (node.clone_cap === undefined)
46
+ return "";
47
+ return ` child~${node.clone_cap}${cloneModeSuffix(node)}`;
48
+ }
49
+ function loopCueLine(node) {
50
+ if (!node.feedback_loop)
51
+ return "";
52
+ const fl = node.feedback_loop;
53
+ const note = fl.on_max_replays ? ` (${fl.on_max_replays})` : "";
54
+ return ` ↩ loop ×${fl.max_replays} → ${fl.target}${note}`;
55
+ }
56
+ export function renderGraph(dag) {
57
+ const byId = new Map(dag.nodes.map((node) => [node.id, node]));
58
+ const order = orderedNodeIds(dag);
59
+ const lines = [];
60
+ const push = (line) => lines.push(clampWidth(line));
61
+ order.forEach((id, idx) => {
62
+ const node = byId.get(id);
63
+ const marker = node.entry ? "▶ " : " ";
64
+ const annotations = buildAnnotations(node);
65
+ push(`${marker}${id}` + (annotations ? ` ${annotations}` : ""));
66
+ const loopCue = loopCueLine(node);
67
+ if (loopCue)
68
+ push(loopCue);
69
+ if (idx < order.length - 1) {
70
+ const children = dag.childrenOf[id] ?? [];
71
+ if (children.length === 1) {
72
+ push(" │");
73
+ push(` ▼${cloneBandLabel(node)}`);
74
+ }
75
+ else if (children.length > 1) {
76
+ children.forEach((childId, ci) => {
77
+ const isLast = ci === children.length - 1;
78
+ const branch = isLast ? "└─" : "├─";
79
+ push(` ${branch}▶ ${childId}${cloneBandLabel(node)}`);
80
+ });
81
+ }
82
+ }
83
+ });
84
+ return lines.join("\n") + "\n";
85
+ }