@snappedly-tools/shipyard 0.7.0 → 0.8.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 +34 -41
- 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 +322 -344
- package/dist/index.js.map +1 -1
- package/dist/main.js +235 -139
- 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/main.mts +67 -9
- package/dist/templates/parallel-planner/planner-branch.mts +151 -0
- package/dist/templates/parallel-planner-with-review/main.mts +70 -12
- package/dist/templates/parallel-planner-with-review/planner-branch.mts +151 -0
- package/dist/templates/sequential-reviewer/main.mts +52 -3
- package/dist/templates/simple-loop/main.mts +51 -2
- 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,5 +1,4 @@
|
|
|
1
|
-
import { I as IsolatedSandboxProvider } from '../
|
|
2
|
-
import { M as MountConfig } from '../MountConfig-bZoCs4Dd.js';
|
|
1
|
+
import { M as MountConfig, I as IsolatedSandboxProvider } from '../MountConfig-BHnKnA4h.js';
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Shared mount utilities for the Docker sandbox provider.
|
|
@@ -16,7 +15,6 @@ import { M as MountConfig } from '../MountConfig-bZoCs4Dd.js';
|
|
|
16
15
|
* - `false` — disable labeling entirely.
|
|
17
16
|
*/
|
|
18
17
|
type SelinuxLabel = "z" | "Z" | false;
|
|
19
|
-
|
|
20
18
|
/**
|
|
21
19
|
* Derive the default image name from the repo directory.
|
|
22
20
|
* Returns `shipyard:<dir-name>` where dir-name is the last path segment,
|
package/dist/sandboxes/docker.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
export { docker } from '../chunk-
|
|
2
|
-
|
|
3
|
-
import '../chunk-KMGNFXKN.js';
|
|
4
|
-
import '../chunk-ACD46ZM4.js';
|
|
1
|
+
export { defaultImageName, docker } from '../chunk-44I2BL6E.js';
|
|
2
|
+
import '../chunk-L6PX5QTU.js';
|
|
5
3
|
//# sourceMappingURL=docker.js.map
|
|
6
4
|
//# sourceMappingURL=docker.js.map
|
|
@@ -5,9 +5,59 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import * as shipyard from "@snappedly-tools/shipyard";
|
|
6
6
|
import { docker } from "@snappedly-tools/shipyard/sandboxes/docker";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
+
import { resolvePlannerBranch } from "./planner-branch.mjs";
|
|
8
9
|
|
|
9
10
|
if (process.loadEnvFile && existsSync(".shipyard/.env"))
|
|
10
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
|
+
};
|
|
11
61
|
const targetBranch = execFileSync("git", ["branch", "--show-current"], {
|
|
12
62
|
encoding: "utf8",
|
|
13
63
|
}).trim();
|
|
@@ -114,7 +164,7 @@ const runWorker = async (scope: Scope, ticket: Ticket) => {
|
|
|
114
164
|
await sandbox.run({
|
|
115
165
|
name: `triage #${ticket.id}`,
|
|
116
166
|
maxIterations: 1,
|
|
117
|
-
agent:
|
|
167
|
+
agent: roleAgent("routine", shipyard.CODEX_MODELS.routine),
|
|
118
168
|
promptFile: "./.shipyard/triage-prompt.md",
|
|
119
169
|
promptArgs: { TASK_ID: ticket.id },
|
|
120
170
|
});
|
|
@@ -122,7 +172,7 @@ const runWorker = async (scope: Scope, ticket: Ticket) => {
|
|
|
122
172
|
const implementation = await sandbox.run({
|
|
123
173
|
name: "implementer",
|
|
124
174
|
maxIterations: 100,
|
|
125
|
-
agent:
|
|
175
|
+
agent: roleAgent("routine", shipyard.CODEX_MODELS.routine),
|
|
126
176
|
promptFile: "./.shipyard/implement-prompt.md",
|
|
127
177
|
promptArgs: {
|
|
128
178
|
TASK_ID: ticket.id,
|
|
@@ -143,13 +193,21 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
143
193
|
execFileSync("node", [".shipyard/select-issues.mjs"], { encoding: "utf8" }),
|
|
144
194
|
) as Scope[];
|
|
145
195
|
if (!scopes.length) break;
|
|
196
|
+
const localBranches = execFileSync(
|
|
197
|
+
"git",
|
|
198
|
+
["for-each-ref", "--format=%(refname:short)", "refs/heads"],
|
|
199
|
+
{ encoding: "utf8" },
|
|
200
|
+
)
|
|
201
|
+
.split(/\r?\n/)
|
|
202
|
+
.filter(Boolean);
|
|
203
|
+
const plannerBranch = await resolvePlannerBranch(localBranches);
|
|
146
204
|
const plan = await shipyard.run({
|
|
147
205
|
hooks,
|
|
148
206
|
sandbox: docker(),
|
|
149
207
|
name: "planner",
|
|
150
|
-
branchStrategy: { type: "branch", branch:
|
|
208
|
+
branchStrategy: { type: "branch", branch: plannerBranch },
|
|
151
209
|
maxIterations: 1,
|
|
152
|
-
agent:
|
|
210
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
153
211
|
promptFile: "./.shipyard/plan-prompt.md",
|
|
154
212
|
output: shipyard.Output.object({ tag: "plan", schema: planSchema }),
|
|
155
213
|
});
|
|
@@ -277,7 +335,7 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
277
335
|
const resolution = await wave.run({
|
|
278
336
|
name: "conflict-resolver",
|
|
279
337
|
maxIterations: 10,
|
|
280
|
-
agent:
|
|
338
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
281
339
|
promptFile: "./.shipyard/conflict-prompt.md",
|
|
282
340
|
promptArgs: {
|
|
283
341
|
TASK_ID: ready[index]!.id,
|
|
@@ -331,7 +389,7 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
331
389
|
const integrated = await wave.run({
|
|
332
390
|
name: "spec-integrator",
|
|
333
391
|
maxIterations: 10,
|
|
334
|
-
agent:
|
|
392
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
335
393
|
promptFile: "./.shipyard/spec-wave-prompt.md",
|
|
336
394
|
promptArgs: {
|
|
337
395
|
TASK_ID: id,
|
|
@@ -371,7 +429,7 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
371
429
|
await integration.run({
|
|
372
430
|
name: `triage #${id}`,
|
|
373
431
|
maxIterations: 1,
|
|
374
|
-
agent:
|
|
432
|
+
agent: roleAgent("routine", shipyard.CODEX_MODELS.routine),
|
|
375
433
|
promptFile: "./.shipyard/triage-prompt.md",
|
|
376
434
|
promptArgs: { TASK_ID: id },
|
|
377
435
|
});
|
|
@@ -379,7 +437,7 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
379
437
|
const standalone = await integration.run({
|
|
380
438
|
name: "implementer",
|
|
381
439
|
maxIterations: 100,
|
|
382
|
-
agent:
|
|
440
|
+
agent: roleAgent("routine", shipyard.CODEX_MODELS.routine),
|
|
383
441
|
promptFile: "./.shipyard/implement-prompt.md",
|
|
384
442
|
promptArgs: {
|
|
385
443
|
TASK_ID: id,
|
|
@@ -393,7 +451,7 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
393
451
|
const final = await integration.run({
|
|
394
452
|
name: "merger",
|
|
395
453
|
maxIterations: 1,
|
|
396
|
-
agent:
|
|
454
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
397
455
|
promptFile: "./.shipyard/merge-prompt.md",
|
|
398
456
|
promptArgs: {
|
|
399
457
|
TASK_ID: id,
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
|
|
4
|
+
const PLANNER_BRANCH = "shipyard/planner";
|
|
5
|
+
|
|
6
|
+
type BranchPrompt = (message: string) => Promise<string>;
|
|
7
|
+
|
|
8
|
+
const askInTerminal: BranchPrompt = async (message) => {
|
|
9
|
+
const readline = createInterface({
|
|
10
|
+
input: process.stdin,
|
|
11
|
+
output: process.stdout,
|
|
12
|
+
});
|
|
13
|
+
try {
|
|
14
|
+
return await readline.question(message);
|
|
15
|
+
} finally {
|
|
16
|
+
readline.close();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const refsConflict = (left: string, right: string): boolean =>
|
|
21
|
+
left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
22
|
+
|
|
23
|
+
const nextPlannerBranch = (localBranches: readonly string[]): string => {
|
|
24
|
+
for (let suffix = 2; ; suffix++) {
|
|
25
|
+
for (const candidate of [
|
|
26
|
+
`${PLANNER_BRANCH}-${suffix}`,
|
|
27
|
+
`shipyard-planner-${suffix}`,
|
|
28
|
+
]) {
|
|
29
|
+
if (!localBranches.some((branch) => refsConflict(candidate, branch)))
|
|
30
|
+
return candidate;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const reusablePlannerBranch = (
|
|
36
|
+
localBranches: readonly string[],
|
|
37
|
+
): string | undefined => {
|
|
38
|
+
const candidates = localBranches
|
|
39
|
+
.map((branch) => ({
|
|
40
|
+
branch,
|
|
41
|
+
suffix: /^(?:shipyard\/planner-|shipyard-planner-)(\d+)$/.exec(
|
|
42
|
+
branch,
|
|
43
|
+
)?.[1],
|
|
44
|
+
}))
|
|
45
|
+
.filter((candidate) => candidate.suffix !== undefined)
|
|
46
|
+
.sort((left, right) => Number(left.suffix) - Number(right.suffix));
|
|
47
|
+
|
|
48
|
+
return candidates.find(({ branch }) =>
|
|
49
|
+
localBranches.every(
|
|
50
|
+
(other) => other === branch || !refsConflict(branch, other),
|
|
51
|
+
),
|
|
52
|
+
)?.branch;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const resolvePlannerBranch = async (
|
|
56
|
+
localBranches: readonly string[],
|
|
57
|
+
options: {
|
|
58
|
+
isInteractive?: boolean;
|
|
59
|
+
ask?: BranchPrompt;
|
|
60
|
+
checkedOutBranches?: readonly string[];
|
|
61
|
+
mergedBranches?: readonly string[];
|
|
62
|
+
deleteBranches?: (branches: readonly string[]) => void;
|
|
63
|
+
} = {},
|
|
64
|
+
): Promise<string> => {
|
|
65
|
+
const conflicts = localBranches.filter((branch) =>
|
|
66
|
+
refsConflict(PLANNER_BRANCH, branch),
|
|
67
|
+
);
|
|
68
|
+
if (conflicts.length === 0) return PLANNER_BRANCH;
|
|
69
|
+
|
|
70
|
+
const reusable = reusablePlannerBranch(localBranches);
|
|
71
|
+
if (reusable) return reusable;
|
|
72
|
+
|
|
73
|
+
const alternate = nextPlannerBranch(localBranches);
|
|
74
|
+
|
|
75
|
+
const conflictList = conflicts.map((branch) => ` - ${branch}`).join("\n");
|
|
76
|
+
const context =
|
|
77
|
+
`Planner branch '${PLANNER_BRANCH}' conflicts with local branch refs:\n${conflictList}\n` +
|
|
78
|
+
`Git cannot create both names. The listed local branches will be kept unless you choose deletion.`;
|
|
79
|
+
const isInteractive =
|
|
80
|
+
options.isInteractive ??
|
|
81
|
+
Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
82
|
+
if (!isInteractive) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`${context}\nRerun in an interactive terminal to keep them and use '${alternate}', or request local deletion after Git checks that they are merged and not checked out. No branches were changed.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const checkedOutBranches = new Set(
|
|
89
|
+
options.checkedOutBranches ??
|
|
90
|
+
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
|
91
|
+
encoding: "utf8",
|
|
92
|
+
})
|
|
93
|
+
.split(/\r?\n/)
|
|
94
|
+
.filter((line) => line.startsWith("branch refs/heads/"))
|
|
95
|
+
.map((line) => line.slice("branch refs/heads/".length)),
|
|
96
|
+
);
|
|
97
|
+
const mergedBranches = new Set(
|
|
98
|
+
options.mergedBranches ??
|
|
99
|
+
execFileSync(
|
|
100
|
+
"git",
|
|
101
|
+
["branch", "--merged", "HEAD", "--format=%(refname:short)"],
|
|
102
|
+
{ encoding: "utf8" },
|
|
103
|
+
)
|
|
104
|
+
.split(/\r?\n/)
|
|
105
|
+
.filter(Boolean),
|
|
106
|
+
);
|
|
107
|
+
const branchesSafeToDelete = conflicts.every(
|
|
108
|
+
(branch) => !checkedOutBranches.has(branch) && mergedBranches.has(branch),
|
|
109
|
+
);
|
|
110
|
+
const unsafeDeleteReasons = conflicts.flatMap((branch) => {
|
|
111
|
+
if (checkedOutBranches.has(branch))
|
|
112
|
+
return [`'${branch}' is checked out in a worktree`];
|
|
113
|
+
if (!mergedBranches.has(branch))
|
|
114
|
+
return [`'${branch}' has commits not merged into HEAD`];
|
|
115
|
+
return [];
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const ask = options.ask ?? askInTerminal;
|
|
119
|
+
const answer = await ask(
|
|
120
|
+
`${context}\n[Y] Keep them and use '${alternate}' (recommended)\n` +
|
|
121
|
+
`[d] Delete these local branches and use '${PLANNER_BRANCH}'${
|
|
122
|
+
branchesSafeToDelete
|
|
123
|
+
? ""
|
|
124
|
+
: ` (unavailable: ${unsafeDeleteReasons.join("; ")})`
|
|
125
|
+
}\n` +
|
|
126
|
+
`[n] Abort without changes\nChoose [Y/d/n]: `,
|
|
127
|
+
);
|
|
128
|
+
const choice = answer.trim().toLowerCase();
|
|
129
|
+
if (choice === "" || choice === "y" || choice === "yes") return alternate;
|
|
130
|
+
if (choice === "d" || choice === "delete") {
|
|
131
|
+
if (!branchesSafeToDelete) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Cannot safely delete the conflicting branches: ${unsafeDeleteReasons.join("; ")}. No branches were changed. Use '${alternate}' or inspect the listed refs manually.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const deleteBranches =
|
|
137
|
+
options.deleteBranches ??
|
|
138
|
+
((branches: readonly string[]) => {
|
|
139
|
+
for (const branch of branches)
|
|
140
|
+
execFileSync("git", ["branch", "-d", branch], {
|
|
141
|
+
encoding: "utf8",
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
deleteBranches(conflicts);
|
|
145
|
+
return PLANNER_BRANCH;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Planner branch selection cancelled. No branches were changed. Inspect with 'git worktree list' and 'git branch --list "shipyard/planner*"', then rerun.`,
|
|
150
|
+
);
|
|
151
|
+
};
|
|
@@ -5,9 +5,59 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import * as shipyard from "@snappedly-tools/shipyard";
|
|
6
6
|
import { docker } from "@snappedly-tools/shipyard/sandboxes/docker";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
+
import { resolvePlannerBranch } from "./planner-branch.mjs";
|
|
8
9
|
|
|
9
10
|
if (process.loadEnvFile && existsSync(".shipyard/.env"))
|
|
10
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
|
+
};
|
|
11
61
|
const targetBranch = execFileSync("git", ["branch", "--show-current"], {
|
|
12
62
|
encoding: "utf8",
|
|
13
63
|
}).trim();
|
|
@@ -124,7 +174,7 @@ const runWorker = async (scope: Scope, ticket: Ticket) => {
|
|
|
124
174
|
await sandbox.run({
|
|
125
175
|
name: `triage #${ticket.id}`,
|
|
126
176
|
maxIterations: 1,
|
|
127
|
-
agent:
|
|
177
|
+
agent: roleAgent("routine", shipyard.CODEX_MODELS.routine),
|
|
128
178
|
promptFile: "./.shipyard/triage-prompt.md",
|
|
129
179
|
promptArgs: { TASK_ID: ticket.id },
|
|
130
180
|
});
|
|
@@ -132,7 +182,7 @@ const runWorker = async (scope: Scope, ticket: Ticket) => {
|
|
|
132
182
|
const implementation = await sandbox.run({
|
|
133
183
|
name: "implementer",
|
|
134
184
|
maxIterations: 100,
|
|
135
|
-
agent:
|
|
185
|
+
agent: roleAgent("routine", shipyard.CODEX_MODELS.routine),
|
|
136
186
|
promptFile: "./.shipyard/implement-prompt.md",
|
|
137
187
|
promptArgs: {
|
|
138
188
|
TASK_ID: ticket.id,
|
|
@@ -145,7 +195,7 @@ const runWorker = async (scope: Scope, ticket: Ticket) => {
|
|
|
145
195
|
const review = await sandbox.run({
|
|
146
196
|
name: "reviewer",
|
|
147
197
|
maxIterations: 1,
|
|
148
|
-
agent:
|
|
198
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
149
199
|
promptFile: "./.shipyard/review-prompt.md",
|
|
150
200
|
promptArgs: {
|
|
151
201
|
TASK_ID: ticket.id,
|
|
@@ -166,13 +216,21 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
166
216
|
execFileSync("node", [".shipyard/select-issues.mjs"], { encoding: "utf8" }),
|
|
167
217
|
) as Scope[];
|
|
168
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);
|
|
169
227
|
const plan = await shipyard.run({
|
|
170
228
|
hooks,
|
|
171
229
|
sandbox: docker(),
|
|
172
230
|
name: "planner",
|
|
173
|
-
branchStrategy: { type: "branch", branch:
|
|
231
|
+
branchStrategy: { type: "branch", branch: plannerBranch },
|
|
174
232
|
maxIterations: 1,
|
|
175
|
-
agent:
|
|
233
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
176
234
|
promptFile: "./.shipyard/plan-prompt.md",
|
|
177
235
|
output: shipyard.Output.object({ tag: "plan", schema: planSchema }),
|
|
178
236
|
});
|
|
@@ -300,7 +358,7 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
300
358
|
const resolution = await wave.run({
|
|
301
359
|
name: "conflict-resolver",
|
|
302
360
|
maxIterations: 10,
|
|
303
|
-
agent:
|
|
361
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
304
362
|
promptFile: "./.shipyard/conflict-prompt.md",
|
|
305
363
|
promptArgs: {
|
|
306
364
|
TASK_ID: ready[index]!.id,
|
|
@@ -354,7 +412,7 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
354
412
|
const integrated = await wave.run({
|
|
355
413
|
name: "spec-integrator",
|
|
356
414
|
maxIterations: 10,
|
|
357
|
-
agent:
|
|
415
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
358
416
|
promptFile: "./.shipyard/spec-wave-prompt.md",
|
|
359
417
|
promptArgs: {
|
|
360
418
|
TASK_ID: id,
|
|
@@ -394,7 +452,7 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
394
452
|
await integration.run({
|
|
395
453
|
name: `triage #${id}`,
|
|
396
454
|
maxIterations: 1,
|
|
397
|
-
agent:
|
|
455
|
+
agent: roleAgent("routine", shipyard.CODEX_MODELS.routine),
|
|
398
456
|
promptFile: "./.shipyard/triage-prompt.md",
|
|
399
457
|
promptArgs: { TASK_ID: id },
|
|
400
458
|
});
|
|
@@ -402,7 +460,7 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
402
460
|
const standalone = await integration.run({
|
|
403
461
|
name: "implementer",
|
|
404
462
|
maxIterations: 100,
|
|
405
|
-
agent:
|
|
463
|
+
agent: roleAgent("routine", shipyard.CODEX_MODELS.routine),
|
|
406
464
|
promptFile: "./.shipyard/implement-prompt.md",
|
|
407
465
|
promptArgs: {
|
|
408
466
|
TASK_ID: id,
|
|
@@ -416,7 +474,7 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
416
474
|
const review = await integration.run({
|
|
417
475
|
name: "reviewer",
|
|
418
476
|
maxIterations: 1,
|
|
419
|
-
agent:
|
|
477
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
420
478
|
promptFile: "./.shipyard/review-prompt.md",
|
|
421
479
|
promptArgs: {
|
|
422
480
|
TASK_ID: id,
|
|
@@ -429,7 +487,7 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
429
487
|
const final = await integration.run({
|
|
430
488
|
name: "merger",
|
|
431
489
|
maxIterations: 1,
|
|
432
|
-
agent:
|
|
490
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
433
491
|
promptFile: "./.shipyard/merge-prompt.md",
|
|
434
492
|
promptArgs: {
|
|
435
493
|
TASK_ID: id,
|
|
@@ -445,7 +503,7 @@ for (let iteration = 0; iteration < 10; iteration++) {
|
|
|
445
503
|
const finalReview = await integration.run({
|
|
446
504
|
name: "reviewer",
|
|
447
505
|
maxIterations: 1,
|
|
448
|
-
agent:
|
|
506
|
+
agent: roleAgent("strong", shipyard.CODEX_MODELS.strong),
|
|
449
507
|
promptFile: "./.shipyard/review-prompt.md",
|
|
450
508
|
promptArgs: {
|
|
451
509
|
TASK_ID: id,
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
|
|
4
|
+
const PLANNER_BRANCH = "shipyard/planner";
|
|
5
|
+
|
|
6
|
+
type BranchPrompt = (message: string) => Promise<string>;
|
|
7
|
+
|
|
8
|
+
const askInTerminal: BranchPrompt = async (message) => {
|
|
9
|
+
const readline = createInterface({
|
|
10
|
+
input: process.stdin,
|
|
11
|
+
output: process.stdout,
|
|
12
|
+
});
|
|
13
|
+
try {
|
|
14
|
+
return await readline.question(message);
|
|
15
|
+
} finally {
|
|
16
|
+
readline.close();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const refsConflict = (left: string, right: string): boolean =>
|
|
21
|
+
left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
22
|
+
|
|
23
|
+
const nextPlannerBranch = (localBranches: readonly string[]): string => {
|
|
24
|
+
for (let suffix = 2; ; suffix++) {
|
|
25
|
+
for (const candidate of [
|
|
26
|
+
`${PLANNER_BRANCH}-${suffix}`,
|
|
27
|
+
`shipyard-planner-${suffix}`,
|
|
28
|
+
]) {
|
|
29
|
+
if (!localBranches.some((branch) => refsConflict(candidate, branch)))
|
|
30
|
+
return candidate;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const reusablePlannerBranch = (
|
|
36
|
+
localBranches: readonly string[],
|
|
37
|
+
): string | undefined => {
|
|
38
|
+
const candidates = localBranches
|
|
39
|
+
.map((branch) => ({
|
|
40
|
+
branch,
|
|
41
|
+
suffix: /^(?:shipyard\/planner-|shipyard-planner-)(\d+)$/.exec(
|
|
42
|
+
branch,
|
|
43
|
+
)?.[1],
|
|
44
|
+
}))
|
|
45
|
+
.filter((candidate) => candidate.suffix !== undefined)
|
|
46
|
+
.sort((left, right) => Number(left.suffix) - Number(right.suffix));
|
|
47
|
+
|
|
48
|
+
return candidates.find(({ branch }) =>
|
|
49
|
+
localBranches.every(
|
|
50
|
+
(other) => other === branch || !refsConflict(branch, other),
|
|
51
|
+
),
|
|
52
|
+
)?.branch;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const resolvePlannerBranch = async (
|
|
56
|
+
localBranches: readonly string[],
|
|
57
|
+
options: {
|
|
58
|
+
isInteractive?: boolean;
|
|
59
|
+
ask?: BranchPrompt;
|
|
60
|
+
checkedOutBranches?: readonly string[];
|
|
61
|
+
mergedBranches?: readonly string[];
|
|
62
|
+
deleteBranches?: (branches: readonly string[]) => void;
|
|
63
|
+
} = {},
|
|
64
|
+
): Promise<string> => {
|
|
65
|
+
const conflicts = localBranches.filter((branch) =>
|
|
66
|
+
refsConflict(PLANNER_BRANCH, branch),
|
|
67
|
+
);
|
|
68
|
+
if (conflicts.length === 0) return PLANNER_BRANCH;
|
|
69
|
+
|
|
70
|
+
const reusable = reusablePlannerBranch(localBranches);
|
|
71
|
+
if (reusable) return reusable;
|
|
72
|
+
|
|
73
|
+
const alternate = nextPlannerBranch(localBranches);
|
|
74
|
+
|
|
75
|
+
const conflictList = conflicts.map((branch) => ` - ${branch}`).join("\n");
|
|
76
|
+
const context =
|
|
77
|
+
`Planner branch '${PLANNER_BRANCH}' conflicts with local branch refs:\n${conflictList}\n` +
|
|
78
|
+
`Git cannot create both names. The listed local branches will be kept unless you choose deletion.`;
|
|
79
|
+
const isInteractive =
|
|
80
|
+
options.isInteractive ??
|
|
81
|
+
Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
82
|
+
if (!isInteractive) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`${context}\nRerun in an interactive terminal to keep them and use '${alternate}', or request local deletion after Git checks that they are merged and not checked out. No branches were changed.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const checkedOutBranches = new Set(
|
|
89
|
+
options.checkedOutBranches ??
|
|
90
|
+
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
|
91
|
+
encoding: "utf8",
|
|
92
|
+
})
|
|
93
|
+
.split(/\r?\n/)
|
|
94
|
+
.filter((line) => line.startsWith("branch refs/heads/"))
|
|
95
|
+
.map((line) => line.slice("branch refs/heads/".length)),
|
|
96
|
+
);
|
|
97
|
+
const mergedBranches = new Set(
|
|
98
|
+
options.mergedBranches ??
|
|
99
|
+
execFileSync(
|
|
100
|
+
"git",
|
|
101
|
+
["branch", "--merged", "HEAD", "--format=%(refname:short)"],
|
|
102
|
+
{ encoding: "utf8" },
|
|
103
|
+
)
|
|
104
|
+
.split(/\r?\n/)
|
|
105
|
+
.filter(Boolean),
|
|
106
|
+
);
|
|
107
|
+
const branchesSafeToDelete = conflicts.every(
|
|
108
|
+
(branch) => !checkedOutBranches.has(branch) && mergedBranches.has(branch),
|
|
109
|
+
);
|
|
110
|
+
const unsafeDeleteReasons = conflicts.flatMap((branch) => {
|
|
111
|
+
if (checkedOutBranches.has(branch))
|
|
112
|
+
return [`'${branch}' is checked out in a worktree`];
|
|
113
|
+
if (!mergedBranches.has(branch))
|
|
114
|
+
return [`'${branch}' has commits not merged into HEAD`];
|
|
115
|
+
return [];
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const ask = options.ask ?? askInTerminal;
|
|
119
|
+
const answer = await ask(
|
|
120
|
+
`${context}\n[Y] Keep them and use '${alternate}' (recommended)\n` +
|
|
121
|
+
`[d] Delete these local branches and use '${PLANNER_BRANCH}'${
|
|
122
|
+
branchesSafeToDelete
|
|
123
|
+
? ""
|
|
124
|
+
: ` (unavailable: ${unsafeDeleteReasons.join("; ")})`
|
|
125
|
+
}\n` +
|
|
126
|
+
`[n] Abort without changes\nChoose [Y/d/n]: `,
|
|
127
|
+
);
|
|
128
|
+
const choice = answer.trim().toLowerCase();
|
|
129
|
+
if (choice === "" || choice === "y" || choice === "yes") return alternate;
|
|
130
|
+
if (choice === "d" || choice === "delete") {
|
|
131
|
+
if (!branchesSafeToDelete) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Cannot safely delete the conflicting branches: ${unsafeDeleteReasons.join("; ")}. No branches were changed. Use '${alternate}' or inspect the listed refs manually.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const deleteBranches =
|
|
137
|
+
options.deleteBranches ??
|
|
138
|
+
((branches: readonly string[]) => {
|
|
139
|
+
for (const branch of branches)
|
|
140
|
+
execFileSync("git", ["branch", "-d", branch], {
|
|
141
|
+
encoding: "utf8",
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
deleteBranches(conflicts);
|
|
145
|
+
return PLANNER_BRANCH;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Planner branch selection cancelled. No branches were changed. Inspect with 'git worktree list' and 'git branch --list "shipyard/planner*"', then rerun.`,
|
|
150
|
+
);
|
|
151
|
+
};
|