@snappedly-tools/shipyard 0.6.0 → 0.7.0-staging.36082887921

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +64 -165
  2. package/dist/MountConfig-BHnKnA4h.d.ts +145 -0
  3. package/dist/{chunk-WAXEMZUV.js → chunk-44I2BL6E.js} +225 -33
  4. package/dist/chunk-44I2BL6E.js.map +1 -0
  5. package/dist/{chunk-56TDSWFU.js → chunk-JI3HDDMS.js} +3 -3
  6. package/dist/{chunk-56TDSWFU.js.map → chunk-JI3HDDMS.js.map} +1 -1
  7. package/dist/{chunk-FDYOTN55.js → chunk-L6PX5QTU.js} +253 -878
  8. package/dist/chunk-L6PX5QTU.js.map +1 -0
  9. package/dist/index.d.ts +117 -97
  10. package/dist/index.js +322 -344
  11. package/dist/index.js.map +1 -1
  12. package/dist/main.js +235 -139
  13. package/dist/main.js.map +1 -1
  14. package/dist/sandboxes/docker.d.ts +1 -3
  15. package/dist/sandboxes/docker.js +2 -4
  16. package/dist/templates/parallel-planner/main.mts +67 -9
  17. package/dist/templates/parallel-planner/planner-branch.mts +151 -0
  18. package/dist/templates/parallel-planner-with-review/main.mts +70 -12
  19. package/dist/templates/parallel-planner-with-review/planner-branch.mts +151 -0
  20. package/dist/templates/sequential-reviewer/main.mts +52 -3
  21. package/dist/templates/simple-loop/main.mts +51 -2
  22. package/package.json +1 -17
  23. package/dist/MountConfig-bZoCs4Dd.d.ts +0 -26
  24. package/dist/SandboxProvider-XJQqEdSf.d.ts +0 -261
  25. package/dist/chunk-ACD46ZM4.js +0 -136
  26. package/dist/chunk-ACD46ZM4.js.map +0 -1
  27. package/dist/chunk-FDYOTN55.js.map +0 -1
  28. package/dist/chunk-KMGNFXKN.js +0 -38
  29. package/dist/chunk-KMGNFXKN.js.map +0 -1
  30. package/dist/chunk-SOJTAJTF.js +0 -78
  31. package/dist/chunk-SOJTAJTF.js.map +0 -1
  32. package/dist/chunk-WAXEMZUV.js.map +0 -1
  33. package/dist/sandboxes/no-sandbox.d.ts +0 -37
  34. package/dist/sandboxes/no-sandbox.js +0 -4
  35. package/dist/sandboxes/no-sandbox.js.map +0 -1
  36. package/dist/sandboxes/vercel.d.ts +0 -104
  37. package/dist/sandboxes/vercel.js +0 -166
  38. package/dist/sandboxes/vercel.js.map +0 -1
  39. package/dist/templates/blank/main.mts +0 -13
  40. package/dist/templates/blank/prompt.md +0 -12
  41. package/dist/templates/blank/template.json +0 -4
@@ -1,5 +1,4 @@
1
- import { I as IsolatedSandboxProvider } from '../SandboxProvider-XJQqEdSf.js';
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,
@@ -1,6 +1,4 @@
1
- export { docker } from '../chunk-WAXEMZUV.js';
2
- export { defaultImageName } from '../chunk-FDYOTN55.js';
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.routine),
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: "shipyard/planner" },
208
+ branchStrategy: { type: "branch", branch: plannerBranch },
151
209
  maxIterations: 1,
152
- agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.routine),
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.routine),
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: "shipyard/planner" },
231
+ branchStrategy: { type: "branch", branch: plannerBranch },
174
232
  maxIterations: 1,
175
- agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.routine),
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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: shipyard.codex(shipyard.CODEX_MODELS.strong),
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
+ };