@snappedly-tools/shipyard 0.5.0 → 0.6.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 (58) hide show
  1. package/README.md +56 -8
  2. package/dist/index.js +27 -9
  3. package/dist/index.js.map +1 -1
  4. package/dist/main.js +51 -15
  5. package/dist/main.js.map +1 -1
  6. package/dist/templates/parallel-planner/block-scope.sh +160 -0
  7. package/dist/templates/parallel-planner/conflict-prompt.md +22 -0
  8. package/dist/templates/parallel-planner/handoff.sh +175 -0
  9. package/dist/templates/parallel-planner/implement-prompt.md +10 -56
  10. package/dist/templates/parallel-planner/main.mts +434 -188
  11. package/dist/templates/parallel-planner/merge-prompt.md +9 -21
  12. package/dist/templates/parallel-planner/plan-prompt.md +5 -31
  13. package/dist/templates/parallel-planner/select-issues.mjs +788 -0
  14. package/dist/templates/parallel-planner/setup.sh +51 -0
  15. package/dist/templates/parallel-planner/spec-wave-prompt.md +16 -0
  16. package/dist/templates/parallel-planner/template.json +1 -1
  17. package/dist/templates/parallel-planner/triage-prompt.md +5 -0
  18. package/dist/templates/parallel-planner/verify-triage.sh +19 -0
  19. package/dist/templates/parallel-planner-with-review/block-scope.sh +160 -0
  20. package/dist/templates/parallel-planner-with-review/conflict-prompt.md +22 -0
  21. package/dist/templates/parallel-planner-with-review/handoff.sh +175 -0
  22. package/dist/templates/parallel-planner-with-review/implement-prompt.md +10 -56
  23. package/dist/templates/parallel-planner-with-review/main.mts +473 -205
  24. package/dist/templates/parallel-planner-with-review/merge-prompt.md +9 -21
  25. package/dist/templates/parallel-planner-with-review/plan-prompt.md +5 -31
  26. package/dist/templates/parallel-planner-with-review/review-prompt.md +11 -49
  27. package/dist/templates/parallel-planner-with-review/select-issues.mjs +788 -0
  28. package/dist/templates/parallel-planner-with-review/setup.sh +51 -0
  29. package/dist/templates/parallel-planner-with-review/spec-wave-prompt.md +16 -0
  30. package/dist/templates/parallel-planner-with-review/template.json +1 -1
  31. package/dist/templates/parallel-planner-with-review/triage-prompt.md +5 -0
  32. package/dist/templates/parallel-planner-with-review/verify-triage.sh +19 -0
  33. package/dist/templates/sequential-reviewer/block-scope.sh +160 -0
  34. package/dist/templates/sequential-reviewer/handoff.sh +175 -0
  35. package/dist/templates/sequential-reviewer/implement-prompt.md +12 -46
  36. package/dist/templates/sequential-reviewer/main.mts +199 -103
  37. package/dist/templates/sequential-reviewer/review-prompt.md +11 -49
  38. package/dist/templates/sequential-reviewer/select-issues.mjs +788 -0
  39. package/dist/templates/sequential-reviewer/setup.sh +51 -0
  40. package/dist/templates/sequential-reviewer/template.json +1 -1
  41. package/dist/templates/sequential-reviewer/triage-prompt.md +5 -0
  42. package/dist/templates/sequential-reviewer/verify-triage.sh +19 -0
  43. package/dist/templates/shared/block-scope.sh +160 -0
  44. package/dist/templates/shared/handoff.sh +175 -0
  45. package/dist/templates/shared/select-issues.mjs +788 -0
  46. package/dist/templates/shared/setup.sh +51 -0
  47. package/dist/templates/shared/triage-prompt.md +5 -0
  48. package/dist/templates/shared/verify-triage.sh +19 -0
  49. package/dist/templates/simple-loop/block-scope.sh +160 -0
  50. package/dist/templates/simple-loop/handoff.sh +175 -0
  51. package/dist/templates/simple-loop/main.mts +190 -45
  52. package/dist/templates/simple-loop/prompt.md +12 -46
  53. package/dist/templates/simple-loop/select-issues.mjs +788 -0
  54. package/dist/templates/simple-loop/setup.sh +51 -0
  55. package/dist/templates/simple-loop/template.json +1 -1
  56. package/dist/templates/simple-loop/triage-prompt.md +5 -0
  57. package/dist/templates/simple-loop/verify-triage.sh +19 -0
  58. package/package.json +1 -1
@@ -1,237 +1,505 @@
1
- // Parallel Planner with Review — four-phase orchestration loop
2
- //
3
- // This template drives a multi-phase workflow:
4
- // Phase 1 (Plan): A strong Codex agent analyzes open issues, builds a
5
- // dependency graph, and outputs a <plan> JSON
6
- // listing unblocked issues with branch names.
7
- // Phase 2 (Execute + Review): For each issue, a sandbox is created via
8
- // createSandbox(). The implementer runs first
9
- // (100 iterations). If it completes, a reviewer
10
- // runs in the same sandbox on the same branch
11
- // (1 iteration). All issue pipelines run
12
- // concurrently via Promise.allSettled(). A completion
13
- // signal also qualifies a branch whose work predates
14
- // the current run.
15
- // Phase 3 (Merge): A strong Codex agent merges all completed
16
- // branches into the current branch.
17
- //
18
- // The outer loop repeats up to MAX_ITERATIONS times so that newly unblocked
19
- // issues are picked up after each round of merges.
20
- //
21
- // Generated entrypoint: .shipyard/main.mts
22
- // Usage:
23
- // npx shipyard run
24
- // Or add to package.json:
25
- // "scripts": { "shipyard": "shipyard run" }
26
-
1
+ // Plan activated scopes, implement independent tickets in parallel, then
2
+ // integrate each scope on one branch for human review.
3
+ import { execFileSync } from "node:child_process";
4
+ import { existsSync } from "node:fs";
27
5
  import * as shipyard from "@snappedly-tools/shipyard";
28
6
  import { docker } from "@snappedly-tools/shipyard/sandboxes/docker";
29
7
  import { z } from "zod";
30
8
 
31
- // The planner emits its plan as JSON inside <plan> tags; Output.object extracts
32
- // and validates it against this schema. We use Zod here, but any Standard
33
- // Schema validator works just as well — Valibot, ArkType, etc. See
34
- // https://standardschema.dev.
35
- const planSchema = z.object({
36
- issues: z.array(
37
- z.object({ id: z.string(), title: z.string(), branch: z.string() }),
38
- ),
39
- });
40
-
41
- // ---------------------------------------------------------------------------
42
- // Configuration
43
- // ---------------------------------------------------------------------------
44
-
45
- // Maximum number of plan→execute→merge cycles before stopping.
46
- // Raise this if your backlog is large; lower it for a quick smoke-test run.
47
- const MAX_ITERATIONS = 10;
48
-
49
- // Hooks run inside the sandbox before the agent starts each iteration.
50
- // npm install ensures the sandbox always has fresh dependencies.
9
+ if (process.loadEnvFile && existsSync(".shipyard/.env"))
10
+ process.loadEnvFile(".shipyard/.env");
11
+ const targetBranch = execFileSync("git", ["branch", "--show-current"], {
12
+ encoding: "utf8",
13
+ }).trim();
14
+ const repository = execFileSync(
15
+ "gh",
16
+ ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
17
+ { encoding: "utf8" },
18
+ ).trim();
19
+ if (
20
+ !/^[A-Za-z0-9._/-]+$/.test(targetBranch) ||
21
+ !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)
22
+ )
23
+ throw new Error("Invalid target branch or GitHub repository");
24
+ process.env.GH_REPO = repository;
51
25
  const hooks = {
52
- sandbox: { onSandboxReady: [{ command: "npm install" }] },
26
+ sandbox: {
27
+ onSandboxReady: [
28
+ { command: "timeout 300 bash .shipyard/setup.sh", timeoutMs: 300_000 },
29
+ ],
30
+ },
31
+ };
32
+ const closeClean = async (sandbox: {
33
+ close: () => Promise<{ preservedWorktreePath?: string }>;
34
+ }) => {
35
+ const { preservedWorktreePath } = await sandbox.close();
36
+ if (preservedWorktreePath)
37
+ throw new Error(`Sandbox has uncommitted work at ${preservedWorktreePath}`);
38
+ };
39
+ const verifyTriage = (ticketId: string) => {
40
+ try {
41
+ execFileSync("bash", [".shipyard/verify-triage.sh", ticketId, repository], {
42
+ encoding: "utf8",
43
+ });
44
+ } catch (error) {
45
+ const detail = (error as { stderr?: string | Buffer }).stderr
46
+ ?.toString()
47
+ .trim();
48
+ throw new Error(detail || `Could not verify triage for #${ticketId}`);
49
+ }
50
+ };
51
+ const planSchema = z.object({ issues: z.array(z.object({ id: z.string() })) });
52
+ type Ticket = {
53
+ id: string;
54
+ title: string;
55
+ body: string;
56
+ state: string;
57
+ blockedBy: Array<{ id: string; title: string; state: string }>;
58
+ };
59
+ type Scope = {
60
+ id: string;
61
+ title: string;
62
+ branch: string;
63
+ kind: "standalone" | "spec";
64
+ body?: string;
65
+ tickets?: Ticket[];
66
+ completedTicketIds?: string[];
67
+ outstandingTicketIds?: string[];
68
+ };
69
+ const complete = (
70
+ result: { stdout: string; completionSignal?: string | null },
71
+ stage: string,
72
+ ) => {
73
+ const packet = [...result.stdout.matchAll(/<handoff>([\s\S]*?)<\/handoff>/g)]
74
+ .at(-1)?.[1]
75
+ ?.trim();
76
+ if (!result.completionSignal || !packet)
77
+ throw new Error(
78
+ `${stage} lacks verified completion evidence: ${result.stdout.trim().slice(-1200)}`,
79
+ );
80
+ return packet;
81
+ };
82
+ const approved = (
83
+ result: { stdout: string; completionSignal?: string | null },
84
+ stage: string,
85
+ ) => {
86
+ if (!result.stdout.includes("<review>APPROVED</review>"))
87
+ throw new Error(
88
+ `${stage} has unresolved review findings: ${result.stdout.trim().slice(-1200)}`,
89
+ );
90
+ return complete(result, stage);
91
+ };
92
+ class TicketFailures extends Error {
93
+ constructor(readonly failures: Array<{ id: string; reason: string }>) {
94
+ super(
95
+ failures.map((failure) => `#${failure.id}: ${failure.reason}`).join("; "),
96
+ );
97
+ }
98
+ }
99
+ const blockScope = (scope: Scope, failedId: string, reason: string) => {
100
+ reason = reason.slice(0, 3000);
101
+ execFileSync(
102
+ "bash",
103
+ [
104
+ ".shipyard/block-scope.sh",
105
+ scope.id,
106
+ failedId,
107
+ repository,
108
+ [scope.id, ...(scope.tickets ?? []).map((ticket) => ticket.id)].join(","),
109
+ scope.branch,
110
+ ],
111
+ { input: reason, encoding: "utf8" },
112
+ );
113
+ console.error(`Shipyard blocked issue #${failedId}: ${reason}`);
114
+ };
115
+ const runWorker = async (scope: Scope, ticket: Ticket) => {
116
+ const branch = `shipyard/spec-${scope.id}-issue-${ticket.id}`;
117
+ const sandbox = await shipyard.createSandbox({
118
+ branch,
119
+ baseBranch: scope.branch,
120
+ sandbox: docker(),
121
+ hooks,
122
+ });
123
+ try {
124
+ await sandbox.run({
125
+ name: `triage #${ticket.id}`,
126
+ maxIterations: 1,
127
+ agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
128
+ promptFile: "./.shipyard/triage-prompt.md",
129
+ promptArgs: { TASK_ID: ticket.id },
130
+ });
131
+ verifyTriage(ticket.id);
132
+ const implementation = await sandbox.run({
133
+ name: "implementer",
134
+ maxIterations: 100,
135
+ agent: shipyard.codex(shipyard.CODEX_MODELS.routine),
136
+ promptFile: "./.shipyard/implement-prompt.md",
137
+ promptArgs: {
138
+ TASK_ID: ticket.id,
139
+ ISSUE_TITLE: ticket.title,
140
+ BRANCH: branch,
141
+ SCOPE: JSON.stringify(scope),
142
+ },
143
+ });
144
+ let packet = complete(implementation, `Implementation of #${ticket.id}`);
145
+ const review = await sandbox.run({
146
+ name: "reviewer",
147
+ maxIterations: 1,
148
+ agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
149
+ promptFile: "./.shipyard/review-prompt.md",
150
+ promptArgs: {
151
+ TASK_ID: ticket.id,
152
+ BRANCH: branch,
153
+ BASE_BRANCH: scope.branch,
154
+ SCOPE: JSON.stringify(scope),
155
+ },
156
+ });
157
+ packet += `\n\n${approved(review, `Review of #${ticket.id}`)}`;
158
+ return { branch, packet };
159
+ } finally {
160
+ await closeClean(sandbox);
161
+ }
53
162
  };
54
163
 
55
- // Copy node_modules from the host into the worktree before each sandbox
56
- // starts. Avoids a full npm install from scratch; the hook above handles
57
- // platform-specific binaries and any packages added since the last copy.
58
- const copyToWorktree = ["node_modules"];
59
-
60
- // ---------------------------------------------------------------------------
61
- // Main loop
62
- // ---------------------------------------------------------------------------
63
-
64
- for (let iteration = 1; iteration <= MAX_ITERATIONS; iteration++) {
65
- console.log(`\n=== Iteration ${iteration}/${MAX_ITERATIONS} ===\n`);
66
-
67
- // -------------------------------------------------------------------------
68
- // Phase 1: Plan
69
- //
70
- // The planning agent (GPT-5.6 Sol, for deeper reasoning) reads the open issue list,
71
- // builds a dependency graph, and selects the issues that can be worked in
72
- // parallel right now (i.e., no blocking dependencies on other open issues).
73
- //
74
- // It outputs a <plan> JSON block — Output.object parses and validates it.
75
- // -------------------------------------------------------------------------
164
+ for (let iteration = 0; iteration < 10; iteration++) {
165
+ const scopes = JSON.parse(
166
+ execFileSync("node", [".shipyard/select-issues.mjs"], { encoding: "utf8" }),
167
+ ) as Scope[];
168
+ if (!scopes.length) break;
76
169
  const plan = await shipyard.run({
77
170
  hooks,
78
171
  sandbox: docker(),
79
172
  name: "planner",
80
- // One iteration is enough: the planner just needs to read and reason,
81
- // not write code. (Structured output requires maxIterations: 1.)
173
+ branchStrategy: { type: "branch", branch: "shipyard/planner" },
82
174
  maxIterations: 1,
83
- // Strong Codex for planning: dependency analysis benefits from deeper reasoning.
84
175
  agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
85
176
  promptFile: "./.shipyard/plan-prompt.md",
86
- // Extract and validate the <plan> JSON into a typed object. Throws
87
- // StructuredOutputError if the tag is missing, the JSON is malformed, or
88
- // validation fails — which aborts the loop.
89
177
  output: shipyard.Output.object({ tag: "plan", schema: planSchema }),
90
178
  });
179
+ const ids = plan.output.issues.map((item: { id: string }) => item.id);
180
+ if (!ids.length)
181
+ throw new Error("Planner returned no work for activated scopes");
182
+ if (new Set(ids).size !== ids.length)
183
+ throw new Error("Planner duplicated an issue scope");
184
+ for (const id of ids)
185
+ if (!scopes.some((scope) => scope.id === id))
186
+ throw new Error(`Planner selected unknown scope #${id}`);
91
187
 
92
- const issues = plan.output.issues;
93
-
94
- if (issues.length === 0) {
95
- // No unblocked work — either everything is done or everything is blocked.
96
- console.log("No unblocked issues to work on. Exiting.");
97
- break;
98
- }
99
-
100
- console.log(
101
- `Planning complete. ${issues.length} issue(s) to work in parallel:`,
102
- );
103
- for (const issue of issues) {
104
- console.log(` ${issue.id}: ${issue.title} → ${issue.branch}`);
105
- }
106
-
107
- // -------------------------------------------------------------------------
108
- // Phase 2: Execute + Review
109
- //
110
- // For each issue, create a sandbox via createSandbox() so the implementer
111
- // and reviewer share the same sandbox instance per branch. The implementer
112
- // runs first; if it produces commits, the reviewer runs in the same sandbox.
113
- //
114
- // Promise.allSettled means one failing pipeline doesn't cancel the others.
115
- // -------------------------------------------------------------------------
116
-
117
- const settled = await Promise.allSettled(
118
- issues.map(async (issue) => {
119
- const sandbox = await shipyard.createSandbox({
120
- branch: issue.branch,
121
- sandbox: docker(),
122
- hooks,
123
- copyToWorktree,
124
- });
125
-
188
+ const outcomes = await Promise.allSettled(
189
+ ids.map(async (id) => {
190
+ const scope = scopes.find((item) => item.id === id)!;
191
+ let handedOff = false;
192
+ let publicationUncertain = false;
126
193
  try {
127
- // Run the implementer
128
- const implement = await sandbox.run({
129
- name: "implementer",
130
- maxIterations: 100,
131
- agent: shipyard.codex(shipyard.CODEX_MODELS.routine),
132
- promptFile: "./.shipyard/implement-prompt.md",
133
- promptArgs: {
134
- TASK_ID: issue.id,
135
- ISSUE_TITLE: issue.title,
136
- BRANCH: issue.branch,
137
- },
138
- });
139
-
140
- // Review new work and deterministic branches that completed with work
141
- // from an earlier run but produced no new commit this time.
194
+ execFileSync("gh", [
195
+ "label",
196
+ "create",
197
+ "shipyard:pending",
198
+ "--repo",
199
+ repository,
200
+ "--color",
201
+ "1D76DB",
202
+ "--description",
203
+ "Shipyard is working on this ticket",
204
+ "--force",
205
+ ]);
206
+ for (const ticketId of scope.kind === "spec"
207
+ ? (scope.tickets ?? []).map((ticket) => ticket.id)
208
+ : [scope.id])
209
+ execFileSync("gh", [
210
+ "issue",
211
+ "edit",
212
+ ticketId,
213
+ "--repo",
214
+ repository,
215
+ "--add-label",
216
+ "shipyard:pending",
217
+ ]);
142
218
  if (
143
- implement.commits.length > 0 ||
144
- implement.completionSignal !== undefined
145
- ) {
146
- const review = await sandbox.run({
219
+ scope.branch !==
220
+ (scope.kind === "spec"
221
+ ? `shipyard/spec-${id}`
222
+ : `shipyard/issue-${id}`)
223
+ )
224
+ throw new Error(`Invalid branch for #${id}`);
225
+ let workerEvidence = "";
226
+ if (scope.kind === "spec") {
227
+ const seed = await shipyard.createSandbox({
228
+ branch: scope.branch,
229
+ sandbox: docker(),
230
+ hooks,
231
+ });
232
+ await closeClean(seed);
233
+ const tickets = scope.tickets ?? [];
234
+ if (!tickets.length)
235
+ throw new Error(`Spec #${id} has no executable tickets`);
236
+ const remaining = new Map(
237
+ tickets.map((ticket) => [ticket.id, ticket]),
238
+ );
239
+ const completed = new Set<string>(scope.completedTicketIds ?? []);
240
+ while (remaining.size) {
241
+ const ready = [...remaining.values()].filter((ticket) =>
242
+ ticket.blockedBy.every(
243
+ (blocker) =>
244
+ String(blocker.state).toLowerCase() === "closed" ||
245
+ completed.has(blocker.id),
246
+ ),
247
+ );
248
+ if (!ready.length)
249
+ throw new Error(
250
+ `Spec #${id} has unresolved or cyclic ticket dependencies`,
251
+ );
252
+ const settled = await Promise.allSettled(
253
+ ready.map((ticket) => runWorker(scope, ticket)),
254
+ );
255
+ const failures = settled.flatMap((outcome, index) =>
256
+ outcome.status === "rejected"
257
+ ? [{ id: ready[index]!.id, reason: String(outcome.reason) }]
258
+ : [],
259
+ );
260
+ if (failures.length) throw new TicketFailures(failures);
261
+ const wave = await shipyard.createSandbox({
262
+ branch: scope.branch,
263
+ sandbox: docker(),
264
+ hooks,
265
+ });
266
+ try {
267
+ for (const [index, outcome] of settled.entries()) {
268
+ if (outcome.status !== "fulfilled") continue;
269
+ const commits = await wave.exec(
270
+ `git rev-list --reverse HEAD..origin/${outcome.value.branch}`,
271
+ );
272
+ if (commits.exitCode !== 0 || !commits.stdout.trim())
273
+ throw new TicketFailures([
274
+ {
275
+ id: ready[index]!.id,
276
+ reason: "No committed changes to integrate",
277
+ },
278
+ ]);
279
+ const shas = commits.stdout.trim().split(/\s+/);
280
+ if (!shas.every((sha) => /^[a-f0-9]{40}$/.test(sha)))
281
+ throw new TicketFailures([
282
+ {
283
+ id: ready[index]!.id,
284
+ reason: "Invalid commit on ticket branch",
285
+ },
286
+ ]);
287
+ const before = await wave.exec("git rev-parse HEAD");
288
+ if (
289
+ before.exitCode !== 0 ||
290
+ !/^[a-f0-9]{40}\s*$/.test(before.stdout)
291
+ )
292
+ throw new Error(
293
+ "Could not read spec branch commit before integration",
294
+ );
295
+ const picked = await wave.exec(
296
+ `git -c user.name=Shipyard -c user.email=shipyard@users.noreply.github.com cherry-pick -x ${shas.join(" ")}`,
297
+ );
298
+ if (picked.exitCode !== 0) {
299
+ try {
300
+ const resolution = await wave.run({
301
+ name: "conflict-resolver",
302
+ maxIterations: 10,
303
+ agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
304
+ promptFile: "./.shipyard/conflict-prompt.md",
305
+ promptArgs: {
306
+ TASK_ID: ready[index]!.id,
307
+ BRANCH: scope.branch,
308
+ SCOPE: JSON.stringify(scope),
309
+ COMMITS: shas.join(","),
310
+ CONFLICT: picked.stderr || picked.stdout,
311
+ },
312
+ });
313
+ const resolutionEvidence = complete(
314
+ resolution,
315
+ `Conflict resolution of #${ready[index]!.id}`,
316
+ );
317
+ const status = await wave.exec("git status --porcelain");
318
+ const pending = await wave.exec(
319
+ "git rev-parse -q --verify CHERRY_PICK_HEAD",
320
+ );
321
+ const after = await wave.exec("git rev-parse HEAD");
322
+ const integratedCommits = await wave.exec(
323
+ `git log --format=%B ${before.stdout.trim()}..HEAD`,
324
+ );
325
+ if (
326
+ status.exitCode !== 0 ||
327
+ status.stdout.trim() ||
328
+ pending.exitCode === 0 ||
329
+ after.exitCode !== 0 ||
330
+ after.stdout.trim() === before.stdout.trim() ||
331
+ integratedCommits.exitCode !== 0 ||
332
+ shas.some(
333
+ (sha) =>
334
+ !integratedCommits.stdout.includes(
335
+ `(cherry picked from commit ${sha})`,
336
+ ),
337
+ )
338
+ )
339
+ throw new Error(
340
+ "Cherry-pick remains unresolved or ticket commits are missing from the spec branch",
341
+ );
342
+ workerEvidence += `\n\n#${ready[index]!.id} conflict: ${resolutionEvidence}`;
343
+ } catch (error) {
344
+ throw new TicketFailures([
345
+ {
346
+ id: ready[index]!.id,
347
+ reason: `Cherry-pick conflict: ${error instanceof Error ? error.message : String(error)}`,
348
+ },
349
+ ]);
350
+ }
351
+ }
352
+ workerEvidence += `\n\n#${ready[index]!.id}: ${outcome.value.packet}`;
353
+ }
354
+ const integrated = await wave.run({
355
+ name: "spec-integrator",
356
+ maxIterations: 10,
357
+ agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
358
+ promptFile: "./.shipyard/spec-wave-prompt.md",
359
+ promptArgs: {
360
+ TASK_ID: id,
361
+ BRANCH: scope.branch,
362
+ SCOPE: JSON.stringify(scope),
363
+ WAVE_BRANCHES: ready
364
+ .map((ticket) => `shipyard/spec-${id}-issue-${ticket.id}`)
365
+ .join(","),
366
+ },
367
+ });
368
+ workerEvidence += `\n\n${complete(integrated, `Integration wave of #${id}`)}`;
369
+ } catch (error) {
370
+ try {
371
+ await closeClean(wave);
372
+ } catch (closeError) {
373
+ console.error(
374
+ `Failed wave cleanup after ${error}: ${closeError}`,
375
+ );
376
+ }
377
+ throw error;
378
+ }
379
+ await closeClean(wave);
380
+ for (const ticket of ready) {
381
+ completed.add(ticket.id);
382
+ remaining.delete(ticket.id);
383
+ }
384
+ }
385
+ }
386
+ const integration = await shipyard.createSandbox({
387
+ branch: scope.branch,
388
+ sandbox: docker(),
389
+ hooks,
390
+ });
391
+ let handoffEvidence: string;
392
+ try {
393
+ if (scope.kind === "standalone") {
394
+ await integration.run({
395
+ name: `triage #${id}`,
396
+ maxIterations: 1,
397
+ agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
398
+ promptFile: "./.shipyard/triage-prompt.md",
399
+ promptArgs: { TASK_ID: id },
400
+ });
401
+ verifyTriage(id);
402
+ const standalone = await integration.run({
403
+ name: "implementer",
404
+ maxIterations: 100,
405
+ agent: shipyard.codex(shipyard.CODEX_MODELS.routine),
406
+ promptFile: "./.shipyard/implement-prompt.md",
407
+ promptArgs: {
408
+ TASK_ID: id,
409
+ ISSUE_TITLE: scope.title,
410
+ BRANCH: scope.branch,
411
+ SCOPE: JSON.stringify(scope),
412
+ },
413
+ });
414
+ workerEvidence = complete(standalone, `Implementation of #${id}`);
415
+ }
416
+ const review = await integration.run({
147
417
  name: "reviewer",
148
418
  maxIterations: 1,
149
419
  agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
150
420
  promptFile: "./.shipyard/review-prompt.md",
151
421
  promptArgs: {
152
- BRANCH: issue.branch,
422
+ TASK_ID: id,
423
+ BRANCH: scope.branch,
424
+ BASE_BRANCH: targetBranch,
425
+ SCOPE: JSON.stringify(scope),
153
426
  },
154
427
  });
155
-
156
- // Merge commits from both runs so the merge phase sees all of them.
157
- // Each sandbox.run() only returns commits from its own run.
158
- return {
159
- ...review,
160
- commits: [...implement.commits, ...review.commits],
161
- completionSignal:
162
- review.completionSignal ?? implement.completionSignal,
163
- };
428
+ workerEvidence += `\n\n${approved(review, `Review of #${id}`)}`;
429
+ const final = await integration.run({
430
+ name: "merger",
431
+ maxIterations: 1,
432
+ agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
433
+ promptFile: "./.shipyard/merge-prompt.md",
434
+ promptArgs: {
435
+ TASK_ID: id,
436
+ ISSUE_TITLE: scope.title,
437
+ BRANCH: scope.branch,
438
+ BASE_BRANCH: targetBranch,
439
+ SCOPE: JSON.stringify(scope),
440
+ },
441
+ });
442
+ const finalEvidence = complete(final, `Final integration of #${id}`);
443
+ handoffEvidence = `${workerEvidence}\n\n${finalEvidence}`;
444
+ if (final.commits.length) {
445
+ const finalReview = await integration.run({
446
+ name: "reviewer",
447
+ maxIterations: 1,
448
+ agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
449
+ promptFile: "./.shipyard/review-prompt.md",
450
+ promptArgs: {
451
+ TASK_ID: id,
452
+ BRANCH: scope.branch,
453
+ BASE_BRANCH: targetBranch,
454
+ SCOPE: JSON.stringify(scope),
455
+ },
456
+ });
457
+ handoffEvidence += `\n\n${approved(finalReview, `Final review of #${id}`)}`;
458
+ }
459
+ } finally {
460
+ await closeClean(integration);
461
+ }
462
+ const publication = await shipyard.createSandbox({
463
+ branch: scope.branch,
464
+ sandbox: docker(),
465
+ });
466
+ try {
467
+ const scopeIds = [
468
+ id,
469
+ ...(scope.tickets ?? []).map((ticket) => ticket.id),
470
+ ].join(",");
471
+ publicationUncertain = true;
472
+ const handoff = await publication.exec(
473
+ `bash .shipyard/handoff.sh ${id} ${scope.branch} ${targetBranch} ${repository} ${scopeIds} ${scope.outstandingTicketIds?.join(",") || "-"} ${scope.completedTicketIds?.join(",") || "-"}`,
474
+ { stdin: handoffEvidence },
475
+ );
476
+ publicationUncertain = handoff.exitCode === 75;
477
+ if (handoff.exitCode !== 0)
478
+ throw new Error(
479
+ `PR handoff for #${id} failed: ${handoff.stderr || handoff.stdout}`,
480
+ );
481
+ console.log(handoff.stdout.trim());
482
+ handedOff = true;
483
+ } finally {
484
+ await publication.close();
485
+ }
486
+ } catch (error) {
487
+ if (handedOff || publicationUncertain) throw error;
488
+ if (error instanceof TicketFailures) {
489
+ for (const failure of error.failures)
490
+ blockScope(scope, failure.id, failure.reason);
491
+ } else {
492
+ blockScope(
493
+ scope,
494
+ scope.id,
495
+ error instanceof Error ? error.message : String(error),
496
+ );
164
497
  }
165
-
166
- return implement;
167
- } finally {
168
- await sandbox.close();
169
498
  }
170
499
  }),
171
500
  );
172
-
173
- // Log any agents that threw (network error, sandbox crash, etc.).
174
- for (const [i, outcome] of settled.entries()) {
175
- if (outcome.status === "rejected") {
176
- console.error(
177
- ` ✗ ${issues[i]!.id} (${issues[i]!.branch}) failed: ${outcome.reason}`,
178
- );
179
- }
501
+ for (const [index, outcome] of outcomes.entries()) {
502
+ if (outcome.status === "rejected")
503
+ throw new Error(`Scope #${ids[index]} failed: ${outcome.reason}`);
180
504
  }
181
-
182
- // A run's commits are limited to changes made during that run. A deterministic
183
- // branch can already contain the implementation from an earlier run, so an
184
- // explicit completion signal must also make the branch eligible for merge.
185
- const completedIssues = settled
186
- .map((outcome, i) => ({ outcome, issue: issues[i]! }))
187
- .filter(
188
- (entry) =>
189
- entry.outcome.status === "fulfilled" &&
190
- (entry.outcome.value.commits.length > 0 ||
191
- entry.outcome.value.completionSignal !== undefined),
192
- )
193
- .map((entry) => entry.issue);
194
-
195
- const completedBranches = completedIssues.map((i) => i.branch);
196
-
197
- console.log(
198
- `\nExecution complete. ${completedBranches.length} completed branch(es):`,
199
- );
200
- for (const branch of completedBranches) {
201
- console.log(` ${branch}`);
202
- }
203
-
204
- if (completedBranches.length === 0) {
205
- // No agent completed and retrying the same plan would repeat unchanged work.
206
- console.log("No implementations completed. Stopping.");
207
- break;
208
- }
209
-
210
- // -------------------------------------------------------------------------
211
- // Phase 3: Merge
212
- //
213
- // One GPT-5.6 Sol agent merges all completed branches into the current branch,
214
- // resolving any conflicts and running tests to confirm everything works.
215
- //
216
- // The {{BRANCHES}} and {{ISSUES}} prompt arguments are lists that the agent
217
- // uses to know which branches to merge and which issues to close.
218
- // -------------------------------------------------------------------------
219
- await shipyard.run({
220
- hooks,
221
- sandbox: docker(),
222
- name: "merger",
223
- maxIterations: 1,
224
- agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
225
- promptFile: "./.shipyard/merge-prompt.md",
226
- promptArgs: {
227
- // A markdown list of branch names, one per line.
228
- BRANCHES: completedBranches.map((b) => `- ${b}`).join("\n"),
229
- // A markdown list of issue IDs and titles, one per line.
230
- ISSUES: completedIssues.map((i) => `- ${i.id}: ${i.title}`).join("\n"),
231
- },
232
- });
233
-
234
- console.log("\nBranches merged.");
235
505
  }
236
-
237
- console.log("\nAll done.");