stageflow 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +43 -18
  2. package/dist/agent/claudeAdapter.js +15 -1
  3. package/dist/agent/claudeTools.d.ts +13 -0
  4. package/dist/agent/claudeTools.js +98 -0
  5. package/dist/agent/piAdapter.js +38 -16
  6. package/dist/agent/sandboxPiTools.d.ts +99 -0
  7. package/dist/agent/sandboxPiTools.js +109 -0
  8. package/dist/config/hostConfig.js +2 -0
  9. package/dist/config/loadTask.js +34 -1
  10. package/dist/mcp/catalogTools.js +11 -1
  11. package/dist/package-meta.d.ts +1 -1
  12. package/dist/package-meta.js +1 -1
  13. package/dist/runtime/gitCheckoutCapability.d.ts +1 -0
  14. package/dist/runtime/gitCheckoutCapability.js +1 -1
  15. package/dist/runtime/gitWorktreeCheckout.d.ts +22 -0
  16. package/dist/runtime/gitWorktreeCheckout.js +120 -0
  17. package/dist/runtime/sandboxContainer.d.ts +62 -0
  18. package/dist/runtime/sandboxContainer.js +151 -0
  19. package/dist/runtime/sandboxDesk.d.ts +28 -0
  20. package/dist/runtime/sandboxDesk.js +242 -0
  21. package/dist/runtime/sandboxFiles.d.ts +20 -0
  22. package/dist/runtime/sandboxFiles.js +150 -0
  23. package/dist/runtime/stageEnvironment.js +2 -0
  24. package/dist/runtime/stageRoots.d.ts +9 -0
  25. package/dist/runtime/stageRoots.js +14 -0
  26. package/dist/runtime/stageRunner.d.ts +5 -0
  27. package/dist/runtime/stageRunner.js +38 -4
  28. package/dist/runtime/stageSandbox.d.ts +16 -0
  29. package/dist/runtime/stageSandbox.js +54 -0
  30. package/dist/runtime/stageWorker.js +138 -105
  31. package/dist/runtime/workspaceBinding.d.ts +2 -1
  32. package/dist/runtime/workspaceBinding.js +20 -4
  33. package/dist/tools/readEmail.d.ts +3 -3
  34. package/dist/types/task.d.ts +11 -1
  35. package/examples/memory/README.md +2 -0
  36. package/examples/memory/probe-bash.task.yaml +3 -0
  37. package/examples/memory/sandbox-memory-bash.pipeline.yaml +28 -0
  38. package/examples/ship-feature/README.md +15 -0
  39. package/package.json +1 -1
@@ -11,6 +11,7 @@ import { bindPiAgentDirEnv, overlayStageBindingEnv, rootsForStageWorker, stageBi
11
11
  import { buildStageEnvironment } from "./stageEnvironment.js";
12
12
  import { ensureStageCacheDirs } from "./stageCacheEnv.js";
13
13
  import { PACKAGE_VERSION } from "../package-meta.js";
14
+ import { openStageSandbox } from "./stageSandbox.js";
14
15
  import { runStage, isRunStageWaiting, } from "./stageRunner.js";
15
16
  import { openStageAttempt } from "./stageAttemptBootstrap.js";
16
17
  import { attemptContext } from "./stageAttemptContext.js";
@@ -60,97 +61,137 @@ export async function runStageWorker(input) {
60
61
  .filter((e) => e[1] !== undefined));
61
62
  const mode = input.mode ?? "run";
62
63
  const attempt = input.attempt ?? 1;
63
- loadNamedSecretsFromAttemptDir(attemptWorkspaceDir(workspaceDir, input.stageId, attempt));
64
+ const attemptDir = attemptWorkspaceDir(workspaceDir, input.stageId, attempt);
65
+ loadNamedSecretsFromAttemptDir(attemptDir);
64
66
  const attemptCtx = input.attempt !== undefined ? attemptContext(input.attempt) : undefined;
65
67
  const eventOptions = { attempt };
66
- const roots = withResolvedAuthPath(rootsForStageWorker(workspaceDir, input.stageId, stage.model, checkoutRoot, attemptCtx), input.rootDir);
68
+ let roots = withResolvedAuthPath(rootsForStageWorker(workspaceDir, input.stageId, stage.model, checkoutRoot, attemptCtx), input.rootDir);
67
69
  const unbindAgentDir = bindPiAgentDirEnv(roots.agentDir);
70
+ const sandbox = await openStageSandbox({
71
+ env: process.env,
72
+ roots,
73
+ runId: input.runId,
74
+ stageId: input.stageId,
75
+ attempt,
76
+ attemptDir,
77
+ ...(checkoutRoot !== undefined ? { checkoutRoot } : {}),
78
+ });
79
+ roots = sandbox.roots;
80
+ const beforeVerify = sandbox.beforeVerify;
81
+ let outcome;
82
+ let thrown;
68
83
  try {
69
- if (mode === "resume") {
70
- const opened = await openStageAttempt({
71
- agent,
72
- store,
73
- runId: input.runId,
74
- stage,
75
- stageId: input.stageId,
76
- task,
77
- dag,
78
- checkoutRoot,
79
- workspaceDir,
80
- factoryCwd: input.rootDir,
81
- attemptCtx,
82
- operatorCatalog: input.operatorCatalog,
83
- roots,
84
- resumeToken: input.sessionFilePath,
85
- sessionMode: "waiting_resume",
86
- stageEnv,
87
- onActivity: (event) => {
88
- void store.appendStageEvent(input.runId, input.stageId, event, eventOptions);
89
- },
90
- });
91
- if (!opened.ok) {
92
- return { ok: false, reason: opened.reason };
84
+ outcome = await (async () => {
85
+ if (sandbox.failure !== undefined) {
86
+ return { ok: false, reason: sandbox.failure };
93
87
  }
94
- opened.handle.deliverAnswer(input.resumeAnswer);
95
- const outcome = await runStage({
96
- agent,
97
- store,
98
- runId: input.runId,
99
- stage,
100
- stageId: input.stageId,
101
- task,
102
- dag,
103
- checkoutRoot,
104
- workspaceDir,
105
- skipStarted: true,
106
- existingHandle: opened.handle,
107
- workerMode: true,
108
- roots,
109
- attemptCtx,
110
- factoryCwd: input.rootDir,
111
- operatorCatalog: input.operatorCatalog,
112
- skipGates: input.skipGates,
113
- stageEnv,
114
- });
115
- return outcome;
116
- }
117
- if (mode === "feedback_resume" || mode === "new_session") {
118
- const feedbackLoopContext = await loadActiveFeedbackLoopContext(store, input.runId, input.stageId);
119
- if (mode === "feedback_resume" && feedbackLoopContext === undefined) {
120
- return {
121
- ok: false,
122
- reason: "feedback_resume requires an active feedback-loop context",
123
- };
88
+ if (mode === "resume") {
89
+ const opened = await openStageAttempt({
90
+ agent,
91
+ store,
92
+ runId: input.runId,
93
+ stage,
94
+ stageId: input.stageId,
95
+ task,
96
+ dag,
97
+ checkoutRoot,
98
+ workspaceDir,
99
+ factoryCwd: input.rootDir,
100
+ attemptCtx,
101
+ operatorCatalog: input.operatorCatalog,
102
+ roots,
103
+ resumeToken: input.sessionFilePath,
104
+ sessionMode: "waiting_resume",
105
+ stageEnv,
106
+ onActivity: (event) => {
107
+ void store.appendStageEvent(input.runId, input.stageId, event, eventOptions);
108
+ },
109
+ });
110
+ if (!opened.ok) {
111
+ return { ok: false, reason: opened.reason };
112
+ }
113
+ opened.handle.deliverAnswer(input.resumeAnswer);
114
+ const outcome = await runStage({
115
+ agent,
116
+ store,
117
+ runId: input.runId,
118
+ stage,
119
+ stageId: input.stageId,
120
+ task,
121
+ dag,
122
+ checkoutRoot,
123
+ workspaceDir,
124
+ skipStarted: true,
125
+ existingHandle: opened.handle,
126
+ workerMode: true,
127
+ roots,
128
+ attemptCtx,
129
+ factoryCwd: input.rootDir,
130
+ operatorCatalog: input.operatorCatalog,
131
+ skipGates: input.skipGates,
132
+ stageEnv,
133
+ beforeVerify,
134
+ });
135
+ return outcome;
124
136
  }
125
- const priorAttempt = feedbackLoopContext?.prior_stage_attempt;
126
- const resumeToken = input.sessionFilePath ??
127
- (mode === "feedback_resume" && priorAttempt !== undefined
128
- ? attemptContext(priorAttempt).sessionPath(workspaceDir, input.stageId)
129
- : undefined);
130
- const opened = await openStageAttempt({
131
- agent,
132
- store,
133
- runId: input.runId,
134
- stage,
135
- stageId: input.stageId,
136
- task,
137
- dag,
138
- checkoutRoot,
139
- workspaceDir,
140
- factoryCwd: input.rootDir,
141
- attemptCtx,
142
- operatorCatalog: input.operatorCatalog,
143
- roots,
144
- ...(resumeToken !== undefined ? { resumeToken } : {}),
145
- sessionMode: mode,
146
- ...(feedbackLoopContext !== undefined ? { feedbackLoopContext } : {}),
147
- stageEnv,
148
- onActivity: (event) => {
149
- void store.appendStageEvent(input.runId, input.stageId, event, eventOptions);
150
- },
151
- });
152
- if (!opened.ok) {
153
- return { ok: false, reason: opened.reason };
137
+ if (mode === "feedback_resume" || mode === "new_session") {
138
+ const feedbackLoopContext = await loadActiveFeedbackLoopContext(store, input.runId, input.stageId);
139
+ if (mode === "feedback_resume" && feedbackLoopContext === undefined) {
140
+ return {
141
+ ok: false,
142
+ reason: "feedback_resume requires an active feedback-loop context",
143
+ };
144
+ }
145
+ const priorAttempt = feedbackLoopContext?.prior_stage_attempt;
146
+ const resumeToken = input.sessionFilePath ??
147
+ (mode === "feedback_resume" && priorAttempt !== undefined
148
+ ? attemptContext(priorAttempt).sessionPath(workspaceDir, input.stageId)
149
+ : undefined);
150
+ const opened = await openStageAttempt({
151
+ agent,
152
+ store,
153
+ runId: input.runId,
154
+ stage,
155
+ stageId: input.stageId,
156
+ task,
157
+ dag,
158
+ checkoutRoot,
159
+ workspaceDir,
160
+ factoryCwd: input.rootDir,
161
+ attemptCtx,
162
+ operatorCatalog: input.operatorCatalog,
163
+ roots,
164
+ ...(resumeToken !== undefined ? { resumeToken } : {}),
165
+ sessionMode: mode,
166
+ ...(feedbackLoopContext !== undefined ? { feedbackLoopContext } : {}),
167
+ stageEnv,
168
+ onActivity: (event) => {
169
+ void store.appendStageEvent(input.runId, input.stageId, event, eventOptions);
170
+ },
171
+ });
172
+ if (!opened.ok) {
173
+ return { ok: false, reason: opened.reason };
174
+ }
175
+ return runStage({
176
+ agent,
177
+ store,
178
+ runId: input.runId,
179
+ stage,
180
+ stageId: input.stageId,
181
+ task,
182
+ dag,
183
+ checkoutRoot,
184
+ workspaceDir,
185
+ existingHandle: opened.handle,
186
+ workerMode: true,
187
+ roots,
188
+ attemptCtx,
189
+ factoryCwd: input.rootDir,
190
+ operatorCatalog: input.operatorCatalog,
191
+ skipGates: input.skipGates,
192
+ stageEnv,
193
+ beforeVerify,
194
+ });
154
195
  }
155
196
  return runStage({
156
197
  agent,
@@ -162,7 +203,6 @@ export async function runStageWorker(input) {
162
203
  dag,
163
204
  checkoutRoot,
164
205
  workspaceDir,
165
- existingHandle: opened.handle,
166
206
  workerMode: true,
167
207
  roots,
168
208
  attemptCtx,
@@ -170,30 +210,23 @@ export async function runStageWorker(input) {
170
210
  operatorCatalog: input.operatorCatalog,
171
211
  skipGates: input.skipGates,
172
212
  stageEnv,
213
+ beforeVerify,
173
214
  });
174
- }
175
- return runStage({
176
- agent,
177
- store,
178
- runId: input.runId,
179
- stage,
180
- stageId: input.stageId,
181
- task,
182
- dag,
183
- checkoutRoot,
184
- workspaceDir,
185
- workerMode: true,
186
- roots,
187
- attemptCtx,
188
- factoryCwd: input.rootDir,
189
- operatorCatalog: input.operatorCatalog,
190
- skipGates: input.skipGates,
191
- stageEnv,
192
- });
215
+ })();
216
+ }
217
+ catch (err) {
218
+ thrown = err;
193
219
  }
194
220
  finally {
195
221
  unbindAgentDir();
222
+ await sandbox.close();
223
+ }
224
+ if (thrown !== undefined)
225
+ throw thrown;
226
+ if (outcome === undefined) {
227
+ throw new Error("stage worker finished without an outcome");
196
228
  }
229
+ return outcome;
197
230
  }
198
231
  export function sendWorkerResult(result) {
199
232
  if (typeof process.send !== "function") {
@@ -1,4 +1,5 @@
1
1
  import { type LoadOutcome } from "../config/loadOutcome.js";
2
+ import type { CheckoutDescriptor } from "../types/task.js";
2
3
  export type WorkspaceBinding = {
3
4
  kind: "repository";
4
5
  repository: string;
@@ -12,7 +13,7 @@ export type WorkspaceBinding = {
12
13
  export type WorkspaceBindingInput = {
13
14
  repository?: string;
14
15
  ref?: string;
15
- checkout?: string;
16
+ checkout?: CheckoutDescriptor;
16
17
  };
17
18
  export type ResolveWorkspaceBindingOptions = {
18
19
  checkoutOverride?: string;
@@ -9,12 +9,25 @@ function nonEmptyTrimmed(value) {
9
9
  function isGithubOwnerRepo(repository) {
10
10
  return OWNER_REPO_RE.test(repository);
11
11
  }
12
+ function resolveCheckoutBinding(checkout, checkoutOverride) {
13
+ const override = nonEmptyTrimmed(checkoutOverride);
14
+ if (override !== undefined)
15
+ return override;
16
+ if (checkout === undefined)
17
+ return undefined;
18
+ if (typeof checkout === "string")
19
+ return nonEmptyTrimmed(checkout);
20
+ if (typeof checkout === "object" && checkout !== null && !Array.isArray(checkout)) {
21
+ return checkout;
22
+ }
23
+ return undefined;
24
+ }
12
25
  export function resolveWorkspaceBinding(task, options) {
13
26
  const repository = nonEmptyTrimmed(task.repository);
14
27
  const ref = nonEmptyTrimmed(task.ref);
15
- const checkoutPath = nonEmptyTrimmed(options?.checkoutOverride ?? task.checkout);
28
+ const checkoutBinding = resolveCheckoutBinding(task.checkout, options?.checkoutOverride);
16
29
  const hasRepositoryField = repository !== undefined;
17
- if (hasRepositoryField && checkoutPath !== undefined) {
30
+ if (hasRepositoryField && checkoutBinding !== undefined) {
18
31
  return loadFailure([
19
32
  {
20
33
  code: "task.binding_conflict",
@@ -53,8 +66,11 @@ export function resolveWorkspaceBinding(task, options) {
53
66
  }
54
67
  return loadSuccess({ kind: "repository", repository, ref });
55
68
  }
56
- if (checkoutPath !== undefined) {
57
- return loadSuccess({ kind: "checkout", path: checkoutPath });
69
+ if (typeof checkoutBinding === "string") {
70
+ return loadSuccess({ kind: "checkout", path: checkoutBinding });
71
+ }
72
+ if (typeof checkoutBinding === "object") {
73
+ return loadSuccess({ kind: "checkout", path: "" });
58
74
  }
59
75
  return loadSuccess({ kind: "unbound" });
60
76
  }
@@ -6,12 +6,12 @@ export declare function createSearchEmailTool(email: StageEmail): {
6
6
  description: string;
7
7
  parameters: Type.TObject<Type.TProperties>;
8
8
  execute(_toolCallId: string, input: unknown): Promise<{
9
- isError?: undefined;
10
9
  content: {
11
10
  type: "text";
12
11
  text: string;
13
12
  }[];
14
13
  details: unknown;
14
+ isError?: undefined;
15
15
  } | {
16
16
  content: {
17
17
  type: "text";
@@ -31,12 +31,12 @@ export declare function createGetEmailTool(email: StageEmail): {
31
31
  description: string;
32
32
  parameters: Type.TObject<Type.TProperties>;
33
33
  execute(_toolCallId: string, input: unknown): Promise<{
34
- isError?: undefined;
35
34
  content: {
36
35
  type: "text";
37
36
  text: string;
38
37
  }[];
39
38
  details: unknown;
39
+ isError?: undefined;
40
40
  } | {
41
41
  content: {
42
42
  type: "text";
@@ -56,12 +56,12 @@ export declare function createDownloadEmailAttachmentTool(email: StageEmail): {
56
56
  description: string;
57
57
  parameters: Type.TObject<Type.TProperties>;
58
58
  execute(_toolCallId: string, input: unknown): Promise<{
59
- isError?: undefined;
60
59
  content: {
61
60
  type: "text";
62
61
  text: string;
63
62
  }[];
64
63
  details: unknown;
64
+ isError?: undefined;
65
65
  } | {
66
66
  content: {
67
67
  type: "text";
@@ -2,12 +2,22 @@ export type TaskGitIdentity = {
2
2
  name?: string;
3
3
  email?: string;
4
4
  };
5
+ /**
6
+ * A string points at a pre-made checkout directory (validated, never
7
+ * created — today's behavior). An object asks Stageflow to create/reuse a
8
+ * git worktree for `branch` (auto-named from the task id when omitted),
9
+ * branching off `base` (default: HEAD) the first time it's created.
10
+ */
11
+ export type CheckoutDescriptor = string | {
12
+ branch?: string;
13
+ base?: string;
14
+ };
5
15
  export type TaskFile = {
6
16
  id: string;
7
17
  goal: string;
8
18
  context?: string;
9
19
  constraints?: string;
10
- checkout?: string;
20
+ checkout?: CheckoutDescriptor;
11
21
  repository?: string;
12
22
  ref?: string;
13
23
  run_branch_template?: string;
@@ -96,4 +96,6 @@ After `desk-revisit-outage`, the release card should be the rollback-finished no
96
96
 
97
97
  The operator, in **Memories**, can list keys, open the `TICKET-1842` card, correct it, and delete it. Delete removes the current card and the previous copy and cannot be undone. The page shows the previous card copy and the previous cover text when they exist. The operator can edit the cover. The operator does not create cards; a session adds them. There is one previous copy, not a full history, and no restore action.
98
98
 
99
+ `sandbox-memory-bash` is the sandbox probe. It attaches `shared-notes` and its `probe` stage runs one bash command, then reports whether that command printed a `body.md` path. The task is `probe-bash`. Create `shared-notes` before running it. Edit the `probe` stage prompt in the pipeline file.
100
+
99
101
  This directory is excluded from the repo-root catalog scan, because those commands fail until `notebook` exists.
@@ -0,0 +1,3 @@
1
+ id: probe-bash
2
+ goal: See whether bash can find memory files on the host.
3
+ input: {}
@@ -0,0 +1,28 @@
1
+ id: sandbox-memory-bash
2
+ stages:
3
+ - id: probe
4
+ model: openrouter/nvidia/nemotron-3.5-lightning:free
5
+ system_prompt: |
6
+ Use the bash tool to look up the other memory which we dont have access to if its their. it might not be if its not found.
7
+ if found or not then call the following emit stage tool.
8
+ then also add something to the memory you have access to .
9
+ Then call emit_stage_envelope once with status success, a one-line summary, artifacts [], and payload {"found": true if the bash found the other memory, otherwise false, "output":"<the bash stdout>"}.
10
+ io:
11
+ input:
12
+ schema:
13
+ type: object
14
+ additionalProperties: true
15
+ output:
16
+ schema:
17
+ type: object
18
+ required:
19
+ - found
20
+ - output
21
+ properties:
22
+ found:
23
+ type: boolean
24
+ output:
25
+ type: string
26
+ additionalProperties: false
27
+ memories:
28
+ - shared-notes
@@ -121,6 +121,21 @@ run finishes (or if it fails partway through), remove the worktree with
121
121
  `git worktree remove ../your-repo-feature-branch` from the main clone once
122
122
  you no longer need it.
123
123
 
124
+ Alternatively, let Stageflow do this for you: point `checkout` at an object
125
+ instead of a path —
126
+
127
+ ```yaml
128
+ checkout:
129
+ branch: your-feature-branch # optional — defaults to stageflow/<task id>
130
+ base: origin/main # optional — defaults to HEAD
131
+ ```
132
+
133
+ — and Stageflow creates (or reuses, if it already exists) a worktree for
134
+ that branch the same way, as a sibling of your project root
135
+ (`../.stageflow-worktrees/<branch>`). It's never auto-removed, same as one
136
+ you make by hand — clean it up yourself with `git worktree remove` when
137
+ you're done.
138
+
124
139
  ## Create a task
125
140
 
126
141
  Task context is the pipeline's portable feature brief. Include enough detail
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stageflow",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Stageflow — CLI pipeline runtime for configurable stages on Pi",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",