taskplane 0.0.1 → 0.1.1

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -20
  3. package/bin/taskplane.mjs +706 -0
  4. package/dashboard/public/app.js +900 -0
  5. package/dashboard/public/index.html +92 -0
  6. package/dashboard/public/style.css +924 -0
  7. package/dashboard/server.cjs +531 -0
  8. package/extensions/task-orchestrator.ts +28 -0
  9. package/extensions/task-runner.ts +1923 -0
  10. package/extensions/taskplane/abort.ts +466 -0
  11. package/extensions/taskplane/config.ts +102 -0
  12. package/extensions/taskplane/discovery.ts +988 -0
  13. package/extensions/taskplane/engine.ts +758 -0
  14. package/extensions/taskplane/execution.ts +1752 -0
  15. package/extensions/taskplane/extension.ts +577 -0
  16. package/extensions/taskplane/formatting.ts +718 -0
  17. package/extensions/taskplane/git.ts +38 -0
  18. package/extensions/taskplane/index.ts +22 -0
  19. package/extensions/taskplane/merge.ts +795 -0
  20. package/extensions/taskplane/messages.ts +134 -0
  21. package/extensions/taskplane/persistence.ts +1121 -0
  22. package/extensions/taskplane/resume.ts +1092 -0
  23. package/extensions/taskplane/sessions.ts +92 -0
  24. package/extensions/taskplane/types.ts +1514 -0
  25. package/extensions/taskplane/waves.ts +900 -0
  26. package/extensions/taskplane/worktree.ts +1624 -0
  27. package/package.json +50 -4
  28. package/skills/create-taskplane-task/SKILL.md +326 -0
  29. package/skills/create-taskplane-task/references/context-template.md +78 -0
  30. package/skills/create-taskplane-task/references/prompt-template.md +246 -0
  31. package/templates/agents/task-merger.md +256 -0
  32. package/templates/agents/task-reviewer.md +81 -0
  33. package/templates/agents/task-worker.md +140 -0
  34. package/templates/config/task-orchestrator.yaml +89 -0
  35. package/templates/config/task-runner.yaml +99 -0
  36. package/templates/tasks/CONTEXT.md +31 -0
  37. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +90 -0
  38. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
@@ -0,0 +1,795 @@
1
+ /**
2
+ * Merge orchestration, merge agents, merge worktree
3
+ * @module orch/merge
4
+ */
5
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
6
+ import { spawnSync } from "child_process";
7
+ import { join } from "path";
8
+
9
+ import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
10
+ import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MS, MergeError, VALID_MERGE_STATUSES } from "./types.ts";
11
+ import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, WaveExecutionResult } from "./types.ts";
12
+ import { sleepSync } from "./worktree.ts";
13
+
14
+ // ── Merge Implementation ─────────────────────────────────────────────
15
+
16
+ /**
17
+ * Parse and validate a merge result JSON file.
18
+ *
19
+ * Strict validation:
20
+ * - Must be valid JSON
21
+ * - Must have required fields: status, source_branch, verification
22
+ * - status must be a known MergeResultStatus
23
+ * - Unknown status values are mapped to BUILD_FAILURE (fail-safe)
24
+ *
25
+ * Retry-read strategy: if initial parse fails, waits and retries up to
26
+ * MERGE_RESULT_READ_RETRIES times to handle partially-written files.
27
+ *
28
+ * @param resultPath - Absolute path to the merge result JSON file
29
+ * @returns Validated MergeResult
30
+ * @throws MergeError with appropriate code on validation failure
31
+ */
32
+ export function parseMergeResult(resultPath: string): MergeResult {
33
+ if (!existsSync(resultPath)) {
34
+ throw new MergeError(
35
+ "MERGE_RESULT_INVALID",
36
+ `Merge result file not found: ${resultPath}`,
37
+ );
38
+ }
39
+
40
+ // Retry-read loop for partially-written files
41
+ let lastParseError = "";
42
+ for (let attempt = 1; attempt <= MERGE_RESULT_READ_RETRIES; attempt++) {
43
+ try {
44
+ const raw = readFileSync(resultPath, "utf-8").trim();
45
+ if (!raw) {
46
+ lastParseError = "File is empty";
47
+ if (attempt < MERGE_RESULT_READ_RETRIES) {
48
+ sleepSync(MERGE_RESULT_READ_RETRY_DELAY_MS);
49
+ continue;
50
+ }
51
+ throw new MergeError(
52
+ "MERGE_RESULT_INVALID",
53
+ `Merge result file is empty after ${MERGE_RESULT_READ_RETRIES} attempts: ${resultPath}`,
54
+ );
55
+ }
56
+
57
+ const parsed = JSON.parse(raw);
58
+
59
+ // Validate required fields
60
+ if (typeof parsed.status !== "string") {
61
+ throw new MergeError(
62
+ "MERGE_RESULT_MISSING_FIELDS",
63
+ `Merge result missing required field "status": ${resultPath}`,
64
+ );
65
+ }
66
+ if (typeof parsed.source_branch !== "string") {
67
+ throw new MergeError(
68
+ "MERGE_RESULT_MISSING_FIELDS",
69
+ `Merge result missing required field "source_branch": ${resultPath}`,
70
+ );
71
+ }
72
+ if (!parsed.verification || typeof parsed.verification !== "object") {
73
+ throw new MergeError(
74
+ "MERGE_RESULT_MISSING_FIELDS",
75
+ `Merge result missing required field "verification": ${resultPath}`,
76
+ );
77
+ }
78
+
79
+ // Validate status value
80
+ if (!VALID_MERGE_STATUSES.has(parsed.status)) {
81
+ execLog("merge", "parse", `unknown merge status "${parsed.status}" — treating as BUILD_FAILURE`, {
82
+ resultPath,
83
+ });
84
+ parsed.status = "BUILD_FAILURE";
85
+ }
86
+
87
+ // Normalize optional fields with defaults
88
+ return {
89
+ status: parsed.status as MergeResultStatus,
90
+ source_branch: parsed.source_branch,
91
+ target_branch: parsed.target_branch || "",
92
+ merge_commit: parsed.merge_commit || "",
93
+ conflicts: Array.isArray(parsed.conflicts) ? parsed.conflicts : [],
94
+ verification: {
95
+ ran: !!parsed.verification.ran,
96
+ passed: !!parsed.verification.passed,
97
+ output: typeof parsed.verification.output === "string"
98
+ ? parsed.verification.output.slice(0, 2000)
99
+ : "",
100
+ },
101
+ };
102
+ } catch (err: unknown) {
103
+ if (err instanceof MergeError) throw err;
104
+
105
+ // JSON parse error — possibly partially written
106
+ lastParseError = err instanceof Error ? err.message : String(err);
107
+ if (attempt < MERGE_RESULT_READ_RETRIES) {
108
+ sleepSync(MERGE_RESULT_READ_RETRY_DELAY_MS);
109
+ continue;
110
+ }
111
+ }
112
+ }
113
+
114
+ throw new MergeError(
115
+ "MERGE_RESULT_INVALID",
116
+ `Failed to parse merge result JSON after ${MERGE_RESULT_READ_RETRIES} attempts. ` +
117
+ `Last error: ${lastParseError}. File: ${resultPath}`,
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Determine merge order for completed lanes.
123
+ *
124
+ * Default heuristic: fewest-files-first.
125
+ * - Lanes with fewer files in their file scope merge first
126
+ * - Smaller changes are less likely to conflict, establishing a clean base
127
+ * - Tie-breaker: branch name alphabetically (deterministic)
128
+ *
129
+ * Alternative: sequential (lane number order).
130
+ *
131
+ * @param lanes - Completed lanes to order
132
+ * @param order - Ordering strategy from config
133
+ * @returns Lanes sorted in merge order
134
+ */
135
+ export function determineMergeOrder(
136
+ lanes: AllocatedLane[],
137
+ order: "fewest-files-first" | "sequential",
138
+ ): AllocatedLane[] {
139
+ const sorted = [...lanes];
140
+
141
+ if (order === "sequential") {
142
+ sorted.sort((a, b) => a.laneNumber - b.laneNumber);
143
+ return sorted;
144
+ }
145
+
146
+ // fewest-files-first: count total file scope across all tasks in the lane
147
+ sorted.sort((a, b) => {
148
+ const aFiles = a.tasks.reduce((sum, t) => sum + (t.task.fileScope?.length || 0), 0);
149
+ const bFiles = b.tasks.reduce((sum, t) => sum + (t.task.fileScope?.length || 0), 0);
150
+
151
+ if (aFiles !== bFiles) return aFiles - bFiles;
152
+
153
+ // Tie-breaker: branch name alphabetically
154
+ return a.branch.localeCompare(b.branch);
155
+ });
156
+
157
+ return sorted;
158
+ }
159
+
160
+ /**
161
+ * Build merge request content for the merge agent.
162
+ *
163
+ * The merge request is a structured text document that tells the merge agent:
164
+ * - Which branch to merge (source)
165
+ * - Which branch to merge into (target)
166
+ * - What tasks were completed in this lane
167
+ * - File scope of those tasks
168
+ * - Verification commands to run
169
+ * - Where to write the result file
170
+ *
171
+ * @param lane - The lane to merge
172
+ * @param targetBranch - Target branch (typically "develop")
173
+ * @param waveIndex - Wave number (1-indexed)
174
+ * @param verifyCommands - Verification commands from config
175
+ * @param resultFilePath - Path where the merge agent should write results
176
+ * @returns Formatted merge request text
177
+ */
178
+ export function buildMergeRequest(
179
+ lane: AllocatedLane,
180
+ targetBranch: string,
181
+ waveIndex: number,
182
+ verifyCommands: string[],
183
+ resultFilePath: string,
184
+ ): string {
185
+ const taskIds = lane.tasks.map(t => t.taskId).join(", ");
186
+ const fileScopes = lane.tasks
187
+ .flatMap(t => t.task.fileScope || [])
188
+ .filter((f, i, arr) => arr.indexOf(f) === i); // deduplicate
189
+
190
+ const mergeMessage = `merge: wave ${waveIndex} lane ${lane.laneNumber} — ${taskIds}`;
191
+
192
+ const lines: string[] = [
193
+ "# Merge Request",
194
+ "",
195
+ `## Source Branch`,
196
+ `${lane.branch}`,
197
+ "",
198
+ `## Target Branch`,
199
+ `${targetBranch}`,
200
+ "",
201
+ `## Merge Message`,
202
+ `${mergeMessage}`,
203
+ "",
204
+ `## Tasks Completed`,
205
+ ...lane.tasks.map(t => `- ${t.taskId}: ${t.task.taskName}`),
206
+ "",
207
+ `## File Scope`,
208
+ ...(fileScopes.length > 0
209
+ ? fileScopes.map(f => `- ${f}`)
210
+ : ["- (no file scope declared)"]),
211
+ "",
212
+ `## Verification Commands`,
213
+ ...verifyCommands.map(cmd => `\`\`\`bash\n${cmd}\n\`\`\``),
214
+ "",
215
+ `## Result File`,
216
+ `result_file: ${resultFilePath}`,
217
+ `Write your JSON result to: ${resultFilePath}`,
218
+ "",
219
+
220
+ "## Important",
221
+ "- You are working in an ISOLATED MERGE WORKTREE (not the user's main repo)",
222
+ "- The correct branch is ALREADY checked out — do NOT checkout any other branch",
223
+ "- Simply merge the source branch into the current HEAD",
224
+ "- Run ALL verification commands after a successful merge",
225
+ "- If verification fails, revert the merge commit before writing the result",
226
+ "- Write the result file LAST, after all git operations are complete",
227
+ ];
228
+
229
+ return lines.join("\n");
230
+ }
231
+
232
+ /**
233
+ * Spawn a TMUX session for the merge agent.
234
+ *
235
+ * Creates a TMUX session in the main repo directory (not a worktree)
236
+ * that runs pi with the task-merger agent definition and the merge request.
237
+ *
238
+ * Handles:
239
+ * - Stale session cleanup
240
+ * - Retry on transient spawn failures
241
+ * - Structured logging
242
+ *
243
+ * @param sessionName - TMUX session name (e.g., "orch-merge-1")
244
+ * @param repoRoot - Main repository root (merge happens here)
245
+ * @param mergeRequestPath - Path to the merge request temp file
246
+ * @param config - Orchestrator config (for model, tools)
247
+ * @throws MergeError if spawn fails after retries
248
+ */
249
+ export function spawnMergeAgent(
250
+ sessionName: string,
251
+ repoRoot: string,
252
+ mergeWorkDir: string,
253
+ mergeRequestPath: string,
254
+ config: OrchestratorConfig,
255
+ ): void {
256
+ execLog("merge", sessionName, "preparing to spawn merge agent", {
257
+ mergeWorkDir,
258
+ mergeRequestPath,
259
+ });
260
+
261
+ // Clean up stale session if exists
262
+ if (tmuxHasSession(sessionName)) {
263
+ execLog("merge", sessionName, "killing stale merge session");
264
+ tmuxKillSession(sessionName);
265
+ sleepSync(500);
266
+ }
267
+
268
+ // Build the pi command for the merge agent.
269
+ // Uses --no-session to prevent interactive session management.
270
+ // --append-system-prompt loads the merger agent definition.
271
+ // The merge request file is passed as a prompt via @file syntax.
272
+ const shellQuote = (s: string): string => {
273
+ if (/[\s"'`$\\!&|;()<>{}#*?~]/.test(s)) {
274
+ return `'${s.replace(/'/g, "'\\''")}'`;
275
+ }
276
+ return s;
277
+ };
278
+
279
+ // Build model args if specified
280
+ const modelArgs = config.merge.model ? `--model ${shellQuote(config.merge.model)}` : "";
281
+
282
+ // Build tools override if specified
283
+ const toolsArgs = config.merge.tools ? `--tools ${shellQuote(config.merge.tools)}` : "";
284
+
285
+ const piCommand = [
286
+ "pi --no-session",
287
+ modelArgs,
288
+ toolsArgs,
289
+ `--append-system-prompt ${shellQuote(join(repoRoot, ".pi", "agents", "task-merger.md"))}`,
290
+ `@${shellQuote(mergeRequestPath)}`,
291
+ ].filter(Boolean).join(" ");
292
+
293
+ const tmuxMergeDir = toTmuxPath(mergeWorkDir);
294
+ // Pi's TUI (ink/react) hangs silently with TERM=tmux-256color (tmux default).
295
+ // Force xterm-256color so pi can render and start execution.
296
+ // Same fix as buildTmuxSpawnArgs / buildLaneEnvVars.
297
+ const wrappedCommand = `cd ${shellQuote(tmuxMergeDir)} && TERM=xterm-256color ${piCommand}`;
298
+ const tmuxArgs = [
299
+ "new-session", "-d",
300
+ "-s", sessionName,
301
+ wrappedCommand,
302
+ ];
303
+
304
+ // Attempt to spawn with retry
305
+ let lastError = "";
306
+ for (let attempt = 1; attempt <= MERGE_SPAWN_RETRY_MAX + 1; attempt++) {
307
+ const result = spawnSync("tmux", tmuxArgs);
308
+
309
+ if (result.status === 0) {
310
+ execLog("merge", sessionName, "merge agent session spawned", { attempt });
311
+ return;
312
+ }
313
+
314
+ lastError = result.stderr?.toString().trim() || "unknown spawn error";
315
+ execLog("merge", sessionName, `merge spawn attempt ${attempt} failed: ${lastError}`);
316
+
317
+ if (attempt <= MERGE_SPAWN_RETRY_MAX) {
318
+ sleepSync(attempt * 1000);
319
+ }
320
+ }
321
+
322
+ throw new MergeError(
323
+ "MERGE_SPAWN_FAILED",
324
+ `Failed to create merge TMUX session '${sessionName}' after ` +
325
+ `${MERGE_SPAWN_RETRY_MAX + 1} attempts. Last error: ${lastError}`,
326
+ );
327
+ }
328
+
329
+ /**
330
+ * Wait for merge agent to produce a result file.
331
+ *
332
+ * Polling loop with timeout and session liveness detection:
333
+ * 1. Check if result file exists → parse and return
334
+ * 2. Check if TMUX session is still alive
335
+ * 3. If session died without result → grace period → check again → fail
336
+ * 4. If timeout exceeded → kill session → fail
337
+ *
338
+ * @param resultPath - Path to the expected result JSON file
339
+ * @param sessionName - TMUX session name for liveness checking
340
+ * @param timeoutMs - Maximum wait time (default: MERGE_TIMEOUT_MS)
341
+ * @returns Validated MergeResult
342
+ * @throws MergeError on timeout, session death, or invalid result
343
+ */
344
+ export function waitForMergeResult(
345
+ resultPath: string,
346
+ sessionName: string,
347
+ timeoutMs: number = MERGE_TIMEOUT_MS,
348
+ ): MergeResult {
349
+ const startTime = Date.now();
350
+ let sessionDiedAt: number | null = null;
351
+
352
+ execLog("merge", sessionName, "waiting for merge result", {
353
+ resultPath,
354
+ timeoutMs,
355
+ });
356
+
357
+ while (true) {
358
+ const elapsed = Date.now() - startTime;
359
+
360
+ // Check timeout
361
+ if (elapsed >= timeoutMs) {
362
+ execLog("merge", sessionName, "merge timeout — killing session", {
363
+ elapsed,
364
+ timeoutMs,
365
+ });
366
+ tmuxKillSession(sessionName);
367
+
368
+ // One final check for result file (agent may have written it just before timeout)
369
+ if (existsSync(resultPath)) {
370
+ try {
371
+ return parseMergeResult(resultPath);
372
+ } catch {
373
+ // Fall through to timeout error
374
+ }
375
+ }
376
+
377
+ throw new MergeError(
378
+ "MERGE_TIMEOUT",
379
+ `Merge agent '${sessionName}' did not produce a result within ` +
380
+ `${Math.round(timeoutMs / 1000)}s. The session has been killed. ` +
381
+ `Check the merge request and agent logs.`,
382
+ );
383
+ }
384
+
385
+ // Check if result file exists
386
+ if (existsSync(resultPath)) {
387
+ try {
388
+ const result = parseMergeResult(resultPath);
389
+ execLog("merge", sessionName, "merge result received", {
390
+ status: result.status,
391
+ elapsed,
392
+ });
393
+ // Kill session if still alive (agent should exit, but ensure cleanup)
394
+ if (tmuxHasSession(sessionName)) {
395
+ tmuxKillSession(sessionName);
396
+ }
397
+ return result;
398
+ } catch (err: unknown) {
399
+ // File exists but invalid — might be partially written.
400
+ // parseMergeResult already retries, so if it throws, it's final.
401
+ if (err instanceof MergeError && err.code === "MERGE_RESULT_INVALID") {
402
+ // Wait a bit and try once more (file might still be in flight)
403
+ sleepSync(MERGE_RESULT_READ_RETRY_DELAY_MS);
404
+ if (existsSync(resultPath)) {
405
+ try {
406
+ return parseMergeResult(resultPath);
407
+ } catch {
408
+ // Give up on this file
409
+ }
410
+ }
411
+ }
412
+ // If still failing, continue polling (agent might rewrite)
413
+ }
414
+ }
415
+
416
+ // Check session liveness
417
+ const sessionAlive = tmuxHasSession(sessionName);
418
+
419
+ if (!sessionAlive) {
420
+ if (sessionDiedAt === null) {
421
+ // First detection of session death — start grace period
422
+ sessionDiedAt = Date.now();
423
+ execLog("merge", sessionName, "session exited — starting grace period", {
424
+ graceMs: MERGE_RESULT_GRACE_MS,
425
+ });
426
+ } else if (Date.now() - sessionDiedAt >= MERGE_RESULT_GRACE_MS) {
427
+ // Grace period expired — no result file
428
+ // One final check
429
+ if (existsSync(resultPath)) {
430
+ try {
431
+ return parseMergeResult(resultPath);
432
+ } catch {
433
+ // Fall through to session died error
434
+ }
435
+ }
436
+
437
+ throw new MergeError(
438
+ "MERGE_SESSION_DIED",
439
+ `Merge agent session '${sessionName}' exited without writing ` +
440
+ `a result file to '${resultPath}'. The merge may have crashed. ` +
441
+ `Check the session output: tmux capture-pane is unavailable ` +
442
+ `after session exit.`,
443
+ );
444
+ }
445
+ // Within grace period — continue polling
446
+ }
447
+
448
+ // Poll interval
449
+ sleepSync(MERGE_POLL_INTERVAL_MS);
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Merge a completed wave's lane branches into the integration branch.
455
+ *
456
+ * Orchestration flow:
457
+ * 1. Filter to only succeeded lanes (failed lanes are not merged)
458
+ * 2. Determine merge order (fewest-files-first or sequential)
459
+ * 3. For each lane, sequentially:
460
+ * a. Build merge request content
461
+ * b. Write merge request to temp file
462
+ * c. Spawn merge agent in TMUX session (in main repo)
463
+ * d. Wait for merge result
464
+ * e. Handle result (continue, log, or pause)
465
+ * 4. Return MergeWaveResult
466
+ *
467
+ * Sequential execution is mandatory — the integration branch is a shared
468
+ * resource, and each merge must see the prior merge's result.
469
+ *
470
+ * On CONFLICT_UNRESOLVED or BUILD_FAILURE: stops merging remaining lanes
471
+ * and returns with failure status.
472
+ *
473
+ * Temp file cleanup: merge request files are cleaned up after each lane,
474
+ * regardless of outcome. Result files are left for debugging.
475
+ *
476
+ * @param completedLanes - Lanes that completed execution (from wave result)
477
+ * @param waveResult - The wave execution result (for lane status filtering)
478
+ * @param waveIndex - Wave number (1-indexed)
479
+ * @param config - Orchestrator configuration
480
+ * @param repoRoot - Main repository root
481
+ * @param batchId - Batch ID for session naming
482
+ * @returns MergeWaveResult with per-lane outcomes
483
+ */
484
+ export function mergeWave(
485
+ completedLanes: AllocatedLane[],
486
+ waveResult: WaveExecutionResult,
487
+ waveIndex: number,
488
+ config: OrchestratorConfig,
489
+ repoRoot: string,
490
+ batchId: string,
491
+ ): MergeWaveResult {
492
+ const startTime = Date.now();
493
+ const tmuxPrefix = config.orchestrator.tmux_prefix;
494
+ const targetBranch = config.orchestrator.integration_branch;
495
+ const laneResults: MergeLaneResult[] = [];
496
+
497
+ // Build lane outcome lookup for merge eligibility checks.
498
+ const laneOutcomeByNumber = new Map<number, LaneExecutionResult>();
499
+ for (const laneOutcome of waveResult.laneResults) {
500
+ laneOutcomeByNumber.set(laneOutcome.laneNumber, laneOutcome);
501
+ }
502
+
503
+ // A lane is mergeable if:
504
+ // - It has at least one succeeded task, AND
505
+ // - It has no hard failures (failed/stalled).
506
+ //
507
+ // This allows succeeded+skipped lanes (e.g., stop-wave skip of remaining tasks)
508
+ // to merge their committed work, while excluding mixed succeeded+failed lanes.
509
+ const mergeableLanes = completedLanes.filter(lane => {
510
+ const outcome = laneOutcomeByNumber.get(lane.laneNumber);
511
+ if (!outcome) return false;
512
+
513
+ const hasSucceeded = outcome.tasks.some(t => t.status === "succeeded");
514
+ const hasHardFailure = outcome.tasks.some(
515
+ t => t.status === "failed" || t.status === "stalled",
516
+ );
517
+
518
+ return hasSucceeded && !hasHardFailure;
519
+ });
520
+
521
+ if (mergeableLanes.length === 0) {
522
+ execLog("merge", `W${waveIndex}`, "no mergeable lanes (all failed or empty)");
523
+ return {
524
+ waveIndex,
525
+ status: "succeeded", // vacuous success — nothing to merge
526
+ laneResults: [],
527
+ failedLane: null,
528
+ failureReason: null,
529
+ totalDurationMs: Date.now() - startTime,
530
+ };
531
+ }
532
+
533
+ // Determine merge order
534
+ const orderedLanes = determineMergeOrder(mergeableLanes, config.merge.order);
535
+
536
+ execLog("merge", `W${waveIndex}`, `merging ${orderedLanes.length} lane(s)`, {
537
+ order: config.merge.order,
538
+ lanes: orderedLanes.map(l => l.laneNumber).join(","),
539
+ });
540
+
541
+ // ── Create isolated merge worktree ──────────────────────────────
542
+ // Merging in a dedicated worktree prevents dirty-worktree failures
543
+ // caused by user edits or orchestrator-generated files in the main repo.
544
+ const tempBranch = `_merge-temp-${batchId}`;
545
+ const mergeWorkDir = join(repoRoot, ".worktrees", "merge-workspace");
546
+
547
+ // Clean up stale merge worktree/branch from prior failed attempt
548
+ try {
549
+ if (existsSync(mergeWorkDir)) {
550
+ spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { cwd: repoRoot });
551
+ sleepSync(500);
552
+ }
553
+ } catch { /* best effort */ }
554
+ try {
555
+ spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
556
+ } catch { /* branch may not exist */ }
557
+
558
+ // Create temp branch at target branch HEAD, then worktree
559
+ const branchResult = spawnSync("git", ["branch", tempBranch, targetBranch], { cwd: repoRoot });
560
+ if (branchResult.status !== 0) {
561
+ const err = branchResult.stderr?.toString().trim() || "unknown error";
562
+ execLog("merge", `W${waveIndex}`, `failed to create temp branch: ${err}`);
563
+ return {
564
+ waveIndex, status: "failed", laneResults: [],
565
+ failedLane: null, failureReason: `Failed to create merge temp branch: ${err}`,
566
+ totalDurationMs: Date.now() - startTime,
567
+ };
568
+ }
569
+
570
+ const wtResult = spawnSync("git", ["worktree", "add", mergeWorkDir, tempBranch], { cwd: repoRoot });
571
+ if (wtResult.status !== 0) {
572
+ const err = wtResult.stderr?.toString().trim() || "unknown error";
573
+ execLog("merge", `W${waveIndex}`, `failed to create merge worktree: ${err}`);
574
+ spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
575
+ return {
576
+ waveIndex, status: "failed", laneResults: [],
577
+ failedLane: null, failureReason: `Failed to create merge worktree: ${err}`,
578
+ totalDurationMs: Date.now() - startTime,
579
+ };
580
+ }
581
+
582
+ execLog("merge", `W${waveIndex}`, `merge worktree created`, {
583
+ worktree: mergeWorkDir,
584
+ tempBranch,
585
+ });
586
+
587
+ // Sequential merge loop
588
+ let failedLane: number | null = null;
589
+ let failureReason: string | null = null;
590
+
591
+ for (const lane of orderedLanes) {
592
+ const laneStart = Date.now();
593
+ const sessionName = `${tmuxPrefix}-merge-${lane.laneNumber}`;
594
+ const resultFileName = `merge-result-w${waveIndex}-lane${lane.laneNumber}-${batchId}.json`;
595
+ const resultFilePath = join(repoRoot, ".pi", resultFileName);
596
+ const requestFileName = `merge-request-w${waveIndex}-lane${lane.laneNumber}-${batchId}.txt`;
597
+ const requestFilePath = join(repoRoot, ".pi", requestFileName);
598
+
599
+ execLog("merge", sessionName, `starting merge for lane ${lane.laneNumber}`, {
600
+ sourceBranch: lane.branch,
601
+ targetBranch,
602
+ });
603
+
604
+ try {
605
+ // Clean up any stale result file from prior attempt
606
+ if (existsSync(resultFilePath)) {
607
+ try {
608
+ unlinkSync(resultFilePath);
609
+ } catch {
610
+ // Best effort
611
+ }
612
+ }
613
+
614
+ // Build merge request content
615
+ const mergeRequestContent = buildMergeRequest(
616
+ lane,
617
+ targetBranch,
618
+ waveIndex,
619
+ config.merge.verify,
620
+ resultFilePath,
621
+ );
622
+
623
+ // Write merge request to temp file
624
+ writeFileSync(requestFilePath, mergeRequestContent, "utf-8");
625
+
626
+ // Spawn merge agent in the isolated merge worktree
627
+ spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config);
628
+
629
+ // Wait for result
630
+ const mergeResult = waitForMergeResult(resultFilePath, sessionName);
631
+
632
+ // Clean up request file (leave result file for debugging)
633
+ try {
634
+ unlinkSync(requestFilePath);
635
+ } catch {
636
+ // Best effort
637
+ }
638
+
639
+ // Record lane result
640
+ laneResults.push({
641
+ laneNumber: lane.laneNumber,
642
+ laneId: lane.laneId,
643
+ sourceBranch: lane.branch,
644
+ targetBranch,
645
+ result: mergeResult,
646
+ error: null,
647
+ durationMs: Date.now() - laneStart,
648
+ });
649
+
650
+ // Handle merge outcome
651
+ switch (mergeResult.status) {
652
+ case "SUCCESS":
653
+ execLog("merge", sessionName, "merge succeeded", {
654
+ mergeCommit: mergeResult.merge_commit.slice(0, 8),
655
+ duration: `${Math.round((Date.now() - laneStart) / 1000)}s`,
656
+ });
657
+ break;
658
+
659
+ case "CONFLICT_RESOLVED":
660
+ execLog("merge", sessionName, "merge succeeded with resolved conflicts", {
661
+ mergeCommit: mergeResult.merge_commit.slice(0, 8),
662
+ conflictCount: mergeResult.conflicts.length,
663
+ duration: `${Math.round((Date.now() - laneStart) / 1000)}s`,
664
+ });
665
+ break;
666
+
667
+ case "CONFLICT_UNRESOLVED":
668
+ execLog("merge", sessionName, "merge failed — unresolved conflicts", {
669
+ conflictCount: mergeResult.conflicts.length,
670
+ files: mergeResult.conflicts.map(c => c.file).join(", "),
671
+ });
672
+ failedLane = lane.laneNumber;
673
+ failureReason = `Unresolved merge conflicts in lane ${lane.laneNumber}: ` +
674
+ mergeResult.conflicts.map(c => c.file).join(", ");
675
+ break;
676
+
677
+ case "BUILD_FAILURE":
678
+ execLog("merge", sessionName, "merge failed — verification failed", {
679
+ output: mergeResult.verification.output.slice(0, 200),
680
+ });
681
+ failedLane = lane.laneNumber;
682
+ failureReason = `Post-merge verification failed in lane ${lane.laneNumber}: ` +
683
+ mergeResult.verification.output.slice(0, 500);
684
+ break;
685
+ }
686
+
687
+ // Stop merging if this lane failed
688
+ if (failedLane !== null) break;
689
+
690
+ } catch (err: unknown) {
691
+ // Clean up request file on error
692
+ try {
693
+ if (existsSync(requestFilePath)) unlinkSync(requestFilePath);
694
+ } catch {
695
+ // Best effort
696
+ }
697
+
698
+ // Kill merge session if still alive
699
+ if (tmuxHasSession(sessionName)) {
700
+ tmuxKillSession(sessionName);
701
+ }
702
+
703
+ const errMsg = err instanceof Error ? err.message : String(err);
704
+ const errCode = err instanceof MergeError ? err.code : "UNKNOWN";
705
+
706
+ execLog("merge", sessionName, `merge error: ${errMsg}`, { code: errCode });
707
+
708
+ laneResults.push({
709
+ laneNumber: lane.laneNumber,
710
+ laneId: lane.laneId,
711
+ sourceBranch: lane.branch,
712
+ targetBranch,
713
+ result: null,
714
+ error: errMsg,
715
+ durationMs: Date.now() - laneStart,
716
+ });
717
+
718
+ failedLane = lane.laneNumber;
719
+ failureReason = `Merge error in lane ${lane.laneNumber}: ${errMsg}`;
720
+ break;
721
+ }
722
+ }
723
+
724
+ // ── Fast-forward develop and clean up merge worktree ────────────
725
+ const anySuccess = laneResults.some(
726
+ r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
727
+ );
728
+
729
+ if (anySuccess) {
730
+ // Fast-forward the real target branch to the temp merge branch.
731
+ // The main repo may have dirty files (user edits) — stash if needed.
732
+ const ffResult = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
733
+
734
+ if (ffResult.status !== 0) {
735
+ // Dirty working tree may block ff — try stash + ff + pop
736
+ execLog("merge", `W${waveIndex}`, "fast-forward blocked — stashing user changes");
737
+ const stashMsg = `merge-agent-autostash-w${waveIndex}-${batchId}`;
738
+ spawnSync("git", ["stash", "push", "--include-untracked", "-m", stashMsg], { cwd: repoRoot });
739
+
740
+ const ffRetry = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
741
+
742
+ // Always pop stash, regardless of ff result
743
+ spawnSync("git", ["stash", "pop"], { cwd: repoRoot });
744
+
745
+ if (ffRetry.status !== 0) {
746
+ const err = ffRetry.stderr?.toString().trim() || "unknown error";
747
+ execLog("merge", `W${waveIndex}`, `fast-forward failed even after stash: ${err}`);
748
+ failedLane = failedLane ?? -1;
749
+ failureReason = `Fast-forward of ${targetBranch} failed: ${err}`;
750
+ } else {
751
+ execLog("merge", `W${waveIndex}`, "fast-forward succeeded after stash/pop");
752
+ }
753
+ } else {
754
+ execLog("merge", `W${waveIndex}`, `fast-forwarded ${targetBranch} to merge result`);
755
+ }
756
+ }
757
+
758
+ // Clean up merge worktree and temp branch (always, regardless of outcome)
759
+ try {
760
+ spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { cwd: repoRoot });
761
+ } catch { /* best effort */ }
762
+ try {
763
+ // Small delay to ensure worktree lock is released
764
+ sleepSync(500);
765
+ spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
766
+ } catch { /* best effort */ }
767
+
768
+ // Determine overall status
769
+ let status: MergeWaveResult["status"];
770
+ if (failedLane === null) {
771
+ status = "succeeded";
772
+ } else if (anySuccess) {
773
+ status = "partial";
774
+ } else {
775
+ status = "failed";
776
+ }
777
+
778
+ const totalDurationMs = Date.now() - startTime;
779
+
780
+ execLog("merge", `W${waveIndex}`, `wave merge complete: ${status}`, {
781
+ mergedLanes: laneResults.filter(r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED").length,
782
+ failedLane: failedLane ?? 0,
783
+ duration: `${Math.round(totalDurationMs / 1000)}s`,
784
+ });
785
+
786
+ return {
787
+ waveIndex,
788
+ status,
789
+ laneResults,
790
+ failedLane,
791
+ failureReason,
792
+ totalDurationMs,
793
+ };
794
+ }
795
+