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