@pi-agents/orchid 0.1.0-beta.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 (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,2725 @@
1
+ /**
2
+ * Worktree CRUD, bulk ops, branch protection, preflight
3
+ * @module orch/worktree
4
+ */
5
+ import { existsSync, mkdirSync, readdirSync, realpathSync, rmdirSync, rmSync } from "fs";
6
+ import { execSync, execFileSync } from "child_process";
7
+ import { join, basename, resolve } from "path";
8
+
9
+ import { execLog } from "./execution.ts";
10
+ import { runGit } from "./git.ts";
11
+ import { resolveOperatorId } from "./naming.ts";
12
+ import { DEFAULT_ORCHESTRATOR_CONFIG, WorktreeError } from "./types.ts";
13
+ import type {
14
+ AllocatedLane,
15
+ BulkWorktreeError,
16
+ CreateLaneWorktreesResult,
17
+ CreateWorktreeOptions,
18
+ LaneTaskOutcome,
19
+ OrchestratorConfig,
20
+ PreflightCheck,
21
+ PreflightResult,
22
+ RemoveAllWorktreesResult,
23
+ RemoveWorktreeOutcome,
24
+ RemoveWorktreeResult,
25
+ WorktreeInfo,
26
+ } from "./types.ts";
27
+
28
+ // ── Worktree Helpers ─────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Generate branch name per naming convention.
32
+ * Format: task/{opId}-lane-{N}-{batchId}
33
+ *
34
+ * Includes the operator identifier for collision resistance across
35
+ * concurrent operators in the same repository.
36
+ *
37
+ * @param laneNumber - Lane number (1-indexed)
38
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
39
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
40
+ */
41
+ export function generateBranchName(laneNumber: number, batchId: string, opId: string): string {
42
+ return `task/${opId}-lane-${laneNumber}-${batchId}`;
43
+ }
44
+
45
+ /**
46
+ * Resolve the base directory where worktrees are created, based on config.
47
+ *
48
+ * Two modes (from `worktree_location` config):
49
+ * "sibling" → resolve(repoRoot, "..") — worktrees sit next to the repo
50
+ * "subdirectory" → resolve(repoRoot, ".worktrees") — worktrees inside the repo (gitignored)
51
+ *
52
+ * The returned path is the parent directory; individual worktree dirs are
53
+ * created as children (e.g., `<base>/{prefix}-1` → `<base>/orchid-wt-1`).
54
+ *
55
+ * @param repoRoot - Absolute path to the main repository root
56
+ * @param config - Orchestrator config (reads `worktree_location`)
57
+ */
58
+ export function resolveWorktreeBasePath(repoRoot: string, config: OrchestratorConfig): string {
59
+ const location = config.orchestrator.worktree_location;
60
+ if (location === "sibling") {
61
+ return resolve(repoRoot, "..");
62
+ }
63
+ // Default to subdirectory for any non-"sibling" value (including "subdirectory")
64
+ return resolve(repoRoot, ".worktrees");
65
+ }
66
+
67
+ /**
68
+ * Generate the batch container directory name.
69
+ *
70
+ * Format: `{opId}-{batchId}`
71
+ * Example: `henrylach-20260308T111750`
72
+ *
73
+ * This is the directory that holds all lane worktrees and the merge
74
+ * worktree for a single batch.
75
+ *
76
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
77
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
78
+ */
79
+ export function generateBatchContainerName(opId: string, batchId: string): string {
80
+ return `${opId}-${batchId}`;
81
+ }
82
+
83
+ /**
84
+ * Generate the absolute path to the batch container directory.
85
+ *
86
+ * All worktrees for a single batch (lanes + merge) live inside this container.
87
+ * Format: `{basePath}/{opId}-{batchId}`
88
+ *
89
+ * Uses `resolveWorktreeBasePath()` to respect `worktree_location` config
90
+ * (sibling vs subdirectory mode). Both `generateWorktreePath()` and
91
+ * `generateMergeWorktreePath()` delegate to this function, ensuring
92
+ * consistent base-path resolution.
93
+ *
94
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
95
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
96
+ * @param repoRoot - Absolute path to the main repository root
97
+ * @param config - Orchestrator config (optional; defaults to subdirectory mode)
98
+ * @returns - Absolute path to the batch container directory
99
+ */
100
+ export function generateBatchContainerPath(
101
+ opId: string,
102
+ batchId: string,
103
+ repoRoot: string,
104
+ config?: OrchestratorConfig,
105
+ ): string {
106
+ const effectiveConfig = config || DEFAULT_ORCHESTRATOR_CONFIG;
107
+ const basePath = resolveWorktreeBasePath(repoRoot, effectiveConfig);
108
+ return resolve(basePath, generateBatchContainerName(opId, batchId));
109
+ }
110
+
111
+ /**
112
+ * Generate worktree path based on config's worktree_location setting.
113
+ *
114
+ * Naming rule: `{basePath}/{opId}-{batchId}/lane-{N}`
115
+ * Sibling mode: ../{opId}-{batchId}/lane-{N}
116
+ * Subdirectory mode: .worktrees/{opId}-{batchId}/lane-{N}
117
+ *
118
+ * Each batch gets its own container directory, preventing collisions
119
+ * between concurrent batches by the same operator.
120
+ *
121
+ * Uses `generateBatchContainerPath()` for the container directory,
122
+ * preserving `worktree_location` semantics (sibling vs subdirectory).
123
+ *
124
+ * Uses path.resolve() for Windows path normalization (R002 requirement).
125
+ *
126
+ * @param prefix - Directory prefix (unused in new scheme, kept for API compat)
127
+ * @param laneNumber - Lane number (1-indexed)
128
+ * @param repoRoot - Absolute path to the main repository root
129
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
130
+ * @param config - Orchestrator config (optional; defaults to subdirectory mode)
131
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
132
+ */
133
+ export function generateWorktreePath(
134
+ prefix: string,
135
+ laneNumber: number,
136
+ repoRoot: string,
137
+ opId: string,
138
+ config?: OrchestratorConfig,
139
+ batchId?: string,
140
+ ): string {
141
+ if (batchId) {
142
+ // New batch-scoped container layout
143
+ const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config);
144
+ return resolve(containerPath, `lane-${laneNumber}`);
145
+ }
146
+
147
+ // Legacy fallback (no batchId) — flat layout for backward compatibility
148
+ const effectiveConfig = config || DEFAULT_ORCHESTRATOR_CONFIG;
149
+ const basePath = resolveWorktreeBasePath(repoRoot, effectiveConfig);
150
+ return resolve(basePath, `${prefix}-${opId}-${laneNumber}`);
151
+ }
152
+
153
+ /**
154
+ * Generate the merge worktree path inside a batch container.
155
+ *
156
+ * Format: `{basePath}/{opId}-{batchId}/merge`
157
+ *
158
+ * Uses `generateBatchContainerPath()` for config-aware, base-path-consistent
159
+ * path resolution (respects `worktree_location` setting). This ensures
160
+ * the merge worktree is co-located with lane worktrees in the same
161
+ * batch container for unified cleanup.
162
+ *
163
+ * @param repoRoot - Absolute path to the main repository root
164
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
165
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
166
+ * @param config - Orchestrator config (optional; defaults to subdirectory mode)
167
+ */
168
+ export function generateMergeWorktreePath(
169
+ repoRoot: string,
170
+ opId: string,
171
+ batchId: string,
172
+ config?: OrchestratorConfig,
173
+ ): string {
174
+ const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config);
175
+ return resolve(containerPath, "merge");
176
+ }
177
+
178
+ /**
179
+ * Ensure the batch container directory exists, creating it if necessary.
180
+ *
181
+ * @param containerPath - Absolute path to the container directory
182
+ */
183
+ export function ensureBatchContainerDir(containerPath: string): void {
184
+ if (!existsSync(containerPath)) {
185
+ mkdirSync(containerPath, { recursive: true });
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Remove a batch container directory if it exists and is empty.
191
+ *
192
+ * Safety rules:
193
+ * - Only removes the directory if it exists
194
+ * - Only removes the directory if it is empty (no files or subdirectories)
195
+ * - Never force-removes a non-empty container (partial failure safety)
196
+ * - Returns whether the container was removed
197
+ *
198
+ * Used after per-worktree removals in `removeAllWorktrees()` and
199
+ * `forceCleanupWorktree()` to clean up the container directory when
200
+ * all worktrees inside it have been removed.
201
+ *
202
+ * @param containerPath - Absolute path to the batch container directory
203
+ * @returns true if the container was removed, false otherwise
204
+ */
205
+ export function removeBatchContainerIfEmpty(containerPath: string): boolean {
206
+ if (!existsSync(containerPath)) {
207
+ return false; // Already gone — no-op
208
+ }
209
+
210
+ try {
211
+ const entries = readdirSync(containerPath);
212
+ if (entries.length > 0) {
213
+ return false; // Non-empty — do not remove (partial failure safety)
214
+ }
215
+ rmdirSync(containerPath);
216
+ return true;
217
+ } catch {
218
+ // If we can't read or remove — leave it alone (safe default)
219
+ return false;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Parse `git worktree list --porcelain` output into structured entries.
225
+ *
226
+ * Porcelain output format (one block per worktree, separated by blank lines):
227
+ * worktree /absolute/path
228
+ * HEAD <sha>
229
+ * branch refs/heads/<name>
230
+ * [detached]
231
+ *
232
+ * @param cwd - Directory to run git from (must be in a git repo)
233
+ */
234
+ export interface ParsedWorktreeEntry {
235
+ path: string;
236
+ head: string;
237
+ branch: string | null; // null if detached HEAD
238
+ bare: boolean;
239
+ }
240
+
241
+ export function parseWorktreeList(cwd: string): ParsedWorktreeEntry[] {
242
+ const result = runGit(["worktree", "list", "--porcelain"], cwd);
243
+ if (!result.ok) return [];
244
+
245
+ const entries: ParsedWorktreeEntry[] = [];
246
+ const blocks = result.stdout.split(/\n\n+/);
247
+
248
+ for (const block of blocks) {
249
+ if (!block.trim()) continue;
250
+
251
+ const lines = block.trim().split("\n");
252
+ let path = "";
253
+ let head = "";
254
+ let branch: string | null = null;
255
+ let bare = false;
256
+
257
+ for (const line of lines) {
258
+ if (line.startsWith("worktree ")) {
259
+ path = line.slice("worktree ".length).trim();
260
+ } else if (line.startsWith("HEAD ")) {
261
+ head = line.slice("HEAD ".length).trim();
262
+ } else if (line.startsWith("branch ")) {
263
+ // "branch refs/heads/develop" → "develop"
264
+ const ref = line.slice("branch ".length).trim();
265
+ branch = ref.replace(/^refs\/heads\//, "");
266
+ } else if (line.trim() === "bare") {
267
+ bare = true;
268
+ }
269
+ }
270
+
271
+ if (path) {
272
+ entries.push({ path, head, branch, bare });
273
+ }
274
+ }
275
+
276
+ return entries;
277
+ }
278
+
279
+ /**
280
+ * Normalize a filesystem path for reliable comparison on Windows.
281
+ *
282
+ * On Windows, paths may contain 8.3 short names (e.g., `HENRYL~1` instead
283
+ * of `HenryLach`). Node's `resolve()` does NOT expand these, but git
284
+ * always reports full long names. This causes path comparison failures.
285
+ *
286
+ * Uses `fs.realpathSync.native()` to expand 8.3 names when the path exists,
287
+ * falls back to `resolve()` for non-existent paths (e.g., pre-creation checks).
288
+ *
289
+ * All comparisons are also lowercased and slash-normalized.
290
+ */
291
+ export function normalizePath(p: string): string {
292
+ let expanded: string;
293
+ try {
294
+ // realpathSync.native expands 8.3 short names on Windows
295
+ expanded = realpathSync.native(resolve(p));
296
+ } catch {
297
+ // Path doesn't exist yet — fall back to resolve()
298
+ expanded = resolve(p);
299
+ }
300
+ return expanded.replace(/\\/g, "/").toLowerCase();
301
+ }
302
+
303
+ /**
304
+ * Check if a given path is already registered as a git worktree.
305
+ * Uses `git worktree list --porcelain` for reliable detection.
306
+ *
307
+ * Path comparison is case-insensitive, slash-normalized, and expands
308
+ * Windows 8.3 short names (e.g., HENRYL~1 → HenryLach) for reliable
309
+ * matching against git's long-name output.
310
+ */
311
+ export function isRegisteredWorktree(targetPath: string, cwd: string): boolean {
312
+ const entries = parseWorktreeList(cwd);
313
+ const normalized = normalizePath(targetPath);
314
+ return entries.some((e) => normalizePath(e.path) === normalized);
315
+ }
316
+
317
+ // ── Worktree CRUD Operations ─────────────────────────────────────────
318
+
319
+ /**
320
+ * Create a new git worktree for a lane.
321
+ *
322
+ * Executes `git worktree add -b <branch> <path> <baseBranch>` from the
323
+ * main repository root. This creates a new branch based on baseBranch
324
+ * and checks it out in the worktree directory.
325
+ *
326
+ * Pre-checks (R002 requirements):
327
+ * 1. Validates baseBranch exists (`git rev-parse --verify`)
328
+ * 2. Checks target path is not already a registered worktree
329
+ * 3. Checks target path is not a non-empty non-worktree directory
330
+ *
331
+ * Post-creation verification:
332
+ * - Branch points to baseBranch HEAD commit
333
+ * - Correct branch is checked out in the worktree
334
+ *
335
+ * @param opts - Creation options (laneNumber, batchId, baseBranch, prefix)
336
+ * @param repoRoot - Absolute path to the main repository root
337
+ * @returns - WorktreeInfo on success
338
+ * @throws - WorktreeError with stable error code on failure
339
+ */
340
+ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): WorktreeInfo {
341
+ const { laneNumber, batchId, baseBranch, prefix, opId, config } = opts;
342
+
343
+ const branch = generateBranchName(laneNumber, batchId, opId);
344
+ const worktreePath = generateWorktreePath(prefix, laneNumber, repoRoot, opId, config, batchId);
345
+
346
+ // ── Pre-check 1: Validate base branch exists ─────────────────
347
+ const baseBranchCheck = runGit(["rev-parse", "--verify", `refs/heads/${baseBranch}`], repoRoot);
348
+ if (!baseBranchCheck.ok) {
349
+ throw new WorktreeError(
350
+ "WORKTREE_INVALID_BASE",
351
+ `Base branch "${baseBranch}" does not exist locally. ` +
352
+ `Verify the branch exists: git branch --list ${baseBranch}`,
353
+ );
354
+ }
355
+ const baseBranchHead = baseBranchCheck.stdout.trim();
356
+
357
+ // ── Pre-check 2: Check if path is already a registered worktree
358
+ if (isRegisteredWorktree(worktreePath, repoRoot)) {
359
+ throw new WorktreeError(
360
+ "WORKTREE_PATH_IS_WORKTREE",
361
+ `Path "${worktreePath}" is already registered as a git worktree. ` +
362
+ `Remove it first: git worktree remove "${worktreePath}"`,
363
+ );
364
+ }
365
+
366
+ // ── Pre-check 3: Check if path exists and is non-empty (non-worktree dir)
367
+ if (existsSync(worktreePath)) {
368
+ try {
369
+ const entries = readdirSync(worktreePath);
370
+ if (entries.length > 0) {
371
+ throw new WorktreeError(
372
+ "WORKTREE_PATH_NOT_EMPTY",
373
+ `Path "${worktreePath}" exists and is not empty. ` +
374
+ `It is not a registered git worktree. Remove or rename it before creating a worktree here.`,
375
+ );
376
+ }
377
+ } catch (err) {
378
+ if (err instanceof WorktreeError) throw err;
379
+ // If we can't read the path (e.g., it's a file not a directory), error
380
+ throw new WorktreeError(
381
+ "WORKTREE_PATH_NOT_EMPTY",
382
+ `Path "${worktreePath}" exists but cannot be read as a directory.`,
383
+ );
384
+ }
385
+ }
386
+
387
+ // ── Pre-check 4: Check if branch already exists ──────────────
388
+ const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot);
389
+ if (branchCheck.ok) {
390
+ throw new WorktreeError(
391
+ "WORKTREE_BRANCH_EXISTS",
392
+ `Branch "${branch}" already exists. ` +
393
+ `This may indicate a stale worktree from a previous batch. ` +
394
+ `Delete it: git branch -D ${branch}`,
395
+ );
396
+ }
397
+
398
+ // ── Ensure batch container directory exists ──────────────────
399
+ // Placed after pre-checks so no empty container is left behind on
400
+ // validation failure (R004 review feedback).
401
+ const containerDir = resolve(worktreePath, "..");
402
+ ensureBatchContainerDir(containerDir);
403
+
404
+ // ── Create worktree ──────────────────────────────────────────
405
+ const createResult = runGit(["worktree", "add", "-b", branch, worktreePath, baseBranch], repoRoot);
406
+ if (!createResult.ok) {
407
+ throw new WorktreeError(
408
+ "WORKTREE_GIT_ERROR",
409
+ `Failed to create worktree at "${worktreePath}" on branch "${branch}" ` +
410
+ `from "${baseBranch}": ${createResult.stderr}`,
411
+ );
412
+ }
413
+
414
+ // ── Post-creation verification (R002 requirements) ───────────
415
+ // Verify 1: Correct branch is checked out
416
+ const headBranchResult = runGit(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
417
+ if (!headBranchResult.ok || headBranchResult.stdout !== branch) {
418
+ throw new WorktreeError(
419
+ "WORKTREE_VERIFY_FAILED",
420
+ `Verification failed: expected branch "${branch}" checked out ` +
421
+ `in worktree, but got "${headBranchResult.stdout || "(unknown)"}".`,
422
+ );
423
+ }
424
+
425
+ // Verify 2: Branch points to baseBranch HEAD commit
426
+ const headCommitResult = runGit(["rev-parse", "HEAD"], worktreePath);
427
+ if (!headCommitResult.ok || headCommitResult.stdout !== baseBranchHead) {
428
+ throw new WorktreeError(
429
+ "WORKTREE_VERIFY_FAILED",
430
+ `Verification failed: worktree HEAD (${headCommitResult.stdout?.slice(0, 8) || "?"}) ` +
431
+ `does not match baseBranch "${baseBranch}" HEAD (${baseBranchHead.slice(0, 8)}).`,
432
+ );
433
+ }
434
+
435
+ return {
436
+ path: resolve(worktreePath),
437
+ branch,
438
+ laneNumber,
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Reset an existing worktree to point at a new target branch/commit.
444
+ *
445
+ * Used after a wave merge to update a lane's worktree to the latest
446
+ * develop HEAD, or any other target branch. The existing lane branch
447
+ * name is preserved — only its target commit changes.
448
+ *
449
+ * Strategy: `git checkout -B <laneBranch> <targetBranch>` inside the worktree.
450
+ * This repoints the existing lane branch to the target commit and checks it out.
451
+ *
452
+ * Precondition checks (R003 requirements):
453
+ * 1. Worktree path exists on disk
454
+ * 2. Path is a registered git worktree (via parseWorktreeList)
455
+ * 3. Target branch resolves (git rev-parse --verify)
456
+ * 4. Working tree is clean (git status --porcelain returns empty)
457
+ *
458
+ * Post-reset verification:
459
+ * - HEAD equals targetBranch commit
460
+ * - Current branch equals worktree.branch (lane branch preserved)
461
+ *
462
+ * Idempotency: Resetting to the same target commit succeeds (no-op semantically).
463
+ *
464
+ * @param worktree - WorktreeInfo returned by createWorktree()
465
+ * @param targetBranch - Branch name to reset to (e.g. "develop")
466
+ * @param repoRoot - Absolute path to the main repository root
467
+ * @returns - Updated WorktreeInfo (same branch/laneNumber, same path)
468
+ * @throws - WorktreeError with stable error code on failure
469
+ */
470
+ export function resetWorktree(
471
+ worktree: WorktreeInfo,
472
+ targetBranch: string,
473
+ repoRoot: string,
474
+ ): WorktreeInfo {
475
+ const { path: worktreePath, branch, laneNumber } = worktree;
476
+
477
+ // ── Pre-check 1: Worktree path exists on disk ────────────────
478
+ if (!existsSync(worktreePath)) {
479
+ throw new WorktreeError(
480
+ "WORKTREE_NOT_FOUND",
481
+ `Worktree path "${worktreePath}" does not exist on disk. ` +
482
+ `It may have been removed externally.`,
483
+ );
484
+ }
485
+
486
+ // ── Pre-check 2: Path is a registered git worktree ───────────
487
+ if (!isRegisteredWorktree(worktreePath, repoRoot)) {
488
+ throw new WorktreeError(
489
+ "WORKTREE_NOT_REGISTERED",
490
+ `Path "${worktreePath}" exists but is not a registered git worktree. ` +
491
+ `It may have been removed from git tracking. Check: git worktree list`,
492
+ );
493
+ }
494
+
495
+ // ── Pre-check 3: Target branch resolves ──────────────────────
496
+ const targetCheck = runGit(["rev-parse", "--verify", `refs/heads/${targetBranch}`], repoRoot);
497
+ if (!targetCheck.ok) {
498
+ throw new WorktreeError(
499
+ "WORKTREE_INVALID_BASE",
500
+ `Target branch "${targetBranch}" does not exist locally. ` +
501
+ `Verify the branch exists: git branch --list ${targetBranch}`,
502
+ );
503
+ }
504
+ const targetCommit = targetCheck.stdout.trim();
505
+
506
+ // ── Pre-check 4: Working tree is clean ───────────────────────
507
+ const statusCheck = runGit(["status", "--porcelain"], worktreePath);
508
+ if (!statusCheck.ok) {
509
+ throw new WorktreeError(
510
+ "WORKTREE_GIT_ERROR",
511
+ `Failed to check working tree status in "${worktreePath}": ${statusCheck.stderr}`,
512
+ );
513
+ }
514
+ if (statusCheck.stdout.length > 0) {
515
+ throw new WorktreeError(
516
+ "WORKTREE_DIRTY",
517
+ `Worktree at "${worktreePath}" has uncommitted changes. ` +
518
+ `Workers must commit or discard all changes before a reset can proceed. ` +
519
+ `Dirty files:\n${statusCheck.stdout}`,
520
+ );
521
+ }
522
+
523
+ // ── Reset: git checkout -B <laneBranch> <targetBranch> ───────
524
+ const resetResult = runGit(["checkout", "-B", branch, targetBranch], worktreePath);
525
+ if (!resetResult.ok) {
526
+ throw new WorktreeError(
527
+ "WORKTREE_RESET_FAILED",
528
+ `Failed to reset worktree at "${worktreePath}" ` +
529
+ `(branch "${branch}" → "${targetBranch}"): ${resetResult.stderr}`,
530
+ );
531
+ }
532
+
533
+ // ── Post-reset verification ──────────────────────────────────
534
+ // Verify 1: Current branch equals expected lane branch
535
+ const headBranchResult = runGit(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
536
+ if (!headBranchResult.ok || headBranchResult.stdout !== branch) {
537
+ throw new WorktreeError(
538
+ "WORKTREE_VERIFY_FAILED",
539
+ `Post-reset verification failed: expected branch "${branch}" ` +
540
+ `checked out, but got "${headBranchResult.stdout || "(unknown)"}".`,
541
+ );
542
+ }
543
+
544
+ // Verify 2: HEAD equals targetBranch commit
545
+ const headCommitResult = runGit(["rev-parse", "HEAD"], worktreePath);
546
+ if (!headCommitResult.ok || headCommitResult.stdout !== targetCommit) {
547
+ throw new WorktreeError(
548
+ "WORKTREE_VERIFY_FAILED",
549
+ `Post-reset verification failed: worktree HEAD ` +
550
+ `(${headCommitResult.stdout?.slice(0, 8) || "?"}) does not match ` +
551
+ `target "${targetBranch}" commit (${targetCommit.slice(0, 8)}).`,
552
+ );
553
+ }
554
+
555
+ // Return updated WorktreeInfo (branch and laneNumber preserved)
556
+ return {
557
+ path: resolve(worktreePath),
558
+ branch,
559
+ laneNumber,
560
+ };
561
+ }
562
+
563
+ /**
564
+ * Sleep for a given number of milliseconds (synchronous busy-wait).
565
+ *
566
+ * Uses execSync("ping") on Windows / ("sleep") on Unix as a synchronous
567
+ * sleep mechanism since this module uses synchronous git operations.
568
+ * The busy-wait is acceptable because retry waits are bounded (max 16s)
569
+ * and this function is only called during cleanup, not hot paths.
570
+ *
571
+ * @param ms - Milliseconds to sleep
572
+ */
573
+ export function sleepSync(ms: number): void {
574
+ const seconds = Math.ceil(ms / 1000);
575
+ try {
576
+ // Cross-platform synchronous sleep
577
+ if (process.platform === "win32") {
578
+ execSync(`ping -n ${seconds + 1} 127.0.0.1 > nul`, { stdio: "ignore", timeout: ms + 5000 });
579
+ } else {
580
+ execSync(`sleep ${seconds}`, { stdio: "ignore", timeout: ms + 5000 });
581
+ }
582
+ } catch {
583
+ // Timeout or error — acceptable, we just needed a delay
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Async sleep for a given number of milliseconds.
589
+ *
590
+ * Unlike `sleepSync`, this yields the event loop so that other async work
591
+ * (supervisor heartbeats, user input, dashboard updates) can proceed while
592
+ * waiting. Use this in async code paths such as merge polling.
593
+ *
594
+ * @param ms - Milliseconds to sleep
595
+ */
596
+ export function sleepAsync(ms: number): Promise<void> {
597
+ return new Promise((resolve) => setTimeout(resolve, ms));
598
+ }
599
+
600
+ /**
601
+ * Determine if a git worktree remove error is retriable.
602
+ *
603
+ * Retriable errors are typically filesystem/lock issues on Windows
604
+ * where another process (antivirus, IDE, explorer) holds file handles.
605
+ *
606
+ * Terminal (non-retriable) errors are git usage errors like
607
+ * "not a valid worktree" or missing arguments.
608
+ *
609
+ * @param stderr - Error output from git worktree remove
610
+ * @returns true if the error is likely transient and worth retrying
611
+ */
612
+ export function isRetriableRemoveError(stderr: string): boolean {
613
+ const lower = stderr.toLowerCase();
614
+ // Windows file locking patterns
615
+ if (lower.includes("cannot lock") || lower.includes("unable to access")) return true;
616
+ if (lower.includes("permission denied")) return true;
617
+ if (lower.includes("device or resource busy")) return true;
618
+ if (lower.includes("the process cannot access")) return true;
619
+ if (lower.includes("used by another process")) return true;
620
+ if (lower.includes("directory not empty")) return true;
621
+ if (lower.includes("failed to remove")) return true;
622
+ // Generic I/O errors that may be transient
623
+ if (lower.includes("i/o error")) return true;
624
+ if (lower.includes("input/output error")) return true;
625
+ return false;
626
+ }
627
+
628
+ /**
629
+ * Detect Windows MAX_PATH ("Filename too long") errors from `git worktree remove`.
630
+ *
631
+ * On Windows with default `core.longpaths = false`, git refuses to delete
632
+ * paths that exceed MAX_PATH (260 characters). Deep `node_modules` trees
633
+ * commonly trip this. Native `cmd` `rd /s /q` uses a different deletion
634
+ * code path (NT object namespace, longer path tolerance) and usually
635
+ * succeeds where git fails.
636
+ *
637
+ * @param stderr - Error output from `git worktree remove`
638
+ * @returns true if the failure looks like the Windows MAX_PATH case
639
+ * @since TP-188 (#543)
640
+ */
641
+ export function isWindowsMaxPathError(stderr: string): boolean {
642
+ if (process.platform !== "win32") return false;
643
+ return /filename too long/i.test(stderr);
644
+ }
645
+
646
+ /**
647
+ * Run `cmd /c rd /s /q <path>` to recursively delete a directory on Windows.
648
+ *
649
+ * Used as a fallback after `git worktree remove` fails with the Windows
650
+ * MAX_PATH ("Filename too long") error. Caller must ensure platform is win32
651
+ * and the path is absolute. Path separators are normalized to backslashes
652
+ * because cmd's `rd` is more reliable with native Windows paths.
653
+ *
654
+ * @param absolutePath - Absolute path to remove (forward or back slashes accepted)
655
+ * @returns { ok, stdout, stderr }
656
+ * @since TP-188 (#543)
657
+ */
658
+ export function runWindowsCmdRd(absolutePath: string): {
659
+ ok: boolean;
660
+ stdout: string;
661
+ stderr: string;
662
+ } {
663
+ const winPath = absolutePath.replace(/\//g, "\\");
664
+ try {
665
+ const stdout = execFileSync("cmd", ["/c", "rd", "/s", "/q", winPath], {
666
+ encoding: "utf-8",
667
+ timeout: 60_000,
668
+ stdio: ["pipe", "pipe", "pipe"],
669
+ })
670
+ .toString()
671
+ .trim();
672
+ return { ok: true, stdout, stderr: "" };
673
+ } catch (err: unknown) {
674
+ const e = err as { stdout?: string; stderr?: string; message?: string };
675
+ return {
676
+ ok: false,
677
+ stdout: (e.stdout ?? "").toString().trim(),
678
+ stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(),
679
+ };
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Remove a git worktree and clean up its associated branch.
685
+ *
686
+ * Executes `git worktree remove --force <path>` from the main repository
687
+ * root, then handles branch cleanup based on merge status.
688
+ *
689
+ * Branch protection (when targetBranch is provided):
690
+ * - If branch has unmerged commits vs targetBranch → preserves as `saved/<branch>`
691
+ * instead of deleting. Returns `{ branchPreserved: true, savedBranch: "saved/..." }`
692
+ * - If fully merged or no new commits → deletes normally
693
+ * - If targetBranch is missing or git error → skips deletion (safe default)
694
+ *
695
+ * Idempotent behavior:
696
+ * - If path is already missing AND branch is already gone → returns
697
+ * `{ removed: false, alreadyRemoved: true, branchDeleted: true }`
698
+ * - If path is already missing BUT branch has unmerged commits → preserves branch,
699
+ * returns `{ removed: false, alreadyRemoved: true, branchPreserved: true }`
700
+ *
701
+ * Retry policy (Windows file locking):
702
+ * - Up to 5 retries with exponential backoff: 1s, 2s, 4s, 8s, 16s
703
+ * - Only retriable errors (filesystem/lock) trigger retries
704
+ * - Terminal git errors (invalid worktree, bad args) fail immediately
705
+ * - Branch deletion is not retried (single attempt)
706
+ *
707
+ * Post-removal verification:
708
+ * - Path no longer exists on disk
709
+ * - Path no longer registered via `git worktree list --porcelain`
710
+ *
711
+ * @param worktree - WorktreeInfo returned by createWorktree()
712
+ * @param repoRoot - Absolute path to the main repository root
713
+ * @param targetBranch - Optional target branch for unmerged commit detection (e.g. "develop")
714
+ * @returns RemoveWorktreeResult with status flags
715
+ * @throws WorktreeError with WORKTREE_REMOVE_RETRY_EXHAUSTED if all retries fail
716
+ * @throws WorktreeError with WORKTREE_REMOVE_FAILED for terminal (non-retriable) errors
717
+ * @throws WorktreeError with WORKTREE_BRANCH_DELETE_FAILED if branch cleanup fails
718
+ */
719
+ export function removeWorktree(
720
+ worktree: WorktreeInfo,
721
+ repoRoot: string,
722
+ targetBranch?: string,
723
+ ): RemoveWorktreeResult {
724
+ const { path: worktreePath, branch } = worktree;
725
+
726
+ const pathExists = existsSync(worktreePath);
727
+ const isRegistered = isRegisteredWorktree(worktreePath, repoRoot);
728
+
729
+ // ── Handle already-removed states ────────────────────────────
730
+ if (!pathExists && !isRegistered) {
731
+ // Path is gone and not registered. Clean up stale branch if any.
732
+ const branchResult = ensureBranchDeleted(branch, repoRoot, worktreePath, targetBranch);
733
+ return {
734
+ removed: false,
735
+ alreadyRemoved: true,
736
+ branchDeleted: branchResult.deleted,
737
+ branchPreserved: branchResult.preserved,
738
+ savedBranch: branchResult.savedBranch,
739
+ unmergedCount: branchResult.unmergedCount,
740
+ };
741
+ }
742
+
743
+ // If path is missing but still registered in git, prune first
744
+ if (!pathExists && isRegistered) {
745
+ // `git worktree prune` removes stale worktree entries
746
+ runGit(["worktree", "prune"], repoRoot);
747
+ const branchResult = ensureBranchDeleted(branch, repoRoot, worktreePath, targetBranch);
748
+ return {
749
+ removed: false,
750
+ alreadyRemoved: true,
751
+ branchDeleted: branchResult.deleted,
752
+ branchPreserved: branchResult.preserved,
753
+ savedBranch: branchResult.savedBranch,
754
+ unmergedCount: branchResult.unmergedCount,
755
+ };
756
+ }
757
+
758
+ // ── Attempt removal with retry/backoff ───────────────────────
759
+ const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 16000];
760
+ const MAX_ATTEMPTS = RETRY_DELAYS_MS.length + 1; // first attempt + retries
761
+
762
+ let lastError = "";
763
+
764
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
765
+ const removeResult = runGit(["worktree", "remove", "--force", worktreePath], repoRoot);
766
+
767
+ if (removeResult.ok) {
768
+ // Successful removal — proceed to branch cleanup
769
+ break;
770
+ }
771
+
772
+ lastError = removeResult.stderr;
773
+
774
+ // ── Windows MAX_PATH fallback (#543) ────────────────────────
775
+ // On Windows, `git worktree remove` fails with "Filename too long"
776
+ // when the worktree contains deep `node_modules` trees (most
777
+ // non-trivial Node projects) and `core.longpaths = false` (default).
778
+ // `cmd /c rd /s /q <path>` uses a different deletion code path
779
+ // that tolerates long paths better. Try it ONCE before classifying
780
+ // the error as terminal/retriable so other error classes still
781
+ // surface unchanged.
782
+ if (isWindowsMaxPathError(lastError)) {
783
+ execLog("cleanup", "worktree", `Windows MAX_PATH detected — falling back to cmd "rd /s /q"`, {
784
+ path: worktreePath,
785
+ attempt,
786
+ });
787
+ const fallback = runWindowsCmdRd(worktreePath);
788
+ if (fallback.ok) {
789
+ execLog(
790
+ "cleanup",
791
+ "worktree",
792
+ `cmd "rd /s /q" fallback succeeded; pruning git worktree state`,
793
+ { path: worktreePath },
794
+ );
795
+ // The on-disk tree is gone; git's bookkeeping still has a
796
+ // stale entry. Prune so isRegisteredWorktree() returns false
797
+ // during post-removal verification below.
798
+ runGit(["worktree", "prune"], repoRoot);
799
+ break;
800
+ }
801
+ // Fallback also failed — enrich error so the operator sees both
802
+ // attempts, then fall through to the existing terminal/retry
803
+ // classification (which will throw because "Filename too long"
804
+ // is non-retriable per isRetriableRemoveError).
805
+ execLog("cleanup", "worktree", `cmd "rd /s /q" fallback failed`, {
806
+ path: worktreePath,
807
+ error: fallback.stderr.slice(0, 200),
808
+ });
809
+ lastError =
810
+ `git worktree remove failed: ${lastError}; ` +
811
+ `cmd rd /s /q fallback failed: ${fallback.stderr}`;
812
+ }
813
+
814
+ // Check if error is terminal (non-retriable)
815
+ if (!isRetriableRemoveError(lastError)) {
816
+ throw new WorktreeError(
817
+ "WORKTREE_REMOVE_FAILED",
818
+ `Failed to remove worktree at "${worktreePath}" ` +
819
+ `(terminal error, not retried): ${lastError}`,
820
+ );
821
+ }
822
+
823
+ // If we've exhausted all retries, throw
824
+ if (attempt >= MAX_ATTEMPTS) {
825
+ throw new WorktreeError(
826
+ "WORKTREE_REMOVE_RETRY_EXHAUSTED",
827
+ `Failed to remove worktree at "${worktreePath}" after ` +
828
+ `${MAX_ATTEMPTS} attempts. Last error: ${lastError}. ` +
829
+ `This is likely a Windows file locking issue. ` +
830
+ `Close any programs accessing "${worktreePath}" and try again.`,
831
+ );
832
+ }
833
+
834
+ // Wait before retrying (exponential backoff)
835
+ const delayMs = RETRY_DELAYS_MS[attempt - 1];
836
+ sleepSync(delayMs);
837
+ }
838
+
839
+ // ── Post-removal verification ────────────────────────────────
840
+ if (existsSync(worktreePath)) {
841
+ throw new WorktreeError(
842
+ "WORKTREE_VERIFY_FAILED",
843
+ `Post-removal verification failed: path "${worktreePath}" ` +
844
+ `still exists on disk after successful git worktree remove.`,
845
+ );
846
+ }
847
+
848
+ if (isRegisteredWorktree(worktreePath, repoRoot)) {
849
+ // Try pruning stale entries
850
+ runGit(["worktree", "prune"], repoRoot);
851
+ if (isRegisteredWorktree(worktreePath, repoRoot)) {
852
+ throw new WorktreeError(
853
+ "WORKTREE_VERIFY_FAILED",
854
+ `Post-removal verification failed: path "${worktreePath}" ` +
855
+ `is still registered as a git worktree after removal and prune.`,
856
+ );
857
+ }
858
+ }
859
+
860
+ // ── Branch cleanup (single attempt, fail loud if still present) ─
861
+ const branchResult = ensureBranchDeleted(branch, repoRoot, worktreePath, targetBranch);
862
+
863
+ return {
864
+ removed: true,
865
+ alreadyRemoved: false,
866
+ branchDeleted: branchResult.deleted,
867
+ branchPreserved: branchResult.preserved,
868
+ savedBranch: branchResult.savedBranch,
869
+ unmergedCount: branchResult.unmergedCount,
870
+ };
871
+ }
872
+
873
+ /**
874
+ * Result of ensureBranchDeleted — either deleted or preserved.
875
+ */
876
+ export interface EnsureBranchDeletedResult {
877
+ /** Whether the branch was deleted */
878
+ deleted: boolean;
879
+ /** Whether the branch was preserved (unmerged commits) */
880
+ preserved: boolean;
881
+ /** Saved branch name (if preserved) */
882
+ savedBranch?: string;
883
+ /** Number of unmerged commits (if preserved) */
884
+ unmergedCount?: number;
885
+ }
886
+
887
+ /**
888
+ * Ensure a lane branch is deleted — or preserved if it has unmerged commits.
889
+ *
890
+ * When `targetBranch` is provided, checks for unmerged commits first:
891
+ * - If unmerged: preserves via `saved/<branch>` ref instead of deleting
892
+ * - If fully merged or no unmerged: deletes normally
893
+ *
894
+ * When `targetBranch` is omitted (backward compat), deletes unconditionally
895
+ * using deleteBranchBestEffort() with the original fail-loud semantics.
896
+ *
897
+ * Upgrades a persistent deletion failure into a hard WorktreeError so
898
+ * callers cannot silently proceed with stale lane branches.
899
+ */
900
+ export function ensureBranchDeleted(
901
+ branch: string,
902
+ repoRoot: string,
903
+ worktreePath: string,
904
+ targetBranch?: string,
905
+ ): EnsureBranchDeletedResult {
906
+ // If targetBranch provided, check for unmerged commits before deleting
907
+ if (targetBranch) {
908
+ const preserveResult = preserveBranch(branch, targetBranch, repoRoot);
909
+
910
+ switch (preserveResult.action) {
911
+ case "preserved":
912
+ case "already-preserved": {
913
+ // Branch had unmerged commits — saved ref exists, now delete the original
914
+ // This implements rename semantics: create saved + delete original
915
+ const sourceDeleted = deleteBranchBestEffort(branch, repoRoot);
916
+ return {
917
+ deleted: sourceDeleted,
918
+ preserved: true,
919
+ savedBranch: preserveResult.savedBranch,
920
+ unmergedCount: preserveResult.unmergedCount,
921
+ };
922
+ }
923
+
924
+ case "fully-merged":
925
+ case "no-branch":
926
+ // Safe to delete — fall through to deletion below
927
+ break;
928
+
929
+ case "error":
930
+ // Preservation check failed — log but still try to preserve by skipping deletion
931
+ // This is the safe default: don't delete if we can't verify merge status
932
+ return {
933
+ deleted: false,
934
+ preserved: false,
935
+ };
936
+ }
937
+ }
938
+
939
+ // No unmerged commits (or no targetBranch) — delete normally
940
+ const branchDeleted = deleteBranchBestEffort(branch, repoRoot);
941
+ if (!branchDeleted) {
942
+ throw new WorktreeError(
943
+ "WORKTREE_BRANCH_DELETE_FAILED",
944
+ `Worktree "${worktreePath}" was removed, but failed to delete lane branch ` +
945
+ `"${branch}". Delete it manually: git branch -D ${branch}`,
946
+ );
947
+ }
948
+ return { deleted: true, preserved: false };
949
+ }
950
+
951
+ /**
952
+ * Delete a branch with best-effort semantics.
953
+ *
954
+ * Uses `git branch -D` (force delete) since lane branches are ephemeral
955
+ * and may not have been merged anywhere.
956
+ *
957
+ * "Branch not found" is treated as idempotent success (returns true).
958
+ *
959
+ * @param branch - Branch name to delete
960
+ * @param repoRoot - Repository root directory
961
+ * @returns true if branch was deleted or was already absent
962
+ */
963
+ export function deleteBranchBestEffort(branch: string, repoRoot: string): boolean {
964
+ // Check if branch exists first
965
+ const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot);
966
+
967
+ if (!branchCheck.ok) {
968
+ // Branch doesn't exist — idempotent success
969
+ return true;
970
+ }
971
+
972
+ // Force delete (lane branches are ephemeral, may not be merged)
973
+ const deleteResult = runGit(["branch", "-D", branch], repoRoot);
974
+
975
+ if (deleteResult.ok) {
976
+ return true;
977
+ }
978
+
979
+ // If delete failed but branch is now gone (race condition), treat as success
980
+ const recheckResult = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot);
981
+ if (!recheckResult.ok) {
982
+ return true;
983
+ }
984
+
985
+ // Branch still exists and delete failed — return false
986
+ return false;
987
+ }
988
+
989
+ // ── Branch Protection Helpers ────────────────────────────────────────
990
+
991
+ /** Typed error codes for unmerged commit checks */
992
+ export type UnmergedCommitsErrorCode =
993
+ | "BRANCH_NOT_FOUND"
994
+ | "TARGET_BRANCH_MISSING"
995
+ | "UNMERGED_COUNT_FAILED"
996
+ | "UNMERGED_COUNT_PARSE_FAILED";
997
+
998
+ /**
999
+ * Result of checking for unmerged commits on a branch.
1000
+ */
1001
+ export interface UnmergedCommitsResult {
1002
+ /** Whether the check succeeded (git command ran without error) */
1003
+ ok: boolean;
1004
+ /** Number of commits on `branch` not reachable from `targetBranch` */
1005
+ count: number;
1006
+ /** Typed error code if check failed */
1007
+ code?: UnmergedCommitsErrorCode;
1008
+ /** Error message if check failed */
1009
+ error?: string;
1010
+ }
1011
+
1012
+ /**
1013
+ * Check if a branch has commits not reachable from a target branch.
1014
+ *
1015
+ * Uses `git rev-list --count <targetBranch>..<branch>` which is
1016
+ * Windows-safe (no shell pipes). Returns the count of unmerged commits.
1017
+ *
1018
+ * Pure logic with git dependency — designed so the git call can be
1019
+ * tested in integration tests with real repos, while the decision
1020
+ * logic is tested via the count result.
1021
+ *
1022
+ * @param branch - Branch to check for unmerged commits
1023
+ * @param targetBranch - Target branch to compare against (e.g. "develop")
1024
+ * @param repoRoot - Repository root directory
1025
+ * @returns UnmergedCommitsResult with count and status
1026
+ */
1027
+ export function hasUnmergedCommits(
1028
+ branch: string,
1029
+ targetBranch: string,
1030
+ repoRoot: string,
1031
+ ): UnmergedCommitsResult {
1032
+ // Verify branch exists
1033
+ const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot);
1034
+ if (!branchCheck.ok) {
1035
+ return {
1036
+ ok: false,
1037
+ count: 0,
1038
+ code: "BRANCH_NOT_FOUND",
1039
+ error: `Branch "${branch}" does not exist`,
1040
+ };
1041
+ }
1042
+
1043
+ // Verify target branch exists
1044
+ const targetCheck = runGit(["rev-parse", "--verify", `refs/heads/${targetBranch}`], repoRoot);
1045
+ if (!targetCheck.ok) {
1046
+ return {
1047
+ ok: false,
1048
+ count: 0,
1049
+ code: "TARGET_BRANCH_MISSING",
1050
+ error: `Target branch "${targetBranch}" does not exist`,
1051
+ };
1052
+ }
1053
+
1054
+ // Count commits on branch not reachable from target
1055
+ const countResult = runGit(["rev-list", "--count", `${targetBranch}..${branch}`], repoRoot);
1056
+ if (!countResult.ok) {
1057
+ return {
1058
+ ok: false,
1059
+ count: 0,
1060
+ code: "UNMERGED_COUNT_FAILED",
1061
+ error: `Failed to count unmerged commits: ${countResult.stderr}`,
1062
+ };
1063
+ }
1064
+
1065
+ const count = parseInt(countResult.stdout.trim(), 10);
1066
+ if (isNaN(count)) {
1067
+ return {
1068
+ ok: false,
1069
+ count: 0,
1070
+ code: "UNMERGED_COUNT_PARSE_FAILED",
1071
+ error: `Failed to parse commit count: "${countResult.stdout}"`,
1072
+ };
1073
+ }
1074
+
1075
+ return { ok: true, count };
1076
+ }
1077
+
1078
+ /**
1079
+ * Compute the saved branch name for a given original branch.
1080
+ *
1081
+ * Pure function — no side effects. Maps a branch name to its saved
1082
+ * counterpart under the `saved/` namespace.
1083
+ *
1084
+ * Examples:
1085
+ * "task/lane-1-20260308T111750" → "saved/task/lane-1-20260308T111750"
1086
+ * "feature/my-branch" → "saved/feature/my-branch"
1087
+ *
1088
+ * @param originalBranch - The branch name to compute a saved name for
1089
+ * @returns The saved branch name (always prefixed with "saved/")
1090
+ */
1091
+ export function computeSavedBranchName(originalBranch: string): string {
1092
+ return `saved/${originalBranch}`;
1093
+ }
1094
+
1095
+ /**
1096
+ * Result of saved branch collision resolution.
1097
+ */
1098
+ export interface SavedBranchResolution {
1099
+ /** The action to take */
1100
+ action: "create" | "keep-existing" | "create-suffixed";
1101
+ /** The final saved branch name to use */
1102
+ savedName: string;
1103
+ }
1104
+
1105
+ /**
1106
+ * Resolve a collision when a saved branch name already exists.
1107
+ *
1108
+ * Decision table:
1109
+ * - saved ref absent → action: "create", use savedName
1110
+ * - saved ref exists, same SHA → action: "keep-existing", use existing savedName
1111
+ * - saved ref exists, different SHA → action: "create-suffixed", append timestamp
1112
+ *
1113
+ * Pure function — no side effects. All git state is passed in as parameters.
1114
+ *
1115
+ * @param savedName - The desired saved branch name (e.g. "saved/task/lane-1-...")
1116
+ * @param existingSHA - SHA of existing saved branch (empty string if absent)
1117
+ * @param newSHA - SHA of the branch being preserved
1118
+ * @param timestamp - ISO timestamp for suffix (injectable for testability)
1119
+ * @returns SavedBranchResolution with action and final name
1120
+ */
1121
+ export function resolveSavedBranchCollision(
1122
+ savedName: string,
1123
+ existingSHA: string,
1124
+ newSHA: string,
1125
+ timestamp?: string,
1126
+ ): SavedBranchResolution {
1127
+ // Saved ref doesn't exist — create it
1128
+ if (!existingSHA) {
1129
+ return { action: "create", savedName };
1130
+ }
1131
+
1132
+ // Same SHA — no-op, keep existing
1133
+ if (existingSHA === newSHA) {
1134
+ return { action: "keep-existing", savedName };
1135
+ }
1136
+
1137
+ // Different SHA — create with timestamp suffix
1138
+ const ts = timestamp || new Date().toISOString().replace(/[:.]/g, "-");
1139
+ return { action: "create-suffixed", savedName: `${savedName}-${ts}` };
1140
+ }
1141
+
1142
+ /** Typed error codes for branch preservation */
1143
+ export type PreserveBranchErrorCode =
1144
+ | "TARGET_BRANCH_MISSING"
1145
+ | "UNMERGED_COUNT_FAILED"
1146
+ | "SAVED_BRANCH_CREATE_FAILED"
1147
+ | "UNKNOWN_RESOLUTION";
1148
+
1149
+ /**
1150
+ * Result of a branch preservation attempt.
1151
+ */
1152
+ export interface PreserveBranchResult {
1153
+ /** Whether the branch was preserved (or was already preserved / fully merged) */
1154
+ ok: boolean;
1155
+ /** What action was taken */
1156
+ action: "preserved" | "already-preserved" | "fully-merged" | "no-branch" | "error";
1157
+ /** The saved branch name (if preserved) */
1158
+ savedBranch?: string;
1159
+ /** Number of unmerged commits (if checked) */
1160
+ unmergedCount?: number;
1161
+ /** Typed error code (if action is "error") */
1162
+ code?: PreserveBranchErrorCode;
1163
+ /** Error message (if action is "error") */
1164
+ error?: string;
1165
+ }
1166
+
1167
+ /**
1168
+ * Preserve a branch by creating a saved ref if it has unmerged commits.
1169
+ *
1170
+ * Orchestrates: hasUnmergedCommits → computeSavedBranchName →
1171
+ * resolveSavedBranchCollision → git branch create/rename.
1172
+ *
1173
+ * Idempotent: if the saved ref already exists at the same SHA, it's a no-op.
1174
+ * If the target branch doesn't exist, logs warning and returns gracefully.
1175
+ *
1176
+ * @param branch - Branch to check and potentially preserve
1177
+ * @param targetBranch - Target branch to compare against (e.g. "develop")
1178
+ * @param repoRoot - Repository root directory
1179
+ * @returns PreserveBranchResult describing what was done
1180
+ */
1181
+ export function preserveBranch(
1182
+ branch: string,
1183
+ targetBranch: string,
1184
+ repoRoot: string,
1185
+ ): PreserveBranchResult {
1186
+ // Check if branch exists
1187
+ const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot);
1188
+ if (!branchCheck.ok) {
1189
+ return { ok: true, action: "no-branch" };
1190
+ }
1191
+ const branchSHA = branchCheck.stdout.trim();
1192
+
1193
+ // Check for unmerged commits
1194
+ const unmergedResult = hasUnmergedCommits(branch, targetBranch, repoRoot);
1195
+ if (!unmergedResult.ok) {
1196
+ // Target branch missing or git error — skip preservation gracefully
1197
+ // Map unmerged error codes to preserve error codes
1198
+ const preserveCode: PreserveBranchErrorCode =
1199
+ unmergedResult.code === "TARGET_BRANCH_MISSING"
1200
+ ? "TARGET_BRANCH_MISSING"
1201
+ : "UNMERGED_COUNT_FAILED";
1202
+ return {
1203
+ ok: false,
1204
+ action: "error",
1205
+ code: preserveCode,
1206
+ error: unmergedResult.error,
1207
+ };
1208
+ }
1209
+
1210
+ if (unmergedResult.count === 0) {
1211
+ return { ok: true, action: "fully-merged", unmergedCount: 0 };
1212
+ }
1213
+
1214
+ // Branch has unmerged commits — compute saved name
1215
+ const savedName = computeSavedBranchName(branch);
1216
+
1217
+ // Check for collision
1218
+ const existingCheck = runGit(["rev-parse", "--verify", `refs/heads/${savedName}`], repoRoot);
1219
+ const existingSHA = existingCheck.ok ? existingCheck.stdout.trim() : "";
1220
+
1221
+ const resolution = resolveSavedBranchCollision(savedName, existingSHA, branchSHA);
1222
+
1223
+ switch (resolution.action) {
1224
+ case "keep-existing":
1225
+ return {
1226
+ ok: true,
1227
+ action: "already-preserved",
1228
+ savedBranch: resolution.savedName,
1229
+ unmergedCount: unmergedResult.count,
1230
+ };
1231
+
1232
+ case "create":
1233
+ case "create-suffixed": {
1234
+ // Create saved branch at same SHA
1235
+ const createResult = runGit(["branch", resolution.savedName, branchSHA], repoRoot);
1236
+ if (!createResult.ok) {
1237
+ return {
1238
+ ok: false,
1239
+ action: "error",
1240
+ code: "SAVED_BRANCH_CREATE_FAILED",
1241
+ error: `Failed to create saved branch "${resolution.savedName}": ${createResult.stderr}`,
1242
+ unmergedCount: unmergedResult.count,
1243
+ };
1244
+ }
1245
+ return {
1246
+ ok: true,
1247
+ action: "preserved",
1248
+ savedBranch: resolution.savedName,
1249
+ unmergedCount: unmergedResult.count,
1250
+ };
1251
+ }
1252
+
1253
+ default:
1254
+ return {
1255
+ ok: false,
1256
+ action: "error",
1257
+ code: "UNKNOWN_RESOLUTION",
1258
+ error: `Unknown resolution action`,
1259
+ };
1260
+ }
1261
+ }
1262
+
1263
+ // ── Bulk Worktree Operations ─────────────────────────────────────────
1264
+
1265
+ /**
1266
+ * List all orchestrator worktrees matching a prefix and operator pattern.
1267
+ *
1268
+ * Parses `git worktree list --porcelain` via parseWorktreeList() and filters
1269
+ * entries whose path basename matches `{prefix}-{opId}-{N}` (where N is a number).
1270
+ *
1271
+ * **Batch-scoped discovery:** When `batchId` is provided, only returns worktrees
1272
+ * inside the specific batch container `{opId}-{batchId}/lane-{N}`. This prevents
1273
+ * cross-batch interference when the same operator runs concurrent batches.
1274
+ *
1275
+ * **Operator-scoped discovery:** When `batchId` is omitted, returns ALL worktrees
1276
+ * belonging to the operator (across all batches). This supports cleanup scenarios
1277
+ * that need to discover all operator worktrees regardless of batch.
1278
+ *
1279
+ * For backward compatibility, also matches the legacy flat pattern `{prefix}-{opId}-{N}`
1280
+ * and (when opId is "op") `{prefix}-{N}`. This supports transition from old naming.
1281
+ *
1282
+ * Lane number is extracted from the path basename pattern. Entries with
1283
+ * malformed/partial data (missing path, unparseable lane number) are
1284
+ * silently skipped — they are not orchestrator worktrees.
1285
+ *
1286
+ * @param prefix - Worktree directory prefix (e.g. "orchid-wt")
1287
+ * @param repoRoot - Absolute path to the main repository root
1288
+ * @param opId - Operator identifier for scoping (e.g., "henrylach")
1289
+ * @param batchId - Optional batch ID for batch-scoped filtering; when provided,
1290
+ * only returns worktrees inside the `{opId}-{batchId}/` container
1291
+ * @returns - WorktreeInfo[] sorted by laneNumber (ascending)
1292
+ */
1293
+ export function listWorktrees(
1294
+ prefix: string,
1295
+ repoRoot: string,
1296
+ opId: string,
1297
+ batchId?: string,
1298
+ ): WorktreeInfo[] {
1299
+ const entries = parseWorktreeList(repoRoot);
1300
+ const results: WorktreeInfo[] = [];
1301
+
1302
+ // ── Legacy flat patterns ─────────────────────────────────────
1303
+ // Primary pattern: {prefix}-{opId}-{N}
1304
+ // Example: "orchid-wt-henrylach-1"
1305
+ const primaryPattern = new RegExp(`^${escapeRegex(prefix)}-${escapeRegex(opId)}-(\\d+)$`);
1306
+
1307
+ // Legacy pattern: {prefix}-{N} (only matched when opId is the default fallback)
1308
+ // This allows cleanup of worktrees from prior batches without operator IDs.
1309
+ const legacyPattern = opId === "op" ? new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`) : null;
1310
+
1311
+ // ── New batch-scoped nested pattern ──────────────────────────
1312
+ // Basename: lane-{N}
1313
+ // Parent directory: {opId}-{batchId} (e.g., "henrylach-20260308T111750")
1314
+ // Full: {basePath}/{opId}-{batchId}/lane-{N}
1315
+ const nestedLanePattern = /^lane-(\d+)$/;
1316
+ // When batchId is provided, match only the exact container for batch isolation.
1317
+ // When omitted, match any container belonging to this operator (all batches).
1318
+ const containerPattern = batchId
1319
+ ? new RegExp(`^${escapeRegex(generateBatchContainerName(opId, batchId))}$`)
1320
+ : new RegExp(`^${escapeRegex(opId)}-\\S+$`);
1321
+
1322
+ for (const entry of entries) {
1323
+ if (!entry.path) continue;
1324
+
1325
+ const resolvedPath = resolve(entry.path);
1326
+ const entryBasename = basename(resolvedPath);
1327
+
1328
+ // ── Try new nested pattern first ─────────────────────────
1329
+ const nestedMatch = entryBasename.match(nestedLanePattern);
1330
+ if (nestedMatch) {
1331
+ // Verify the parent directory matches the container pattern
1332
+ const parentDir = basename(resolve(resolvedPath, ".."));
1333
+ if (containerPattern.test(parentDir)) {
1334
+ const laneNumber = parseInt(nestedMatch[1], 10);
1335
+ if (!isNaN(laneNumber) && laneNumber >= 1) {
1336
+ results.push({
1337
+ path: resolvedPath,
1338
+ branch: entry.branch || "",
1339
+ laneNumber,
1340
+ });
1341
+ continue;
1342
+ }
1343
+ }
1344
+ }
1345
+
1346
+ // ── Try legacy flat patterns (only when not batch-scoped) ─
1347
+ // When batchId is provided, skip legacy matching — the caller
1348
+ // explicitly wants only this batch's worktrees.
1349
+ if (!batchId) {
1350
+ let match = entryBasename.match(primaryPattern);
1351
+ if (!match && legacyPattern) {
1352
+ match = entryBasename.match(legacyPattern);
1353
+ }
1354
+ if (match) {
1355
+ const laneNumber = parseInt(match[1], 10);
1356
+ if (!isNaN(laneNumber) && laneNumber >= 1) {
1357
+ results.push({
1358
+ path: resolvedPath,
1359
+ branch: entry.branch || "",
1360
+ laneNumber,
1361
+ });
1362
+ }
1363
+ }
1364
+ }
1365
+ }
1366
+
1367
+ // Sort by laneNumber ascending (deterministic output)
1368
+ results.sort((a, b) => a.laneNumber - b.laneNumber);
1369
+
1370
+ return results;
1371
+ }
1372
+
1373
+ /**
1374
+ * Escape special regex characters in a string for safe use in RegExp constructor.
1375
+ */
1376
+ export function escapeRegex(str: string): string {
1377
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1378
+ }
1379
+
1380
+ /**
1381
+ * Create multiple lane worktrees in a single batch.
1382
+ *
1383
+ * Creates `count` worktrees sequentially (lanes 1..count). Git worktree
1384
+ * operations are not safe to parallelize (shared lock file), so sequential
1385
+ * creation is the correct approach.
1386
+ *
1387
+ * Partial failure rollback:
1388
+ * - If lane K fails after lanes 1..(K-1) succeeded, ALL previously-created
1389
+ * worktrees are rolled back via removeWorktree().
1390
+ * - Rollback is best-effort: individual rollback failures are collected in
1391
+ * `rollbackErrors` but do not prevent other rollbacks from proceeding.
1392
+ * - On successful rollback, `worktrees` is empty (clean slate).
1393
+ *
1394
+ * @param count - Number of worktrees to create (1-indexed: lane 1..count)
1395
+ * @param batchId - Batch ID timestamp for branch naming
1396
+ * @param config - Orchestrator config (prefix extracted from it)
1397
+ * @param repoRoot - Absolute path to the main repository root
1398
+ * @param baseBranch - Branch to base worktrees on (captured at batch start)
1399
+ * @param opId - Operator identifier for collision-resistant naming
1400
+ * @returns - CreateLaneWorktreesResult with success flag and details
1401
+ */
1402
+ export function createLaneWorktrees(
1403
+ count: number,
1404
+ batchId: string,
1405
+ config: OrchestratorConfig,
1406
+ repoRoot: string,
1407
+ baseBranch: string,
1408
+ ): CreateLaneWorktreesResult {
1409
+ const prefix = config.orchestrator.worktree_prefix;
1410
+ const opId = resolveOperatorId(config);
1411
+ const created: WorktreeInfo[] = [];
1412
+ const errors: BulkWorktreeError[] = [];
1413
+
1414
+ for (let lane = 1; lane <= count; lane++) {
1415
+ try {
1416
+ const wt = createWorktree(
1417
+ { laneNumber: lane, batchId, baseBranch, prefix, opId, config },
1418
+ repoRoot,
1419
+ );
1420
+ created.push(wt);
1421
+ } catch (err: unknown) {
1422
+ const wtErr = err instanceof WorktreeError ? err : null;
1423
+ errors.push({
1424
+ laneNumber: lane,
1425
+ code: wtErr?.code || "UNKNOWN",
1426
+ message: wtErr?.message || String(err),
1427
+ });
1428
+
1429
+ // Rollback all previously-created worktrees
1430
+ const rollbackErrors: BulkWorktreeError[] = [];
1431
+ for (const wt of created) {
1432
+ try {
1433
+ removeWorktree(wt, repoRoot);
1434
+ } catch (rbErr: unknown) {
1435
+ const rbWtErr = rbErr instanceof WorktreeError ? rbErr : null;
1436
+ rollbackErrors.push({
1437
+ laneNumber: wt.laneNumber,
1438
+ code: rbWtErr?.code || "UNKNOWN",
1439
+ message: rbWtErr?.message || String(rbErr),
1440
+ });
1441
+ }
1442
+ }
1443
+
1444
+ return {
1445
+ success: false,
1446
+ worktrees: [],
1447
+ errors,
1448
+ rolledBack: rollbackErrors.length === 0,
1449
+ rollbackErrors,
1450
+ };
1451
+ }
1452
+ }
1453
+
1454
+ // All created successfully
1455
+ // Sort by laneNumber (should already be in order, but enforce)
1456
+ created.sort((a, b) => a.laneNumber - b.laneNumber);
1457
+
1458
+ return {
1459
+ success: true,
1460
+ worktrees: created,
1461
+ errors: [],
1462
+ rolledBack: false,
1463
+ rollbackErrors: [],
1464
+ };
1465
+ }
1466
+
1467
+ /**
1468
+ * Ensure required lane worktrees exist for the current wave.
1469
+ *
1470
+ * Reuses existing worktrees when present (multi-wave behavior), resetting
1471
+ * them to the base branch HEAD before use, and only creates missing lanes.
1472
+ * If creation of a missing lane fails, newly-created lanes in this call are
1473
+ * rolled back.
1474
+ *
1475
+ * This prevents wave 2+ allocation from failing on WORKTREE_PATH_IS_WORKTREE
1476
+ * while still supporting wave growth (e.g., 1 lane in wave 1, 3 lanes in wave 2).
1477
+ */
1478
+ export function ensureLaneWorktrees(
1479
+ laneNumbers: number[],
1480
+ batchId: string,
1481
+ config: OrchestratorConfig,
1482
+ repoRoot: string,
1483
+ baseBranch: string,
1484
+ ): CreateLaneWorktreesResult {
1485
+ const prefix = config.orchestrator.worktree_prefix;
1486
+ const opId = resolveOperatorId(config);
1487
+
1488
+ const existing = listWorktrees(prefix, repoRoot, opId, batchId);
1489
+ const existingByLane = new Map<number, WorktreeInfo>();
1490
+ for (const wt of existing) {
1491
+ existingByLane.set(wt.laneNumber, wt);
1492
+ }
1493
+
1494
+ const needed = [...new Set(laneNumbers)].sort((a, b) => a - b);
1495
+ const selected: WorktreeInfo[] = [];
1496
+ const createdNow: WorktreeInfo[] = [];
1497
+ const errors: BulkWorktreeError[] = [];
1498
+
1499
+ for (const lane of needed) {
1500
+ const reused = existingByLane.get(lane);
1501
+ if (reused) {
1502
+ // Reused worktrees must be reset to base branch HEAD before use.
1503
+ // This covers normal multi-wave reuse and stale leftovers from prior batches.
1504
+ const resetResult = safeResetWorktree(reused, baseBranch, repoRoot);
1505
+ if (resetResult.success) {
1506
+ selected.push(reused);
1507
+ continue;
1508
+ }
1509
+
1510
+ // Reset failed: remove and recreate this lane worktree.
1511
+ try {
1512
+ removeWorktree(reused, repoRoot);
1513
+ } catch {
1514
+ // Best effort — creation below may still fail with a clear error.
1515
+ }
1516
+ }
1517
+
1518
+ try {
1519
+ const wt = createWorktree(
1520
+ { laneNumber: lane, batchId, baseBranch, prefix, opId, config },
1521
+ repoRoot,
1522
+ );
1523
+ createdNow.push(wt);
1524
+ selected.push(wt);
1525
+ } catch (err: unknown) {
1526
+ const wtErr = err instanceof WorktreeError ? err : null;
1527
+ errors.push({
1528
+ laneNumber: lane,
1529
+ code: wtErr?.code || "UNKNOWN",
1530
+ message: wtErr?.message || String(err),
1531
+ });
1532
+
1533
+ const rollbackErrors: BulkWorktreeError[] = [];
1534
+ for (const wt of createdNow) {
1535
+ try {
1536
+ removeWorktree(wt, repoRoot);
1537
+ } catch (rbErr: unknown) {
1538
+ const rbWtErr = rbErr instanceof WorktreeError ? rbErr : null;
1539
+ rollbackErrors.push({
1540
+ laneNumber: wt.laneNumber,
1541
+ code: rbWtErr?.code || "UNKNOWN",
1542
+ message: rbWtErr?.message || String(rbErr),
1543
+ });
1544
+ }
1545
+ }
1546
+
1547
+ return {
1548
+ success: false,
1549
+ worktrees: [],
1550
+ errors,
1551
+ rolledBack: rollbackErrors.length === 0,
1552
+ rollbackErrors,
1553
+ };
1554
+ }
1555
+ }
1556
+
1557
+ selected.sort((a, b) => a.laneNumber - b.laneNumber);
1558
+ return {
1559
+ success: true,
1560
+ worktrees: selected,
1561
+ errors: [],
1562
+ rolledBack: false,
1563
+ rollbackErrors: [],
1564
+ };
1565
+ }
1566
+
1567
+ /**
1568
+ * Remove all orchestrator worktrees matching a prefix and operator scope.
1569
+ *
1570
+ * Uses listWorktrees() to discover matching worktrees (operator-scoped),
1571
+ * then removes each one via removeWorktree(). Best-effort: continues on
1572
+ * per-worktree errors (does not fail-fast).
1573
+ *
1574
+ * When `targetBranch` is provided, branches with unmerged commits are
1575
+ * preserved as `saved/<branch>` refs instead of being force-deleted.
1576
+ *
1577
+ * **Batch-scoped cleanup:** When `batchId` is provided, only removes
1578
+ * worktrees inside the specific batch container `{opId}-{batchId}/`.
1579
+ * After removing all worktrees, attempts to remove the empty container
1580
+ * directory. When `batchId` is omitted, removes all operator worktrees
1581
+ * (all batches, including legacy flat-layout).
1582
+ *
1583
+ * **Container cleanup:** After per-worktree removals, each touched batch
1584
+ * container directory is checked and removed if empty. Non-empty containers
1585
+ * (from partial failures or active worktrees) are left intact.
1586
+ *
1587
+ * @param prefix - Worktree directory prefix (e.g. "orchid-wt")
1588
+ * @param repoRoot - Absolute path to the main repository root
1589
+ * @param opId - Operator identifier for scoping (e.g., "henrylach")
1590
+ * @param targetBranch - Optional target branch for unmerged commit detection (e.g. "develop")
1591
+ * @param batchId - Optional batch ID for batch-scoped cleanup
1592
+ * @param config - Optional orchestrator config (needed for container path resolution when batchId is provided)
1593
+ * @returns - RemoveAllWorktreesResult with per-worktree outcomes
1594
+ */
1595
+ export function removeAllWorktrees(
1596
+ prefix: string,
1597
+ repoRoot: string,
1598
+ opId: string,
1599
+ targetBranch?: string,
1600
+ batchId?: string,
1601
+ config?: OrchestratorConfig,
1602
+ ): RemoveAllWorktreesResult {
1603
+ const worktrees = listWorktrees(prefix, repoRoot, opId, batchId);
1604
+ const outcomes: RemoveWorktreeOutcome[] = [];
1605
+ const removed: WorktreeInfo[] = [];
1606
+ const failed: RemoveWorktreeOutcome[] = [];
1607
+ const preserved: Array<{
1608
+ branch: string;
1609
+ savedBranch: string;
1610
+ laneNumber: number;
1611
+ unmergedCount?: number;
1612
+ }> = [];
1613
+
1614
+ for (const wt of worktrees) {
1615
+ try {
1616
+ const result = removeWorktree(wt, repoRoot, targetBranch);
1617
+ const outcome: RemoveWorktreeOutcome = {
1618
+ worktree: wt,
1619
+ result,
1620
+ error: null,
1621
+ };
1622
+ outcomes.push(outcome);
1623
+ removed.push(wt);
1624
+
1625
+ // Track preserved branches for caller logging
1626
+ if (result.branchPreserved && result.savedBranch) {
1627
+ preserved.push({
1628
+ branch: wt.branch,
1629
+ savedBranch: result.savedBranch,
1630
+ laneNumber: wt.laneNumber,
1631
+ unmergedCount: result.unmergedCount,
1632
+ });
1633
+ }
1634
+ } catch (err: unknown) {
1635
+ const wtErr = err instanceof WorktreeError ? err : null;
1636
+ const bulkErr: BulkWorktreeError = {
1637
+ laneNumber: wt.laneNumber,
1638
+ code: wtErr?.code || "UNKNOWN",
1639
+ message: wtErr?.message || String(err),
1640
+ };
1641
+ const outcome: RemoveWorktreeOutcome = {
1642
+ worktree: wt,
1643
+ result: null,
1644
+ error: bulkErr,
1645
+ };
1646
+ outcomes.push(outcome);
1647
+ failed.push(outcome);
1648
+ }
1649
+ }
1650
+
1651
+ // ── Container cleanup ────────────────────────────────────────
1652
+ // After removing worktrees, attempt to remove empty batch container
1653
+ // directories. Collect unique container paths from removed worktrees,
1654
+ // then remove each one only if empty (partial failure safety).
1655
+ const containerPaths = new Set<string>();
1656
+ for (const wt of removed) {
1657
+ const parentDir = resolve(wt.path, "..");
1658
+ // Only consider directories that look like batch containers
1659
+ // (i.e., parent is not the base worktree path itself)
1660
+ const parentName = basename(parentDir);
1661
+ if (parentName.startsWith(`${opId}-`)) {
1662
+ containerPaths.add(parentDir);
1663
+ }
1664
+ }
1665
+ // When batchId is explicitly provided, also add the expected container path
1666
+ // even if no worktrees were found (cleanup of empty containers from prior runs)
1667
+ if (batchId && config) {
1668
+ const expectedContainer = generateBatchContainerPath(opId, batchId, repoRoot, config);
1669
+ containerPaths.add(expectedContainer);
1670
+ }
1671
+ for (const containerPath of containerPaths) {
1672
+ removeBatchContainerIfEmpty(containerPath);
1673
+ }
1674
+
1675
+ // TP-029: Remove empty .worktrees/ base directory in subdirectory mode.
1676
+ // In sibling mode the base dir is the repo's parent (e.g., "..") — never remove that.
1677
+ // Only attempt removal when empty (same safety as container cleanup).
1678
+ if (config && config.orchestrator.worktree_location !== "sibling") {
1679
+ const basePath = resolveWorktreeBasePath(repoRoot, config);
1680
+ try {
1681
+ if (existsSync(basePath)) {
1682
+ const entries = readdirSync(basePath);
1683
+ if (entries.length === 0) {
1684
+ rmdirSync(basePath);
1685
+ }
1686
+ }
1687
+ } catch {
1688
+ /* safe default — leave it alone */
1689
+ }
1690
+ }
1691
+
1692
+ return {
1693
+ totalAttempted: worktrees.length,
1694
+ removed,
1695
+ failed,
1696
+ outcomes,
1697
+ preserved,
1698
+ };
1699
+ }
1700
+
1701
+ /**
1702
+ * Execute a command synchronously and return { ok, stdout }.
1703
+ * Returns ok=false on any error (non-zero exit, command not found, etc.).
1704
+ */
1705
+ /**
1706
+ * Result of an `execCheck` invocation. When `ok === false`, `errorKind`
1707
+ * classifies the failure so callers can surface accurate diagnostics instead
1708
+ * of the historical "binary not found" catch-all.
1709
+ *
1710
+ * @since TP-185
1711
+ */
1712
+ export type ExecCheckResult = {
1713
+ ok: boolean;
1714
+ stdout: string;
1715
+ errorKind?: "not-found" | "timeout" | "exit-code" | "signal" | "unknown";
1716
+ errorDetail?: string;
1717
+ };
1718
+
1719
+ /**
1720
+ * Run a shell command and report whether it succeeded. Used by the orchestrator
1721
+ * preflight to probe `git`, `git worktree`, and `pi`.
1722
+ *
1723
+ * @param command - Full command line (passed to `execSync`).
1724
+ * @param cwd - Optional working directory.
1725
+ * @param timeoutMs - Per-invocation timeout in milliseconds. Defaults to 10s,
1726
+ * which is fine for warm tools but can be tight for cold-start scenarios on
1727
+ * Windows (mise shim + Node bootstrap + AV scan + tool startup). Pass a
1728
+ * larger value (e.g. 30_000) for tools that may pay a cold-start tax.
1729
+ */
1730
+ export function execCheck(command: string, cwd?: string, timeoutMs = 10_000): ExecCheckResult {
1731
+ try {
1732
+ const stdout = execSync(command, {
1733
+ encoding: "utf-8",
1734
+ timeout: timeoutMs,
1735
+ stdio: ["pipe", "pipe", "pipe"],
1736
+ ...(cwd ? { cwd } : {}),
1737
+ }).trim();
1738
+ return { ok: true, stdout };
1739
+ } catch (err: unknown) {
1740
+ // Classify the failure mode so the caller can produce a useful hint.
1741
+ // Node's `execSync` reports failures via:
1742
+ // - `code === 'ENOENT'` → binary not found on PATH (POSIX direct spawn)
1743
+ // - `status === 127` → POSIX shell reported "command not found"
1744
+ // - cmd.exe stderr "not recognized" → Windows shell missing-binary indicator (exit 1)
1745
+ // - `signal === 'SIGTERM'` → timeout fired (Node killed the child)
1746
+ // - `status` is a number != 0 → child exited non-zero on its own
1747
+ // - `signal` is set otherwise → child killed externally
1748
+ // Note: when `execSync`'s `timeout` option fires, the resulting error has
1749
+ // `signal: 'SIGTERM'` AND `errno` populated (the signal-kill errno on the
1750
+ // platform). We attribute SIGTERM to the timeout because `execCheck` is
1751
+ // the one setting the timeout option — there's no other realistic source
1752
+ // of SIGTERM for a short-lived diagnostic command we just spawned.
1753
+ const e = err as {
1754
+ code?: string | number;
1755
+ status?: number | null;
1756
+ signal?: NodeJS.Signals | null;
1757
+ errno?: number;
1758
+ message?: string;
1759
+ path?: string;
1760
+ stderr?: string | Buffer;
1761
+ };
1762
+ const stderrText =
1763
+ typeof e?.stderr === "string"
1764
+ ? e.stderr
1765
+ : e?.stderr instanceof Buffer
1766
+ ? e.stderr.toString("utf-8")
1767
+ : "";
1768
+ const commandName = command.split(/\s+/)[0];
1769
+ if (e?.code === "ENOENT") {
1770
+ return { ok: false, stdout: "", errorKind: "not-found", errorDetail: e.path ?? commandName };
1771
+ }
1772
+ if (e?.status === 127) {
1773
+ return { ok: false, stdout: "", errorKind: "not-found", errorDetail: commandName };
1774
+ }
1775
+ // Windows cmd.exe pattern: exit 1 + "is not recognized" in stderr.
1776
+ if (
1777
+ e?.signal !== "SIGTERM" &&
1778
+ /is not recognized as an internal or external command|command not found/i.test(stderrText)
1779
+ ) {
1780
+ return { ok: false, stdout: "", errorKind: "not-found", errorDetail: commandName };
1781
+ }
1782
+ if (e?.signal === "SIGTERM") {
1783
+ return {
1784
+ ok: false,
1785
+ stdout: "",
1786
+ errorKind: "timeout",
1787
+ errorDetail: `exceeded ${timeoutMs}ms timeout`,
1788
+ };
1789
+ }
1790
+ if (typeof e?.status === "number") {
1791
+ return { ok: false, stdout: "", errorKind: "exit-code", errorDetail: `exit ${e.status}` };
1792
+ }
1793
+ if (e?.signal) {
1794
+ return { ok: false, stdout: "", errorKind: "signal", errorDetail: String(e.signal) };
1795
+ }
1796
+ return {
1797
+ ok: false,
1798
+ stdout: "",
1799
+ errorKind: "unknown",
1800
+ errorDetail: e?.message ?? "unknown error",
1801
+ };
1802
+ }
1803
+ }
1804
+
1805
+ /**
1806
+ * Parse a version string like "git version 2.43.0.windows.1" or "tmux 3.3a"
1807
+ * into a comparable [major, minor] tuple. Returns [0, 0] on parse failure.
1808
+ */
1809
+ export function parseVersion(raw: string): [number, number] {
1810
+ const match = raw.match(/(\d+)\.(\d+)/);
1811
+ if (!match) return [0, 0];
1812
+ return [parseInt(match[1], 10), parseInt(match[2], 10)];
1813
+ }
1814
+
1815
+ /**
1816
+ * Check if actual version meets minimum required version.
1817
+ */
1818
+ export function meetsMinVersion(actual: [number, number], minimum: [number, number]): boolean {
1819
+ if (actual[0] > minimum[0]) return true;
1820
+ if (actual[0] === minimum[0] && actual[1] >= minimum[1]) return true;
1821
+ return false;
1822
+ }
1823
+
1824
+ /**
1825
+ * Run preflight checks for all orchestrator dependencies.
1826
+ *
1827
+ * Required checks (fail blocks execution):
1828
+ * - git version >= 2.15
1829
+ * - git worktree support
1830
+ * - pi availability
1831
+ *
1832
+ * Compatibility checks:
1833
+ * - Runtime backend mode visibility (subprocess-only)
1834
+ */
1835
+ export function runPreflight(config: OrchestratorConfig, repoRoot?: string): PreflightResult {
1836
+ const checks: PreflightCheck[] = [];
1837
+
1838
+ // ── Git version ──────────────────────────────────────────────
1839
+ const gitResult = execCheck("git --version");
1840
+ if (gitResult.ok) {
1841
+ const version = parseVersion(gitResult.stdout);
1842
+ const versionStr = `${version[0]}.${version[1]}`;
1843
+ if (meetsMinVersion(version, [2, 15])) {
1844
+ checks.push({
1845
+ name: "git",
1846
+ status: "pass",
1847
+ message: `Git ${versionStr} available`,
1848
+ });
1849
+ } else {
1850
+ checks.push({
1851
+ name: "git",
1852
+ status: "fail",
1853
+ message: `Git ${versionStr} found, but 2.15+ required for worktree support`,
1854
+ hint: "Upgrade Git: https://git-scm.com/downloads",
1855
+ });
1856
+ }
1857
+ } else {
1858
+ checks.push({
1859
+ name: "git",
1860
+ status: "fail",
1861
+ message: "Git not found",
1862
+ hint: "Install Git: https://git-scm.com/downloads",
1863
+ });
1864
+ }
1865
+
1866
+ // ── Git worktree support ─────────────────────────────────────
1867
+ // In workspace mode, cwd may not be a git repo — run from a repo root
1868
+ const worktreeResult = execCheck("git worktree list", repoRoot);
1869
+ checks.push({
1870
+ name: "git-worktree",
1871
+ status: worktreeResult.ok ? "pass" : "fail",
1872
+ message: worktreeResult.ok ? "Worktree support available" : "Git worktree not available",
1873
+ hint: worktreeResult.ok
1874
+ ? undefined
1875
+ : repoRoot
1876
+ ? "Upgrade Git to 2.15+"
1877
+ : "Workspace root is not a git repo. Check workspace config repo paths.",
1878
+ });
1879
+
1880
+ // ── Runtime backend contract (Runtime V2) ─────────────────────
1881
+ checks.push({
1882
+ name: "runtime-backend",
1883
+ status: "pass",
1884
+ message: `Runtime V2 subprocess backend active (configured spawn_mode: ${config.orchestrator.spawn_mode})`,
1885
+ });
1886
+
1887
+ // ── Pi availability ──────────────────────────────────────────
1888
+ // Use a 30s timeout (vs default 10s) and retry once on timeout to absorb
1889
+ // cold-start variance. Each `pi --version` invocation is a fresh Node
1890
+ // process: mise shim resolution + Node bootstrap + Windows Defender
1891
+ // process-launch scan + pi's own startup can comfortably exceed 10s on
1892
+ // the first invocation after sleep/wake, even when pi is correctly
1893
+ // installed and on PATH. (#TP-185)
1894
+ const PI_PREFLIGHT_TIMEOUT_MS = 30_000;
1895
+ let piResult = execCheck("pi --version", undefined, PI_PREFLIGHT_TIMEOUT_MS);
1896
+ if (!piResult.ok && piResult.errorKind === "timeout") {
1897
+ // Single retry: the first call typically warms the OS file cache and
1898
+ // satisfies AV pre-scan, so a follow-up usually completes in <1s.
1899
+ piResult = execCheck("pi --version", undefined, PI_PREFLIGHT_TIMEOUT_MS);
1900
+ }
1901
+
1902
+ if (piResult.ok) {
1903
+ checks.push({
1904
+ name: "pi",
1905
+ status: "pass",
1906
+ message: `Pi ${piResult.stdout || "available"}`,
1907
+ });
1908
+ } else {
1909
+ // Tailor the failure message and hint to the actual error mode.
1910
+ // The legacy code reported every failure as "Pi not found" with an
1911
+ // `npm install -g` hint, which is misleading when the real cause is
1912
+ // a timeout or non-zero exit from a correctly-installed pi.
1913
+ let message: string;
1914
+ let hint: string;
1915
+ switch (piResult.errorKind) {
1916
+ case "not-found":
1917
+ message = "Pi not found on PATH";
1918
+ // Issue #560: Pi was renamed from @mariozechner to @earendil-works
1919
+ // in v0.74.0. Recommend the new scope for new installs; the legacy
1920
+ // scope still resolves at runtime via Pi's bundled aliasing if a
1921
+ // transitional install has it.
1922
+ hint =
1923
+ "Install Pi: npm install -g @earendil-works/pi-coding-agent (legacy: @mariozechner/pi-coding-agent)";
1924
+ break;
1925
+ case "timeout":
1926
+ message = `Pi did not respond within ${PI_PREFLIGHT_TIMEOUT_MS / 1000}s (retried once)`;
1927
+ hint =
1928
+ "Pi appears installed but is responding slowly. Common causes: antivirus scanning the Node binary on first launch, slow disk, a zombie pi process holding a lock, or a stale mise shim. Try running `pi --version` directly to see how long it takes.";
1929
+ break;
1930
+ case "exit-code":
1931
+ message = `Pi exited with error (${piResult.errorDetail ?? "non-zero status"})`;
1932
+ hint = "Run `pi --version` directly to see the error output.";
1933
+ break;
1934
+ case "signal":
1935
+ message = `Pi was killed by signal (${piResult.errorDetail ?? "unknown"})`;
1936
+ hint =
1937
+ "The pi process was killed externally. Check for OOM, antivirus quarantine, or interrupted shell.";
1938
+ break;
1939
+ default:
1940
+ message = `Pi check failed (${piResult.errorDetail ?? "unknown error"})`;
1941
+ hint = "Run `pi --version` manually to diagnose.";
1942
+ }
1943
+ checks.push({ name: "pi", status: "fail", message, hint });
1944
+ }
1945
+
1946
+ return {
1947
+ passed: checks.every((c) => c.status !== "fail"),
1948
+ checks,
1949
+ };
1950
+ }
1951
+
1952
+ /**
1953
+ * Format preflight results as a readable string for display.
1954
+ */
1955
+ export function formatPreflightResults(result: PreflightResult): string {
1956
+ const lines: string[] = ["Preflight Check:"];
1957
+
1958
+ for (const check of result.checks) {
1959
+ const icon = check.status === "pass" ? "✅" : check.status === "warn" ? "⚠️ " : "❌";
1960
+ const nameCol = check.name.padEnd(18);
1961
+ lines.push(` ${icon} ${nameCol} ${check.message}`);
1962
+ if (check.hint && check.status !== "pass") {
1963
+ // Indent hint lines under the check
1964
+ for (const hintLine of check.hint.split("\n")) {
1965
+ lines.push(` ${" ".repeat(18)} ${hintLine}`);
1966
+ }
1967
+ }
1968
+ }
1969
+
1970
+ lines.push("");
1971
+ if (result.passed) {
1972
+ lines.push("All required checks passed.");
1973
+ } else {
1974
+ const failedNames = result.checks
1975
+ .filter((c) => c.status === "fail")
1976
+ .map((c) => c.name)
1977
+ .join(", ");
1978
+ lines.push(`❌ Preflight FAILED: ${failedNames}`);
1979
+ lines.push("Fix the issues above before running the orchestrator.");
1980
+ }
1981
+
1982
+ return lines.join("\n");
1983
+ }
1984
+
1985
+ // ── Worktree Reset with Safety ───────────────────────────────────────
1986
+
1987
+ /**
1988
+ * Reset a worktree with safety handling for dirty trees.
1989
+ *
1990
+ * For failed/stalled tasks, the worktree may have uncommitted changes.
1991
+ * This function first tries a clean reset, and if that fails due to dirty
1992
+ * tree, force-cleans it before resetting.
1993
+ *
1994
+ * @param worktree - WorktreeInfo to reset
1995
+ * @param targetBranch - Branch to reset to (e.g., "develop")
1996
+ * @param repoRoot - Main repository root
1997
+ * @returns { success: boolean, error?: string }
1998
+ */
1999
+ export function safeResetWorktree(
2000
+ worktree: WorktreeInfo,
2001
+ targetBranch: string,
2002
+ repoRoot: string,
2003
+ ): { success: boolean; error?: string } {
2004
+ try {
2005
+ resetWorktree(worktree, targetBranch, repoRoot);
2006
+ return { success: true };
2007
+ } catch (err: unknown) {
2008
+ // If it's a dirty worktree, force clean and retry
2009
+ if (err instanceof WorktreeError && err.code === "WORKTREE_DIRTY") {
2010
+ execLog("reset", `lane-${worktree.laneNumber}`, "worktree dirty — force cleaning", {
2011
+ path: worktree.path,
2012
+ });
2013
+
2014
+ // Force discard all changes
2015
+ const checkoutResult = runGit(["checkout", "--", "."], worktree.path);
2016
+ if (!checkoutResult.ok) {
2017
+ return {
2018
+ success: false,
2019
+ error: `git checkout -- . failed: ${checkoutResult.stderr}`,
2020
+ };
2021
+ }
2022
+
2023
+ // Remove untracked files.
2024
+ // git clean may warn about files it can't delete (e.g., Windows reserved
2025
+ // names like "nul", "con", "aux") but still clean everything else.
2026
+ // We treat this as non-fatal: check porcelain status afterward instead
2027
+ // of failing on the exit code.
2028
+ const cleanResult = runGit(["clean", "-fd"], worktree.path);
2029
+ if (!cleanResult.ok) {
2030
+ execLog(
2031
+ "reset",
2032
+ `lane-${worktree.laneNumber}`,
2033
+ "git clean -fd returned non-zero (may be partial)",
2034
+ {
2035
+ stderr: cleanResult.stderr.slice(0, 200),
2036
+ },
2037
+ );
2038
+ }
2039
+
2040
+ // Check if the worktree is clean enough to proceed.
2041
+ // If git status --porcelain shows no tracked changes, the reset can work
2042
+ // even if some untracked files couldn't be deleted.
2043
+ const statusCheck = runGit(["status", "--porcelain"], worktree.path);
2044
+ if (statusCheck.ok && statusCheck.stdout.length > 0) {
2045
+ // Still dirty after cleaning — check if only untracked files remain
2046
+ const lines = statusCheck.stdout.split("\n").filter((l) => l.trim());
2047
+ const onlyUntracked = lines.every((l) => l.startsWith("??"));
2048
+ if (!onlyUntracked) {
2049
+ return {
2050
+ success: false,
2051
+ error: `Worktree still dirty after clean: ${statusCheck.stdout.slice(0, 200)}`,
2052
+ };
2053
+ }
2054
+ // Only untracked files remain (e.g., undeletable "nul") — safe to proceed
2055
+ execLog(
2056
+ "reset",
2057
+ `lane-${worktree.laneNumber}`,
2058
+ "untracked files remain after clean (non-blocking)",
2059
+ {
2060
+ files: lines.map((l) => l.slice(3)).join(", "),
2061
+ },
2062
+ );
2063
+ }
2064
+
2065
+ // Retry reset after cleaning
2066
+ try {
2067
+ resetWorktree(worktree, targetBranch, repoRoot);
2068
+ return { success: true };
2069
+ } catch (retryErr: unknown) {
2070
+ return {
2071
+ success: false,
2072
+ error: `Reset failed after clean: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
2073
+ };
2074
+ }
2075
+ }
2076
+
2077
+ return {
2078
+ success: false,
2079
+ error: err instanceof Error ? err.message : String(err),
2080
+ };
2081
+ }
2082
+ }
2083
+
2084
+ // ── Force Cleanup ────────────────────────────────────────────────────
2085
+
2086
+ /**
2087
+ * Last-resort worktree cleanup: force-remove the directory and prune git state.
2088
+ *
2089
+ * Used when both `safeResetWorktree()` and `removeWorktree()` fail — typically
2090
+ * because undeletable files (e.g., Windows reserved names like "nul", "con")
2091
+ * block `git clean` and `git worktree remove`, leaving git in an inconsistent state.
2092
+ *
2093
+ * Recovery steps:
2094
+ * 1. Force-remove the worktree directory (`rm -rf` equivalent)
2095
+ * 2. Prune stale git worktree references (`git worktree prune`)
2096
+ * 3. Delete the lane branch if it exists (`git branch -D`)
2097
+ *
2098
+ * This allows the next wave to recreate the worktree from scratch.
2099
+ *
2100
+ * @param worktree - WorktreeInfo for the failed worktree
2101
+ * @param repoRoot - Main repository root
2102
+ * @param batchId - Batch ID for logging context
2103
+ */
2104
+ export function forceCleanupWorktree(
2105
+ worktree: WorktreeInfo,
2106
+ repoRoot: string,
2107
+ batchId: string,
2108
+ ): void {
2109
+ const { path: worktreePath, branch, laneNumber } = worktree;
2110
+
2111
+ // Step 1: Force-remove the directory
2112
+ if (existsSync(worktreePath)) {
2113
+ try {
2114
+ // On Windows, undeletable reserved-name files (nul, con, aux) need
2115
+ // special handling. Try rmSync first, then fall back to OS-specific
2116
+ // removal for stubborn files.
2117
+ rmSync(worktreePath, { recursive: true, force: true });
2118
+ execLog("cleanup", `lane-${laneNumber}`, `force-removed worktree directory`, {
2119
+ path: worktreePath,
2120
+ });
2121
+ } catch (rmErr: unknown) {
2122
+ // If Node's rmSync fails (e.g., Windows reserved names), try platform-specific
2123
+ const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr);
2124
+ execLog("cleanup", `lane-${laneNumber}`, `rmSync failed, trying OS-level removal`, {
2125
+ error: rmMsg,
2126
+ });
2127
+
2128
+ try {
2129
+ if (process.platform === "win32") {
2130
+ // rd /s /q handles Windows reserved names that Node.js cannot delete
2131
+ execSync(`rd /s /q "${worktreePath}"`, { stdio: "pipe", timeout: 30_000 });
2132
+ } else {
2133
+ execSync(`rm -rf "${worktreePath}"`, { stdio: "pipe", timeout: 30_000 });
2134
+ }
2135
+ execLog("cleanup", `lane-${laneNumber}`, `OS-level removal succeeded`, { path: worktreePath });
2136
+ } catch (osErr: unknown) {
2137
+ const osMsg = osErr instanceof Error ? osErr.message : String(osErr);
2138
+ execLog(
2139
+ "cleanup",
2140
+ `lane-${laneNumber}`,
2141
+ `OS-level removal also failed — manual cleanup needed`,
2142
+ {
2143
+ path: worktreePath,
2144
+ error: osMsg,
2145
+ },
2146
+ );
2147
+ }
2148
+ }
2149
+ }
2150
+
2151
+ // Step 2: Prune stale worktree references
2152
+ runGit(["worktree", "prune"], repoRoot);
2153
+ execLog("cleanup", `lane-${laneNumber}`, `pruned stale worktree references`);
2154
+
2155
+ // Step 3: Delete the lane branch if it still exists
2156
+ const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot);
2157
+ if (branchCheck.ok) {
2158
+ const deleteResult = runGit(["branch", "-D", branch], repoRoot);
2159
+ if (deleteResult.ok) {
2160
+ execLog("cleanup", `lane-${laneNumber}`, `deleted stale lane branch`, { branch });
2161
+ } else {
2162
+ execLog("cleanup", `lane-${laneNumber}`, `could not delete lane branch`, {
2163
+ branch,
2164
+ error: deleteResult.stderr,
2165
+ });
2166
+ }
2167
+ }
2168
+
2169
+ // Step 4: Attempt to remove the batch container directory if empty
2170
+ // The worktree path is {basePath}/{opId}-{batchId}/lane-{N}, so the
2171
+ // container is the parent directory.
2172
+ const containerDir = resolve(worktreePath, "..");
2173
+ const containerName = basename(containerDir);
2174
+ // Only attempt container cleanup if the parent looks like a batch container
2175
+ // (contains a hyphen, indicating {opId}-{batchId} naming)
2176
+ if (containerName.includes("-")) {
2177
+ const containerRemoved = removeBatchContainerIfEmpty(containerDir);
2178
+ if (containerRemoved) {
2179
+ execLog("cleanup", `lane-${laneNumber}`, `removed empty batch container`, {
2180
+ path: containerDir,
2181
+ });
2182
+ }
2183
+ }
2184
+ }
2185
+
2186
+ // ── Partial Progress Preservation ────────────────────────────────────
2187
+
2188
+ /**
2189
+ * Result of saving partial progress for a single failed task.
2190
+ */
2191
+ export interface SavePartialProgressResult {
2192
+ /** Whether partial progress was saved (branch created or already existed) */
2193
+ saved: boolean;
2194
+ /** The saved branch name, if saved */
2195
+ savedBranch?: string;
2196
+ /** Number of commits ahead of the target branch */
2197
+ commitCount: number;
2198
+ /** Task ID this progress belongs to */
2199
+ taskId: string;
2200
+ /** Error message if save failed */
2201
+ error?: string;
2202
+ }
2203
+
2204
+ /**
2205
+ * Compute the saved branch name for partial progress from a failed task.
2206
+ *
2207
+ * Naming convention per roadmap Phase 2 section 2a:
2208
+ * - Repo mode: `saved/{opId}-{taskId}-{batchId}`
2209
+ * - Workspace mode: `saved/{opId}-{repoId}-{taskId}-{batchId}`
2210
+ *
2211
+ * Pure function — no side effects.
2212
+ *
2213
+ * @param opId - Operator identifier (sanitized)
2214
+ * @param taskId - Task identifier (e.g., "TP-028")
2215
+ * @param batchId - Batch ID timestamp (e.g., "20260308T111750")
2216
+ * @param repoId - Repo identifier (workspace mode only; omit for repo mode)
2217
+ * @returns Saved branch name
2218
+ */
2219
+ export function computePartialProgressBranchName(
2220
+ opId: string,
2221
+ taskId: string,
2222
+ batchId: string,
2223
+ repoId?: string,
2224
+ ): string {
2225
+ if (repoId) {
2226
+ return `saved/${opId}-${repoId}-${taskId}-${batchId}`;
2227
+ }
2228
+ return `saved/${opId}-${taskId}-${batchId}`;
2229
+ }
2230
+
2231
+ /**
2232
+ * Save partial progress from a failed task's lane branch.
2233
+ *
2234
+ * Checks if the lane branch has commits ahead of the target branch,
2235
+ * and if so, creates a saved branch preserving those commits.
2236
+ *
2237
+ * Uses `resolveSavedBranchCollision()` for idempotent collision handling:
2238
+ * - Same SHA → no-op (keep existing)
2239
+ * - Different SHA → create with timestamp suffix
2240
+ *
2241
+ * @param laneBranch - The lane branch that may have partial commits
2242
+ * @param targetBranch - The base/target branch to compare against
2243
+ * @param opId - Operator identifier
2244
+ * @param taskId - Task identifier
2245
+ * @param batchId - Batch ID
2246
+ * @param repoRoot - Repository root for git operations
2247
+ * @param repoId - Repo identifier (workspace mode only)
2248
+ * @returns SavePartialProgressResult describing what was done
2249
+ */
2250
+ export function savePartialProgress(
2251
+ laneBranch: string,
2252
+ targetBranch: string,
2253
+ opId: string,
2254
+ taskId: string,
2255
+ batchId: string,
2256
+ repoRoot: string,
2257
+ repoId?: string,
2258
+ ): SavePartialProgressResult {
2259
+ // Check if lane branch exists
2260
+ const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${laneBranch}`], repoRoot);
2261
+ if (!branchCheck.ok) {
2262
+ return { saved: false, commitCount: 0, taskId, error: `Lane branch "${laneBranch}" not found` };
2263
+ }
2264
+ const branchSHA = branchCheck.stdout.trim();
2265
+
2266
+ // Count commits ahead of target branch
2267
+ const unmergedResult = hasUnmergedCommits(laneBranch, targetBranch, repoRoot);
2268
+ if (!unmergedResult.ok) {
2269
+ return {
2270
+ saved: false,
2271
+ commitCount: 0,
2272
+ taskId,
2273
+ error: `Failed to count commits: ${unmergedResult.error}`,
2274
+ };
2275
+ }
2276
+
2277
+ if (unmergedResult.count === 0) {
2278
+ // No partial progress — lane branch has no new commits
2279
+ return { saved: false, commitCount: 0, taskId };
2280
+ }
2281
+
2282
+ // Compute saved branch name using task-ID naming convention
2283
+ const savedName = computePartialProgressBranchName(opId, taskId, batchId, repoId);
2284
+
2285
+ // Check for collision (idempotent re-runs, retries)
2286
+ const existingCheck = runGit(["rev-parse", "--verify", `refs/heads/${savedName}`], repoRoot);
2287
+ const existingSHA = existingCheck.ok ? existingCheck.stdout.trim() : "";
2288
+
2289
+ const resolution = resolveSavedBranchCollision(savedName, existingSHA, branchSHA);
2290
+
2291
+ switch (resolution.action) {
2292
+ case "keep-existing":
2293
+ // Already preserved at the same SHA — idempotent success
2294
+ return {
2295
+ saved: true,
2296
+ savedBranch: resolution.savedName,
2297
+ commitCount: unmergedResult.count,
2298
+ taskId,
2299
+ };
2300
+
2301
+ case "create":
2302
+ case "create-suffixed": {
2303
+ const createResult = runGit(["branch", resolution.savedName, branchSHA], repoRoot);
2304
+ if (!createResult.ok) {
2305
+ return {
2306
+ saved: false,
2307
+ commitCount: unmergedResult.count,
2308
+ taskId,
2309
+ error: `Failed to create saved branch "${resolution.savedName}": ${createResult.stderr}`,
2310
+ };
2311
+ }
2312
+ return {
2313
+ saved: true,
2314
+ savedBranch: resolution.savedName,
2315
+ commitCount: unmergedResult.count,
2316
+ taskId,
2317
+ };
2318
+ }
2319
+
2320
+ default:
2321
+ return {
2322
+ saved: false,
2323
+ commitCount: unmergedResult.count,
2324
+ taskId,
2325
+ error: `Unknown collision resolution action`,
2326
+ };
2327
+ }
2328
+ }
2329
+
2330
+ /**
2331
+ * Result of preserving partial progress across all failed tasks.
2332
+ */
2333
+ export interface PreserveFailedLaneProgressResult {
2334
+ /** Per-task results for each failed task that was checked */
2335
+ results: SavePartialProgressResult[];
2336
+ /**
2337
+ * Set of saved branch names that were created (e.g., `saved/{opId}-{taskId}-{batchId}`).
2338
+ * These branches independently preserve the commits — lane branches can still be
2339
+ * safely deleted during cleanup since the saved refs retain reachability.
2340
+ */
2341
+ preservedBranches: Set<string>;
2342
+ /**
2343
+ * Set of lane branch names where preservation FAILED but commits existed.
2344
+ * These branches are unsafe to reset/delete — doing so would lose commits
2345
+ * that were not successfully saved to a separate branch. Callers should skip
2346
+ * worktree reset and branch deletion for these branches to prevent data loss.
2347
+ */
2348
+ unsafeBranches: Set<string>;
2349
+ }
2350
+
2351
+ /**
2352
+ * Callback for resolving repo root and target branch for a given repoId.
2353
+ *
2354
+ * Allows callers (engine.ts, resume.ts) to pass workspace-aware resolution
2355
+ * logic without creating a circular dependency (worktree.ts → waves.ts → worktree.ts).
2356
+ *
2357
+ * @param repoId - Repo identifier (undefined in repo mode)
2358
+ * @returns { repoRoot, targetBranch } for the given repo
2359
+ */
2360
+ export type ResolveRepoContext = (repoId: string | undefined) => {
2361
+ repoRoot: string;
2362
+ targetBranch: string;
2363
+ };
2364
+
2365
+ /**
2366
+ * Preserve partial progress for all failed tasks before cleanup/reset.
2367
+ *
2368
+ * Iterates task outcomes to find failed/stalled tasks, maps each to its
2369
+ * lane branch via the allocated lanes, and saves any partial commits as
2370
+ * task-ID-named saved branches.
2371
+ *
2372
+ * Returns two branch sets:
2373
+ * - `preservedBranches`: saved branch names that were successfully created
2374
+ * (lane branches can be safely deleted since these refs retain commits)
2375
+ * - `unsafeBranches`: lane branch names where preservation FAILED but commits
2376
+ * existed (callers must NOT reset/delete these to prevent data loss)
2377
+ *
2378
+ * Workspace-aware: uses the provided `resolveRepo` callback to resolve
2379
+ * per-repo target branches and repo roots for correct commit counting
2380
+ * in workspace mode.
2381
+ *
2382
+ * @param allocatedLanes - Lanes from the current/last wave (maps tasks to branches)
2383
+ * @param taskOutcomes - All task outcomes accumulated so far
2384
+ * @param opId - Operator identifier
2385
+ * @param batchId - Batch ID
2386
+ * @param resolveRepo - Callback to resolve repo root and target branch per repoId
2387
+ * @returns PreserveFailedLaneProgressResult with per-task results and preserved branch set
2388
+ */
2389
+ export function preserveFailedLaneProgress(
2390
+ allocatedLanes: AllocatedLane[],
2391
+ taskOutcomes: LaneTaskOutcome[],
2392
+ opId: string,
2393
+ batchId: string,
2394
+ resolveRepo: ResolveRepoContext,
2395
+ ): PreserveFailedLaneProgressResult {
2396
+ const results: SavePartialProgressResult[] = [];
2397
+ const preservedBranches = new Set<string>();
2398
+ const unsafeBranches = new Set<string>();
2399
+
2400
+ // Build a map: taskId → { laneBranch, repoId } from allocated lanes
2401
+ const taskToLane = new Map<string, { branch: string; repoId?: string }>();
2402
+ for (const lane of allocatedLanes) {
2403
+ for (const allocatedTask of lane.tasks) {
2404
+ taskToLane.set(allocatedTask.taskId, {
2405
+ branch: lane.branch,
2406
+ repoId: lane.repoId,
2407
+ });
2408
+ }
2409
+ }
2410
+
2411
+ // Find failed/stalled tasks
2412
+ const failedTasks = taskOutcomes.filter((to) => to.status === "failed" || to.status === "stalled");
2413
+
2414
+ // Track which lane branches we've already processed (a lane may have
2415
+ // multiple tasks; only save once per branch since all commits are shared)
2416
+ const processedBranches = new Set<string>();
2417
+
2418
+ for (const failedTask of failedTasks) {
2419
+ const laneInfo = taskToLane.get(failedTask.taskId);
2420
+ if (!laneInfo) {
2421
+ // Task not found in allocated lanes — skip (shouldn't happen)
2422
+ results.push({
2423
+ saved: false,
2424
+ commitCount: 0,
2425
+ taskId: failedTask.taskId,
2426
+ error: "Task not found in allocated lanes",
2427
+ });
2428
+ continue;
2429
+ }
2430
+
2431
+ // Skip if we've already processed this branch (multiple failed tasks on same lane)
2432
+ if (processedBranches.has(laneInfo.branch)) {
2433
+ continue;
2434
+ }
2435
+ processedBranches.add(laneInfo.branch);
2436
+
2437
+ // Resolve repo-specific target branch and repo root
2438
+ const { repoRoot: perRepoRoot, targetBranch } = resolveRepo(laneInfo.repoId);
2439
+
2440
+ const result = savePartialProgress(
2441
+ laneInfo.branch,
2442
+ targetBranch,
2443
+ opId,
2444
+ failedTask.taskId,
2445
+ batchId,
2446
+ perRepoRoot,
2447
+ laneInfo.repoId,
2448
+ );
2449
+
2450
+ results.push(result);
2451
+
2452
+ if (result.saved) {
2453
+ // Track the saved branch name for caller visibility
2454
+ preservedBranches.add(result.savedBranch!);
2455
+
2456
+ execLog(
2457
+ "partial-progress",
2458
+ failedTask.taskId,
2459
+ `Task ${failedTask.taskId} failed but has ${result.commitCount} commit(s) of partial progress on branch ${result.savedBranch}`,
2460
+ {
2461
+ laneBranch: laneInfo.branch,
2462
+ savedBranch: result.savedBranch,
2463
+ commitCount: result.commitCount,
2464
+ repoId: laneInfo.repoId ?? "(default)",
2465
+ },
2466
+ );
2467
+ } else if (result.commitCount > 0 || result.error) {
2468
+ // Preservation FAILED but commits may exist on the lane branch.
2469
+ // Mark this branch as unsafe to reset/delete — doing so would
2470
+ // irreversibly lose the partial work.
2471
+ unsafeBranches.add(laneInfo.branch);
2472
+
2473
+ execLog(
2474
+ "partial-progress",
2475
+ failedTask.taskId,
2476
+ `WARNING: Failed to preserve partial progress for task ${failedTask.taskId} ` +
2477
+ `(${result.commitCount} commit(s) at risk on branch "${laneInfo.branch}")`,
2478
+ {
2479
+ laneBranch: laneInfo.branch,
2480
+ commitCount: result.commitCount,
2481
+ error: result.error ?? "unknown",
2482
+ repoId: laneInfo.repoId ?? "(default)",
2483
+ },
2484
+ );
2485
+ }
2486
+ }
2487
+
2488
+ return { results, preservedBranches, unsafeBranches };
2489
+ }
2490
+
2491
+ /**
2492
+ * TP-147: Preserve partial progress for all skipped tasks before cleanup/reset.
2493
+ *
2494
+ * Skipped tasks may have worker commits (STATUS.md updates, partial code)
2495
+ * that would be lost when the worktree is cleaned up. This function saves
2496
+ * their lane branches as task-ID-named saved branches, similar to how
2497
+ * preserveFailedLaneProgress works for failed tasks.
2498
+ *
2499
+ * Unlike failed tasks, skipped-task branches are NOT merged (partial work
2500
+ * could break verification). Instead they are preserved for manual recovery.
2501
+ *
2502
+ * @param allocatedLanes - Lanes from the current/last wave
2503
+ * @param taskOutcomes - All task outcomes accumulated so far
2504
+ * @param opId - Operator identifier
2505
+ * @param batchId - Batch ID
2506
+ * @param resolveRepo - Callback to resolve repo root and target branch per repoId
2507
+ * @returns PreserveFailedLaneProgressResult with per-task results and preserved branch set
2508
+ */
2509
+ export function preserveSkippedLaneProgress(
2510
+ allocatedLanes: AllocatedLane[],
2511
+ taskOutcomes: LaneTaskOutcome[],
2512
+ opId: string,
2513
+ batchId: string,
2514
+ resolveRepo: ResolveRepoContext,
2515
+ ): PreserveFailedLaneProgressResult {
2516
+ const results: SavePartialProgressResult[] = [];
2517
+ const preservedBranches = new Set<string>();
2518
+ const unsafeBranches = new Set<string>();
2519
+
2520
+ // Build a map: taskId → { laneBranch, repoId } from allocated lanes
2521
+ const taskToLane = new Map<string, { branch: string; repoId?: string }>();
2522
+ for (const lane of allocatedLanes) {
2523
+ for (const allocatedTask of lane.tasks) {
2524
+ taskToLane.set(allocatedTask.taskId, {
2525
+ branch: lane.branch,
2526
+ repoId: lane.repoId,
2527
+ });
2528
+ }
2529
+ }
2530
+
2531
+ // Find skipped tasks
2532
+ const skippedTasks = taskOutcomes.filter((to) => to.status === "skipped");
2533
+
2534
+ // Track which lane branches we've already processed (a lane may have
2535
+ // multiple tasks; only save once per branch since all commits are shared)
2536
+ const processedBranches = new Set<string>();
2537
+
2538
+ for (const skippedTask of skippedTasks) {
2539
+ const laneInfo = taskToLane.get(skippedTask.taskId);
2540
+ if (!laneInfo) {
2541
+ results.push({
2542
+ saved: false,
2543
+ commitCount: 0,
2544
+ taskId: skippedTask.taskId,
2545
+ error: "Task not found in allocated lanes",
2546
+ });
2547
+ continue;
2548
+ }
2549
+
2550
+ // Skip if we've already processed this branch
2551
+ if (processedBranches.has(laneInfo.branch)) {
2552
+ continue;
2553
+ }
2554
+ processedBranches.add(laneInfo.branch);
2555
+
2556
+ // Resolve repo-specific target branch and repo root
2557
+ const { repoRoot: perRepoRoot, targetBranch } = resolveRepo(laneInfo.repoId);
2558
+
2559
+ const result = savePartialProgress(
2560
+ laneInfo.branch,
2561
+ targetBranch,
2562
+ opId,
2563
+ skippedTask.taskId,
2564
+ batchId,
2565
+ perRepoRoot,
2566
+ laneInfo.repoId,
2567
+ );
2568
+
2569
+ results.push(result);
2570
+
2571
+ if (result.saved) {
2572
+ preservedBranches.add(result.savedBranch!);
2573
+
2574
+ execLog(
2575
+ "partial-progress",
2576
+ skippedTask.taskId,
2577
+ `Task ${skippedTask.taskId} was skipped but has ${result.commitCount} commit(s) of partial progress preserved on branch ${result.savedBranch}`,
2578
+ {
2579
+ laneBranch: laneInfo.branch,
2580
+ savedBranch: result.savedBranch,
2581
+ commitCount: result.commitCount,
2582
+ repoId: laneInfo.repoId ?? "(default)",
2583
+ },
2584
+ );
2585
+ } else if (result.commitCount > 0 || result.error) {
2586
+ unsafeBranches.add(laneInfo.branch);
2587
+
2588
+ execLog(
2589
+ "partial-progress",
2590
+ skippedTask.taskId,
2591
+ `WARNING: Failed to preserve partial progress for skipped task ${skippedTask.taskId} ` +
2592
+ `(${result.commitCount} commit(s) at risk on branch "${laneInfo.branch}")`,
2593
+ {
2594
+ laneBranch: laneInfo.branch,
2595
+ commitCount: result.commitCount,
2596
+ error: result.error ?? "unknown",
2597
+ repoId: laneInfo.repoId ?? "(default)",
2598
+ },
2599
+ );
2600
+ }
2601
+ }
2602
+
2603
+ return { results, preservedBranches, unsafeBranches };
2604
+ }
2605
+
2606
+ // ── Stale Branch Cleanup (TP-051) ────────────────────────────────────
2607
+
2608
+ /**
2609
+ * Result of stale branch cleanup after integration.
2610
+ */
2611
+ export interface StaleBranchCleanupResult {
2612
+ /** task/* branches deleted */
2613
+ deletedTaskBranches: string[];
2614
+ /** saved/task/* branches deleted */
2615
+ deletedSavedBranches: string[];
2616
+ /** Branches that failed to delete (best-effort) */
2617
+ failedDeletes: string[];
2618
+ }
2619
+
2620
+ /**
2621
+ * Delete stale task/* and saved/* branches after integration.
2622
+ *
2623
+ * After `/orch-integrate` merges or creates a PR, the lane branches
2624
+ * (`task/{opId}-lane-{N}-{batchId}`) and their saved counterparts
2625
+ * are no longer needed. This function cleans them up.
2626
+ *
2627
+ * Cleanup scope:
2628
+ * 1. **Lane branches:** `task/{opId}-lane-*` (any batch from this operator)
2629
+ * 2. **Saved lane branches:** `saved/task/{opId}-lane-*` (preserved lane refs)
2630
+ * 3. **Partial-progress branches:** `saved/{opId}-*` (per-task partial progress refs)
2631
+ *
2632
+ * Targets all branches matching the operator's prefix, not just the current
2633
+ * batch — this also cleans up orphans from previous batches that were never
2634
+ * cleaned.
2635
+ *
2636
+ * All deletions are best-effort — individual failures are logged but don't
2637
+ * prevent other branches from being cleaned.
2638
+ *
2639
+ * @param repoRoot - Repository root directory
2640
+ * @param opId - Operator identifier (e.g., "henrylach")
2641
+ * @param batchId - Current batch ID (for logging context)
2642
+ * @returns Cleanup result with lists of deleted and failed branches
2643
+ */
2644
+ export function deleteStaleBranches(
2645
+ repoRoot: string,
2646
+ opId: string,
2647
+ batchId: string,
2648
+ ): StaleBranchCleanupResult {
2649
+ const deletedTaskBranches: string[] = [];
2650
+ const deletedSavedBranches: string[] = [];
2651
+ const failedDeletes: string[] = [];
2652
+
2653
+ // 1. Delete task/{opId}-lane-* branches
2654
+ const taskBranchResult = runGit(["branch", "--list", `task/${opId}-lane-*`], repoRoot);
2655
+ if (taskBranchResult.ok && taskBranchResult.stdout.trim()) {
2656
+ const branches = taskBranchResult.stdout
2657
+ .split("\n")
2658
+ .map((b) => b.replace(/^\*?\s+/, "").trim())
2659
+ .filter(Boolean);
2660
+
2661
+ for (const branch of branches) {
2662
+ const deleted = deleteBranchBestEffort(branch, repoRoot);
2663
+ if (deleted) {
2664
+ deletedTaskBranches.push(branch);
2665
+ } else {
2666
+ failedDeletes.push(branch);
2667
+ }
2668
+ }
2669
+ }
2670
+
2671
+ // 2. Delete saved/task/{opId}-lane-* branches (preserved lane refs)
2672
+ const savedTaskResult = runGit(["branch", "--list", `saved/task/${opId}-lane-*`], repoRoot);
2673
+ if (savedTaskResult.ok && savedTaskResult.stdout.trim()) {
2674
+ const branches = savedTaskResult.stdout
2675
+ .split("\n")
2676
+ .map((b) => b.replace(/^\*?\s+/, "").trim())
2677
+ .filter(Boolean);
2678
+
2679
+ for (const branch of branches) {
2680
+ const deleted = deleteBranchBestEffort(branch, repoRoot);
2681
+ if (deleted) {
2682
+ deletedSavedBranches.push(branch);
2683
+ } else {
2684
+ failedDeletes.push(branch);
2685
+ }
2686
+ }
2687
+ }
2688
+
2689
+ // 3. Delete saved/{opId}-*-{batchId} branches (partial-progress refs from this batch)
2690
+ // Pattern: saved/{opId}-{taskId}-{batchId} or saved/{opId}-{repoId}-{taskId}-{batchId}
2691
+ // Only deletes branches ending with the current batchId to avoid removing
2692
+ // partial-progress refs from other batches that the operator may still need.
2693
+ const savedProgressResult = runGit(["branch", "--list", `saved/${opId}-*`], repoRoot);
2694
+ if (savedProgressResult.ok && savedProgressResult.stdout.trim()) {
2695
+ const branches = savedProgressResult.stdout
2696
+ .split("\n")
2697
+ .map((b) => b.replace(/^\*?\s+/, "").trim())
2698
+ .filter(Boolean);
2699
+
2700
+ const batchSuffix = `-${batchId}`;
2701
+ for (const branch of branches) {
2702
+ // Avoid double-deleting saved/task/* already handled above
2703
+ if (branch.startsWith("saved/task/")) continue;
2704
+ // Only delete partial-progress refs from the current batch
2705
+ if (!branch.endsWith(batchSuffix)) continue;
2706
+ const deleted = deleteBranchBestEffort(branch, repoRoot);
2707
+ if (deleted) {
2708
+ deletedSavedBranches.push(branch);
2709
+ } else {
2710
+ failedDeletes.push(branch);
2711
+ }
2712
+ }
2713
+ }
2714
+
2715
+ const totalDeleted = deletedTaskBranches.length + deletedSavedBranches.length;
2716
+ if (totalDeleted > 0) {
2717
+ execLog("cleanup", "branches", `deleted ${totalDeleted} stale branch(es) for batch ${batchId}`, {
2718
+ taskBranches: deletedTaskBranches.length,
2719
+ savedBranches: deletedSavedBranches.length,
2720
+ failed: failedDeletes.length,
2721
+ });
2722
+ }
2723
+
2724
+ return { deletedTaskBranches, deletedSavedBranches, failedDeletes };
2725
+ }