@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,1062 @@
1
+ /**
2
+ * User-facing message templates (ORCH_MESSAGES)
3
+ * @module orch/messages
4
+ */
5
+ import type {
6
+ AbortMode,
7
+ MergeFailureClassification,
8
+ MergeRetryCallbacks,
9
+ MergeRetryDecision,
10
+ MergeRetryLoopOutcome,
11
+ MergeRetryPolicy,
12
+ MergeWaveResult,
13
+ OrchestratorConfig,
14
+ RepoMergeOutcome,
15
+ } from "./types.ts";
16
+ import { MERGE_RETRY_POLICY_MATRIX } from "./types.ts";
17
+
18
+ // โ”€โ”€ Message Templates โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
19
+
20
+ /**
21
+ * Deterministic message templates for user-facing /orch commands.
22
+ * Ensures consistent UX across invocations.
23
+ */
24
+ export const ORCH_MESSAGES = {
25
+ // /orch
26
+ orchStarting: (batchId: string, waves: number, tasks: number) =>
27
+ `๐Ÿš€ Starting batch ${batchId}: ${waves} wave(s), ${tasks} task(s)`,
28
+ orchWaveStart: (waveNum: number, totalWaves: number, tasks: number, lanes: number) =>
29
+ `\n๐ŸŒŠ Wave ${waveNum}/${totalWaves}: ${tasks} task(s) across ${lanes} lane(s)`,
30
+ orchWaveComplete: (
31
+ waveNum: number,
32
+ succeeded: number,
33
+ failed: number,
34
+ skipped: number,
35
+ elapsedSec: number,
36
+ ) =>
37
+ `โœ… Wave ${waveNum} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped (${elapsedSec}s)`,
38
+ orchMergeStart: (waveNum: number, laneCount: number) =>
39
+ `๐Ÿ”€ [Wave ${waveNum}] Merging ${laneCount} lane(s) into target branch...`,
40
+ orchMergeLaneSuccess: (laneNum: number, commit: string, durationSec: number) =>
41
+ ` โœ… Lane ${laneNum} merged (${commit.slice(0, 8)}, ${durationSec}s)`,
42
+ orchMergeLaneConflictResolved: (laneNum: number, conflictCount: number, durationSec: number) =>
43
+ ` โšก Lane ${laneNum} merged with ${conflictCount} auto-resolved conflict(s) (${durationSec}s)`,
44
+ orchMergeLaneFailed: (laneNum: number, reason: string) =>
45
+ ` โŒ Lane ${laneNum} merge failed: ${reason}`,
46
+ orchMergeComplete: (waveNum: number, mergedCount: number, totalSec: number) =>
47
+ `๐Ÿ”€ [Wave ${waveNum}] Merge complete: ${mergedCount} lane(s) merged (${totalSec}s)`,
48
+ orchMergeFailed: (waveNum: number, laneNum: number, reason: string) =>
49
+ `โŒ [Wave ${waveNum}] Merge failed at lane ${laneNum}: ${reason}`,
50
+ orchMergeSkipped: (waveNum: number) => `๐Ÿ“ [Wave ${waveNum}] No successful lanes to merge`,
51
+ orchMergePlaceholder: (waveNum: number) =>
52
+ `๐Ÿ”€ [Wave ${waveNum}] Merge: placeholder โ€” Step 3 (TS-008) will replace with mergeWave()`,
53
+ orchWorktreeReset: (waveNum: number, lanes: number) =>
54
+ `๐Ÿ”„ Resetting ${lanes} worktree(s) to target branch HEAD after wave ${waveNum}`,
55
+ orchBatchComplete: (
56
+ batchId: string,
57
+ succeeded: number,
58
+ failed: number,
59
+ skipped: number,
60
+ blocked: number,
61
+ elapsedSec: number,
62
+ orchBranch?: string,
63
+ baseBranch?: string,
64
+ ) => {
65
+ const lines = [
66
+ `\n๐Ÿ Batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s)`,
67
+ ];
68
+ if (failed > 0 || blocked > 0) {
69
+ lines.push("");
70
+ if (blocked > 0) {
71
+ lines.push(` ${blocked} task(s) were blocked because upstream tasks failed.`);
72
+ }
73
+ lines.push(" Next steps:");
74
+ lines.push(" โ€ข /orch-status โ€” review what failed and why");
75
+ lines.push(" โ€ข /orch-resume โ€” retry from the failed wave");
76
+ lines.push(" โ€ข /orch-abort โ€” clean up and start fresh");
77
+ }
78
+ if (orchBranch && succeeded > 0) {
79
+ lines.push("");
80
+ lines.push(" โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”");
81
+ lines.push(` โ”‚ Your changes are on branch: ${orchBranch}`);
82
+ lines.push(` โ”‚ Your ${baseBranch || "working"} branch was not modified.`);
83
+ if (baseBranch) {
84
+ lines.push(` โ”‚ Preview: git log ${baseBranch}..${orchBranch}`);
85
+ }
86
+ lines.push(" โ”‚");
87
+ lines.push(" โ”‚ ๐Ÿ‘‰ To bring changes into your working branch:");
88
+ lines.push(" โ”‚");
89
+ lines.push(" โ”‚ /orch-integrate โ€” merge directly (recommended)");
90
+ lines.push(" โ”‚ /orch-integrate --pr โ€” create a pull request");
91
+ lines.push(" โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜");
92
+ }
93
+ return lines.join("\n");
94
+ },
95
+ orchBatchFailed: (batchId: string, reason: string) => `\nโŒ Batch ${batchId} failed: ${reason}`,
96
+ orchBatchStopped: (batchId: string, policy: string) =>
97
+ `\nโ›” Batch ${batchId} stopped by ${policy} policy`,
98
+
99
+ // /orch-pause
100
+ pauseNoBatch: () => "No active batch is running. Use /orch <areas|all> to start.",
101
+ pauseAlreadyPaused: (batchId: string) => `Batch ${batchId} is already paused.`,
102
+ pauseActivated: (batchId: string) =>
103
+ `โธ๏ธ Pausing batch ${batchId}... lanes will stop after their current tasks complete.`,
104
+
105
+ // /orch-sessions
106
+ sessionsNone: () => "No active orchestrator sessions found.",
107
+ sessionsHeader: (count: number) => `๐Ÿ–ฅ๏ธ ${count} orchestrator session(s):`,
108
+
109
+ // /orch orphan detection
110
+ orphanDetectionResume: (batchId: string, sessionCount: number) =>
111
+ `๐Ÿ”„ Found ${sessionCount} running orchestrator session(s) from batch ${batchId}.\n` +
112
+ ` Use /orch-resume to continue, or /orch-abort to clean up.`,
113
+ orphanDetectionAbort: (sessionCount: number) =>
114
+ `โš ๏ธ Found ${sessionCount} orphan orchestrator session(s) without usable state.\n` +
115
+ ` Use /orch-abort to clean up before starting a new batch.`,
116
+ orphanDetectionCleanup: () => `๐Ÿงน Cleaned up stale batch state file. Starting fresh.`,
117
+
118
+ // /orch-resume
119
+ resumeStarting: (batchId: string, phase: string) =>
120
+ `๐Ÿ”„ Resuming batch ${batchId} (was: ${phase})...`,
121
+ resumeReconciled: (
122
+ batchId: string,
123
+ completed: number,
124
+ pending: number,
125
+ failed: number,
126
+ reconnecting: number,
127
+ reExecuting: number = 0,
128
+ ) =>
129
+ `๐Ÿ“Š Batch ${batchId} reconciliation: ${completed} completed, ${pending} pending, ${failed} failed, ${reconnecting} reconnecting` +
130
+ (reExecuting > 0 ? `, ${reExecuting} re-executing` : ""),
131
+ resumeSkippedWaves: (skippedCount: number) => `โญ๏ธ Skipping ${skippedCount} completed wave(s)`,
132
+ resumeReconnecting: (sessionCount: number) =>
133
+ `๐Ÿ”— Reconnecting to ${sessionCount} alive session(s)...`,
134
+ resumeNoState: () =>
135
+ `โŒ No batch to resume. No batch-state.json file found.\n` +
136
+ ` Use /orch <areas|all> to start a new batch.`,
137
+
138
+ /**
139
+ * TP-187 (#539): Successful reconstruction from .pi/runtime/<batchId>/
140
+ * runtime artifacts during force-resume after `orch_abort()`.
141
+ */
142
+ resumeReconstructed: (batchId: string, selectionNote: string) =>
143
+ `๐Ÿ”จ Reconstructed batch ${batchId} from .pi/runtime/ artifacts (${selectionNote}).\n` +
144
+ ` Force-resume will proceed with a fresh wave-zero pass; the existing\n` +
145
+ ` reconciliation logic will re-detect succeeded tasks via .DONE markers.`,
146
+
147
+ /**
148
+ * TP-187 (#539): Fail-loud message when force-resume can't reconstruct
149
+ * after `orch_abort()` because required runtime artifacts are missing.
150
+ */
151
+ resumeNoStateAfterAbort: (missingArtifact: string, batchId: string | null) =>
152
+ `โŒ Cannot resume after abort: ${missingArtifact}.\n` +
153
+ (batchId ? ` Last known batch: ${batchId}.\n` : "") +
154
+ ` To start fresh from the preserved worktree state, run\n` +
155
+ ` \`orch_start <PROMPT.md>\` (or \`/orch <areas|all>\`).`,
156
+ resumeInvalidState: (error: string) =>
157
+ `โŒ Cannot resume: batch state file is invalid.\n` +
158
+ ` Error: ${error}\n` +
159
+ ` Delete .pi/batch-state.json and start a new batch.`,
160
+ resumePhaseNotResumable: (batchId: string, phase: string, reason: string) =>
161
+ `โŒ Cannot resume batch ${batchId} (phase: ${phase}).\n` + ` ${reason}`,
162
+ resumeComplete: (
163
+ batchId: string,
164
+ succeeded: number,
165
+ failed: number,
166
+ skipped: number,
167
+ blocked: number,
168
+ elapsedSec: number,
169
+ ) =>
170
+ `\n๐Ÿ Resumed batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s total)`,
171
+
172
+ // /orch-resume --force
173
+ forceResumeStarting: (batchId: string, phase: string) =>
174
+ `โš ๏ธ Force-resuming batch ${batchId} from ${phase} state. Running pre-resume diagnostics...`,
175
+ forceResumeDiagnosticsFailed: (batchId: string) =>
176
+ `โŒ Cannot force-resume batch ${batchId}: pre-resume diagnostics failed.\n` +
177
+ ` Fix the issues above, then retry /orch-resume --force.`,
178
+
179
+ // /orch-abort
180
+ abortGracefulStarting: (batchId: string, sessionCount: number) =>
181
+ `โณ Graceful abort of batch ${batchId}: signaling ${sessionCount} session(s) to checkpoint and exit...`,
182
+ abortGracefulWaiting: (batchId: string, graceSec: number) =>
183
+ `โณ Waiting up to ${graceSec}s for sessions to checkpoint and exit...`,
184
+ abortGracefulForceKill: (count: number) =>
185
+ `โš ๏ธ Force-killing ${count} session(s) that did not exit within timeout`,
186
+ abortGracefulComplete: (
187
+ batchId: string,
188
+ graceful: number,
189
+ forceKilled: number,
190
+ durationSec: number,
191
+ ) =>
192
+ `โœ… Graceful abort complete for batch ${batchId}: ${graceful} exited gracefully, ${forceKilled} force-killed (${durationSec}s)`,
193
+ abortHardStarting: (batchId: string, sessionCount: number) =>
194
+ `โšก Hard abort of batch ${batchId}: killing ${sessionCount} session(s) immediately...`,
195
+ abortHardComplete: (batchId: string, killed: number, durationSec: number) =>
196
+ `โœ… Hard abort complete for batch ${batchId}: ${killed} session(s) killed (${durationSec}s)`,
197
+ abortPartialFailure: (failureCount: number) =>
198
+ `โš ๏ธ ${failureCount} error(s) during abort (see details above)`,
199
+ abortNoBatch: () => `No active batch to abort. Use /orch <areas|all> to start a batch.`,
200
+ abortComplete: (mode: AbortMode, sessionsKilled: number) =>
201
+ `๐Ÿ Abort (${mode}) complete: ${sessionsKilled} session(s) terminated. Worktrees and branches preserved.`,
202
+ // /orch merge โ€” repo-scoped partial summary (TP-005 Step 1)
203
+ orchMergePartialRepoSummary: (waveNum: number, repoLines: string[]) =>
204
+ `โš ๏ธ [Wave ${waveNum}] Merge partially succeeded โ€” repo outcomes diverged:\n${repoLines.join("\n")}`,
205
+
206
+ // /orch integration โ€” post-batch integration guidance (TP-022 Step 4)
207
+ orchIntegrationAutoSuccess: (orchBranch: string, baseBranch: string) =>
208
+ `โœ… Auto-integrated: ${baseBranch} fast-forwarded to ${orchBranch}.`,
209
+ orchIntegrationAutoFailed: (orchBranch: string, baseBranch: string, reason: string) =>
210
+ `โš ๏ธ Auto-integration skipped: ${reason}\n` +
211
+ ` Orch branch ${orchBranch} preserved. Integrate manually:\n` +
212
+ ` git log ${baseBranch}..${orchBranch}\n` +
213
+ ` git merge ${orchBranch}`,
214
+ orchIntegrationManual: (orchBranch: string, baseBranch: string, mergedTaskCount: number) => {
215
+ const lines = [
216
+ `โ„น๏ธ Batch complete. Orch branch ${orchBranch} has ${mergedTaskCount} merged task(s).`,
217
+ ` Review and integrate:`,
218
+ ` git log ${baseBranch}..${orchBranch}`,
219
+ ` git merge ${orchBranch}`,
220
+ ];
221
+ return lines.join("\n");
222
+ },
223
+ } as const;
224
+
225
+ // โ”€โ”€ Repo-Scoped Merge Summary (TP-005) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
226
+
227
+ /**
228
+ * Status emoji for repo merge outcome.
229
+ */
230
+ function repoStatusIcon(status: RepoMergeOutcome["status"]): string {
231
+ switch (status) {
232
+ case "succeeded":
233
+ return "โœ…";
234
+ case "partial":
235
+ return "โš ๏ธ";
236
+ case "failed":
237
+ return "โŒ";
238
+ default:
239
+ return "โ“";
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Format a repo-divergence summary for a partial merge wave result.
245
+ *
246
+ * Returns null if:
247
+ * - repoResults is empty or undefined (mono-repo mode)
248
+ * - all repos have the same status (no divergence)
249
+ * - there is only one repo group (divergence is meaningless)
250
+ *
251
+ * When the partial result is caused by mixed-outcome lanes within
252
+ * a single repo (not repo divergence), this returns null to avoid
253
+ * misleading "cross-repo divergence" messaging.
254
+ *
255
+ * The returned string is a complete, ready-to-emit message.
256
+ *
257
+ * @param mergeResult - The MergeWaveResult with status "partial"
258
+ * @returns Formatted summary string, or null if no repo-divergence summary applies
259
+ */
260
+ export function formatRepoMergeSummary(mergeResult: MergeWaveResult): string | null {
261
+ const repoResults = mergeResult.repoResults;
262
+
263
+ // No repo attribution โ†’ mono-repo mode, no summary
264
+ if (!repoResults || repoResults.length === 0) {
265
+ return null;
266
+ }
267
+
268
+ // Single repo group โ†’ divergence is meaningless (partial is lane-level)
269
+ if (repoResults.length < 2) {
270
+ return null;
271
+ }
272
+
273
+ // Check for actual divergence: are there different statuses across repos?
274
+ const statuses = new Set(repoResults.map((r) => r.status));
275
+ if (statuses.size < 2) {
276
+ // All repos have the same status (e.g., all "partial") โ€”
277
+ // the partial is from within-repo lane failures, not cross-repo divergence
278
+ return null;
279
+ }
280
+
281
+ // Build per-repo summary lines (sorted by repoId, which repoResults already is)
282
+ const repoLines = repoResults.map((r) => {
283
+ const repoLabel = r.repoId ?? "(default)";
284
+ const icon = repoStatusIcon(r.status);
285
+ // TP-032 R006-3: Exclude verification_new_failure lanes from success count
286
+ const mergedCount = r.laneResults.filter(
287
+ (lr) =>
288
+ !lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED"),
289
+ ).length;
290
+ const totalCount = r.laneResults.length;
291
+ let detail = `${mergedCount}/${totalCount} lane(s) merged`;
292
+ if (r.failureReason) {
293
+ detail += ` โ€” ${r.failureReason.slice(0, 150)}`;
294
+ }
295
+ return ` ${icon} ${repoLabel}: ${detail}`;
296
+ });
297
+
298
+ return ORCH_MESSAGES.orchMergePartialRepoSummary(mergeResult.waveIndex, repoLines);
299
+ }
300
+
301
+ // โ”€โ”€ Merge Failure Policy Application (TP-005 Step 2) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
302
+
303
+ /**
304
+ * Result of applying the merge failure policy.
305
+ *
306
+ * Pure function output โ€” callers use this to perform state mutations
307
+ * and notifications consistently. Ensures engine.ts and resume.ts
308
+ * apply identical pause/abort transitions.
309
+ */
310
+ export interface MergeFailurePolicyResult {
311
+ /** The applied policy: "pause" or "abort". */
312
+ policy: "pause" | "abort";
313
+ /** Target phase for batchState.phase. */
314
+ targetPhase: "paused" | "stopped";
315
+ /** Error message to push to batchState.errors. */
316
+ errorMessage: string;
317
+ /** Persistence trigger label. */
318
+ persistTrigger: "merge-failure-pause" | "merge-failure-abort";
319
+ /** User-facing notification message. */
320
+ notifyMessage: string;
321
+ /** Notification level for onNotify. */
322
+ notifyLevel: "error";
323
+ /** Comma-separated failed lane identifiers for logging. */
324
+ failedLaneIds: string;
325
+ /** Structured log details for execLog. */
326
+ logDetails: {
327
+ failedLane: number;
328
+ failedLaneIds: string;
329
+ reason: string;
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Compute the merge failure policy application result.
335
+ *
336
+ * This is a **pure function** โ€” it computes all outputs deterministically
337
+ * from the merge result and config, without performing any side effects.
338
+ *
339
+ * Both engine.ts and resume.ts MUST use this function to guarantee
340
+ * identical failure attribution, phase transitions, error messages,
341
+ * and notifications on repo-scoped merge failures.
342
+ *
343
+ * Failure attribution rules (priority chain):
344
+ * 1. Lane-level: lanes with CONFLICT_UNRESOLVED, BUILD_FAILURE, or error
345
+ * โ†’ formatted as `lane-<N>` (comma-separated).
346
+ * 2. Fallback: if no lane-level failures but `mergeResult.failedLane`
347
+ * is non-null, uses `lane-<N>` as the identifier.
348
+ * 3. Repo-level: if no lane-level failures and failedLane is null
349
+ * (repo setup failure), uses `repo:<repoId>` from repoResults
350
+ * entries with non-succeeded status. Sorted deterministically.
351
+ * - The failure reason is truncated to 200 chars for notifications and
352
+ * logged in full in batchState.errors.
353
+ *
354
+ * @param mergeResult - The merge wave result with status "failed" or "partial"
355
+ * @param waveIndex - 0-based wave index (displayed as 1-indexed)
356
+ * @param config - Orchestrator configuration (for on_merge_failure policy)
357
+ * @returns Policy result object for callers to apply
358
+ */
359
+ export function computeMergeFailurePolicy(
360
+ mergeResult: MergeWaveResult,
361
+ waveIndex: number,
362
+ config: OrchestratorConfig,
363
+ ): MergeFailurePolicyResult {
364
+ const waveNum = waveIndex + 1;
365
+ const mergeFailurePolicy = config.failure.on_merge_failure;
366
+
367
+ // Build failed lane identifiers from lane results.
368
+ // Priority chain:
369
+ // 1. Lane-level: lanes with CONFLICT_UNRESOLVED, BUILD_FAILURE, or error
370
+ // 2. Fallback: failedLane from mergeResult (single lane ID)
371
+ // 3. Repo-level: repos with non-succeeded status from repoResults
372
+ // (catches setup failures where failedLane=null and no lane results)
373
+ let failedLaneIds = mergeResult.laneResults
374
+ .filter(
375
+ (r) =>
376
+ r.result?.status === "CONFLICT_UNRESOLVED" || r.result?.status === "BUILD_FAILURE" || r.error,
377
+ )
378
+ .map((r) => `lane-${r.laneNumber}`)
379
+ .join(", ");
380
+ if (!failedLaneIds && mergeResult.failedLane !== null) {
381
+ failedLaneIds = `lane-${mergeResult.failedLane}`;
382
+ }
383
+ if (!failedLaneIds && mergeResult.repoResults && mergeResult.repoResults.length > 0) {
384
+ // Repo-level fallback for setup failures (no lane results, failedLane=null).
385
+ // Uses sorted repoResults order for determinism.
386
+ failedLaneIds = mergeResult.repoResults
387
+ .filter((r) => r.status !== "succeeded")
388
+ .map((r) => `repo:${r.repoId ?? "default"}`)
389
+ .join(", ");
390
+ }
391
+
392
+ const reason = mergeResult.failureReason || "unknown";
393
+ const reasonTruncated = reason.slice(0, 200);
394
+
395
+ const logDetails = {
396
+ failedLane: mergeResult.failedLane ?? 0,
397
+ failedLaneIds,
398
+ reason: reasonTruncated,
399
+ };
400
+
401
+ const errorMessage =
402
+ `Merge failed at wave ${waveNum}: ${reason}. ` +
403
+ (mergeFailurePolicy === "pause"
404
+ ? `Batch paused. Resolve conflicts and use /orch-resume to continue.`
405
+ : `Batch aborted by on_merge_failure policy.`);
406
+
407
+ const laneDetail = failedLaneIds ? ` (${failedLaneIds})` : "";
408
+
409
+ let notifyMessage: string;
410
+ if (mergeFailurePolicy === "pause") {
411
+ notifyMessage =
412
+ `โธ๏ธ Batch paused due to merge failure at wave ${waveNum}${laneDetail}. ` +
413
+ `Reason: ${reasonTruncated}. ` +
414
+ `Resolve conflicts and resume.`;
415
+ } else {
416
+ notifyMessage =
417
+ `โ›” Batch aborted due to merge failure at wave ${waveNum}${laneDetail}. ` +
418
+ `Reason: ${reasonTruncated}.`;
419
+ }
420
+
421
+ return {
422
+ policy: mergeFailurePolicy,
423
+ targetPhase: mergeFailurePolicy === "pause" ? "paused" : "stopped",
424
+ errorMessage,
425
+ persistTrigger: mergeFailurePolicy === "pause" ? "merge-failure-pause" : "merge-failure-abort",
426
+ notifyMessage,
427
+ notifyLevel: "error",
428
+ failedLaneIds,
429
+ logDetails,
430
+ };
431
+ }
432
+
433
+ // โ”€โ”€ Cleanup Gate Policy (TP-029 Step 2) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
434
+
435
+ /**
436
+ * Per-repo cleanup failure detail.
437
+ * Collected during post-merge inter-wave verification.
438
+ */
439
+ export interface CleanupGateRepoFailure {
440
+ /** Repo root path that has stale worktrees */
441
+ repoRoot: string;
442
+ /** Repo ID (undefined for primary/repo-mode) */
443
+ repoId: string | undefined;
444
+ /** Paths of stale worktrees still registered after cleanup */
445
+ staleWorktrees: string[];
446
+ }
447
+
448
+ /**
449
+ * Result of applying the cleanup gate policy.
450
+ *
451
+ * Pure function output โ€” callers use this to perform state mutations
452
+ * and notifications consistently. Ensures engine.ts and resume.ts
453
+ * apply identical pause transitions on cleanup failure.
454
+ */
455
+ export interface CleanupGatePolicyResult {
456
+ /** Always "pause" โ€” cleanup failures block next wave but preserve merged work */
457
+ policy: "pause";
458
+ /** Target phase for batchState.phase */
459
+ targetPhase: "paused";
460
+ /** Error message to push to batchState.errors */
461
+ errorMessage: string;
462
+ /** Persistence trigger label โ€” matches spec classification naming */
463
+ persistTrigger: "cleanup_post_merge_failed";
464
+ /** User-facing notification message */
465
+ notifyMessage: string;
466
+ /** Notification level for onNotify */
467
+ notifyLevel: "error";
468
+ /** Structured log details for execLog */
469
+ logDetails: {
470
+ waveNumber: number;
471
+ failedRepoCount: number;
472
+ totalStaleWorktrees: number;
473
+ repos: Array<{ repoId: string; staleCount: number }>;
474
+ };
475
+ }
476
+
477
+ /**
478
+ * Compute the cleanup gate policy result for post-merge verification failure.
479
+ *
480
+ * This is a **pure function** โ€” it computes all outputs deterministically
481
+ * from the wave index and per-repo failure details, without performing any
482
+ * side effects.
483
+ *
484
+ * Both engine.ts and resume.ts MUST use this function to guarantee
485
+ * identical failure attribution, phase transitions, error messages,
486
+ * and notifications when post-merge cleanup leaves stale worktrees.
487
+ *
488
+ * The cleanup gate always pauses (never aborts) because:
489
+ * - Merged commits are already on the orch branch and must not be lost
490
+ * - The operator can manually remove stale worktrees and `/orch-resume`
491
+ *
492
+ * @param waveIndex - 0-based wave index (displayed as 1-indexed)
493
+ * @param failures - Per-repo cleanup failure details
494
+ * @returns Policy result object for callers to apply
495
+ */
496
+ export function computeCleanupGatePolicy(
497
+ waveIndex: number,
498
+ failures: CleanupGateRepoFailure[],
499
+ ): CleanupGatePolicyResult {
500
+ const waveNum = waveIndex + 1;
501
+ const failedRepoCount = failures.length;
502
+ const totalStaleWorktrees = failures.reduce((sum, f) => sum + f.staleWorktrees.length, 0);
503
+
504
+ const repos = failures.map((f) => ({
505
+ repoId: f.repoId ?? "(default)",
506
+ staleCount: f.staleWorktrees.length,
507
+ }));
508
+
509
+ const repoDetail = repos.map((r) => `${r.repoId} (${r.staleCount} stale)`).join(", ");
510
+
511
+ const errorMessage =
512
+ `Post-merge cleanup failed at wave ${waveNum}: ${totalStaleWorktrees} stale worktree(s) ` +
513
+ `in ${failedRepoCount} repo(s) [${repoDetail}]. ` +
514
+ `Batch paused. Remove stale worktrees manually and use /orch-resume to continue.`;
515
+
516
+ // Build recovery commands for each failed repo
517
+ const recoveryLines: string[] = [];
518
+ for (const f of failures) {
519
+ const label = f.repoId ?? "default";
520
+ for (const wt of f.staleWorktrees) {
521
+ recoveryLines.push(` git worktree remove --force "${wt}" # repo: ${label}`);
522
+ }
523
+ }
524
+
525
+ const notifyMessage =
526
+ `โธ๏ธ Batch paused: post-merge cleanup failed at wave ${waveNum}.\n` +
527
+ ` ${totalStaleWorktrees} stale worktree(s) in ${failedRepoCount} repo(s): ${repoDetail}\n` +
528
+ ` Manual recovery:\n` +
529
+ recoveryLines.join("\n") +
530
+ "\n" +
531
+ ` Then: /orch-resume`;
532
+
533
+ return {
534
+ policy: "pause",
535
+ targetPhase: "paused",
536
+ errorMessage,
537
+ persistTrigger: "cleanup_post_merge_failed",
538
+ notifyMessage,
539
+ notifyLevel: "error",
540
+ logDetails: {
541
+ waveNumber: waveNum,
542
+ failedRepoCount,
543
+ totalStaleWorktrees,
544
+ repos,
545
+ },
546
+ };
547
+ }
548
+
549
+ // โ”€โ”€ Merge Retry Policy (TP-033 Step 2) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
550
+
551
+ /**
552
+ * Classify a merge failure into a MergeFailureClassification.
553
+ *
554
+ * Inspects the MergeWaveResult โ€” lane errors, failure reasons, and merge
555
+ * result statuses โ€” to determine which retry policy class applies.
556
+ *
557
+ * Classification priority (first match wins):
558
+ * 1. `verification_new_failure` โ€” any lane error starts with "verification_new_failure"
559
+ * 2. `merge_conflict_unresolved` โ€” any lane result has CONFLICT_UNRESOLVED status
560
+ * 3. `cleanup_post_merge_failed` โ€” failure reason contains "cleanup" or "stale worktree"
561
+ * 4. `git_lock_file` โ€” failure reason contains "lock" or ".lock"
562
+ * 5. `git_worktree_dirty` โ€” failure reason contains "dirty" or "worktree"
563
+ * 6. `null` โ€” unclassifiable (treated as non-retriable by callers)
564
+ *
565
+ * This is a **pure function** โ€” no side effects.
566
+ *
567
+ * @param mergeResult - The failed MergeWaveResult to classify
568
+ * @returns Classification or null if no merge-retry class matches
569
+ * @since TP-033
570
+ */
571
+ export function classifyMergeFailure(
572
+ mergeResult: MergeWaveResult,
573
+ ): MergeFailureClassification | null {
574
+ // Check lane-level errors first (most specific)
575
+ for (const lr of mergeResult.laneResults) {
576
+ if (lr.error && lr.error.startsWith("verification_new_failure")) {
577
+ return "verification_new_failure";
578
+ }
579
+ }
580
+
581
+ // Check lane result statuses
582
+ for (const lr of mergeResult.laneResults) {
583
+ if (lr.result?.status === "CONFLICT_UNRESOLVED") {
584
+ return "merge_conflict_unresolved";
585
+ }
586
+ }
587
+
588
+ // Check failure reason string patterns
589
+ const reason = (mergeResult.failureReason || "").toLowerCase();
590
+
591
+ // Lock file detection: git operations fail with "Unable to create '.../.git/index.lock': File exists"
592
+ if (reason.includes("lock") || reason.includes(".lock")) {
593
+ return "git_lock_file";
594
+ }
595
+
596
+ // Cleanup failures: stale worktrees or cleanup errors
597
+ if (reason.includes("cleanup") || reason.includes("stale worktree")) {
598
+ return "cleanup_post_merge_failed";
599
+ }
600
+
601
+ // Dirty worktree: git operations fail due to uncommitted changes
602
+ if (reason.includes("dirty") || reason.includes("worktree")) {
603
+ return "git_worktree_dirty";
604
+ }
605
+
606
+ return null;
607
+ }
608
+
609
+ /**
610
+ * Compute the retry decision for a merge failure.
611
+ *
612
+ * Given the failure classification and the current retry count for the
613
+ * relevant scope, returns a decision indicating whether to retry, the
614
+ * cooldown to wait, or the exhaustion action to take.
615
+ *
616
+ * This is a **pure function** โ€” both engine.ts and resume.ts MUST use
617
+ * this function to guarantee identical retry behavior.
618
+ *
619
+ * @param classification - The classified merge failure (null = unclassifiable)
620
+ * @param currentRetryCount - Current retry attempts for this scope (0 = first failure)
621
+ * @returns Retry decision with all fields populated
622
+ * @since TP-033
623
+ */
624
+ export function computeMergeRetryDecision(
625
+ classification: MergeFailureClassification | null,
626
+ currentRetryCount: number,
627
+ ): MergeRetryDecision {
628
+ // Unclassifiable failures are never retried
629
+ if (classification === null) {
630
+ return {
631
+ shouldRetry: false,
632
+ cooldownMs: 0,
633
+ reason: "Unclassifiable merge failure โ€” no retry policy available",
634
+ currentAttempt: currentRetryCount,
635
+ maxAttempts: 0,
636
+ classification: "merge_conflict_unresolved", // placeholder for type safety
637
+ exhaustionAction: "pause",
638
+ };
639
+ }
640
+
641
+ const policy: MergeRetryPolicy = MERGE_RETRY_POLICY_MATRIX[classification];
642
+
643
+ if (!policy.retriable) {
644
+ return {
645
+ shouldRetry: false,
646
+ cooldownMs: 0,
647
+ reason: `${classification} is not retriable โ€” immediate ${policy.exhaustionAction}`,
648
+ currentAttempt: currentRetryCount,
649
+ maxAttempts: 0,
650
+ classification,
651
+ exhaustionAction: policy.exhaustionAction,
652
+ };
653
+ }
654
+
655
+ if (currentRetryCount >= policy.maxAttempts) {
656
+ return {
657
+ shouldRetry: false,
658
+ cooldownMs: 0,
659
+ reason: `${classification} retry exhausted (${currentRetryCount}/${policy.maxAttempts}) โ€” ${policy.exhaustionAction}`,
660
+ currentAttempt: currentRetryCount,
661
+ maxAttempts: policy.maxAttempts,
662
+ classification,
663
+ exhaustionAction: policy.exhaustionAction,
664
+ };
665
+ }
666
+
667
+ return {
668
+ shouldRetry: true,
669
+ cooldownMs: policy.cooldownMs,
670
+ reason:
671
+ `${classification} retry ${currentRetryCount + 1}/${policy.maxAttempts}` +
672
+ (policy.cooldownMs > 0 ? ` (cooldown: ${policy.cooldownMs}ms)` : ""),
673
+ currentAttempt: currentRetryCount + 1,
674
+ maxAttempts: policy.maxAttempts,
675
+ classification,
676
+ exhaustionAction: policy.exhaustionAction,
677
+ };
678
+ }
679
+
680
+ /**
681
+ * Build the merge retry scope key for persisted retry counters.
682
+ *
683
+ * Format: `{repoId}:w{waveIndex}:l{laneNumber}`
684
+ * - In workspace mode: uses the repo ID (e.g., "api:w0:l1")
685
+ * - In repo mode (repoId undefined/null): uses "default" (e.g., "default:w0:l1")
686
+ *
687
+ * NOTE: This is a different key format from the task-scoped format in v3 types
688
+ * (`{taskId}:w{waveIndex}:l{laneNumber}`). The merge retry scope is intentionally
689
+ * repo-scoped because merge failures are per-repo, not per-task. Both formats
690
+ * coexist in `resilience.retryCountByScope` โ€” the prefix disambiguates them.
691
+ *
692
+ * @param repoId - Repo ID (undefined/null in repo mode)
693
+ * @param waveIndex - 0-based wave index
694
+ * @param laneNumber - Lane number
695
+ * @returns Scope key string
696
+ * @since TP-033
697
+ */
698
+ export function buildMergeRetryScopeKey(
699
+ repoId: string | undefined | null,
700
+ waveIndex: number,
701
+ laneNumber: number,
702
+ ): string {
703
+ const repo = repoId ?? "default";
704
+ return `${repo}:w${waveIndex}:l${laneNumber}`;
705
+ }
706
+
707
+ /**
708
+ * Extract the repo ID for a failed merge from the MergeWaveResult.
709
+ *
710
+ * Priority:
711
+ * 1. Lane-level: find the failed lane result and use its repoId
712
+ * 2. Repo-level: when failedLane is null (setup failure), check repoResults
713
+ * for the first failed repo group
714
+ * 3. Fallback: undefined (will become "default" in scope key)
715
+ *
716
+ * This ensures workspace-mode setup failures (e.g., worktree dirty before
717
+ * any lane starts) still get repo-scoped counters rather than all collapsing
718
+ * into "default:w{N}:l0".
719
+ *
720
+ * @param mergeResult - The failed MergeWaveResult
721
+ * @returns Repo ID or undefined if not determinable
722
+ * @since TP-033 R006
723
+ */
724
+ export function extractFailedRepoId(mergeResult: MergeWaveResult): string | undefined {
725
+ const failedLaneNum = mergeResult.failedLane;
726
+
727
+ // 1. Try lane-level extraction
728
+ if (failedLaneNum !== null && failedLaneNum !== undefined) {
729
+ const failedLaneResult = mergeResult.laneResults.find(
730
+ (lr) =>
731
+ lr.laneNumber === failedLaneNum &&
732
+ (lr.error ||
733
+ lr.result?.status === "CONFLICT_UNRESOLVED" ||
734
+ lr.result?.status === "BUILD_FAILURE"),
735
+ );
736
+ if (failedLaneResult?.repoId) return failedLaneResult.repoId;
737
+ }
738
+
739
+ // 2. Repo-level fallback for setup failures (failedLane === null)
740
+ if (mergeResult.repoResults && mergeResult.repoResults.length > 0) {
741
+ const failedRepo = mergeResult.repoResults.find(
742
+ (rr) => rr.status === "failed" || rr.status === "partial",
743
+ );
744
+ if (failedRepo?.repoId) return failedRepo.repoId;
745
+ }
746
+
747
+ // 3. If failureReason mentions a specific repo path, we could parse it,
748
+ // but that's fragile. Return undefined โ†’ "default" in scope key.
749
+ return undefined;
750
+ }
751
+
752
+ /**
753
+ * Shared merge retry loop used by both engine.ts and resume.ts.
754
+ *
755
+ * Wraps the retry cycle in a loop: after each failed retry, re-classifies
756
+ * the latest mergeResult, recomputes the retry decision using the persisted
757
+ * counter, and continues until success, safe-stop, or exhaustion/non-retriable.
758
+ *
759
+ * This is the **single implementation** of retry loop semantics.
760
+ * Engine.ts and resume.ts provide callbacks for their specific side effects
761
+ * (persistence, merge invocation, notification) to guarantee parity.
762
+ *
763
+ * **Important:** On retry exhaustion, this returns `kind: "exhausted"` which
764
+ * the caller MUST handle by forcing `paused` phase regardless of
765
+ * `on_merge_failure` config. The exhaustion action from the matrix takes
766
+ * precedence over config policy.
767
+ *
768
+ * @param mergeResult - The initial failed merge result
769
+ * @param waveIdx - 0-based wave index (for logging)
770
+ * @param retryCountByScope - Mutable reference to persisted retry counters
771
+ * @param callbacks - Side-effect callbacks for persistence/merge/logging
772
+ * @returns Outcome describing what happened during the retry cycle
773
+ * @since TP-033 R006
774
+ */
775
+ export async function applyMergeRetryLoop(
776
+ mergeResult: MergeWaveResult,
777
+ waveIdx: number,
778
+ retryCountByScope: Record<string, number>,
779
+ callbacks: MergeRetryCallbacks,
780
+ ): Promise<MergeRetryLoopOutcome> {
781
+ let currentResult = mergeResult;
782
+
783
+ // Classify the initial failure
784
+ let classification = classifyMergeFailure(currentResult);
785
+ const failedRepoId = extractFailedRepoId(currentResult);
786
+ const failedLaneNum = currentResult.failedLane ?? 0;
787
+ const scopeKey = buildMergeRetryScopeKey(failedRepoId, waveIdx, failedLaneNum);
788
+ const currentRetryCount = retryCountByScope[scopeKey] ?? 0;
789
+
790
+ // Check if any retry is possible at all
791
+ const initialDecision = computeMergeRetryDecision(classification, currentRetryCount);
792
+
793
+ if (!initialDecision.shouldRetry) {
794
+ // Non-retriable or already exhausted before we start
795
+ if (classification !== null && initialDecision.currentAttempt > 0) {
796
+ // Previously had retries โ€” this is exhaustion
797
+ return {
798
+ kind: "exhausted",
799
+ mergeResult: currentResult,
800
+ classification,
801
+ scopeKey,
802
+ lastDecision: initialDecision,
803
+ errorMessage: `Merge retry exhausted at wave ${waveIdx + 1}: ${initialDecision.reason}`,
804
+ notifyMessage: `โธ๏ธ Merge retry exhausted at wave ${waveIdx + 1}. ${initialDecision.reason}`,
805
+ };
806
+ }
807
+ // No retry was ever possible
808
+ return {
809
+ kind: "no_retry",
810
+ mergeResult: currentResult,
811
+ classification,
812
+ scopeKey,
813
+ };
814
+ }
815
+
816
+ // Enter retry loop
817
+ let lastDecision = initialDecision;
818
+
819
+ while (lastDecision.shouldRetry) {
820
+ // Increment counter in persisted state
821
+ retryCountByScope[scopeKey] = lastDecision.currentAttempt;
822
+
823
+ callbacks.log(`merge retry: ${lastDecision.reason}`, {
824
+ classification,
825
+ scopeKey,
826
+ attempt: lastDecision.currentAttempt,
827
+ maxAttempts: lastDecision.maxAttempts,
828
+ cooldownMs: lastDecision.cooldownMs,
829
+ });
830
+
831
+ callbacks.persist("merge-retry-increment");
832
+
833
+ // Emit Tier 0 attempt event via callback (TP-039 R004: emit only when retry is scheduled)
834
+ callbacks.onRetryAttempt?.(lastDecision);
835
+
836
+ callbacks.notify(
837
+ `๐Ÿ”„ Merge retry (${lastDecision.reason}) at wave ${waveIdx + 1}. ` +
838
+ (lastDecision.cooldownMs > 0
839
+ ? `Waiting ${lastDecision.cooldownMs}ms before retry...`
840
+ : "Retrying immediately..."),
841
+ "warning",
842
+ );
843
+
844
+ if (lastDecision.cooldownMs > 0) {
845
+ await callbacks.sleep(lastDecision.cooldownMs);
846
+ }
847
+
848
+ // Re-invoke merge
849
+ callbacks.persist("merge-retry-start");
850
+ currentResult = await callbacks.performMerge();
851
+ callbacks.updateMergeResult(currentResult);
852
+ callbacks.persist("merge-retry-complete");
853
+
854
+ // Check outcome
855
+ if (currentResult.status === "succeeded") {
856
+ callbacks.notify(`โœ… Merge retry succeeded at wave ${waveIdx + 1}.`, "info");
857
+ return {
858
+ kind: "retry_succeeded",
859
+ mergeResult: currentResult,
860
+ classification,
861
+ scopeKey,
862
+ lastDecision,
863
+ };
864
+ }
865
+
866
+ if (currentResult.rollbackFailed) {
867
+ // Safe-stop takes priority
868
+ const hasPersistErrors =
869
+ currentResult.persistenceErrors && currentResult.persistenceErrors.length > 0;
870
+ const persistWarning = hasPersistErrors
871
+ ? ` WARNING: ${currentResult.persistenceErrors!.length} transaction record(s) failed to persist.`
872
+ : "";
873
+
874
+ return {
875
+ kind: "safe_stop",
876
+ mergeResult: currentResult,
877
+ classification,
878
+ scopeKey,
879
+ lastDecision,
880
+ errorMessage:
881
+ `Safe-stop at wave ${waveIdx + 1}: verification rollback failed after retry. ` +
882
+ `Merge worktree and temp branch preserved for recovery.` +
883
+ persistWarning,
884
+ notifyMessage:
885
+ `๐Ÿ›‘ Safe-stop: verification rollback failed at wave ${waveIdx + 1} after retry. ` +
886
+ `Batch force-paused.` +
887
+ persistWarning,
888
+ };
889
+ }
890
+
891
+ // Retry failed โ€” re-classify and check if we can retry again
892
+ classification = classifyMergeFailure(currentResult);
893
+ const updatedCount = retryCountByScope[scopeKey] ?? 0;
894
+ lastDecision = computeMergeRetryDecision(classification, updatedCount);
895
+ }
896
+
897
+ // Loop ended: exhaustion
898
+ return {
899
+ kind: "exhausted",
900
+ mergeResult: currentResult,
901
+ classification,
902
+ scopeKey,
903
+ lastDecision,
904
+ errorMessage: `Merge retry exhausted at wave ${waveIdx + 1}: ${lastDecision.reason}`,
905
+ notifyMessage: `โธ๏ธ Merge retry exhausted at wave ${waveIdx + 1}. ${lastDecision.reason}`,
906
+ };
907
+ }
908
+
909
+ // โ”€โ”€ Integrate Cleanup Acceptance (TP-029 Step 3) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
910
+
911
+ /**
912
+ * Per-repo acceptance check findings after /orch-integrate.
913
+ * Collected by scanning all workspace repos (not just repos that had the orch branch).
914
+ */
915
+ export interface IntegrateCleanupRepoFindings {
916
+ /** Repo root path */
917
+ repoRoot: string;
918
+ /** Repo ID (undefined for repo-mode / primary) */
919
+ repoId: string | undefined;
920
+ /** Stale lane worktrees still registered (git worktree list matches) */
921
+ staleWorktrees: string[];
922
+ /** Stale lane branches (task/{opId}-lane-*) */
923
+ staleLaneBranches: string[];
924
+ /** Stale orch branches (orch/{opId}-{batchId}) */
925
+ staleOrchBranches: string[];
926
+ /** Batch-scoped autostash entries still present */
927
+ staleAutostashEntries: string[];
928
+ /** Non-empty .worktrees/ containers */
929
+ nonEmptyWorktreeContainers: string[];
930
+ }
931
+
932
+ /**
933
+ * Result of the /orch-integrate cleanup acceptance check.
934
+ * Pure function output โ€” callers use this to format the summary notification.
935
+ */
936
+ export interface IntegrateCleanupResult {
937
+ /** True if all repos pass all acceptance criteria */
938
+ clean: boolean;
939
+ /** Notification severity level: "info" when clean, "warning" when dirty */
940
+ notifyLevel: "info" | "warning";
941
+ /** Per-repo findings (only repos with at least one finding) */
942
+ dirtyRepos: IntegrateCleanupRepoFindings[];
943
+ /** User-facing cleanup report (appended to integrate summary) */
944
+ report: string;
945
+ }
946
+
947
+ /**
948
+ * Compute the integrate cleanup result from per-repo acceptance findings.
949
+ *
950
+ * This is a **pure function** โ€” computes all outputs deterministically
951
+ * from the per-repo findings without side effects.
952
+ *
953
+ * The acceptance criteria (roadmap 2d) are:
954
+ * 1. No registered lane worktrees remain in any workspace repo
955
+ * 2. No lane branches remain (task/{opId}-lane-*)
956
+ * 3. No orch branches remain (orch/{opId}-{batchId})
957
+ * 4. No stale autostash from current batch remains
958
+ * 5. No non-empty .worktrees/ containers remain
959
+ *
960
+ * @param repoFindings - Per-repo findings from scanning all workspace repos
961
+ * @returns Cleanup result with pass/fail verdict and human-readable report
962
+ */
963
+ export function computeIntegrateCleanupResult(
964
+ repoFindings: IntegrateCleanupRepoFindings[],
965
+ ): IntegrateCleanupResult {
966
+ // Filter to repos that have at least one issue
967
+ const dirtyRepos = repoFindings.filter(
968
+ (r) =>
969
+ r.staleWorktrees.length > 0 ||
970
+ r.staleLaneBranches.length > 0 ||
971
+ r.staleOrchBranches.length > 0 ||
972
+ r.staleAutostashEntries.length > 0 ||
973
+ r.nonEmptyWorktreeContainers.length > 0,
974
+ );
975
+
976
+ if (dirtyRepos.length === 0) {
977
+ return {
978
+ clean: true,
979
+ notifyLevel: "info",
980
+ dirtyRepos: [],
981
+ report: "๐Ÿงน Cleanup verified: no stale worktrees, branches, or autostash entries remain.",
982
+ };
983
+ }
984
+
985
+ // Build per-repo detail lines
986
+ const details: string[] = [];
987
+ for (const repo of dirtyRepos) {
988
+ const label = repo.repoId ?? "(default)";
989
+ const issues: string[] = [];
990
+ if (repo.staleWorktrees.length > 0) {
991
+ issues.push(`${repo.staleWorktrees.length} stale worktree(s)`);
992
+ }
993
+ if (repo.staleLaneBranches.length > 0) {
994
+ issues.push(`${repo.staleLaneBranches.length} lane branch(es)`);
995
+ }
996
+ if (repo.staleOrchBranches.length > 0) {
997
+ issues.push(`${repo.staleOrchBranches.length} orch branch(es)`);
998
+ }
999
+ if (repo.staleAutostashEntries.length > 0) {
1000
+ issues.push(`${repo.staleAutostashEntries.length} autostash entr(ies)`);
1001
+ }
1002
+ if (repo.nonEmptyWorktreeContainers.length > 0) {
1003
+ issues.push(`${repo.nonEmptyWorktreeContainers.length} non-empty .worktrees/ container(s)`);
1004
+ }
1005
+ details.push(` ${label}: ${issues.join(", ")}`);
1006
+ }
1007
+
1008
+ // Build recovery commands
1009
+ const recovery: string[] = [];
1010
+ for (const repo of dirtyRepos) {
1011
+ const label = repo.repoId ?? "default";
1012
+ for (const wt of repo.staleWorktrees) {
1013
+ recovery.push(` git worktree remove --force "${wt}" # repo: ${label}`);
1014
+ }
1015
+ for (const br of repo.staleLaneBranches) {
1016
+ recovery.push(` git branch -D "${br}" # repo: ${label}`);
1017
+ }
1018
+ for (const br of repo.staleOrchBranches) {
1019
+ recovery.push(` git branch -D "${br}" # repo: ${label}`);
1020
+ }
1021
+ for (const entry of repo.staleAutostashEntries) {
1022
+ recovery.push(` git stash drop "${entry}" # repo: ${label}`);
1023
+ }
1024
+ }
1025
+
1026
+ const report =
1027
+ `โš ๏ธ Cleanup incomplete โ€” residual artifacts found:\n` +
1028
+ details.join("\n") +
1029
+ (recovery.length > 0 ? `\n Manual cleanup:\n${recovery.join("\n")}` : "");
1030
+
1031
+ return {
1032
+ clean: false,
1033
+ notifyLevel: "warning",
1034
+ dirtyRepos,
1035
+ report,
1036
+ };
1037
+ }
1038
+
1039
+ // โ”€โ”€ Resume ORCH_MESSAGES โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1040
+
1041
+ // Note: These are added via extension to the ORCH_MESSAGES object below.
1042
+
1043
+ // โ”€โ”€ Resume Orchestration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1044
+
1045
+ /**
1046
+ * Resume an interrupted batch from persisted state.
1047
+ *
1048
+ * Flow:
1049
+ * 1. Load and validate batch-state.json
1050
+ * 2. Check phase eligibility (paused/executing/merging only)
1051
+ * 3. Check for alive TMUX sessions and .DONE files
1052
+ * 4. Reconcile persisted state against live signals
1053
+ * 5. Compute resume point (which wave to start from)
1054
+ * 6. Reconstruct runtime state and continue execution
1055
+ *
1056
+ * @param orchConfig - Orchestrator configuration
1057
+ * @param runnerConfig - Task runner configuration
1058
+ * @param cwd - Repository root
1059
+ * @param batchState - Mutable batch state (will be populated from persisted state)
1060
+ * @param onNotify - Callback for user-facing messages
1061
+ * @param onMonitorUpdate - Optional callback for dashboard updates
1062
+ */