@yaawn/workflow 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +172 -2
  2. package/agents/instructions/output-styles/yaawn-eli5.instructions.md +45 -0
  3. package/agents/instructions/yaawn-accessibility.css.instructions.md +58 -0
  4. package/agents/instructions/yaawn-accessibility.html.instructions.md +90 -0
  5. package/agents/instructions/yaawn-accessibility.javascript.instructions.md +50 -0
  6. package/agents/instructions/yaawn-clean-code.typescript.instructions.md +74 -0
  7. package/agents/instructions/yaawn-function-coupling.typescript.instructions.md +100 -0
  8. package/agents/instructions/yaawn-html-css-js.html.instructions.md +32 -0
  9. package/agents/instructions/yaawn-open-source-first.instructions.md +38 -0
  10. package/agents/instructions/yaawn-personas.instructions.md +18 -0
  11. package/agents/instructions/yaawn-semantic-html.html.instructions.md +40 -0
  12. package/agents/instructions/yaawn-vanilla-css.css.instructions.md +50 -0
  13. package/agents/output-styles/yaawn-eli5.instructions.md +45 -0
  14. package/agents/personas/yaawn-developer.md +16 -0
  15. package/agents/personas/yaawn-product-owner.md +18 -0
  16. package/agents/personas/yaawn-reviewer.md +16 -0
  17. package/agents/skills/yaawn-clean-code/SKILL.md +61 -0
  18. package/agents/skills/yaawn-clean-code/references/classes.md +114 -0
  19. package/agents/skills/yaawn-clean-code/references/comments.md +164 -0
  20. package/agents/skills/yaawn-clean-code/references/error-handling.md +151 -0
  21. package/agents/skills/yaawn-clean-code/references/formatting.md +146 -0
  22. package/agents/skills/yaawn-clean-code/references/functions.md +261 -0
  23. package/agents/skills/yaawn-clean-code/references/meaningful-names.md +234 -0
  24. package/agents/skills/yaawn-clean-code/references/objects-and-data-structures.md +130 -0
  25. package/agents/skills/yaawn-clean-code/references/principles.md +76 -0
  26. package/agents/skills/yaawn-clean-code/references/simple-design-and-code-smells.md +130 -0
  27. package/agents/skills/yaawn-clean-code/references/unit-tests.md +114 -0
  28. package/agents/skills/yaawn-create-plan/SKILL.md +234 -0
  29. package/agents/skills/yaawn-grill-bug-report/SKILL.md +31 -0
  30. package/agents/skills/yaawn-grill-bug-report/references/bug-report-checklist.md +27 -0
  31. package/agents/skills/yaawn-grill-design/SKILL.md +68 -0
  32. package/agents/skills/yaawn-grill-design/references/design-checklist.md +36 -0
  33. package/agents/skills/yaawn-grill-other/SKILL.md +31 -0
  34. package/agents/skills/yaawn-grill-other/references/other-checklist.md +19 -0
  35. package/agents/skills/yaawn-grill-refactor/SKILL.md +31 -0
  36. package/agents/skills/yaawn-grill-refactor/references/refactor-checklist.md +26 -0
  37. package/agents/skills/yaawn-grill-spike/SKILL.md +31 -0
  38. package/agents/skills/yaawn-grill-spike/references/spike-checklist.md +25 -0
  39. package/agents/skills/yaawn-grill-story/SKILL.md +34 -0
  40. package/agents/skills/yaawn-grill-story/references/concept-challenge.md +77 -0
  41. package/agents/skills/yaawn-grill-story/references/story-checklist.md +44 -0
  42. package/agents/skills/yaawn-implement-plan/SKILL.md +196 -0
  43. package/agents/skills/yaawn-implement-plan/assets/fix-prompt.md +66 -0
  44. package/agents/skills/yaawn-implement-plan/assets/review-prompt.md +89 -0
  45. package/agents/skills/yaawn-implement-plan/assets/session-prompt.md +80 -0
  46. package/agents/skills/yaawn-implement-plan/scripts/agent.ts +218 -0
  47. package/agents/skills/yaawn-implement-plan/scripts/caffeinate.ts +56 -0
  48. package/agents/skills/yaawn-implement-plan/scripts/groups.ts +233 -0
  49. package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +772 -0
  50. package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +1176 -0
  51. package/agents/skills/yaawn-implement-plan/scripts/processes.ts +53 -0
  52. package/agents/skills/yaawn-implement-plan/scripts/stages.ts +65 -0
  53. package/agents/skills/yaawn-implement-plan/scripts/targets.ts +381 -0
  54. package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +431 -0
  55. package/agents/skills/yaawn-owasp-security/SKILL.md +106 -0
  56. package/agents/skills/yaawn-owasp-security/references/a01-broken-access-control.md +58 -0
  57. package/agents/skills/yaawn-owasp-security/references/a02-security-misconfiguration.md +56 -0
  58. package/agents/skills/yaawn-owasp-security/references/a03-software-supply-chain-failures.md +56 -0
  59. package/agents/skills/yaawn-owasp-security/references/a04-cryptographic-failures.md +67 -0
  60. package/agents/skills/yaawn-owasp-security/references/a05-injection.md +53 -0
  61. package/agents/skills/yaawn-owasp-security/references/a06-insecure-design.md +48 -0
  62. package/agents/skills/yaawn-owasp-security/references/a07-authentication-failures.md +68 -0
  63. package/agents/skills/yaawn-owasp-security/references/a08-software-data-integrity-failures.md +45 -0
  64. package/agents/skills/yaawn-owasp-security/references/a09-security-logging-alerting-failures.md +59 -0
  65. package/agents/skills/yaawn-owasp-security/references/a10-mishandling-exceptional-conditions.md +63 -0
  66. package/agents/skills/yaawn-prepare-plan/SKILL.md +110 -0
  67. package/agents/skills/yaawn-save-plan/SKILL.md +84 -0
  68. package/agents/skills/yaawn-save-plan/scripts/plan-body.ts +397 -0
  69. package/agents/skills/yaawn-save-plan/scripts/save-plan.ts +539 -0
  70. package/agents/skills/yaawn-split-story/SKILL.md +189 -0
  71. package/agents/skills/yaawn-split-story/references/spider.md +64 -0
  72. package/agents/skills/yaawn-split-story/scripts/create-issue.ts +162 -0
  73. package/agents/skills/yaawn-verify-executed-plan/SKILL.md +154 -0
  74. package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +362 -0
  75. package/agents/skills/yaawn-verify-executed-plan/scripts/pull-request.ts +131 -0
  76. package/agents/skills/yaawn-write-bug-report/SKILL.md +87 -0
  77. package/agents/skills/yaawn-write-other/SKILL.md +90 -0
  78. package/agents/skills/yaawn-write-refactor/SKILL.md +94 -0
  79. package/agents/skills/yaawn-write-spike/SKILL.md +94 -0
  80. package/agents/skills/yaawn-write-user-story/SKILL.md +187 -0
  81. package/agents/skills/yaawn-write-user-story/references/acceptance-criteria.md +186 -0
  82. package/agents/skills/yaawn-write-user-story/references/five-types-of-value.md +82 -0
  83. package/agents/skills/yaawn-write-user-story/references/user-story-fundamentals.md +75 -0
  84. package/agents/yaawn-glossary.md +146 -0
  85. package/agents/yaawn-grilling.md +75 -0
  86. package/agents/yaawn-worktrees.md +69 -0
  87. package/config/install-tasks.json +35 -0
  88. package/config/settings.json +23 -0
  89. package/config/wizard.json +128 -0
  90. package/github/ISSUE_TEMPLATE/yaawn-bug-report.md +59 -0
  91. package/github/ISSUE_TEMPLATE/yaawn-other.md +38 -0
  92. package/github/ISSUE_TEMPLATE/yaawn-refactor.md +38 -0
  93. package/github/ISSUE_TEMPLATE/yaawn-spike.md +16 -0
  94. package/github/ISSUE_TEMPLATE/yaawn-story.md +56 -0
  95. package/package.json +33 -4
  96. package/scripts/install.js +73 -0
  97. package/yaawn/gitignore +1 -0
  98. package/yaawn/yaawn-definition-of-done.md +5 -0
  99. package/yaawn/yaawn-project-glossary.md +7 -0
  100. package/yaawn/yaawn-user-roles.md +3 -0
  101. package/yaawn/yaawn-verification-commands.md +7 -0
@@ -0,0 +1,1176 @@
1
+ import { type ChildProcess, execFile, spawn } from "node:child_process";
2
+ import {
3
+ closeSync,
4
+ constants,
5
+ existsSync,
6
+ fstatSync,
7
+ openSync,
8
+ readFileSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import path from "node:path";
13
+ import { createInterface } from "node:readline";
14
+ import { createInterface as createPrompt } from "node:readline/promises";
15
+ import type { Readable } from "node:stream";
16
+ import { promisify } from "node:util";
17
+ import {
18
+ type AgentChoice,
19
+ type AgentName,
20
+ readAgent,
21
+ } from "./agent.js";
22
+ import { keepMacAwakeWhileRunning, printWalkAwayBanner } from "./caffeinate.js";
23
+ import {
24
+ type ImplementationGroup,
25
+ type LabelAction,
26
+ type Lane,
27
+ allCompatibleGroups,
28
+ assignGroups,
29
+ conflictGraph,
30
+ filesToChange,
31
+ filesToChangeHeading,
32
+ groupLabel,
33
+ groupLabelRemovals,
34
+ groupsOf,
35
+ labelActions,
36
+ laneFiles,
37
+ laneIssueNumbers,
38
+ laneOwners,
39
+ lanePairs,
40
+ } from "./groups.js";
41
+ import { drainOutput } from "./processes.js";
42
+ import {
43
+ type LaneOutcomeState,
44
+ type StageLabel,
45
+ laneStartedLabel,
46
+ parseStageMarker,
47
+ runResultLine as outcomeResultLine,
48
+ runningResult,
49
+ stoppedResult,
50
+ } from "./stages.js";
51
+ import {
52
+ type Issue,
53
+ addLabel,
54
+ ensureGroupLabel,
55
+ executionOrder,
56
+ gh,
57
+ ghAsync,
58
+ issueFailuresAsync,
59
+ issueRefs,
60
+ plansDir,
61
+ removeGroupLabels,
62
+ removeLabel,
63
+ resolveTargets,
64
+ resolveTargetsAsync,
65
+ resultPath,
66
+ succeeds,
67
+ succeedsAsync,
68
+ uncommittedChangesAsync,
69
+ } from "./targets.js";
70
+ import {
71
+ type MainCopyCheck,
72
+ blockedUpdateReason,
73
+ branchName,
74
+ describeMainCopyState,
75
+ fetchMainCopy,
76
+ git,
77
+ isBareLayout,
78
+ laneWorktreePath,
79
+ registeredWorktrees,
80
+ syncMainCopy,
81
+ } from "./worktrees.js";
82
+
83
+ // Every launch: one lane per issue, each in its own git worktree, each
84
+ // running `overnight.ts lane <issue> --agent <name>` there.
85
+
86
+ export interface LaunchRequest {
87
+ issues: number[];
88
+ ready: boolean;
89
+ maxParallel: number | null;
90
+ }
91
+
92
+ type LaneState = LaneOutcomeState;
93
+
94
+ // The lane issue's title names a new worktree directory; an existing checkout
95
+ // of the lane branch is reused instead.
96
+ interface TitledLane extends Lane {
97
+ title: string;
98
+ }
99
+
100
+ // What a pre-flight lookup found, or why it could not look.
101
+ interface Lookup<T> {
102
+ value: T;
103
+ failures: string[];
104
+ }
105
+
106
+ // The lanes that passed every pre-flight check, and the main copy's
107
+ // uncommitted changes (without the plans directory).
108
+ interface CheckedLaunch {
109
+ lanes: TitledLane[];
110
+ changes: string;
111
+ }
112
+
113
+ interface ActiveRun {
114
+ pid: number;
115
+ tokens: string[];
116
+ }
117
+
118
+ interface LaneRun {
119
+ lane: TitledLane;
120
+ agent: AgentName;
121
+ worktree: string;
122
+ state: LaneState;
123
+ stage: StageLabel;
124
+ tail: string[];
125
+ result: string;
126
+ }
127
+
128
+ const overnightScript =
129
+ ".agents/skills/yaawn-implement-plan/scripts/overnight.ts";
130
+ const readyLabel = "Yaawn: Ready To Implement";
131
+ const issueListLimit = "1000";
132
+ const numberPattern = /^\d+$/;
133
+ const exitCodeTerminated = 143;
134
+ const pgrepNoMatch = 1;
135
+ const maxResultBytes = 1024 * 1024;
136
+ const failureTailLines = 20;
137
+ const execFileAsync = promisify(execFile);
138
+
139
+ // --- arguments
140
+
141
+ function positiveInteger(token: string | undefined, label: string): number {
142
+ const value = Number(token);
143
+ if (
144
+ !numberPattern.test(token ?? "") ||
145
+ !Number.isSafeInteger(value) ||
146
+ value < 1
147
+ ) {
148
+ throw new Error(
149
+ `${label} must be a positive integer, got ${JSON.stringify(token ?? "")}`,
150
+ );
151
+ }
152
+ return value;
153
+ }
154
+
155
+ export function parseLaunchRequest(args: string[]): LaunchRequest {
156
+ const issues = new Set<number>();
157
+ let ready = false;
158
+ let maxParallel: number | null = null;
159
+ for (let index = 0; index < args.length; index++) {
160
+ const arg = args[index];
161
+ if (arg === "--ready") ready = true;
162
+ else if (arg === "--max-parallel" && maxParallel === null) {
163
+ index += 1;
164
+ maxParallel = positiveInteger(args[index], "--max-parallel");
165
+ } else if (arg.startsWith("-"))
166
+ throw new Error(`unexpected flag ${JSON.stringify(arg)}`);
167
+ else issues.add(positiveInteger(arg, "issue"));
168
+ }
169
+ if (ready && issues.size > 0)
170
+ throw new Error("--ready takes no issue numbers");
171
+ if (!ready && issues.size === 0) throw new Error("no issue given");
172
+ return { issues: [...issues], ready, maxParallel };
173
+ }
174
+
175
+ // --- lanes
176
+
177
+ function toIssueNumber(text: string): number | null {
178
+ const value = Number(text.trim());
179
+ return numberPattern.test(text.trim()) &&
180
+ Number.isSafeInteger(value) &&
181
+ value > 0
182
+ ? value
183
+ : null;
184
+ }
185
+
186
+ const readyIssuesArgs = [
187
+ "issue",
188
+ "list",
189
+ "--label",
190
+ readyLabel,
191
+ "--state",
192
+ "open",
193
+ "--json",
194
+ "number",
195
+ "--limit",
196
+ issueListLimit,
197
+ "--jq",
198
+ ".[].number",
199
+ ];
200
+
201
+ function laneIssueNumbersFrom(output: string): number[] {
202
+ const issues = output
203
+ .split("\n")
204
+ .map(toIssueNumber)
205
+ .filter((issue): issue is number => issue !== null);
206
+ return [...new Set(issues)].sort((a, b) => a - b);
207
+ }
208
+
209
+ export function readyLaneIssues(): number[] {
210
+ return laneIssueNumbersFrom(gh(readyIssuesArgs));
211
+ }
212
+
213
+ async function readyLaneIssuesAsync(): Promise<number[]> {
214
+ return laneIssueNumbersFrom(await ghAsync(readyIssuesArgs));
215
+ }
216
+
217
+ function resolvedLane(issue: number): Lane {
218
+ return { issue, targets: executionOrder(resolveTargets(issue)) };
219
+ }
220
+
221
+ function titledLane(lane: Lane): TitledLane {
222
+ return { ...lane, title: lane.targets[0].title };
223
+ }
224
+
225
+ async function lookedUpLane(issue: number): Promise<Lookup<TitledLane[]>> {
226
+ return lookup(
227
+ resolveTargetsAsync(issue).then((targets) => [
228
+ titledLane({ issue, targets: executionOrder(targets) }),
229
+ ]),
230
+ [],
231
+ `lane #${issue}: could not resolve`,
232
+ );
233
+ }
234
+
235
+ // Every lane is fetched in parallel and every lookup runs to completion, so
236
+ // all failures show at once; the lanes keep the order of their issues.
237
+ async function resolveLanes(
238
+ request: LaunchRequest,
239
+ ): Promise<Lookup<TitledLane[]>> {
240
+ const laneIssues = request.ready
241
+ ? await lookup(
242
+ readyLaneIssuesAsync(),
243
+ [],
244
+ `could not list "${readyLabel}" issues`,
245
+ )
246
+ : { value: request.issues, failures: [] };
247
+ const lanes = await Promise.all(laneIssues.value.map(lookedUpLane));
248
+ return {
249
+ value: lanes.flatMap(({ value }) => value),
250
+ failures: [
251
+ ...laneIssues.failures,
252
+ ...lanes.flatMap(({ failures }) => failures),
253
+ ],
254
+ };
255
+ }
256
+
257
+ // --- worktrees
258
+
259
+ function branchExists(branch: string): boolean {
260
+ return succeeds("git", [
261
+ "show-ref",
262
+ "--verify",
263
+ "--quiet",
264
+ `refs/heads/${branch}`,
265
+ ]);
266
+ }
267
+
268
+ // Reused on relaunch: an existing worktree keeps the work of an earlier run.
269
+ function ensureWorktree(worktree: string, issue: number): void {
270
+ if (registeredWorktrees().has(worktree) && existsSync(worktree)) return;
271
+ const branch = branchName(issue);
272
+ const args = branchExists(branch)
273
+ ? ["worktree", "add", worktree, branch]
274
+ : ["worktree", "add", "-b", branch, worktree, "HEAD"];
275
+ git(args);
276
+ }
277
+
278
+ // --- pre-flight
279
+
280
+ // Turns a failed lookup into a FAIL line, so parallel lookups never reject.
281
+ async function lookup<T>(
282
+ work: Promise<T>,
283
+ fallback: T,
284
+ failure: string,
285
+ ): Promise<Lookup<T>> {
286
+ try {
287
+ return { value: await work, failures: [] };
288
+ } catch (error) {
289
+ return {
290
+ value: fallback,
291
+ failures: [`${failure}: ${firstLine(errorMessage(error))}`],
292
+ };
293
+ }
294
+ }
295
+
296
+ async function output(command: string, args: string[]): Promise<string> {
297
+ const { stdout } = await execFileAsync(command, args, { encoding: "utf-8" });
298
+ return stdout;
299
+ }
300
+
301
+ async function toolFailure(
302
+ failure: string,
303
+ command: string,
304
+ args: string[],
305
+ ): Promise<string | null> {
306
+ return (await succeedsAsync(command, args)) ? null : failure;
307
+ }
308
+
309
+ // Async counterpart of agent.ts's agentToolCheck; choice.agent comes from its allow-list.
310
+ async function agentToolFailure(choice: AgentChoice): Promise<string | null> {
311
+ if (!choice.ok) return choice.failure;
312
+ return toolFailure(`${choice.agent} not installed`, "which", [choice.agent]);
313
+ }
314
+
315
+ async function toolFailures(choice: AgentChoice): Promise<string[]> {
316
+ const failures = await Promise.all([
317
+ toolFailure("gh not authenticated", "gh", ["auth", "status"]),
318
+ agentToolFailure(choice),
319
+ toolFailure("caffeinate not installed", "which", ["caffeinate"]),
320
+ toolFailure("git worktree not available", "git", ["worktree", "list"]),
321
+ ]);
322
+ return failures.filter((failure): failure is string => failure !== null);
323
+ }
324
+
325
+ async function parentPid(pid: number): Promise<number> {
326
+ try {
327
+ const parent = Number(
328
+ (await output("ps", ["-o", "ppid=", "-p", String(pid)])).trim(),
329
+ );
330
+ return Number.isSafeInteger(parent) ? parent : 0;
331
+ } catch {
332
+ return 0;
333
+ }
334
+ }
335
+
336
+ // npx and tsx start this orchestrator through parent processes whose command
337
+ // lines match the run pattern too; none of them is another run.
338
+ async function ownProcessChain(): Promise<Set<number>> {
339
+ const chain = new Set<number>();
340
+ for (
341
+ let pid = process.pid;
342
+ pid > 1 && !chain.has(pid);
343
+ pid = await parentPid(pid)
344
+ ) {
345
+ chain.add(pid);
346
+ }
347
+ return chain;
348
+ }
349
+
350
+ async function matchingPids(pattern: string): Promise<number[]> {
351
+ try {
352
+ return (await output("pgrep", ["-f", pattern]))
353
+ .split("\n")
354
+ .filter(Boolean)
355
+ .map(Number);
356
+ } catch (error) {
357
+ if ((error as { code?: unknown }).code === pgrepNoMatch) return [];
358
+ throw error;
359
+ }
360
+ }
361
+
362
+ const runCommandMarker = "yaawn-implement-plan/scripts/overnight.ts run";
363
+
364
+ async function commandLine(pid: number): Promise<string> {
365
+ try {
366
+ return (await output("ps", ["-o", "args=", "-p", String(pid)])).trim();
367
+ } catch {
368
+ return "";
369
+ }
370
+ }
371
+
372
+ function runArguments(args: string): string[] {
373
+ const markerIndex = args.indexOf(runCommandMarker);
374
+ if (markerIndex === -1) return [];
375
+ return args
376
+ .slice(markerIndex + runCommandMarker.length)
377
+ .split(/\s+/)
378
+ .filter(Boolean);
379
+ }
380
+
381
+ // The values after --max-parallel and --agent are not issues.
382
+ function runIssueArguments(tokens: string[]): number[] {
383
+ return tokens
384
+ .filter(
385
+ (token, index) =>
386
+ !["--max-parallel", "--agent"].includes(tokens[index - 1]),
387
+ )
388
+ .map(toIssueNumber)
389
+ .filter((value): value is number => value !== null);
390
+ }
391
+
392
+ // Read before the lanes are known, so it can run alongside the other checks.
393
+ async function otherActiveRuns(): Promise<ActiveRun[]> {
394
+ const [ownChain, pids] = await Promise.all([
395
+ ownProcessChain(),
396
+ matchingPids(runCommandMarker),
397
+ ]);
398
+ return Promise.all(
399
+ pids
400
+ .filter((pid) => !ownChain.has(pid))
401
+ .map(async (pid) => ({
402
+ pid,
403
+ tokens: runArguments(await commandLine(pid)),
404
+ })),
405
+ );
406
+ }
407
+
408
+ // A --ready run's lanes are not on its command line, so it conflicts with every lane.
409
+ function otherRunFailures(
410
+ { pid, tokens }: ActiveRun,
411
+ laneIssues: number[],
412
+ ): string[] {
413
+ if (tokens.includes("--ready"))
414
+ return [`overnight --ready run already active (pid ${pid})`];
415
+ const activeIssues = runIssueArguments(tokens);
416
+ return laneIssues
417
+ .filter((issue) => activeIssues.includes(issue))
418
+ .map((issue) => `overnight run already active for #${issue}`);
419
+ }
420
+
421
+ function activeRunFailures(lanes: Lane[], activeRuns: ActiveRun[]): string[] {
422
+ const laneIssues = [...new Set(lanes.flatMap(laneIssueNumbers))];
423
+ const failures = activeRuns.flatMap((run) =>
424
+ otherRunFailures(run, laneIssues),
425
+ );
426
+ return [...new Set(failures)];
427
+ }
428
+
429
+ function filesFailures(issue: Issue): string[] {
430
+ const { files, invalid } = filesToChange(issue);
431
+ const failures: string[] = [];
432
+ if (files.length === 0) {
433
+ failures.push(
434
+ `#${issue.number} lists no files under ### ${filesToChangeHeading} (run yaawn-create-plan)`,
435
+ );
436
+ }
437
+ if (invalid.length > 0) {
438
+ failures.push(
439
+ `#${issue.number} ### ${filesToChangeHeading} entries that are not repo-relative paths: ${invalid.join(", ")}`,
440
+ );
441
+ }
442
+ return failures;
443
+ }
444
+
445
+ // Worktrees live inside the project only in the bare-repo layout, so a repo
446
+ // that has not adopted it fails before any worktree is created.
447
+ async function bareLayoutFailures(): Promise<string[]> {
448
+ const layout = await lookup(
449
+ output("git", ["worktree", "list", "--porcelain"]).then(isBareLayout),
450
+ false,
451
+ "could not read the git worktree layout",
452
+ );
453
+ if (layout.failures.length > 0) return layout.failures;
454
+ return layout.value
455
+ ? []
456
+ : [
457
+ "this repository does not use the bare-repo layout (project root = bare .git, main copy in <project>/main) — set it up as described in .agents/yaawn-worktrees.md, or stay on the previous @yaawn/workflow version",
458
+ ];
459
+ }
460
+
461
+ // Reporting only: pre-flight fetches to count commits but never merges.
462
+ function mainCopyFailures(check: MainCopyCheck, changes: string): string[] {
463
+ if (!check.ok) return [check.reason];
464
+ const { state } = check;
465
+ if (state.kind === "diverged") return [describeMainCopyState(state)];
466
+ return state.kind === "behind" && changes.trim()
467
+ ? [blockedUpdateReason(state.behind)]
468
+ : [];
469
+ }
470
+
471
+ function printMainCopyState(check: MainCopyCheck): void {
472
+ if (check.ok && check.state.kind !== "diverged") {
473
+ console.log(describeMainCopyState(check.state));
474
+ }
475
+ }
476
+
477
+ function worktreeFailures(lane: TitledLane): string[] {
478
+ const worktree = laneWorktreePath(lane.issue, lane.title);
479
+ return existsSync(worktree) && !registeredWorktrees().has(worktree)
480
+ ? [
481
+ `lane #${lane.issue}: ${worktree} exists but is not a git worktree of this repository`,
482
+ ]
483
+ : [];
484
+ }
485
+
486
+ async function targetFailures(target: Issue): Promise<string[]> {
487
+ return [...(await issueFailuresAsync(target)), ...filesFailures(target)];
488
+ }
489
+
490
+ async function laneFailures(lane: TitledLane): Promise<string[]> {
491
+ const targets = await Promise.all(lane.targets.map(targetFailures));
492
+ return [...targets.flat(), ...worktreeFailures(lane)];
493
+ }
494
+
495
+ function fileConflicts(lanes: Lane[]): string[] {
496
+ const files = new Map(lanes.map((lane) => [lane.issue, laneFiles(lane)]));
497
+ return lanePairs(lanes).flatMap(([first, second]) => {
498
+ const shared = [...(files.get(first.issue) ?? [])].filter((file) =>
499
+ files.get(second.issue)?.has(file),
500
+ );
501
+ return shared.length === 0
502
+ ? []
503
+ : [
504
+ `lanes #${first.issue} and #${second.issue} both change: ${shared.join(", ")}`,
505
+ ];
506
+ });
507
+ }
508
+
509
+ function sharedTargetConflicts(owners: Map<number, number[]>): string[] {
510
+ return [...owners]
511
+ .filter(([, laneIssues]) => laneIssues.length > 1)
512
+ .map(
513
+ ([issue, laneIssues]) =>
514
+ `#${issue} is part of lanes ${issueRefs(laneIssues)}`,
515
+ );
516
+ }
517
+
518
+ function dependencyConflicts(
519
+ lanes: Lane[],
520
+ owners: Map<number, number[]>,
521
+ ): string[] {
522
+ return lanes.flatMap((lane) =>
523
+ lane.targets.flatMap((target) =>
524
+ target.externalDependsOn.flatMap((dependency) =>
525
+ (owners.get(dependency) ?? [])
526
+ .filter((owner) => owner !== lane.issue)
527
+ .map(
528
+ (owner) =>
529
+ `#${target.number} (lane #${lane.issue}) depends on #${dependency} (lane #${owner}) — lanes run independently`,
530
+ ),
531
+ ),
532
+ ),
533
+ );
534
+ }
535
+
536
+ function crossLaneConflicts(lanes: Lane[]): string[] {
537
+ const owners = laneOwners(lanes);
538
+ return [
539
+ ...sharedTargetConflicts(owners),
540
+ ...fileConflicts(lanes),
541
+ ...dependencyConflicts(lanes, owners),
542
+ ];
543
+ }
544
+
545
+ function printCompatibleSuggestion(lanes: Lane[], conflicts: string[]): void {
546
+ if (conflicts.length === 0) return;
547
+ const graph = conflictGraph(lanes);
548
+ const allGroups = allCompatibleGroups(
549
+ lanes.map((lane) => lane.issue),
550
+ graph,
551
+ );
552
+ // Groups of 2+ are the useful "run together" suggestions; singletons are
553
+ // just issues that can run alone (always possible, not informative here).
554
+ const groups = allGroups.filter((group) => group.length >= 2);
555
+ console.log("");
556
+ if (groups.length === 0) {
557
+ console.log(
558
+ "No two issues can be implemented together (all conflict pairwise).",
559
+ );
560
+ console.log("Run them one at a time:");
561
+ lanes.forEach((lane) =>
562
+ console.log(` /yaawn-implement-plan ${lane.issue}`),
563
+ );
564
+ } else {
565
+ console.log(
566
+ "These groups can be implemented together (no shared files within a group):",
567
+ );
568
+ groups.forEach((group) =>
569
+ console.log(` /yaawn-implement-plan ${group.join(", ")}`),
570
+ );
571
+ }
572
+ }
573
+
574
+ // --- implementation groups
575
+
576
+ interface OpenIssue {
577
+ number: number;
578
+ labels: { name: string }[];
579
+ }
580
+
581
+ const isSingleIssueRun = (request: LaunchRequest): boolean =>
582
+ !request.ready && request.issues.length === 1;
583
+
584
+ const isIssueNumber = (value: unknown): value is number =>
585
+ Number.isSafeInteger(value) && (value as number) > 0;
586
+
587
+ function openIssues(): OpenIssue[] {
588
+ const raw = JSON.parse(
589
+ gh([
590
+ "issue",
591
+ "list",
592
+ "--state",
593
+ "open",
594
+ "--json",
595
+ "number,labels",
596
+ "--limit",
597
+ issueListLimit,
598
+ ]),
599
+ ) as unknown;
600
+ return Array.isArray(raw) ? (raw as OpenIssue[]) : [];
601
+ }
602
+
603
+ function hasGroupLabel({ number, labels }: OpenIssue): boolean {
604
+ const names = Array.isArray(labels) ? labels.map(({ name }) => name) : [];
605
+ return groupLabelRemovals(number, names).length > 0;
606
+ }
607
+
608
+ // Closed issues keep their labels: they record the group they ran in.
609
+ function wipeGroupLabels(): void {
610
+ openIssues()
611
+ .filter(({ number }) => isIssueNumber(number))
612
+ .filter(hasGroupLabel)
613
+ .forEach(({ number }) => removeGroupLabels(number));
614
+ }
615
+
616
+ // Groups span every ready lane issue, not only the lanes of this run.
617
+ function readyLanes(request: LaunchRequest, runLanes: Lane[]): Lane[] {
618
+ if (request.ready) return runLanes;
619
+ const known = new Map(runLanes.map((lane) => [lane.issue, lane]));
620
+ return readyLaneIssues().map(
621
+ (issue) => known.get(issue) ?? resolvedLane(issue),
622
+ );
623
+ }
624
+
625
+ // A failed action only warns.
626
+ function applyLabelActions(actions: LabelAction[]): void {
627
+ actions.forEach(({ issue, label, kind }) => {
628
+ try {
629
+ if (kind === "add") addLabel(issue, label);
630
+ else removeLabel(issue, label);
631
+ console.log(
632
+ `${kind === "add" ? "added" : "removed"} label "${label}" on #${issue}`,
633
+ );
634
+ } catch (error) {
635
+ console.log(
636
+ `WARN could not ${kind} label "${label}" on #${issue}: ${errorMessage(error)}`,
637
+ );
638
+ }
639
+ });
640
+ }
641
+
642
+ function ensuredGroupLabels(groups: ImplementationGroup[]): Set<string> {
643
+ return new Set(
644
+ groups
645
+ .map(({ number }) => groupLabel(number))
646
+ .filter((label) => {
647
+ try {
648
+ ensureGroupLabel(label);
649
+ return true;
650
+ } catch (error) {
651
+ console.log(
652
+ `WARN could not create label "${label}": ${errorMessage(error)}`,
653
+ );
654
+ return false;
655
+ }
656
+ }),
657
+ );
658
+ }
659
+
660
+ // Every group label was just wiped, so the lanes start without any.
661
+ function labelGroups(lanes: Lane[]): ImplementationGroup[] {
662
+ const assignment = assignGroups(lanes);
663
+ const groups = groupsOf(assignment);
664
+ const ensured = ensuredGroupLabels(groups);
665
+ applyLabelActions(
666
+ labelActions(assignment, new Map()).filter(({ label }) =>
667
+ ensured.has(label),
668
+ ),
669
+ );
670
+ return groups;
671
+ }
672
+
673
+ // At every --ready or multi-issue run start, all group labels are wiped and
674
+ // recalculated from scratch; single-issue runs leave them untouched. Labeling
675
+ // never blocks a launch: on any failure it warns and returns null, as it does
676
+ // for runs without ready lane issues.
677
+ function implementationGroups(
678
+ request: LaunchRequest,
679
+ lanes: Lane[],
680
+ ): ImplementationGroup[] | null {
681
+ if (isSingleIssueRun(request)) return null;
682
+ try {
683
+ wipeGroupLabels();
684
+ const ready = readyLanes(request, lanes);
685
+ return ready.length === 0 ? null : labelGroups(ready);
686
+ } catch (error) {
687
+ console.log(
688
+ `WARN implementation groups not labeled: ${errorMessage(error)}`,
689
+ );
690
+ return null;
691
+ }
692
+ }
693
+
694
+ function printImplementationGroups(groups: ImplementationGroup[]): void {
695
+ console.log("");
696
+ console.log(
697
+ "Implementation groups (the issues of a group can be implemented together):",
698
+ );
699
+ groups.forEach(({ number, issues }) =>
700
+ console.log(` ${number}. /yaawn-implement-plan ${issues.join(" ")}`),
701
+ );
702
+ }
703
+
704
+ function describeLane(lane: TitledLane): string[] {
705
+ const header = ` #${lane.issue} — worktree ${laneWorktreePath(lane.issue, lane.title)}, branch ${branchName(lane.issue)}`;
706
+ const targets = lane.targets.map((target, index) => {
707
+ const after =
708
+ target.dependsOn.length > 0
709
+ ? ` — after ${issueRefs(target.dependsOn)}`
710
+ : "";
711
+ return ` ${index + 1}. #${target.number} ${target.title}${after}`;
712
+ });
713
+ return [header, ...targets];
714
+ }
715
+
716
+ function printLanes(lanes: TitledLane[], maxParallel: number | null): void {
717
+ const limit =
718
+ maxParallel === null ? "all at once" : `at most ${maxParallel} at once`;
719
+ console.log(`mode: parallel lanes (${limit}, one worktree each)`);
720
+ console.log("lanes (targets of a lane run one at a time):");
721
+ lanes.flatMap(describeLane).forEach((line) => console.log(line));
722
+ }
723
+
724
+ function printFailures(failures: string[]): void {
725
+ failures.forEach((failure) => console.log(`FAIL ${failure}`));
726
+ }
727
+
728
+ function printUncommittedChanges(changes: string): void {
729
+ console.log(
730
+ `--- uncommitted changes in the main copy (without ${plansDir}/) — they stay in the main copy and never enter the worktrees; they block the update when the main copy is behind`,
731
+ );
732
+ console.log(changes);
733
+ }
734
+
735
+ // Prints the pre-flight and returns the lanes only when every check passes.
736
+ // Independent checks run in parallel; results print in a fixed order.
737
+ async function checkedLanes(
738
+ request: LaunchRequest,
739
+ choice: AgentChoice,
740
+ ): Promise<CheckedLaunch | null> {
741
+ const [tools, layout, mainCopy, activeRuns, changes] = await Promise.all([
742
+ toolFailures(choice),
743
+ bareLayoutFailures(),
744
+ fetchMainCopy(),
745
+ lookup(otherActiveRuns(), [], "could not check for active overnight runs"),
746
+ lookup(uncommittedChangesAsync(), "", "could not list uncommitted changes"),
747
+ ]);
748
+ if (tools.length > 0) {
749
+ printFailures(tools);
750
+ return null;
751
+ }
752
+ if (layout.length > 0) {
753
+ printFailures(layout);
754
+ return null;
755
+ }
756
+ const resolved = await resolveLanes(request);
757
+ if (resolved.failures.length > 0) {
758
+ printFailures(resolved.failures);
759
+ return null;
760
+ }
761
+ const lanes = resolved.value;
762
+ if (lanes.length === 0) {
763
+ printFailures([
764
+ `nothing to implement: no open issue is labelled "${readyLabel}"`,
765
+ ]);
766
+ return null;
767
+ }
768
+ const conflicts = crossLaneConflicts(lanes);
769
+ const failures = [
770
+ ...activeRuns.failures,
771
+ ...activeRunFailures(lanes, activeRuns.value),
772
+ ...(await Promise.all(lanes.map(laneFailures))).flat(),
773
+ ...changes.failures,
774
+ ...mainCopyFailures(mainCopy, changes.value),
775
+ ...conflicts,
776
+ ];
777
+ printLanes(lanes, request.maxParallel);
778
+ printFailures(failures);
779
+ printMainCopyState(mainCopy);
780
+ printUncommittedChanges(changes.value);
781
+ const groups = implementationGroups(request, lanes);
782
+ if (groups) printImplementationGroups(groups);
783
+ if (failures.length > 0) {
784
+ if (!groups) printCompatibleSuggestion(lanes, conflicts);
785
+ return null;
786
+ }
787
+ return { lanes, changes: changes.value };
788
+ }
789
+
790
+ // Without a terminal nobody can answer: fail closed so preflight never
791
+ // passes without a confirmation, and the caller asks the human instead.
792
+ async function confirmedUncommittedChanges(): Promise<boolean> {
793
+ if (!process.stdin.isTTY) {
794
+ console.log(
795
+ "uncommitted changes in the main copy — no terminal to confirm; not continuing. Confirm with the human (or commit the changes) before launching",
796
+ );
797
+ return false;
798
+ }
799
+ const prompt = createPrompt({ input: process.stdin, output: process.stdout });
800
+ try {
801
+ const answer = await prompt.question("continue anyway? [y/N] ");
802
+ return /^y(es)?$/i.test(answer.trim());
803
+ } finally {
804
+ prompt.close();
805
+ }
806
+ }
807
+
808
+ // The agent is read in the main copy: a lane worktree branches from HEAD and
809
+ // may lack an uncommitted settings file, so each lane gets it as --agent.
810
+ export async function preflightMany(
811
+ request: LaunchRequest,
812
+ agentOption: string | null,
813
+ ): Promise<boolean> {
814
+ const checked = await checkedLanes(request, readAgent(agentOption));
815
+ if (checked === null) return false;
816
+ return checked.changes.trim() ? confirmedUncommittedChanges() : true;
817
+ }
818
+
819
+ // --- process output
820
+
821
+ function keepTail(stream: Readable | null, tail: string[]): void {
822
+ if (!stream) return;
823
+ createInterface({ input: stream }).on("line", (line) => {
824
+ tail.push(line);
825
+ if (tail.length > failureTailLines) tail.shift();
826
+ });
827
+ }
828
+
829
+ function errorMessage(error: unknown): string {
830
+ return error instanceof Error ? error.message : String(error);
831
+ }
832
+
833
+ const firstLine = (text: string): string => text.split("\n")[0];
834
+
835
+ function codeBlock(lines: string[]): string {
836
+ const longestBacktickRun = Math.max(
837
+ 0,
838
+ ...lines
839
+ .flatMap((line) => line.match(/`+/g) ?? [])
840
+ .map((run) => run.length),
841
+ );
842
+ const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
843
+ return [fence, ...lines, fence].join("\n");
844
+ }
845
+
846
+ function failureLines(tail: string[]): string {
847
+ return tail.length === 0
848
+ ? "no output captured"
849
+ : `last ${tail.length} output lines:\n${codeBlock(tail)}`;
850
+ }
851
+
852
+ // --- run
853
+
854
+ function logState(run: LaneRun, label: LaneState | StageLabel): void {
855
+ console.log(`${new Date().toISOString()} #${run.lane.issue} ${label}`);
856
+ }
857
+
858
+ function setState(run: LaneRun, state: LaneState): void {
859
+ run.state = state;
860
+ logState(run, state);
861
+ }
862
+
863
+ // Only the exact marker of the lane's own issue moves its stage; every other
864
+ // line of lane output is never interpreted.
865
+ function watchStages(run: LaneRun, stream: Readable | null): void {
866
+ if (!stream) return;
867
+ createInterface({ input: stream }).on("line", (line) => {
868
+ const stage = parseStageMarker(line, run.lane.issue);
869
+ if (stage === undefined || run.state !== "started") return;
870
+ run.stage = stage;
871
+ logState(run, stage);
872
+ saveRunningResult(run);
873
+ });
874
+ }
875
+
876
+ // The pipe stays open for the lane's life: the lane stops itself when its
877
+ // stdin ends, so it never outlives this process.
878
+ function keepStdinOpen(child: ChildProcess, run: LaneRun): void {
879
+ child.stdin?.on("error", (error) => {
880
+ run.tail.push(`lane stdin closed: ${error.message}`);
881
+ });
882
+ }
883
+
884
+ function startLaneProcess(run: LaneRun): ChildProcess {
885
+ const child = spawn(
886
+ "npx",
887
+ [
888
+ "tsx",
889
+ overnightScript,
890
+ "lane",
891
+ String(run.lane.issue),
892
+ "--agent",
893
+ run.agent,
894
+ ],
895
+ {
896
+ cwd: run.worktree,
897
+ env: { ...process.env, NO_COLOR: "1" },
898
+ stdio: ["pipe", "pipe", "pipe"],
899
+ },
900
+ );
901
+ keepStdinOpen(child, run);
902
+ watchStages(run, child.stdout);
903
+ keepTail(child.stdout, run.tail);
904
+ keepTail(child.stderr, run.tail);
905
+ return child;
906
+ }
907
+
908
+ function runLaneProcess(
909
+ run: LaneRun,
910
+ children: Set<ChildProcess>,
911
+ ): Promise<number | null> {
912
+ return new Promise((resolve) => {
913
+ const child = startLaneProcess(run);
914
+ children.add(child);
915
+ let settled = false;
916
+ const settle = (code: number | null): void => {
917
+ if (settled) return;
918
+ settled = true;
919
+ children.delete(child);
920
+ resolve(code);
921
+ };
922
+ child.on("error", (error) => {
923
+ run.tail.push(`failed to start: ${error.message}`);
924
+ settle(null);
925
+ });
926
+ child.on("exit", (code) => {
927
+ void drainOutput(child).then(() => settle(code));
928
+ });
929
+ });
930
+ }
931
+
932
+ function terminateChild(child: ChildProcess): void {
933
+ try {
934
+ child.kill("SIGTERM");
935
+ } catch (error) {
936
+ console.error(`could not stop lane process: ${errorMessage(error)}`);
937
+ }
938
+ }
939
+
940
+ const isUnfinished = (run: LaneRun): boolean =>
941
+ run.state !== "done" && run.state !== "failed";
942
+
943
+ function stopLane(run: LaneRun): void {
944
+ run.state = "stopped";
945
+ run.result = laneReport(
946
+ run,
947
+ `${stoppedResult(run.lane.issue, run.stage)}\n${failureLines(run.tail)}`,
948
+ );
949
+ writeLiveResult(run);
950
+ }
951
+
952
+ function printSummary(runs: LaneRun[]): void {
953
+ try {
954
+ console.log(`\n${combinedResult(runs)}`);
955
+ } catch (error) {
956
+ console.error(`could not print the summary: ${errorMessage(error)}`);
957
+ }
958
+ }
959
+
960
+ // Writes synchronously and exits without waiting for the lanes: one lane's
961
+ // failed write never keeps the others, the summary, or the exit from happening.
962
+ function stopLanesOnSignal(
963
+ runs: LaneRun[],
964
+ children: Set<ChildProcess>,
965
+ ): void {
966
+ let stopping = false;
967
+ const stop = (): void => {
968
+ if (stopping) return;
969
+ stopping = true;
970
+ children.forEach(terminateChild);
971
+ runs.filter(isUnfinished).forEach(stopLane);
972
+ printSummary(runs);
973
+ process.exit(exitCodeTerminated);
974
+ };
975
+ process.on("SIGTERM", stop);
976
+ process.on("SIGINT", stop);
977
+ process.on("SIGHUP", stop);
978
+ }
979
+
980
+ // O_NOFOLLOW: the lane's sessions run with all tools allowed, so the result
981
+ // file could be a symlink to a secret — never follow it into the main copy.
982
+ function readBoundedFile(file: string): string {
983
+ const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
984
+ try {
985
+ const { size } = fstatSync(descriptor);
986
+ if (size > maxResultBytes)
987
+ throw new Error(`${file} is ${size} bytes (limit ${maxResultBytes})`);
988
+ return readFileSync(descriptor, "utf-8");
989
+ } finally {
990
+ closeSync(descriptor);
991
+ }
992
+ }
993
+
994
+ function readWorktreeResult(run: LaneRun): string | null {
995
+ const file = path.join(run.worktree, resultPath(run.lane.issue));
996
+ try {
997
+ return readBoundedFile(file).trim();
998
+ } catch (error) {
999
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1000
+ run.tail.push(`could not read ${file}: ${errorMessage(error)}`);
1001
+ }
1002
+ return null;
1003
+ }
1004
+ }
1005
+
1006
+ // Mirrors the single-issue run: exit code 0 only when every target is done,
1007
+ // and its result ends with "run result: all N targets done".
1008
+ function isLaneDone(code: number | null, result: string | null): boolean {
1009
+ return (
1010
+ code === 0 &&
1011
+ result !== null &&
1012
+ /^run result: all \d+ targets done$/m.test(result)
1013
+ );
1014
+ }
1015
+
1016
+ function recordExitCode(code: number | null, tail: string[]): void {
1017
+ if (code === 0) return;
1018
+ tail.push(
1019
+ code === null
1020
+ ? "lane run did not exit normally"
1021
+ : `lane run exited with code ${code}`,
1022
+ );
1023
+ }
1024
+
1025
+ function laneReport(run: LaneRun, body: string): string {
1026
+ return [
1027
+ body,
1028
+ `worktree: ${run.worktree}`,
1029
+ `branch: ${branchName(run.lane.issue)}`,
1030
+ ].join("\n\n");
1031
+ }
1032
+
1033
+ function laneResult(run: LaneRun, worktreeResult: string | null): string {
1034
+ return laneReport(
1035
+ run,
1036
+ worktreeResult ??
1037
+ `#${run.lane.issue} ${run.state}\n${failureLines(run.tail)}`,
1038
+ );
1039
+ }
1040
+
1041
+ function writeResultFile(run: LaneRun): void {
1042
+ writeFileSync(resultPath(run.lane.issue), `${run.result}\n`);
1043
+ }
1044
+
1045
+ // A live result that cannot be written leaves the lane's state untouched.
1046
+ function writeLiveResult(run: LaneRun): void {
1047
+ try {
1048
+ writeResultFile(run);
1049
+ } catch (error) {
1050
+ console.error(
1051
+ `could not write ${resultPath(run.lane.issue)}: ${errorMessage(error)}`,
1052
+ );
1053
+ }
1054
+ }
1055
+
1056
+ function saveRunningResult(run: LaneRun): void {
1057
+ run.result = laneReport(run, runningResult(run.lane.issue, run.stage));
1058
+ writeLiveResult(run);
1059
+ }
1060
+
1061
+ // Fails the lane when its main result file cannot be written.
1062
+ function saveLaneResult(run: LaneRun): void {
1063
+ try {
1064
+ writeResultFile(run);
1065
+ } catch (error) {
1066
+ const failure = `could not write ${resultPath(run.lane.issue)}: ${errorMessage(error)}`;
1067
+ console.error(failure);
1068
+ run.state = "failed";
1069
+ run.tail.push(failure);
1070
+ run.result = `${run.result}\n\n${failure}`;
1071
+ }
1072
+ }
1073
+
1074
+ function finishLane(run: LaneRun, code: number | null): void {
1075
+ recordExitCode(code, run.tail);
1076
+ const worktreeResult = readWorktreeResult(run);
1077
+ run.state = isLaneDone(code, worktreeResult) ? "done" : "failed";
1078
+ run.result = laneResult(run, worktreeResult);
1079
+ saveLaneResult(run);
1080
+ setState(run, run.state);
1081
+ }
1082
+
1083
+ // A lane that cannot get its worktree fails alone; the other lanes go on.
1084
+ async function runLane(
1085
+ run: LaneRun,
1086
+ children: Set<ChildProcess>,
1087
+ ): Promise<void> {
1088
+ setState(run, "started");
1089
+ saveRunningResult(run);
1090
+ try {
1091
+ ensureWorktree(run.worktree, run.lane.issue);
1092
+ } catch (error) {
1093
+ run.tail.push(
1094
+ `could not create worktree ${run.worktree}: ${errorMessage(error)}`,
1095
+ );
1096
+ finishLane(run, null);
1097
+ return;
1098
+ }
1099
+ finishLane(run, await runLaneProcess(run, children));
1100
+ }
1101
+
1102
+ // A waiting lane starts as soon as a running one ends.
1103
+ async function runPool(
1104
+ runs: LaneRun[],
1105
+ maxParallel: number,
1106
+ runOne: (run: LaneRun) => Promise<void>,
1107
+ ): Promise<void> {
1108
+ const waiting = [...runs];
1109
+ const worker = async (): Promise<void> => {
1110
+ for (let run = waiting.shift(); run !== undefined; run = waiting.shift()) {
1111
+ await runOne(run);
1112
+ }
1113
+ };
1114
+ await Promise.all(
1115
+ Array.from({ length: Math.min(maxParallel, runs.length) }, worker),
1116
+ );
1117
+ }
1118
+
1119
+ function runResultLine(runs: LaneRun[]): string {
1120
+ return outcomeResultLine(
1121
+ runs.map((run) => ({
1122
+ issue: run.lane.issue,
1123
+ state: run.state,
1124
+ stage: run.stage,
1125
+ })),
1126
+ );
1127
+ }
1128
+
1129
+ function combinedResult(runs: LaneRun[]): string {
1130
+ return [
1131
+ "--- summary",
1132
+ ...runs.map((run) => run.result),
1133
+ runResultLine(runs),
1134
+ ].join("\n\n");
1135
+ }
1136
+
1137
+ function newLaneRun(lane: TitledLane, agent: AgentName): LaneRun {
1138
+ return {
1139
+ lane,
1140
+ agent,
1141
+ worktree: laneWorktreePath(lane.issue, lane.title),
1142
+ state: "started",
1143
+ stage: laneStartedLabel,
1144
+ tail: [],
1145
+ result: "",
1146
+ };
1147
+ }
1148
+
1149
+ export async function runMany(
1150
+ request: LaunchRequest,
1151
+ agentOption: string | null,
1152
+ ): Promise<boolean> {
1153
+ const choice = readAgent(agentOption);
1154
+ const checked = await checkedLanes(request, choice);
1155
+ if (checked === null || !choice.ok) return false;
1156
+ // Once, before any lane's worktree exists, so every lane starts from it.
1157
+ const sync = await syncMainCopy();
1158
+ if (!sync.ok) {
1159
+ printFailures([sync.reason]);
1160
+ return false;
1161
+ }
1162
+ if (sync.updated) console.log("main copy brought up to date");
1163
+ const { lanes } = checked;
1164
+ lanes.forEach((lane) => rmSync(resultPath(lane.issue), { force: true }));
1165
+ const { agent } = choice;
1166
+ const runs = lanes.map((lane) => newLaneRun(lane, agent));
1167
+ await keepMacAwakeWhileRunning();
1168
+ printWalkAwayBanner(agent);
1169
+ const children = new Set<ChildProcess>();
1170
+ stopLanesOnSignal(runs, children);
1171
+ await runPool(runs, request.maxParallel ?? runs.length, (run) =>
1172
+ runLane(run, children),
1173
+ );
1174
+ console.log(`\n${combinedResult(runs)}`);
1175
+ return runs.every((run) => run.state === "done");
1176
+ }