@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.
- package/README.md +56 -8
- package/dist/index.js +27 -9
- package/dist/index.js.map +1 -1
- package/dist/main.js +51 -15
- package/dist/main.js.map +1 -1
- package/dist/templates/parallel-planner/block-scope.sh +160 -0
- package/dist/templates/parallel-planner/conflict-prompt.md +22 -0
- package/dist/templates/parallel-planner/handoff.sh +175 -0
- package/dist/templates/parallel-planner/implement-prompt.md +10 -56
- package/dist/templates/parallel-planner/main.mts +434 -188
- package/dist/templates/parallel-planner/merge-prompt.md +9 -21
- package/dist/templates/parallel-planner/plan-prompt.md +5 -31
- package/dist/templates/parallel-planner/select-issues.mjs +788 -0
- package/dist/templates/parallel-planner/setup.sh +51 -0
- package/dist/templates/parallel-planner/spec-wave-prompt.md +16 -0
- package/dist/templates/parallel-planner/template.json +1 -1
- package/dist/templates/parallel-planner/triage-prompt.md +5 -0
- package/dist/templates/parallel-planner/verify-triage.sh +19 -0
- package/dist/templates/parallel-planner-with-review/block-scope.sh +160 -0
- package/dist/templates/parallel-planner-with-review/conflict-prompt.md +22 -0
- package/dist/templates/parallel-planner-with-review/handoff.sh +175 -0
- package/dist/templates/parallel-planner-with-review/implement-prompt.md +10 -56
- package/dist/templates/parallel-planner-with-review/main.mts +473 -205
- package/dist/templates/parallel-planner-with-review/merge-prompt.md +9 -21
- package/dist/templates/parallel-planner-with-review/plan-prompt.md +5 -31
- package/dist/templates/parallel-planner-with-review/review-prompt.md +11 -49
- package/dist/templates/parallel-planner-with-review/select-issues.mjs +788 -0
- package/dist/templates/parallel-planner-with-review/setup.sh +51 -0
- package/dist/templates/parallel-planner-with-review/spec-wave-prompt.md +16 -0
- package/dist/templates/parallel-planner-with-review/template.json +1 -1
- package/dist/templates/parallel-planner-with-review/triage-prompt.md +5 -0
- package/dist/templates/parallel-planner-with-review/verify-triage.sh +19 -0
- package/dist/templates/sequential-reviewer/block-scope.sh +160 -0
- package/dist/templates/sequential-reviewer/handoff.sh +175 -0
- package/dist/templates/sequential-reviewer/implement-prompt.md +12 -46
- package/dist/templates/sequential-reviewer/main.mts +199 -103
- package/dist/templates/sequential-reviewer/review-prompt.md +11 -49
- package/dist/templates/sequential-reviewer/select-issues.mjs +788 -0
- package/dist/templates/sequential-reviewer/setup.sh +51 -0
- package/dist/templates/sequential-reviewer/template.json +1 -1
- package/dist/templates/sequential-reviewer/triage-prompt.md +5 -0
- package/dist/templates/sequential-reviewer/verify-triage.sh +19 -0
- package/dist/templates/shared/block-scope.sh +160 -0
- package/dist/templates/shared/handoff.sh +175 -0
- package/dist/templates/shared/select-issues.mjs +788 -0
- package/dist/templates/shared/setup.sh +51 -0
- package/dist/templates/shared/triage-prompt.md +5 -0
- package/dist/templates/shared/verify-triage.sh +19 -0
- package/dist/templates/simple-loop/block-scope.sh +160 -0
- package/dist/templates/simple-loop/handoff.sh +175 -0
- package/dist/templates/simple-loop/main.mts +190 -45
- package/dist/templates/simple-loop/prompt.md +12 -46
- package/dist/templates/simple-loop/select-issues.mjs +788 -0
- package/dist/templates/simple-loop/setup.sh +51 -0
- package/dist/templates/simple-loop/template.json +1 -1
- package/dist/templates/simple-loop/triage-prompt.md +5 -0
- package/dist/templates/simple-loop/verify-triage.sh +19 -0
- package/package.json +1 -1
|
@@ -1,208 +1,454 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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: {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
171
|
-
|
|
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.");
|