@snappedly-tools/shipyard 0.5.0 → 0.7.0-staging.36081473933
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 +64 -117
- package/dist/MountConfig-BHnKnA4h.d.ts +145 -0
- package/dist/{chunk-WAXEMZUV.js → chunk-44I2BL6E.js} +225 -33
- package/dist/chunk-44I2BL6E.js.map +1 -0
- package/dist/{chunk-56TDSWFU.js → chunk-JI3HDDMS.js} +3 -3
- package/dist/{chunk-56TDSWFU.js.map → chunk-JI3HDDMS.js.map} +1 -1
- package/dist/{chunk-FDYOTN55.js → chunk-L6PX5QTU.js} +253 -878
- package/dist/chunk-L6PX5QTU.js.map +1 -0
- package/dist/index.d.ts +117 -97
- package/dist/index.js +349 -353
- package/dist/index.js.map +1 -1
- package/dist/main.js +285 -153
- package/dist/main.js.map +1 -1
- package/dist/sandboxes/docker.d.ts +1 -3
- package/dist/sandboxes/docker.js +2 -4
- 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 +493 -189
- 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/planner-branch.mts +151 -0
- 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 +533 -207
- 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/planner-branch.mts +151 -0
- 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 +248 -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 +239 -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 -17
- package/dist/MountConfig-bZoCs4Dd.d.ts +0 -26
- package/dist/SandboxProvider-XJQqEdSf.d.ts +0 -261
- package/dist/chunk-ACD46ZM4.js +0 -136
- package/dist/chunk-ACD46ZM4.js.map +0 -1
- package/dist/chunk-FDYOTN55.js.map +0 -1
- package/dist/chunk-KMGNFXKN.js +0 -38
- package/dist/chunk-KMGNFXKN.js.map +0 -1
- package/dist/chunk-SOJTAJTF.js +0 -78
- package/dist/chunk-SOJTAJTF.js.map +0 -1
- package/dist/chunk-WAXEMZUV.js.map +0 -1
- package/dist/sandboxes/no-sandbox.d.ts +0 -37
- package/dist/sandboxes/no-sandbox.js +0 -4
- package/dist/sandboxes/no-sandbox.js.map +0 -1
- package/dist/sandboxes/vercel.d.ts +0 -104
- package/dist/sandboxes/vercel.js +0 -166
- package/dist/sandboxes/vercel.js.map +0 -1
- package/dist/templates/blank/main.mts +0 -13
- package/dist/templates/blank/prompt.md +0 -12
- package/dist/templates/blank/template.json +0 -4
|
@@ -1,237 +1,563 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
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";
|
|
8
|
+
import { resolvePlannerBranch } from "./planner-branch.mjs";
|
|
30
9
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
10
|
+
if (process.loadEnvFile && existsSync(".shipyard/.env"))
|
|
11
|
+
process.loadEnvFile(".shipyard/.env");
|
|
12
|
+
type ModelRole = "routine" | "strong";
|
|
13
|
+
const CODEX_PROVIDER = true;
|
|
14
|
+
const agentFactory = shipyard.codex;
|
|
15
|
+
type AgentModel = Parameters<typeof agentFactory>[0];
|
|
16
|
+
const readRoleModel = (role: ModelRole): string | undefined => {
|
|
17
|
+
const envName = `SHIPYARD_${role.toUpperCase()}_MODEL`;
|
|
18
|
+
const model = process.env[envName];
|
|
19
|
+
if (model !== undefined && model.trim().length === 0)
|
|
20
|
+
throw new Error(`${envName} must not be empty`);
|
|
21
|
+
return model;
|
|
22
|
+
};
|
|
23
|
+
const roleModels = {
|
|
24
|
+
routine: readRoleModel("routine"),
|
|
25
|
+
strong: readRoleModel("strong"),
|
|
26
|
+
};
|
|
27
|
+
const CODEX_REASONING_EFFORTS = shipyard.CODEX_REASONING_EFFORTS;
|
|
28
|
+
type CodexReasoningEffort = shipyard.CodexReasoningEffort;
|
|
29
|
+
const readCodexReasoningEffort = (
|
|
30
|
+
role: ModelRole,
|
|
31
|
+
): CodexReasoningEffort | undefined => {
|
|
32
|
+
if (!CODEX_PROVIDER) return undefined;
|
|
33
|
+
const envName = `SHIPYARD_CODEX_${role.toUpperCase()}_REASONING_EFFORT`;
|
|
34
|
+
const effort = process.env[envName]?.trim();
|
|
35
|
+
if (!effort) return undefined;
|
|
36
|
+
if (!(CODEX_REASONING_EFFORTS as readonly string[]).includes(effort))
|
|
37
|
+
throw new Error(
|
|
38
|
+
`${envName} must be one of ${CODEX_REASONING_EFFORTS.join(", ")}; received "${effort}"`,
|
|
39
|
+
);
|
|
40
|
+
return effort as CodexReasoningEffort;
|
|
41
|
+
};
|
|
42
|
+
const roleEfforts = {
|
|
43
|
+
routine: readCodexReasoningEffort("routine"),
|
|
44
|
+
strong: readCodexReasoningEffort("strong"),
|
|
45
|
+
};
|
|
46
|
+
const readCodexRoleModel = (role: ModelRole, defaultModel: AgentModel) => {
|
|
47
|
+
if (!CODEX_PROVIDER || typeof defaultModel === "string") return defaultModel;
|
|
48
|
+
const envName = `SHIPYARD_CODEX_${role.toUpperCase()}_MODEL`;
|
|
49
|
+
const model = process.env[envName]?.trim();
|
|
50
|
+
return model ? { ...defaultModel, model } : defaultModel;
|
|
51
|
+
};
|
|
52
|
+
const roleAgent = (role: ModelRole, defaultModel: AgentModel) => {
|
|
53
|
+
const model = roleModels[role] ?? readCodexRoleModel(role, defaultModel);
|
|
54
|
+
const effort = roleEfforts[role];
|
|
55
|
+
if (typeof model !== "string")
|
|
56
|
+
return effort === undefined
|
|
57
|
+
? agentFactory(model)
|
|
58
|
+
: agentFactory(model, { effort });
|
|
59
|
+
return agentFactory(model, { effort: effort ?? null });
|
|
60
|
+
};
|
|
61
|
+
const targetBranch = execFileSync("git", ["branch", "--show-current"], {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
}).trim();
|
|
64
|
+
const repository = execFileSync(
|
|
65
|
+
"gh",
|
|
66
|
+
["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
|
|
67
|
+
{ encoding: "utf8" },
|
|
68
|
+
).trim();
|
|
69
|
+
if (
|
|
70
|
+
!/^[A-Za-z0-9._/-]+$/.test(targetBranch) ||
|
|
71
|
+
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)
|
|
72
|
+
)
|
|
73
|
+
throw new Error("Invalid target branch or GitHub repository");
|
|
74
|
+
process.env.GH_REPO = repository;
|
|
51
75
|
const hooks = {
|
|
52
|
-
sandbox: {
|
|
76
|
+
sandbox: {
|
|
77
|
+
onSandboxReady: [
|
|
78
|
+
{ command: "timeout 300 bash .shipyard/setup.sh", timeoutMs: 300_000 },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
const closeClean = async (sandbox: {
|
|
83
|
+
close: () => Promise<{ preservedWorktreePath?: string }>;
|
|
84
|
+
}) => {
|
|
85
|
+
const { preservedWorktreePath } = await sandbox.close();
|
|
86
|
+
if (preservedWorktreePath)
|
|
87
|
+
throw new Error(`Sandbox has uncommitted work at ${preservedWorktreePath}`);
|
|
88
|
+
};
|
|
89
|
+
const verifyTriage = (ticketId: string) => {
|
|
90
|
+
try {
|
|
91
|
+
execFileSync("bash", [".shipyard/verify-triage.sh", ticketId, repository], {
|
|
92
|
+
encoding: "utf8",
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const detail = (error as { stderr?: string | Buffer }).stderr
|
|
96
|
+
?.toString()
|
|
97
|
+
.trim();
|
|
98
|
+
throw new Error(detail || `Could not verify triage for #${ticketId}`);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const planSchema = z.object({ issues: z.array(z.object({ id: z.string() })) });
|
|
102
|
+
type Ticket = {
|
|
103
|
+
id: string;
|
|
104
|
+
title: string;
|
|
105
|
+
body: string;
|
|
106
|
+
state: string;
|
|
107
|
+
blockedBy: Array<{ id: string; title: string; state: string }>;
|
|
108
|
+
};
|
|
109
|
+
type Scope = {
|
|
110
|
+
id: string;
|
|
111
|
+
title: string;
|
|
112
|
+
branch: string;
|
|
113
|
+
kind: "standalone" | "spec";
|
|
114
|
+
body?: string;
|
|
115
|
+
tickets?: Ticket[];
|
|
116
|
+
completedTicketIds?: string[];
|
|
117
|
+
outstandingTicketIds?: string[];
|
|
118
|
+
};
|
|
119
|
+
const complete = (
|
|
120
|
+
result: { stdout: string; completionSignal?: string | null },
|
|
121
|
+
stage: string,
|
|
122
|
+
) => {
|
|
123
|
+
const packet = [...result.stdout.matchAll(/<handoff>([\s\S]*?)<\/handoff>/g)]
|
|
124
|
+
.at(-1)?.[1]
|
|
125
|
+
?.trim();
|
|
126
|
+
if (!result.completionSignal || !packet)
|
|
127
|
+
throw new Error(
|
|
128
|
+
`${stage} lacks verified completion evidence: ${result.stdout.trim().slice(-1200)}`,
|
|
129
|
+
);
|
|
130
|
+
return packet;
|
|
131
|
+
};
|
|
132
|
+
const approved = (
|
|
133
|
+
result: { stdout: string; completionSignal?: string | null },
|
|
134
|
+
stage: string,
|
|
135
|
+
) => {
|
|
136
|
+
if (!result.stdout.includes("<review>APPROVED</review>"))
|
|
137
|
+
throw new Error(
|
|
138
|
+
`${stage} has unresolved review findings: ${result.stdout.trim().slice(-1200)}`,
|
|
139
|
+
);
|
|
140
|
+
return complete(result, stage);
|
|
141
|
+
};
|
|
142
|
+
class TicketFailures extends Error {
|
|
143
|
+
constructor(readonly failures: Array<{ id: string; reason: string }>) {
|
|
144
|
+
super(
|
|
145
|
+
failures.map((failure) => `#${failure.id}: ${failure.reason}`).join("; "),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const blockScope = (scope: Scope, failedId: string, reason: string) => {
|
|
150
|
+
reason = reason.slice(0, 3000);
|
|
151
|
+
execFileSync(
|
|
152
|
+
"bash",
|
|
153
|
+
[
|
|
154
|
+
".shipyard/block-scope.sh",
|
|
155
|
+
scope.id,
|
|
156
|
+
failedId,
|
|
157
|
+
repository,
|
|
158
|
+
[scope.id, ...(scope.tickets ?? []).map((ticket) => ticket.id)].join(","),
|
|
159
|
+
scope.branch,
|
|
160
|
+
],
|
|
161
|
+
{ input: reason, encoding: "utf8" },
|
|
162
|
+
);
|
|
163
|
+
console.error(`Shipyard blocked issue #${failedId}: ${reason}`);
|
|
164
|
+
};
|
|
165
|
+
const runWorker = async (scope: Scope, ticket: Ticket) => {
|
|
166
|
+
const branch = `shipyard/spec-${scope.id}-issue-${ticket.id}`;
|
|
167
|
+
const sandbox = await shipyard.createSandbox({
|
|
168
|
+
branch,
|
|
169
|
+
baseBranch: scope.branch,
|
|
170
|
+
sandbox: docker(),
|
|
171
|
+
hooks,
|
|
172
|
+
});
|
|
173
|
+
try {
|
|
174
|
+
await sandbox.run({
|
|
175
|
+
name: `triage #${ticket.id}`,
|
|
176
|
+
maxIterations: 1,
|
|
177
|
+
agent: roleAgent("routine", shipyard.CODEX_MODELS.routine),
|
|
178
|
+
promptFile: "./.shipyard/triage-prompt.md",
|
|
179
|
+
promptArgs: { TASK_ID: ticket.id },
|
|
180
|
+
});
|
|
181
|
+
verifyTriage(ticket.id);
|
|
182
|
+
const implementation = await sandbox.run({
|
|
183
|
+
name: "implementer",
|
|
184
|
+
maxIterations: 100,
|
|
185
|
+
agent: roleAgent("routine", shipyard.CODEX_MODELS.routine),
|
|
186
|
+
promptFile: "./.shipyard/implement-prompt.md",
|
|
187
|
+
promptArgs: {
|
|
188
|
+
TASK_ID: ticket.id,
|
|
189
|
+
ISSUE_TITLE: ticket.title,
|
|
190
|
+
BRANCH: branch,
|
|
191
|
+
SCOPE: JSON.stringify(scope),
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
let packet = complete(implementation, `Implementation of #${ticket.id}`);
|
|
195
|
+
const review = await sandbox.run({
|
|
196
|
+
name: "reviewer",
|
|
197
|
+
maxIterations: 1,
|
|
198
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
199
|
+
promptFile: "./.shipyard/review-prompt.md",
|
|
200
|
+
promptArgs: {
|
|
201
|
+
TASK_ID: ticket.id,
|
|
202
|
+
BRANCH: branch,
|
|
203
|
+
BASE_BRANCH: scope.branch,
|
|
204
|
+
SCOPE: JSON.stringify(scope),
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
packet += `\n\n${approved(review, `Review of #${ticket.id}`)}`;
|
|
208
|
+
return { branch, packet };
|
|
209
|
+
} finally {
|
|
210
|
+
await closeClean(sandbox);
|
|
211
|
+
}
|
|
53
212
|
};
|
|
54
213
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
// -------------------------------------------------------------------------
|
|
214
|
+
for (let iteration = 0; iteration < 10; iteration++) {
|
|
215
|
+
const scopes = JSON.parse(
|
|
216
|
+
execFileSync("node", [".shipyard/select-issues.mjs"], { encoding: "utf8" }),
|
|
217
|
+
) as Scope[];
|
|
218
|
+
if (!scopes.length) break;
|
|
219
|
+
const localBranches = execFileSync(
|
|
220
|
+
"git",
|
|
221
|
+
["for-each-ref", "--format=%(refname:short)", "refs/heads"],
|
|
222
|
+
{ encoding: "utf8" },
|
|
223
|
+
)
|
|
224
|
+
.split(/\r?\n/)
|
|
225
|
+
.filter(Boolean);
|
|
226
|
+
const plannerBranch = await resolvePlannerBranch(localBranches);
|
|
76
227
|
const plan = await shipyard.run({
|
|
77
228
|
hooks,
|
|
78
229
|
sandbox: docker(),
|
|
79
230
|
name: "planner",
|
|
80
|
-
|
|
81
|
-
// not write code. (Structured output requires maxIterations: 1.)
|
|
231
|
+
branchStrategy: { type: "branch", branch: plannerBranch },
|
|
82
232
|
maxIterations: 1,
|
|
83
|
-
|
|
84
|
-
agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
|
|
233
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
85
234
|
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
235
|
output: shipyard.Output.object({ tag: "plan", schema: planSchema }),
|
|
90
236
|
});
|
|
237
|
+
const ids = plan.output.issues.map((item: { id: string }) => item.id);
|
|
238
|
+
if (!ids.length)
|
|
239
|
+
throw new Error("Planner returned no work for activated scopes");
|
|
240
|
+
if (new Set(ids).size !== ids.length)
|
|
241
|
+
throw new Error("Planner duplicated an issue scope");
|
|
242
|
+
for (const id of ids)
|
|
243
|
+
if (!scopes.some((scope) => scope.id === id))
|
|
244
|
+
throw new Error(`Planner selected unknown scope #${id}`);
|
|
91
245
|
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
246
|
+
const outcomes = await Promise.allSettled(
|
|
247
|
+
ids.map(async (id) => {
|
|
248
|
+
const scope = scopes.find((item) => item.id === id)!;
|
|
249
|
+
let handedOff = false;
|
|
250
|
+
let publicationUncertain = false;
|
|
126
251
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
252
|
+
execFileSync("gh", [
|
|
253
|
+
"label",
|
|
254
|
+
"create",
|
|
255
|
+
"shipyard:pending",
|
|
256
|
+
"--repo",
|
|
257
|
+
repository,
|
|
258
|
+
"--color",
|
|
259
|
+
"1D76DB",
|
|
260
|
+
"--description",
|
|
261
|
+
"Shipyard is working on this ticket",
|
|
262
|
+
"--force",
|
|
263
|
+
]);
|
|
264
|
+
for (const ticketId of scope.kind === "spec"
|
|
265
|
+
? (scope.tickets ?? []).map((ticket) => ticket.id)
|
|
266
|
+
: [scope.id])
|
|
267
|
+
execFileSync("gh", [
|
|
268
|
+
"issue",
|
|
269
|
+
"edit",
|
|
270
|
+
ticketId,
|
|
271
|
+
"--repo",
|
|
272
|
+
repository,
|
|
273
|
+
"--add-label",
|
|
274
|
+
"shipyard:pending",
|
|
275
|
+
]);
|
|
142
276
|
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
277
|
+
scope.branch !==
|
|
278
|
+
(scope.kind === "spec"
|
|
279
|
+
? `shipyard/spec-${id}`
|
|
280
|
+
: `shipyard/issue-${id}`)
|
|
281
|
+
)
|
|
282
|
+
throw new Error(`Invalid branch for #${id}`);
|
|
283
|
+
let workerEvidence = "";
|
|
284
|
+
if (scope.kind === "spec") {
|
|
285
|
+
const seed = await shipyard.createSandbox({
|
|
286
|
+
branch: scope.branch,
|
|
287
|
+
sandbox: docker(),
|
|
288
|
+
hooks,
|
|
289
|
+
});
|
|
290
|
+
await closeClean(seed);
|
|
291
|
+
const tickets = scope.tickets ?? [];
|
|
292
|
+
if (!tickets.length)
|
|
293
|
+
throw new Error(`Spec #${id} has no executable tickets`);
|
|
294
|
+
const remaining = new Map(
|
|
295
|
+
tickets.map((ticket) => [ticket.id, ticket]),
|
|
296
|
+
);
|
|
297
|
+
const completed = new Set<string>(scope.completedTicketIds ?? []);
|
|
298
|
+
while (remaining.size) {
|
|
299
|
+
const ready = [...remaining.values()].filter((ticket) =>
|
|
300
|
+
ticket.blockedBy.every(
|
|
301
|
+
(blocker) =>
|
|
302
|
+
String(blocker.state).toLowerCase() === "closed" ||
|
|
303
|
+
completed.has(blocker.id),
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
if (!ready.length)
|
|
307
|
+
throw new Error(
|
|
308
|
+
`Spec #${id} has unresolved or cyclic ticket dependencies`,
|
|
309
|
+
);
|
|
310
|
+
const settled = await Promise.allSettled(
|
|
311
|
+
ready.map((ticket) => runWorker(scope, ticket)),
|
|
312
|
+
);
|
|
313
|
+
const failures = settled.flatMap((outcome, index) =>
|
|
314
|
+
outcome.status === "rejected"
|
|
315
|
+
? [{ id: ready[index]!.id, reason: String(outcome.reason) }]
|
|
316
|
+
: [],
|
|
317
|
+
);
|
|
318
|
+
if (failures.length) throw new TicketFailures(failures);
|
|
319
|
+
const wave = await shipyard.createSandbox({
|
|
320
|
+
branch: scope.branch,
|
|
321
|
+
sandbox: docker(),
|
|
322
|
+
hooks,
|
|
323
|
+
});
|
|
324
|
+
try {
|
|
325
|
+
for (const [index, outcome] of settled.entries()) {
|
|
326
|
+
if (outcome.status !== "fulfilled") continue;
|
|
327
|
+
const commits = await wave.exec(
|
|
328
|
+
`git rev-list --reverse HEAD..origin/${outcome.value.branch}`,
|
|
329
|
+
);
|
|
330
|
+
if (commits.exitCode !== 0 || !commits.stdout.trim())
|
|
331
|
+
throw new TicketFailures([
|
|
332
|
+
{
|
|
333
|
+
id: ready[index]!.id,
|
|
334
|
+
reason: "No committed changes to integrate",
|
|
335
|
+
},
|
|
336
|
+
]);
|
|
337
|
+
const shas = commits.stdout.trim().split(/\s+/);
|
|
338
|
+
if (!shas.every((sha) => /^[a-f0-9]{40}$/.test(sha)))
|
|
339
|
+
throw new TicketFailures([
|
|
340
|
+
{
|
|
341
|
+
id: ready[index]!.id,
|
|
342
|
+
reason: "Invalid commit on ticket branch",
|
|
343
|
+
},
|
|
344
|
+
]);
|
|
345
|
+
const before = await wave.exec("git rev-parse HEAD");
|
|
346
|
+
if (
|
|
347
|
+
before.exitCode !== 0 ||
|
|
348
|
+
!/^[a-f0-9]{40}\s*$/.test(before.stdout)
|
|
349
|
+
)
|
|
350
|
+
throw new Error(
|
|
351
|
+
"Could not read spec branch commit before integration",
|
|
352
|
+
);
|
|
353
|
+
const picked = await wave.exec(
|
|
354
|
+
`git -c user.name=Shipyard -c user.email=shipyard@users.noreply.github.com cherry-pick -x ${shas.join(" ")}`,
|
|
355
|
+
);
|
|
356
|
+
if (picked.exitCode !== 0) {
|
|
357
|
+
try {
|
|
358
|
+
const resolution = await wave.run({
|
|
359
|
+
name: "conflict-resolver",
|
|
360
|
+
maxIterations: 10,
|
|
361
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
362
|
+
promptFile: "./.shipyard/conflict-prompt.md",
|
|
363
|
+
promptArgs: {
|
|
364
|
+
TASK_ID: ready[index]!.id,
|
|
365
|
+
BRANCH: scope.branch,
|
|
366
|
+
SCOPE: JSON.stringify(scope),
|
|
367
|
+
COMMITS: shas.join(","),
|
|
368
|
+
CONFLICT: picked.stderr || picked.stdout,
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
const resolutionEvidence = complete(
|
|
372
|
+
resolution,
|
|
373
|
+
`Conflict resolution of #${ready[index]!.id}`,
|
|
374
|
+
);
|
|
375
|
+
const status = await wave.exec("git status --porcelain");
|
|
376
|
+
const pending = await wave.exec(
|
|
377
|
+
"git rev-parse -q --verify CHERRY_PICK_HEAD",
|
|
378
|
+
);
|
|
379
|
+
const after = await wave.exec("git rev-parse HEAD");
|
|
380
|
+
const integratedCommits = await wave.exec(
|
|
381
|
+
`git log --format=%B ${before.stdout.trim()}..HEAD`,
|
|
382
|
+
);
|
|
383
|
+
if (
|
|
384
|
+
status.exitCode !== 0 ||
|
|
385
|
+
status.stdout.trim() ||
|
|
386
|
+
pending.exitCode === 0 ||
|
|
387
|
+
after.exitCode !== 0 ||
|
|
388
|
+
after.stdout.trim() === before.stdout.trim() ||
|
|
389
|
+
integratedCommits.exitCode !== 0 ||
|
|
390
|
+
shas.some(
|
|
391
|
+
(sha) =>
|
|
392
|
+
!integratedCommits.stdout.includes(
|
|
393
|
+
`(cherry picked from commit ${sha})`,
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
throw new Error(
|
|
398
|
+
"Cherry-pick remains unresolved or ticket commits are missing from the spec branch",
|
|
399
|
+
);
|
|
400
|
+
workerEvidence += `\n\n#${ready[index]!.id} conflict: ${resolutionEvidence}`;
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw new TicketFailures([
|
|
403
|
+
{
|
|
404
|
+
id: ready[index]!.id,
|
|
405
|
+
reason: `Cherry-pick conflict: ${error instanceof Error ? error.message : String(error)}`,
|
|
406
|
+
},
|
|
407
|
+
]);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
workerEvidence += `\n\n#${ready[index]!.id}: ${outcome.value.packet}`;
|
|
411
|
+
}
|
|
412
|
+
const integrated = await wave.run({
|
|
413
|
+
name: "spec-integrator",
|
|
414
|
+
maxIterations: 10,
|
|
415
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
416
|
+
promptFile: "./.shipyard/spec-wave-prompt.md",
|
|
417
|
+
promptArgs: {
|
|
418
|
+
TASK_ID: id,
|
|
419
|
+
BRANCH: scope.branch,
|
|
420
|
+
SCOPE: JSON.stringify(scope),
|
|
421
|
+
WAVE_BRANCHES: ready
|
|
422
|
+
.map((ticket) => `shipyard/spec-${id}-issue-${ticket.id}`)
|
|
423
|
+
.join(","),
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
workerEvidence += `\n\n${complete(integrated, `Integration wave of #${id}`)}`;
|
|
427
|
+
} catch (error) {
|
|
428
|
+
try {
|
|
429
|
+
await closeClean(wave);
|
|
430
|
+
} catch (closeError) {
|
|
431
|
+
console.error(
|
|
432
|
+
`Failed wave cleanup after ${error}: ${closeError}`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
await closeClean(wave);
|
|
438
|
+
for (const ticket of ready) {
|
|
439
|
+
completed.add(ticket.id);
|
|
440
|
+
remaining.delete(ticket.id);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const integration = await shipyard.createSandbox({
|
|
445
|
+
branch: scope.branch,
|
|
446
|
+
sandbox: docker(),
|
|
447
|
+
hooks,
|
|
448
|
+
});
|
|
449
|
+
let handoffEvidence: string;
|
|
450
|
+
try {
|
|
451
|
+
if (scope.kind === "standalone") {
|
|
452
|
+
await integration.run({
|
|
453
|
+
name: `triage #${id}`,
|
|
454
|
+
maxIterations: 1,
|
|
455
|
+
agent: roleAgent("routine", shipyard.CODEX_MODELS.routine),
|
|
456
|
+
promptFile: "./.shipyard/triage-prompt.md",
|
|
457
|
+
promptArgs: { TASK_ID: id },
|
|
458
|
+
});
|
|
459
|
+
verifyTriage(id);
|
|
460
|
+
const standalone = await integration.run({
|
|
461
|
+
name: "implementer",
|
|
462
|
+
maxIterations: 100,
|
|
463
|
+
agent: roleAgent("routine", shipyard.CODEX_MODELS.routine),
|
|
464
|
+
promptFile: "./.shipyard/implement-prompt.md",
|
|
465
|
+
promptArgs: {
|
|
466
|
+
TASK_ID: id,
|
|
467
|
+
ISSUE_TITLE: scope.title,
|
|
468
|
+
BRANCH: scope.branch,
|
|
469
|
+
SCOPE: JSON.stringify(scope),
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
workerEvidence = complete(standalone, `Implementation of #${id}`);
|
|
473
|
+
}
|
|
474
|
+
const review = await integration.run({
|
|
147
475
|
name: "reviewer",
|
|
148
476
|
maxIterations: 1,
|
|
149
|
-
agent:
|
|
477
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
150
478
|
promptFile: "./.shipyard/review-prompt.md",
|
|
151
479
|
promptArgs: {
|
|
152
|
-
|
|
480
|
+
TASK_ID: id,
|
|
481
|
+
BRANCH: scope.branch,
|
|
482
|
+
BASE_BRANCH: targetBranch,
|
|
483
|
+
SCOPE: JSON.stringify(scope),
|
|
153
484
|
},
|
|
154
485
|
});
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
486
|
+
workerEvidence += `\n\n${approved(review, `Review of #${id}`)}`;
|
|
487
|
+
const final = await integration.run({
|
|
488
|
+
name: "merger",
|
|
489
|
+
maxIterations: 1,
|
|
490
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
491
|
+
promptFile: "./.shipyard/merge-prompt.md",
|
|
492
|
+
promptArgs: {
|
|
493
|
+
TASK_ID: id,
|
|
494
|
+
ISSUE_TITLE: scope.title,
|
|
495
|
+
BRANCH: scope.branch,
|
|
496
|
+
BASE_BRANCH: targetBranch,
|
|
497
|
+
SCOPE: JSON.stringify(scope),
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
const finalEvidence = complete(final, `Final integration of #${id}`);
|
|
501
|
+
handoffEvidence = `${workerEvidence}\n\n${finalEvidence}`;
|
|
502
|
+
if (final.commits.length) {
|
|
503
|
+
const finalReview = await integration.run({
|
|
504
|
+
name: "reviewer",
|
|
505
|
+
maxIterations: 1,
|
|
506
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
507
|
+
promptFile: "./.shipyard/review-prompt.md",
|
|
508
|
+
promptArgs: {
|
|
509
|
+
TASK_ID: id,
|
|
510
|
+
BRANCH: scope.branch,
|
|
511
|
+
BASE_BRANCH: targetBranch,
|
|
512
|
+
SCOPE: JSON.stringify(scope),
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
handoffEvidence += `\n\n${approved(finalReview, `Final review of #${id}`)}`;
|
|
516
|
+
}
|
|
517
|
+
} finally {
|
|
518
|
+
await closeClean(integration);
|
|
519
|
+
}
|
|
520
|
+
const publication = await shipyard.createSandbox({
|
|
521
|
+
branch: scope.branch,
|
|
522
|
+
sandbox: docker(),
|
|
523
|
+
});
|
|
524
|
+
try {
|
|
525
|
+
const scopeIds = [
|
|
526
|
+
id,
|
|
527
|
+
...(scope.tickets ?? []).map((ticket) => ticket.id),
|
|
528
|
+
].join(",");
|
|
529
|
+
publicationUncertain = true;
|
|
530
|
+
const handoff = await publication.exec(
|
|
531
|
+
`bash .shipyard/handoff.sh ${id} ${scope.branch} ${targetBranch} ${repository} ${scopeIds} ${scope.outstandingTicketIds?.join(",") || "-"} ${scope.completedTicketIds?.join(",") || "-"}`,
|
|
532
|
+
{ stdin: handoffEvidence },
|
|
533
|
+
);
|
|
534
|
+
publicationUncertain = handoff.exitCode === 75;
|
|
535
|
+
if (handoff.exitCode !== 0)
|
|
536
|
+
throw new Error(
|
|
537
|
+
`PR handoff for #${id} failed: ${handoff.stderr || handoff.stdout}`,
|
|
538
|
+
);
|
|
539
|
+
console.log(handoff.stdout.trim());
|
|
540
|
+
handedOff = true;
|
|
541
|
+
} finally {
|
|
542
|
+
await publication.close();
|
|
543
|
+
}
|
|
544
|
+
} catch (error) {
|
|
545
|
+
if (handedOff || publicationUncertain) throw error;
|
|
546
|
+
if (error instanceof TicketFailures) {
|
|
547
|
+
for (const failure of error.failures)
|
|
548
|
+
blockScope(scope, failure.id, failure.reason);
|
|
549
|
+
} else {
|
|
550
|
+
blockScope(
|
|
551
|
+
scope,
|
|
552
|
+
scope.id,
|
|
553
|
+
error instanceof Error ? error.message : String(error),
|
|
554
|
+
);
|
|
164
555
|
}
|
|
165
|
-
|
|
166
|
-
return implement;
|
|
167
|
-
} finally {
|
|
168
|
-
await sandbox.close();
|
|
169
556
|
}
|
|
170
557
|
}),
|
|
171
558
|
);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (outcome.status === "rejected") {
|
|
176
|
-
console.error(
|
|
177
|
-
` ✗ ${issues[i]!.id} (${issues[i]!.branch}) failed: ${outcome.reason}`,
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
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;
|
|
559
|
+
for (const [index, outcome] of outcomes.entries()) {
|
|
560
|
+
if (outcome.status === "rejected")
|
|
561
|
+
throw new Error(`Scope #${ids[index]} failed: ${outcome.reason}`);
|
|
208
562
|
}
|
|
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
563
|
}
|
|
236
|
-
|
|
237
|
-
console.log("\nAll done.");
|