@os-eco/overstory-cli 0.6.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,645 @@
1
+ /**
2
+ * Tiered conflict resolution for merging agent branches.
3
+ *
4
+ * Implements a 4-tier escalation strategy:
5
+ * 1. Clean merge — git merge with no conflicts
6
+ * 2. Auto-resolve — parse conflict markers, keep incoming (agent) changes
7
+ * 3. AI-resolve — use Claude to resolve remaining conflicts
8
+ * 4. Re-imagine — abort merge and reimplement changes from scratch
9
+ *
10
+ * Each tier is attempted in order. If a tier fails, the next is tried.
11
+ * Disabled tiers are skipped. Uses Bun.spawn for all subprocess calls.
12
+ */
13
+
14
+ import { MergeError } from "../errors.ts";
15
+ import type { MulchClient } from "../mulch/client.ts";
16
+ import type {
17
+ ConflictHistory,
18
+ MergeEntry,
19
+ MergeResult,
20
+ ParsedConflictPattern,
21
+ ResolutionTier,
22
+ } from "../types.ts";
23
+
24
+ export interface MergeResolver {
25
+ /** Attempt to merge the entry's branch into the canonical branch with tiered resolution. */
26
+ resolve(entry: MergeEntry, canonicalBranch: string, repoRoot: string): Promise<MergeResult>;
27
+ }
28
+
29
+ /**
30
+ * Run a git command in the given repo root. Returns stdout, stderr, and exit code.
31
+ */
32
+ async function runGit(
33
+ repoRoot: string,
34
+ args: string[],
35
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
36
+ const proc = Bun.spawn(["git", ...args], {
37
+ cwd: repoRoot,
38
+ stdout: "pipe",
39
+ stderr: "pipe",
40
+ });
41
+
42
+ const [stdout, stderr, exitCode] = await Promise.all([
43
+ new Response(proc.stdout).text(),
44
+ new Response(proc.stderr).text(),
45
+ proc.exited,
46
+ ]);
47
+
48
+ return { stdout, stderr, exitCode };
49
+ }
50
+
51
+ /**
52
+ * Get the list of conflicted files from `git diff --name-only --diff-filter=U`.
53
+ */
54
+ async function getConflictedFiles(repoRoot: string): Promise<string[]> {
55
+ const { stdout } = await runGit(repoRoot, ["diff", "--name-only", "--diff-filter=U"]);
56
+ return stdout
57
+ .trim()
58
+ .split("\n")
59
+ .filter((line) => line.length > 0);
60
+ }
61
+
62
+ /**
63
+ * Parse conflict markers in file content and keep the incoming (agent) changes.
64
+ *
65
+ * A conflict block looks like:
66
+ * ```
67
+ * <<<<<<< HEAD
68
+ * canonical content
69
+ * =======
70
+ * incoming content
71
+ * >>>>>>> branch
72
+ * ```
73
+ *
74
+ * This function replaces each conflict block with only the incoming content.
75
+ * Returns the resolved content, or null if no conflict markers were found.
76
+ */
77
+ function resolveConflictsKeepIncoming(content: string): string | null {
78
+ const conflictPattern = /^<{7} .+\n([\s\S]*?)^={7}\n([\s\S]*?)^>{7} .+\n?/gm;
79
+
80
+ if (!conflictPattern.test(content)) {
81
+ return null;
82
+ }
83
+
84
+ // Reset regex lastIndex after test()
85
+ conflictPattern.lastIndex = 0;
86
+
87
+ return content.replace(conflictPattern, (_match, _canonical: string, incoming: string) => {
88
+ return incoming;
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Read a file's content using Bun.file().
94
+ */
95
+ async function readFile(filePath: string): Promise<string> {
96
+ const file = Bun.file(filePath);
97
+ return file.text();
98
+ }
99
+
100
+ /**
101
+ * Write content to a file using Bun.write().
102
+ */
103
+ async function writeFile(filePath: string, content: string): Promise<void> {
104
+ await Bun.write(filePath, content);
105
+ }
106
+
107
+ /**
108
+ * Tier 1: Attempt a clean merge (git merge --no-edit).
109
+ * Returns true if the merge succeeds with no conflicts.
110
+ */
111
+ async function tryCleanMerge(
112
+ entry: MergeEntry,
113
+ repoRoot: string,
114
+ ): Promise<{ success: boolean; conflictFiles: string[] }> {
115
+ const { exitCode } = await runGit(repoRoot, ["merge", "--no-edit", entry.branchName]);
116
+
117
+ if (exitCode === 0) {
118
+ return { success: true, conflictFiles: [] };
119
+ }
120
+
121
+ // Merge failed — get the list of conflicted files
122
+ const conflictFiles = await getConflictedFiles(repoRoot);
123
+ return { success: false, conflictFiles };
124
+ }
125
+
126
+ /**
127
+ * Tier 2: Auto-resolve conflicts by keeping incoming (agent) changes.
128
+ * Parses conflict markers and keeps the content between ======= and >>>>>>>.
129
+ */
130
+ async function tryAutoResolve(
131
+ conflictFiles: string[],
132
+ repoRoot: string,
133
+ ): Promise<{ success: boolean; remainingConflicts: string[] }> {
134
+ const remainingConflicts: string[] = [];
135
+
136
+ for (const file of conflictFiles) {
137
+ const filePath = `${repoRoot}/${file}`;
138
+
139
+ try {
140
+ const content = await readFile(filePath);
141
+ const resolved = resolveConflictsKeepIncoming(content);
142
+
143
+ if (resolved === null) {
144
+ // No conflict markers found (shouldn't happen but be defensive)
145
+ remainingConflicts.push(file);
146
+ continue;
147
+ }
148
+
149
+ await writeFile(filePath, resolved);
150
+ const { exitCode } = await runGit(repoRoot, ["add", file]);
151
+ if (exitCode !== 0) {
152
+ remainingConflicts.push(file);
153
+ }
154
+ } catch {
155
+ remainingConflicts.push(file);
156
+ }
157
+ }
158
+
159
+ if (remainingConflicts.length > 0) {
160
+ return { success: false, remainingConflicts };
161
+ }
162
+
163
+ // All files resolved — commit
164
+ const { exitCode } = await runGit(repoRoot, ["commit", "--no-edit"]);
165
+ return { success: exitCode === 0, remainingConflicts };
166
+ }
167
+
168
+ /**
169
+ * Check if text looks like conversational prose rather than code.
170
+ * Returns true if the output is likely prose from the LLM rather than resolved code.
171
+ */
172
+ export function looksLikeProse(text: string): boolean {
173
+ const trimmed = text.trim();
174
+ if (trimmed.length === 0) return true;
175
+
176
+ // Common conversational opening patterns from LLMs
177
+ const prosePatterns = [
178
+ /^(I |I'[a-z]+ |Here |Here's |The |This |Let me |Sure|Unfortunately|Apologies|Sorry)/i,
179
+ /^(To resolve|Looking at|Based on|After reviewing|The conflict)/i,
180
+ /^```/m, // Markdown fencing — the model wrapped the code
181
+ /I need permission/i,
182
+ /I cannot/i,
183
+ /I don't have/i,
184
+ ];
185
+
186
+ for (const pattern of prosePatterns) {
187
+ if (pattern.test(trimmed)) return true;
188
+ }
189
+
190
+ return false;
191
+ }
192
+
193
+ /**
194
+ * Tier 3: AI-assisted conflict resolution using Claude.
195
+ * Spawns `claude --print` for each conflicted file with the conflict content.
196
+ * Validates that output looks like code, not conversational prose.
197
+ */
198
+ async function tryAiResolve(
199
+ conflictFiles: string[],
200
+ repoRoot: string,
201
+ pastResolutions?: string[],
202
+ ): Promise<{ success: boolean; remainingConflicts: string[] }> {
203
+ const remainingConflicts: string[] = [];
204
+
205
+ for (const file of conflictFiles) {
206
+ const filePath = `${repoRoot}/${file}`;
207
+
208
+ try {
209
+ const content = await readFile(filePath);
210
+ const historyContext =
211
+ pastResolutions && pastResolutions.length > 0
212
+ ? `\n\nHistorical context from past merges:\n${pastResolutions.join("\n")}\n`
213
+ : "";
214
+ const prompt = [
215
+ "You are a merge conflict resolver. Output ONLY the resolved file content.",
216
+ "Rules: NO explanation, NO markdown fencing, NO conversation, NO preamble.",
217
+ "Output the raw file content as it should appear on disk.",
218
+ "Choose the best combination of both sides of this conflict:",
219
+ historyContext,
220
+ "\n\n",
221
+ content,
222
+ ].join(" ");
223
+
224
+ const proc = Bun.spawn(["claude", "--print", "-p", prompt], {
225
+ cwd: repoRoot,
226
+ stdout: "pipe",
227
+ stderr: "pipe",
228
+ });
229
+
230
+ const [resolved, , exitCode] = await Promise.all([
231
+ new Response(proc.stdout).text(),
232
+ new Response(proc.stderr).text(),
233
+ proc.exited,
234
+ ]);
235
+
236
+ if (exitCode !== 0 || resolved.trim() === "") {
237
+ remainingConflicts.push(file);
238
+ continue;
239
+ }
240
+
241
+ // Validate output is code, not prose — fall back to next tier if not
242
+ if (looksLikeProse(resolved)) {
243
+ remainingConflicts.push(file);
244
+ continue;
245
+ }
246
+
247
+ await writeFile(filePath, resolved);
248
+ const { exitCode: addExitCode } = await runGit(repoRoot, ["add", file]);
249
+ if (addExitCode !== 0) {
250
+ remainingConflicts.push(file);
251
+ }
252
+ } catch {
253
+ remainingConflicts.push(file);
254
+ }
255
+ }
256
+
257
+ if (remainingConflicts.length > 0) {
258
+ return { success: false, remainingConflicts };
259
+ }
260
+
261
+ // All files resolved — commit
262
+ const { exitCode } = await runGit(repoRoot, ["commit", "--no-edit"]);
263
+ return { success: exitCode === 0, remainingConflicts };
264
+ }
265
+
266
+ /**
267
+ * Tier 4: Re-imagine — abort the merge and reimplement changes from scratch.
268
+ * Uses Claude to reimplement the agent's changes on top of the canonical version.
269
+ */
270
+ async function tryReimagine(
271
+ entry: MergeEntry,
272
+ canonicalBranch: string,
273
+ repoRoot: string,
274
+ ): Promise<{ success: boolean }> {
275
+ // Abort the current merge
276
+ await runGit(repoRoot, ["merge", "--abort"]);
277
+
278
+ for (const file of entry.filesModified) {
279
+ try {
280
+ // Get the canonical version
281
+ const { stdout: canonicalContent, exitCode: catCanonicalCode } = await runGit(repoRoot, [
282
+ "show",
283
+ `${canonicalBranch}:${file}`,
284
+ ]);
285
+
286
+ // Get the branch version
287
+ const { stdout: branchContent, exitCode: catBranchCode } = await runGit(repoRoot, [
288
+ "show",
289
+ `${entry.branchName}:${file}`,
290
+ ]);
291
+
292
+ if (catCanonicalCode !== 0 || catBranchCode !== 0) {
293
+ return { success: false };
294
+ }
295
+
296
+ const prompt = [
297
+ "You are a merge conflict resolver. Output ONLY the final file content.",
298
+ "Rules: NO explanation, NO markdown fencing, NO conversation, NO preamble.",
299
+ "Output the raw file content as it should appear on disk.",
300
+ "Reimplement the changes from the branch version onto the canonical version.",
301
+ `\n\n=== CANONICAL VERSION (${canonicalBranch}) ===\n`,
302
+ canonicalContent,
303
+ `\n\n=== BRANCH VERSION (${entry.branchName}) ===\n`,
304
+ branchContent,
305
+ ].join("");
306
+
307
+ const proc = Bun.spawn(["claude", "--print", "-p", prompt], {
308
+ cwd: repoRoot,
309
+ stdout: "pipe",
310
+ stderr: "pipe",
311
+ });
312
+
313
+ const [reimagined, , exitCode] = await Promise.all([
314
+ new Response(proc.stdout).text(),
315
+ new Response(proc.stderr).text(),
316
+ proc.exited,
317
+ ]);
318
+
319
+ if (exitCode !== 0 || reimagined.trim() === "") {
320
+ return { success: false };
321
+ }
322
+
323
+ // Validate output is code, not prose
324
+ if (looksLikeProse(reimagined)) {
325
+ return { success: false };
326
+ }
327
+
328
+ const filePath = `${repoRoot}/${file}`;
329
+ await writeFile(filePath, reimagined);
330
+ const { exitCode: addExitCode } = await runGit(repoRoot, ["add", file]);
331
+ if (addExitCode !== 0) {
332
+ return { success: false };
333
+ }
334
+ } catch {
335
+ return { success: false };
336
+ }
337
+ }
338
+
339
+ // Commit the reimagined changes
340
+ const { exitCode } = await runGit(repoRoot, [
341
+ "commit",
342
+ "-m",
343
+ `Reimagine merge: ${entry.branchName} onto ${canonicalBranch}`,
344
+ ]);
345
+
346
+ return { success: exitCode === 0 };
347
+ }
348
+
349
+ /**
350
+ * Parse mulch search output for conflict patterns.
351
+ * Extracts structured data from pattern descriptions recorded by recordConflictPattern().
352
+ */
353
+ export function parseConflictPatterns(searchOutput: string): ParsedConflictPattern[] {
354
+ const patterns: ParsedConflictPattern[] = [];
355
+ // Simple approach: match to end of line/sentence and manually strip trailing period
356
+ const regex =
357
+ /Merge conflict (resolved|failed) at tier (clean-merge|auto-resolve|ai-resolve|reimagine)\.\s*Branch:\s*(\S+)\.\s*Agent:\s*(\S+)\.\s*Conflicting files:\s*(.+?)(?=\.(?:\s|$))/g;
358
+
359
+ let match = regex.exec(searchOutput);
360
+ while (match !== null) {
361
+ const outcome = match[1];
362
+ const tier = match[2];
363
+ const branch = match[3];
364
+ const agent = match[4];
365
+ const filesStr = match[5];
366
+
367
+ if (!outcome || !tier || !branch || !agent || !filesStr) {
368
+ match = regex.exec(searchOutput);
369
+ continue;
370
+ }
371
+
372
+ patterns.push({
373
+ tier: tier as ResolutionTier,
374
+ success: outcome === "resolved",
375
+ files: filesStr
376
+ .split(",")
377
+ .map((f) => f.trim())
378
+ .filter((f) => f.length > 0),
379
+ agent: agent.trim(),
380
+ branch: branch.trim(),
381
+ });
382
+
383
+ match = regex.exec(searchOutput);
384
+ }
385
+
386
+ return patterns;
387
+ }
388
+
389
+ /**
390
+ * Build conflict history from parsed patterns, scoped to the files in the current merge entry.
391
+ *
392
+ * Skip-tier logic: if a tier has failed >= 2 times for any overlapping file
393
+ * and never succeeded for those files, add it to skipTiers.
394
+ *
395
+ * Past resolutions: collect descriptions of successful resolutions involving
396
+ * overlapping files to enrich AI prompts.
397
+ *
398
+ * Predicted conflicts: files from historical patterns that overlap with the
399
+ * current entry files.
400
+ */
401
+ export function buildConflictHistory(
402
+ patterns: ParsedConflictPattern[],
403
+ entryFiles: string[],
404
+ ): ConflictHistory {
405
+ const entryFileSet = new Set(entryFiles);
406
+
407
+ // Filter patterns to those that share files with the current entry
408
+ const relevantPatterns = patterns.filter((p) => p.files.some((f) => entryFileSet.has(f)));
409
+
410
+ if (relevantPatterns.length === 0) {
411
+ return { skipTiers: [], pastResolutions: [], predictedConflictFiles: [] };
412
+ }
413
+
414
+ // Build tier success/failure counts
415
+ const tierCounts = new Map<ResolutionTier, { successes: number; failures: number }>();
416
+ for (const p of relevantPatterns) {
417
+ const counts = tierCounts.get(p.tier) ?? { successes: 0, failures: 0 };
418
+ if (p.success) {
419
+ counts.successes++;
420
+ } else {
421
+ counts.failures++;
422
+ }
423
+ tierCounts.set(p.tier, counts);
424
+ }
425
+
426
+ // Skip tiers that have failed >= 2 times and never succeeded
427
+ const skipTiers: ResolutionTier[] = [];
428
+ for (const [tier, counts] of tierCounts) {
429
+ if (counts.failures >= 2 && counts.successes === 0) {
430
+ skipTiers.push(tier);
431
+ }
432
+ }
433
+
434
+ // Collect past successful resolutions
435
+ const pastResolutions: string[] = [];
436
+ for (const p of relevantPatterns) {
437
+ if (p.success) {
438
+ pastResolutions.push(
439
+ `Previously resolved at tier ${p.tier} for files: ${p.files.join(", ")}`,
440
+ );
441
+ }
442
+ }
443
+
444
+ // Predict conflict files: all files from relevant historical patterns
445
+ const predictedFileSet = new Set<string>();
446
+ for (const p of relevantPatterns) {
447
+ for (const f of p.files) {
448
+ predictedFileSet.add(f);
449
+ }
450
+ }
451
+ const predictedConflictFiles = [...predictedFileSet].sort();
452
+
453
+ return { skipTiers, pastResolutions, predictedConflictFiles };
454
+ }
455
+
456
+ /**
457
+ * Query mulch for historical conflict patterns related to the merge entry.
458
+ * Returns empty history if mulch is unavailable or search fails (fire-and-forget).
459
+ */
460
+ async function queryConflictHistory(
461
+ mulchClient: MulchClient,
462
+ entry: MergeEntry,
463
+ ): Promise<ConflictHistory> {
464
+ try {
465
+ const searchOutput = await mulchClient.search("merge-conflict");
466
+ const patterns = parseConflictPatterns(searchOutput);
467
+ return buildConflictHistory(patterns, entry.filesModified);
468
+ } catch {
469
+ return { skipTiers: [], pastResolutions: [], predictedConflictFiles: [] };
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Record a merge conflict pattern to mulch for future learning.
475
+ * Uses fire-and-forget (try/catch swallowing errors) so recording
476
+ * never blocks or fails the merge itself.
477
+ */
478
+ function recordConflictPattern(
479
+ mulchClient: MulchClient,
480
+ entry: MergeEntry,
481
+ tier: ResolutionTier,
482
+ conflictFiles: string[],
483
+ success: boolean,
484
+ ): void {
485
+ const outcome = success ? "resolved" : "failed";
486
+ const description = [
487
+ `Merge conflict ${outcome} at tier ${tier}.`,
488
+ `Branch: ${entry.branchName}.`,
489
+ `Agent: ${entry.agentName}.`,
490
+ `Conflicting files: ${conflictFiles.join(", ")}.`,
491
+ ].join(" ");
492
+
493
+ // Fire-and-forget per convention mx-09e10f
494
+ mulchClient
495
+ .record("architecture", {
496
+ type: "pattern",
497
+ description,
498
+ tags: ["merge-conflict"],
499
+ evidenceBead: entry.beadId,
500
+ })
501
+ .catch(() => {});
502
+ }
503
+
504
+ /**
505
+ * Create a MergeResolver with configurable tier enablement.
506
+ *
507
+ * @param options.aiResolveEnabled - Enable tier 3 (AI-assisted resolution)
508
+ * @param options.reimagineEnabled - Enable tier 4 (full reimagine)
509
+ * @param options.mulchClient - Optional MulchClient for conflict pattern recording
510
+ */
511
+ export function createMergeResolver(options: {
512
+ aiResolveEnabled: boolean;
513
+ reimagineEnabled: boolean;
514
+ mulchClient?: MulchClient;
515
+ }): MergeResolver {
516
+ return {
517
+ async resolve(
518
+ entry: MergeEntry,
519
+ canonicalBranch: string,
520
+ repoRoot: string,
521
+ ): Promise<MergeResult> {
522
+ // Check current branch — skip checkout if already on canonical.
523
+ // Avoids "already checked out" error when worktrees exist.
524
+ const { stdout: currentRef, exitCode: refCode } = await runGit(repoRoot, [
525
+ "symbolic-ref",
526
+ "--short",
527
+ "HEAD",
528
+ ]);
529
+ const needsCheckout = refCode !== 0 || currentRef.trim() !== canonicalBranch;
530
+
531
+ if (needsCheckout) {
532
+ const { exitCode: checkoutCode, stderr: checkoutErr } = await runGit(repoRoot, [
533
+ "checkout",
534
+ canonicalBranch,
535
+ ]);
536
+ if (checkoutCode !== 0) {
537
+ throw new MergeError(`Failed to checkout ${canonicalBranch}: ${checkoutErr.trim()}`, {
538
+ branchName: canonicalBranch,
539
+ });
540
+ }
541
+ }
542
+
543
+ let lastTier: ResolutionTier = "clean-merge";
544
+ let conflictFiles: string[] = [];
545
+
546
+ // Tier 1: Clean merge
547
+ const cleanResult = await tryCleanMerge(entry, repoRoot);
548
+ if (cleanResult.success) {
549
+ return {
550
+ entry: { ...entry, status: "merged", resolvedTier: "clean-merge" },
551
+ success: true,
552
+ tier: "clean-merge",
553
+ conflictFiles: [],
554
+ errorMessage: null,
555
+ };
556
+ }
557
+ conflictFiles = cleanResult.conflictFiles;
558
+
559
+ // Query conflict history (if mulchClient available)
560
+ let history: ConflictHistory = {
561
+ skipTiers: [],
562
+ pastResolutions: [],
563
+ predictedConflictFiles: [],
564
+ };
565
+ if (options.mulchClient) {
566
+ history = await queryConflictHistory(options.mulchClient, entry);
567
+ }
568
+
569
+ // Tier 2: Auto-resolve (keep incoming)
570
+ if (!history.skipTiers.includes("auto-resolve")) {
571
+ lastTier = "auto-resolve";
572
+ const autoResult = await tryAutoResolve(conflictFiles, repoRoot);
573
+ if (autoResult.success) {
574
+ if (options.mulchClient) {
575
+ recordConflictPattern(options.mulchClient, entry, "auto-resolve", conflictFiles, true);
576
+ }
577
+ return {
578
+ entry: { ...entry, status: "merged", resolvedTier: "auto-resolve" },
579
+ success: true,
580
+ tier: "auto-resolve",
581
+ conflictFiles,
582
+ errorMessage: null,
583
+ };
584
+ }
585
+ conflictFiles = autoResult.remainingConflicts;
586
+ } // If skipped, fall through to next tier
587
+
588
+ // Tier 3: AI-resolve
589
+ if (options.aiResolveEnabled && !history.skipTiers.includes("ai-resolve")) {
590
+ lastTier = "ai-resolve";
591
+ const aiResult = await tryAiResolve(conflictFiles, repoRoot, history.pastResolutions);
592
+ if (aiResult.success) {
593
+ if (options.mulchClient) {
594
+ recordConflictPattern(options.mulchClient, entry, "ai-resolve", conflictFiles, true);
595
+ }
596
+ return {
597
+ entry: { ...entry, status: "merged", resolvedTier: "ai-resolve" },
598
+ success: true,
599
+ tier: "ai-resolve",
600
+ conflictFiles,
601
+ errorMessage: null,
602
+ };
603
+ }
604
+ conflictFiles = aiResult.remainingConflicts;
605
+ }
606
+
607
+ // Tier 4: Re-imagine
608
+ if (options.reimagineEnabled && !history.skipTiers.includes("reimagine")) {
609
+ lastTier = "reimagine";
610
+ const reimagineResult = await tryReimagine(entry, canonicalBranch, repoRoot);
611
+ if (reimagineResult.success) {
612
+ if (options.mulchClient) {
613
+ recordConflictPattern(options.mulchClient, entry, "reimagine", conflictFiles, true);
614
+ }
615
+ return {
616
+ entry: { ...entry, status: "merged", resolvedTier: "reimagine" },
617
+ success: true,
618
+ tier: "reimagine",
619
+ conflictFiles: [],
620
+ errorMessage: null,
621
+ };
622
+ }
623
+ }
624
+
625
+ // All enabled tiers failed — abort any in-progress merge
626
+ try {
627
+ await runGit(repoRoot, ["merge", "--abort"]);
628
+ } catch {
629
+ // merge --abort may fail if there's no merge in progress (e.g., after reimagine)
630
+ }
631
+
632
+ if (options.mulchClient) {
633
+ recordConflictPattern(options.mulchClient, entry, lastTier, conflictFiles, false);
634
+ }
635
+
636
+ return {
637
+ entry: { ...entry, status: "failed", resolvedTier: null },
638
+ success: false,
639
+ tier: lastTier,
640
+ conflictFiles,
641
+ errorMessage: `All enabled resolution tiers failed (last attempted: ${lastTier})`,
642
+ };
643
+ },
644
+ };
645
+ }