@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,1345 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ spyOn,
9
+ test,
10
+ } from "bun:test";
11
+ import { join } from "node:path";
12
+ import { MergeError } from "../errors.ts";
13
+ import type { MulchClient } from "../mulch/client.ts";
14
+ import {
15
+ cleanupTempDir,
16
+ commitFile,
17
+ createTempGitRepo,
18
+ getDefaultBranch,
19
+ runGitInDir,
20
+ } from "../test-helpers.ts";
21
+ import type { MergeEntry, ParsedConflictPattern } from "../types.ts";
22
+ import {
23
+ buildConflictHistory,
24
+ createMergeResolver,
25
+ looksLikeProse,
26
+ parseConflictPatterns,
27
+ } from "./resolver.ts";
28
+
29
+ /**
30
+ * Helper to create a mock Bun.spawn return value for claude CLI mocking.
31
+ *
32
+ * The resolver reads stdout/stderr via `new Response(proc.stdout).text()`
33
+ * and `new Response(proc.stderr).text()`, so we need ReadableStreams.
34
+ */
35
+ function mockSpawnResult(
36
+ stdout: string,
37
+ stderr: string,
38
+ exitCode: number,
39
+ ): {
40
+ stdout: ReadableStream<Uint8Array>;
41
+ stderr: ReadableStream<Uint8Array>;
42
+ exited: Promise<number>;
43
+ pid: number;
44
+ } {
45
+ return {
46
+ stdout: new Response(stdout).body as ReadableStream<Uint8Array>,
47
+ stderr: new Response(stderr).body as ReadableStream<Uint8Array>,
48
+ exited: Promise.resolve(exitCode),
49
+ pid: 12345,
50
+ };
51
+ }
52
+
53
+ function makeTestEntry(overrides?: Partial<MergeEntry>): MergeEntry {
54
+ return {
55
+ branchName: overrides?.branchName ?? "feature-branch",
56
+ beadId: overrides?.beadId ?? "bead-123",
57
+ agentName: overrides?.agentName ?? "test-agent",
58
+ filesModified: overrides?.filesModified ?? ["src/test.ts"],
59
+ enqueuedAt: overrides?.enqueuedAt ?? new Date().toISOString(),
60
+ status: overrides?.status ?? "pending",
61
+ resolvedTier: overrides?.resolvedTier ?? null,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Set up a clean merge scenario: feature branch adds a new file with no conflict.
67
+ */
68
+ async function setupCleanMerge(dir: string, baseBranch: string): Promise<void> {
69
+ await commitFile(dir, "src/main-file.ts", "main content\n");
70
+ await runGitInDir(dir, ["checkout", "-b", "feature-branch"]);
71
+ await commitFile(dir, "src/feature-file.ts", "feature content\n");
72
+ await runGitInDir(dir, ["checkout", baseBranch]);
73
+ }
74
+
75
+ /**
76
+ * Set up a real content conflict: create a file, branch, modify on both
77
+ * branches. Both sides must diverge from the common ancestor to produce
78
+ * conflict markers.
79
+ */
80
+ async function setupContentConflict(dir: string, baseBranch: string): Promise<void> {
81
+ await commitFile(dir, "src/test.ts", "original content\n");
82
+ await runGitInDir(dir, ["checkout", "-b", "feature-branch"]);
83
+ await commitFile(dir, "src/test.ts", "feature content\n");
84
+ await runGitInDir(dir, ["checkout", baseBranch]);
85
+ await commitFile(dir, "src/test.ts", "main modified content\n");
86
+ }
87
+
88
+ /**
89
+ * Create a delete/modify conflict: file is deleted on main but modified on
90
+ * the feature branch. This produces a conflict with NO conflict markers in
91
+ * the working copy, causing Tier 2 auto-resolve to fail (resolveConflictsKeepIncoming
92
+ * returns null). This naturally escalates to Tier 3 or 4.
93
+ */
94
+ async function setupDeleteModifyConflict(
95
+ dir: string,
96
+ baseBranch: string,
97
+ branchName = "feature-branch",
98
+ ): Promise<void> {
99
+ await commitFile(dir, "src/test.ts", "original content\n");
100
+ await runGitInDir(dir, ["checkout", "-b", branchName]);
101
+ await commitFile(dir, "src/test.ts", "modified by agent\n");
102
+ await runGitInDir(dir, ["checkout", baseBranch]);
103
+ await runGitInDir(dir, ["rm", "src/test.ts"]);
104
+ await runGitInDir(dir, ["commit", "-m", "delete src/test.ts"]);
105
+ }
106
+
107
+ /**
108
+ * Set up a scenario where Tier 2 auto-resolve fails but Tier 4 reimagine can
109
+ * succeed. We create a delete/modify conflict on one file (causes Tier 2 to fail)
110
+ * and set entry.filesModified to a different file that exists on both branches
111
+ * (so git show works for both in reimagine).
112
+ */
113
+ async function setupReimagineScenario(dir: string, baseBranch: string): Promise<void> {
114
+ await commitFile(dir, "src/conflict-file.ts", "original content\n");
115
+ await commitFile(dir, "src/reimagine-target.ts", "main version of target\n");
116
+ await runGitInDir(dir, ["checkout", "-b", "feature-branch"]);
117
+ await commitFile(dir, "src/conflict-file.ts", "modified by agent\n");
118
+ await commitFile(dir, "src/reimagine-target.ts", "feature version of target\n");
119
+ await runGitInDir(dir, ["checkout", baseBranch]);
120
+ await runGitInDir(dir, ["rm", "src/conflict-file.ts"]);
121
+ await runGitInDir(dir, ["commit", "-m", "delete conflict file"]);
122
+ }
123
+
124
+ /**
125
+ * Create a mock MulchClient for testing.
126
+ * Optionally override the record method to track calls or simulate failures.
127
+ */
128
+ function createMockMulchClient(
129
+ recordImpl?: (domain: string, options: unknown) => Promise<void>,
130
+ ): MulchClient {
131
+ return {
132
+ async prime() {
133
+ return "";
134
+ },
135
+ async status() {
136
+ return { domains: [] };
137
+ },
138
+ async record(domain: string, options: unknown) {
139
+ if (recordImpl) {
140
+ return recordImpl(domain, options);
141
+ }
142
+ },
143
+ async query() {
144
+ return "";
145
+ },
146
+ async search() {
147
+ return "";
148
+ },
149
+ async diff() {
150
+ return {
151
+ success: true,
152
+ command: "diff",
153
+ since: "HEAD",
154
+ domains: [],
155
+ message: "",
156
+ };
157
+ },
158
+ async learn() {
159
+ return {
160
+ success: true,
161
+ command: "learn",
162
+ changedFiles: [],
163
+ suggestedDomains: [],
164
+ unmatchedFiles: [],
165
+ };
166
+ },
167
+ async prune() {
168
+ return {
169
+ success: true,
170
+ command: "prune",
171
+ dryRun: false,
172
+ totalPruned: 0,
173
+ results: [],
174
+ };
175
+ },
176
+ async doctor() {
177
+ return {
178
+ success: true,
179
+ command: "doctor",
180
+ checks: [],
181
+ summary: {
182
+ pass: 0,
183
+ warn: 0,
184
+ fail: 0,
185
+ totalIssues: 0,
186
+ fixableIssues: 0,
187
+ },
188
+ };
189
+ },
190
+ async ready() {
191
+ return {
192
+ success: true,
193
+ command: "ready",
194
+ count: 0,
195
+ entries: [],
196
+ };
197
+ },
198
+ async compact() {
199
+ return {
200
+ success: true,
201
+ command: "compact",
202
+ action: "analyze",
203
+ };
204
+ },
205
+ };
206
+ }
207
+
208
+ describe("createMergeResolver", () => {
209
+ describe("Tier 1: Clean merge", () => {
210
+ test("returns success with correct result shape and file content", async () => {
211
+ const repoDir = await createTempGitRepo();
212
+ try {
213
+ const defaultBranch = await getDefaultBranch(repoDir);
214
+ await setupCleanMerge(repoDir, defaultBranch);
215
+
216
+ const entry = makeTestEntry({
217
+ branchName: "feature-branch",
218
+ filesModified: ["src/feature-file.ts"],
219
+ });
220
+
221
+ const resolver = createMergeResolver({
222
+ aiResolveEnabled: false,
223
+ reimagineEnabled: false,
224
+ });
225
+
226
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
227
+
228
+ expect(result.success).toBe(true);
229
+ expect(result.tier).toBe("clean-merge");
230
+ expect(result.entry.status).toBe("merged");
231
+ expect(result.entry.resolvedTier).toBe("clean-merge");
232
+ expect(result.conflictFiles).toEqual([]);
233
+ expect(result.errorMessage).toBeNull();
234
+
235
+ // After merge, the feature file should exist on main
236
+ const file = Bun.file(join(repoDir, "src/feature-file.ts"));
237
+ const content = await file.text();
238
+ expect(content).toBe("feature content\n");
239
+ } finally {
240
+ await cleanupTempDir(repoDir);
241
+ }
242
+ });
243
+ });
244
+
245
+ describe("Tier 1: Checkout failure", () => {
246
+ // Both tests only attempt checkout of nonexistent branches -- no repo mutation.
247
+ let repoDir: string;
248
+
249
+ beforeAll(async () => {
250
+ repoDir = await createTempGitRepo();
251
+ });
252
+
253
+ afterAll(async () => {
254
+ await cleanupTempDir(repoDir);
255
+ });
256
+
257
+ test("throws MergeError if checkout fails", async () => {
258
+ const entry = makeTestEntry();
259
+
260
+ const resolver = createMergeResolver({
261
+ aiResolveEnabled: false,
262
+ reimagineEnabled: false,
263
+ });
264
+
265
+ await expect(resolver.resolve(entry, "nonexistent-branch", repoDir)).rejects.toThrow(
266
+ MergeError,
267
+ );
268
+ });
269
+
270
+ test("MergeError from checkout failure includes branch name", async () => {
271
+ const entry = makeTestEntry();
272
+
273
+ const resolver = createMergeResolver({
274
+ aiResolveEnabled: false,
275
+ reimagineEnabled: false,
276
+ });
277
+
278
+ try {
279
+ await resolver.resolve(entry, "develop", repoDir);
280
+ expect(true).toBe(false);
281
+ } catch (err: unknown) {
282
+ expect(err).toBeInstanceOf(MergeError);
283
+ const mergeErr = err as MergeError;
284
+ expect(mergeErr.message).toContain("develop");
285
+ }
286
+ });
287
+ });
288
+
289
+ describe("Tier 1 fail -> Tier 2: Auto-resolve", () => {
290
+ test("auto-resolves conflicts keeping incoming changes with correct content", async () => {
291
+ const repoDir = await createTempGitRepo();
292
+ try {
293
+ const defaultBranch = await getDefaultBranch(repoDir);
294
+ await setupContentConflict(repoDir, defaultBranch);
295
+
296
+ const entry = makeTestEntry({
297
+ branchName: "feature-branch",
298
+ filesModified: ["src/test.ts"],
299
+ });
300
+
301
+ const resolver = createMergeResolver({
302
+ aiResolveEnabled: false,
303
+ reimagineEnabled: false,
304
+ });
305
+
306
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
307
+
308
+ expect(result.success).toBe(true);
309
+ expect(result.tier).toBe("auto-resolve");
310
+ expect(result.entry.status).toBe("merged");
311
+ expect(result.entry.resolvedTier).toBe("auto-resolve");
312
+
313
+ // The resolved file should contain the incoming (feature branch) content
314
+ const file = Bun.file(join(repoDir, "src/test.ts"));
315
+ const content = await file.text();
316
+ expect(content).toBe("feature content\n");
317
+ } finally {
318
+ await cleanupTempDir(repoDir);
319
+ }
320
+ });
321
+ });
322
+
323
+ describe("Tier 3: AI-resolve", () => {
324
+ // After the first test (aiResolve=false), the resolver aborts the merge and
325
+ // leaves the repo clean. The second test can retry the merge on the same repo.
326
+ let repoDir: string;
327
+ let defaultBranch: string;
328
+
329
+ beforeAll(async () => {
330
+ repoDir = await createTempGitRepo();
331
+ defaultBranch = await getDefaultBranch(repoDir);
332
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
333
+ });
334
+
335
+ afterAll(async () => {
336
+ await cleanupTempDir(repoDir);
337
+ });
338
+
339
+ // This test MUST run first -- it fails to merge and aborts, leaving repo clean
340
+ test("is skipped when aiResolveEnabled is false", async () => {
341
+ const entry = makeTestEntry({
342
+ branchName: "feature-branch",
343
+ filesModified: ["src/test.ts"],
344
+ });
345
+
346
+ const resolver = createMergeResolver({
347
+ aiResolveEnabled: false,
348
+ reimagineEnabled: false,
349
+ });
350
+
351
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
352
+
353
+ expect(result.success).toBe(false);
354
+ expect(result.entry.status).toBe("failed");
355
+ });
356
+
357
+ // This test runs second -- repo is clean from the abort, same conflict is available
358
+ test("invokes claude when aiResolveEnabled is true and tier 2 fails", async () => {
359
+ // Selective spy: mock only claude, let git commands through.
360
+ const originalSpawn = Bun.spawn;
361
+ let claudeCalled = false;
362
+
363
+ const selectiveMock = (...args: unknown[]): unknown => {
364
+ const cmd = args[0] as string[];
365
+ if (cmd?.[0] === "claude") {
366
+ claudeCalled = true;
367
+ return mockSpawnResult("resolved content from AI\n", "", 0);
368
+ }
369
+ return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
370
+ };
371
+
372
+ const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
373
+
374
+ try {
375
+ const entry = makeTestEntry({
376
+ branchName: "feature-branch",
377
+ filesModified: ["src/test.ts"],
378
+ });
379
+
380
+ const resolver = createMergeResolver({
381
+ aiResolveEnabled: true,
382
+ reimagineEnabled: false,
383
+ });
384
+
385
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
386
+
387
+ expect(claudeCalled).toBe(true);
388
+ expect(result.success).toBe(true);
389
+ expect(result.tier).toBe("ai-resolve");
390
+ expect(result.entry.status).toBe("merged");
391
+ expect(result.entry.resolvedTier).toBe("ai-resolve");
392
+ } finally {
393
+ spawnSpy.mockRestore();
394
+ }
395
+ });
396
+ });
397
+
398
+ describe("Tier 4: Re-imagine", () => {
399
+ // After the first test (reimagine=false), the resolver aborts the merge and
400
+ // leaves the repo clean. The second test can retry the merge on the same repo.
401
+ let repoDir: string;
402
+ let defaultBranch: string;
403
+
404
+ beforeAll(async () => {
405
+ repoDir = await createTempGitRepo();
406
+ defaultBranch = await getDefaultBranch(repoDir);
407
+ await setupReimagineScenario(repoDir, defaultBranch);
408
+ });
409
+
410
+ afterAll(async () => {
411
+ await cleanupTempDir(repoDir);
412
+ });
413
+
414
+ // This test MUST run first -- it fails to merge and aborts, leaving repo clean
415
+ test("is skipped when reimagineEnabled is false", async () => {
416
+ const entry = makeTestEntry({
417
+ branchName: "feature-branch",
418
+ filesModified: ["src/reimagine-target.ts"],
419
+ });
420
+
421
+ const resolver = createMergeResolver({
422
+ aiResolveEnabled: false,
423
+ reimagineEnabled: false,
424
+ });
425
+
426
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
427
+
428
+ expect(result.success).toBe(false);
429
+ expect(result.entry.status).toBe("failed");
430
+ });
431
+
432
+ // This test runs second -- repo is clean from the abort, same conflict is available
433
+ test("aborts merge and reimplements when reimagineEnabled is true", async () => {
434
+ // Selective spy: mock only claude, let git commands through.
435
+ const originalSpawn = Bun.spawn;
436
+ let claudeCalled = false;
437
+
438
+ const selectiveMock = (...args: unknown[]): unknown => {
439
+ const cmd = args[0] as string[];
440
+ if (cmd?.[0] === "claude") {
441
+ claudeCalled = true;
442
+ return mockSpawnResult("reimagined content\n", "", 0);
443
+ }
444
+ return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
445
+ };
446
+
447
+ const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
448
+
449
+ try {
450
+ const entry = makeTestEntry({
451
+ branchName: "feature-branch",
452
+ filesModified: ["src/reimagine-target.ts"],
453
+ });
454
+
455
+ const resolver = createMergeResolver({
456
+ aiResolveEnabled: false,
457
+ reimagineEnabled: true,
458
+ });
459
+
460
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
461
+
462
+ expect(claudeCalled).toBe(true);
463
+ expect(result.success).toBe(true);
464
+ expect(result.tier).toBe("reimagine");
465
+ expect(result.entry.status).toBe("merged");
466
+ expect(result.entry.resolvedTier).toBe("reimagine");
467
+
468
+ // Verify the reimagined content was written
469
+ const file = Bun.file(join(repoDir, "src/reimagine-target.ts"));
470
+ const content = await file.text();
471
+ expect(content).toBe("reimagined content\n");
472
+ } finally {
473
+ spawnSpy.mockRestore();
474
+ }
475
+ });
476
+ });
477
+
478
+ describe("All tiers fail", () => {
479
+ test("returns failed status and repo is clean when all tiers fail", async () => {
480
+ const repoDir = await createTempGitRepo();
481
+ try {
482
+ const defaultBranch = await getDefaultBranch(repoDir);
483
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
484
+
485
+ const entry = makeTestEntry({
486
+ branchName: "feature-branch",
487
+ filesModified: ["src/test.ts"],
488
+ });
489
+
490
+ const resolver = createMergeResolver({
491
+ aiResolveEnabled: false,
492
+ reimagineEnabled: false,
493
+ });
494
+
495
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
496
+
497
+ expect(result.success).toBe(false);
498
+ expect(result.entry.status).toBe("failed");
499
+ expect(result.entry.resolvedTier).toBeNull();
500
+ expect(result.errorMessage).not.toBeNull();
501
+ expect(result.errorMessage).toContain("failed");
502
+
503
+ // Verify the repo is in a clean state (merge was aborted)
504
+ const status = await runGitInDir(repoDir, ["status", "--porcelain"]);
505
+ expect(status.trim()).toBe("");
506
+ } finally {
507
+ await cleanupTempDir(repoDir);
508
+ }
509
+ });
510
+ });
511
+
512
+ describe("result shape", () => {
513
+ let repoDir: string;
514
+ let defaultBranch: string;
515
+
516
+ beforeEach(async () => {
517
+ repoDir = await createTempGitRepo();
518
+ defaultBranch = await getDefaultBranch(repoDir);
519
+ });
520
+
521
+ afterEach(async () => {
522
+ await cleanupTempDir(repoDir);
523
+ });
524
+
525
+ test("successful result has correct MergeResult shape", async () => {
526
+ await setupCleanMerge(repoDir, defaultBranch);
527
+
528
+ const resolver = createMergeResolver({
529
+ aiResolveEnabled: false,
530
+ reimagineEnabled: false,
531
+ });
532
+
533
+ const result = await resolver.resolve(
534
+ makeTestEntry({
535
+ branchName: "feature-branch",
536
+ filesModified: ["src/feature-file.ts"],
537
+ }),
538
+ defaultBranch,
539
+ repoDir,
540
+ );
541
+
542
+ expect(result).toHaveProperty("entry");
543
+ expect(result).toHaveProperty("success");
544
+ expect(result).toHaveProperty("tier");
545
+ expect(result).toHaveProperty("conflictFiles");
546
+ expect(result).toHaveProperty("errorMessage");
547
+ });
548
+
549
+ test("failed result preserves original entry fields", async () => {
550
+ await setupDeleteModifyConflict(repoDir, defaultBranch, "overstory/my-agent/bead-xyz");
551
+
552
+ const entry = makeTestEntry({
553
+ branchName: "overstory/my-agent/bead-xyz",
554
+ beadId: "bead-xyz",
555
+ agentName: "my-agent",
556
+ filesModified: ["src/test.ts"],
557
+ });
558
+
559
+ const resolver = createMergeResolver({
560
+ aiResolveEnabled: false,
561
+ reimagineEnabled: false,
562
+ });
563
+
564
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
565
+
566
+ expect(result.entry.branchName).toBe("overstory/my-agent/bead-xyz");
567
+ expect(result.entry.beadId).toBe("bead-xyz");
568
+ expect(result.entry.agentName).toBe("my-agent");
569
+ });
570
+ });
571
+
572
+ describe("checkout skip when already on canonical branch", () => {
573
+ test("succeeds when already on canonical branch (skips checkout)", async () => {
574
+ const repoDir = await createTempGitRepo();
575
+ try {
576
+ const defaultBranch = await getDefaultBranch(repoDir);
577
+ await setupCleanMerge(repoDir, defaultBranch);
578
+
579
+ // Verify we're on the default branch
580
+ const branch = await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"]);
581
+ expect(branch.trim()).toBe(defaultBranch);
582
+
583
+ const entry = makeTestEntry({
584
+ branchName: "feature-branch",
585
+ filesModified: ["src/feature-file.ts"],
586
+ });
587
+
588
+ const resolver = createMergeResolver({
589
+ aiResolveEnabled: false,
590
+ reimagineEnabled: false,
591
+ });
592
+
593
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
594
+ expect(result.success).toBe(true);
595
+ expect(result.tier).toBe("clean-merge");
596
+ } finally {
597
+ await cleanupTempDir(repoDir);
598
+ }
599
+ });
600
+
601
+ test("checks out canonical when on a different branch", async () => {
602
+ const repoDir = await createTempGitRepo();
603
+ try {
604
+ const defaultBranch = await getDefaultBranch(repoDir);
605
+ await setupCleanMerge(repoDir, defaultBranch);
606
+
607
+ // Switch to a different branch
608
+ await runGitInDir(repoDir, ["checkout", "-b", "some-other-branch"]);
609
+ const branch = await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"]);
610
+ expect(branch.trim()).toBe("some-other-branch");
611
+
612
+ const entry = makeTestEntry({
613
+ branchName: "feature-branch",
614
+ filesModified: ["src/feature-file.ts"],
615
+ });
616
+
617
+ const resolver = createMergeResolver({
618
+ aiResolveEnabled: false,
619
+ reimagineEnabled: false,
620
+ });
621
+
622
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
623
+ expect(result.success).toBe(true);
624
+ expect(result.tier).toBe("clean-merge");
625
+ } finally {
626
+ await cleanupTempDir(repoDir);
627
+ }
628
+ });
629
+ });
630
+
631
+ describe("looksLikeProse", () => {
632
+ test("detects conversational prose", () => {
633
+ expect(looksLikeProse("I need permission to edit the file")).toBe(true);
634
+ expect(looksLikeProse("Here's the resolved content:")).toBe(true);
635
+ expect(looksLikeProse("Here is the file")).toBe(true);
636
+ expect(looksLikeProse("The conflict can be resolved by")).toBe(true);
637
+ expect(looksLikeProse("Let me resolve this for you")).toBe(true);
638
+ expect(looksLikeProse("Sure, here's the resolved file")).toBe(true);
639
+ expect(looksLikeProse("I cannot access the file")).toBe(true);
640
+ expect(looksLikeProse("I don't have access")).toBe(true);
641
+ expect(looksLikeProse("To resolve this, we need to")).toBe(true);
642
+ expect(looksLikeProse("Looking at the conflict")).toBe(true);
643
+ expect(looksLikeProse("Based on both versions")).toBe(true);
644
+ });
645
+
646
+ test("detects markdown fencing", () => {
647
+ expect(looksLikeProse("```typescript\nconst x = 1;\n```")).toBe(true);
648
+ expect(looksLikeProse("```\nsome code\n```")).toBe(true);
649
+ });
650
+
651
+ test("detects empty output", () => {
652
+ expect(looksLikeProse("")).toBe(true);
653
+ expect(looksLikeProse(" ")).toBe(true);
654
+ });
655
+
656
+ test("accepts valid code", () => {
657
+ expect(looksLikeProse("const x = 1;")).toBe(false);
658
+ expect(looksLikeProse("import { foo } from 'bar';")).toBe(false);
659
+ expect(looksLikeProse("export function resolve() {}")).toBe(false);
660
+ expect(looksLikeProse("function hello() {\n return 'world';\n}")).toBe(false);
661
+ expect(looksLikeProse("// comment\nconst a = 1;")).toBe(false);
662
+ });
663
+ });
664
+
665
+ describe("Tier 3: AI-resolve prose rejection", () => {
666
+ test("rejects prose output and falls through to failure", async () => {
667
+ const repoDir = await createTempGitRepo();
668
+ try {
669
+ const defaultBranch = await getDefaultBranch(repoDir);
670
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
671
+
672
+ const originalSpawn = Bun.spawn;
673
+ const selectiveMock = (...args: unknown[]): unknown => {
674
+ const cmd = args[0] as string[];
675
+ if (cmd?.[0] === "claude") {
676
+ // Return prose instead of code
677
+ return mockSpawnResult(
678
+ "I need permission to edit the file. Here's the resolved content:\n```\nresolved\n```",
679
+ "",
680
+ 0,
681
+ );
682
+ }
683
+ return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
684
+ };
685
+
686
+ const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
687
+
688
+ try {
689
+ const entry = makeTestEntry({
690
+ branchName: "feature-branch",
691
+ filesModified: ["src/test.ts"],
692
+ });
693
+
694
+ const resolver = createMergeResolver({
695
+ aiResolveEnabled: true,
696
+ reimagineEnabled: false,
697
+ });
698
+
699
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
700
+
701
+ // Should fail because prose was rejected
702
+ expect(result.success).toBe(false);
703
+ } finally {
704
+ spawnSpy.mockRestore();
705
+ }
706
+ } finally {
707
+ await cleanupTempDir(repoDir);
708
+ }
709
+ });
710
+ });
711
+
712
+ describe("Conflict pattern recording", () => {
713
+ test("no recording when mulchClient is not provided (backward compatible)", async () => {
714
+ const repoDir = await createTempGitRepo();
715
+ try {
716
+ const defaultBranch = await getDefaultBranch(repoDir);
717
+ await setupContentConflict(repoDir, defaultBranch);
718
+
719
+ const entry = makeTestEntry({
720
+ branchName: "feature-branch",
721
+ filesModified: ["src/test.ts"],
722
+ });
723
+
724
+ // No mulchClient passed — should work as before
725
+ const resolver = createMergeResolver({
726
+ aiResolveEnabled: false,
727
+ reimagineEnabled: false,
728
+ });
729
+
730
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
731
+
732
+ expect(result.success).toBe(true);
733
+ expect(result.tier).toBe("auto-resolve");
734
+ } finally {
735
+ await cleanupTempDir(repoDir);
736
+ }
737
+ });
738
+
739
+ test("records pattern on tier 2 auto-resolve success", async () => {
740
+ const repoDir = await createTempGitRepo();
741
+ try {
742
+ const defaultBranch = await getDefaultBranch(repoDir);
743
+ await setupContentConflict(repoDir, defaultBranch);
744
+
745
+ const entry = makeTestEntry({
746
+ branchName: "feature-branch",
747
+ beadId: "bead-abc-123",
748
+ agentName: "test-builder",
749
+ filesModified: ["src/test.ts"],
750
+ });
751
+
752
+ // Create a mock MulchClient with a spy on record
753
+ const recordCalls: Array<{
754
+ domain: string;
755
+ options: {
756
+ type: string;
757
+ description?: string;
758
+ tags?: string[];
759
+ evidenceBead?: string;
760
+ };
761
+ }> = [];
762
+
763
+ const mockMulchClient = createMockMulchClient(async (domain, options) => {
764
+ recordCalls.push({
765
+ domain,
766
+ options: options as {
767
+ type: string;
768
+ description?: string;
769
+ tags?: string[];
770
+ evidenceBead?: string;
771
+ },
772
+ });
773
+ });
774
+
775
+ const resolver = createMergeResolver({
776
+ aiResolveEnabled: false,
777
+ reimagineEnabled: false,
778
+ mulchClient: mockMulchClient,
779
+ });
780
+
781
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
782
+
783
+ expect(result.success).toBe(true);
784
+ expect(result.tier).toBe("auto-resolve");
785
+
786
+ // Verify record was called
787
+ expect(recordCalls.length).toBe(1);
788
+ const call = recordCalls[0];
789
+ expect(call?.domain).toBe("architecture");
790
+ expect(call?.options.type).toBe("pattern");
791
+ expect(call?.options.tags).toContain("merge-conflict");
792
+ expect(call?.options.evidenceBead).toBe("bead-abc-123");
793
+
794
+ // Verify description contains key details
795
+ const desc = call?.options.description ?? "";
796
+ expect(desc).toContain("resolved");
797
+ expect(desc).toContain("auto-resolve");
798
+ expect(desc).toContain("feature-branch");
799
+ expect(desc).toContain("test-builder");
800
+ expect(desc).toContain("src/test.ts");
801
+ } finally {
802
+ await cleanupTempDir(repoDir);
803
+ }
804
+ });
805
+
806
+ test("records pattern on total failure", async () => {
807
+ const repoDir = await createTempGitRepo();
808
+ try {
809
+ const defaultBranch = await getDefaultBranch(repoDir);
810
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
811
+
812
+ const entry = makeTestEntry({
813
+ branchName: "feature-branch",
814
+ beadId: "bead-fail-456",
815
+ agentName: "test-agent",
816
+ filesModified: ["src/test.ts"],
817
+ });
818
+
819
+ const recordCalls: Array<{
820
+ domain: string;
821
+ options: {
822
+ type: string;
823
+ description?: string;
824
+ tags?: string[];
825
+ evidenceBead?: string;
826
+ };
827
+ }> = [];
828
+
829
+ const mockMulchClient = createMockMulchClient(async (domain, options) => {
830
+ recordCalls.push({
831
+ domain,
832
+ options: options as {
833
+ type: string;
834
+ description?: string;
835
+ tags?: string[];
836
+ evidenceBead?: string;
837
+ },
838
+ });
839
+ });
840
+
841
+ // AI and reimagine disabled — will fail at tier 2
842
+ const resolver = createMergeResolver({
843
+ aiResolveEnabled: false,
844
+ reimagineEnabled: false,
845
+ mulchClient: mockMulchClient,
846
+ });
847
+
848
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
849
+
850
+ expect(result.success).toBe(false);
851
+
852
+ // Verify record was called for failure
853
+ expect(recordCalls.length).toBe(1);
854
+ const call = recordCalls[0];
855
+ expect(call?.domain).toBe("architecture");
856
+ expect(call?.options.type).toBe("pattern");
857
+ expect(call?.options.evidenceBead).toBe("bead-fail-456");
858
+
859
+ // Verify description contains "failed" not "resolved"
860
+ const desc = call?.options.description ?? "";
861
+ expect(desc).toContain("failed");
862
+ expect(desc).not.toContain("resolved");
863
+ expect(desc).toContain("auto-resolve"); // last attempted tier
864
+ } finally {
865
+ await cleanupTempDir(repoDir);
866
+ }
867
+ });
868
+
869
+ test("recording failure does not affect merge result (fire-and-forget)", async () => {
870
+ const repoDir = await createTempGitRepo();
871
+ try {
872
+ const defaultBranch = await getDefaultBranch(repoDir);
873
+ await setupContentConflict(repoDir, defaultBranch);
874
+
875
+ const entry = makeTestEntry({
876
+ branchName: "feature-branch",
877
+ filesModified: ["src/test.ts"],
878
+ });
879
+
880
+ // Mock mulchClient whose record rejects
881
+ const mockMulchClient = createMockMulchClient(async () => {
882
+ throw new Error("Mulch recording failed!");
883
+ });
884
+
885
+ const resolver = createMergeResolver({
886
+ aiResolveEnabled: false,
887
+ reimagineEnabled: false,
888
+ mulchClient: mockMulchClient,
889
+ });
890
+
891
+ // Should still succeed despite recording failure (fire-and-forget)
892
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
893
+
894
+ expect(result.success).toBe(true);
895
+ expect(result.tier).toBe("auto-resolve");
896
+ } finally {
897
+ await cleanupTempDir(repoDir);
898
+ }
899
+ });
900
+
901
+ test("records pattern on tier 3 ai-resolve success", async () => {
902
+ const repoDir = await createTempGitRepo();
903
+ try {
904
+ const defaultBranch = await getDefaultBranch(repoDir);
905
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
906
+
907
+ const entry = makeTestEntry({
908
+ branchName: "feature-branch",
909
+ beadId: "bead-ai-789",
910
+ filesModified: ["src/test.ts"],
911
+ });
912
+
913
+ const recordCalls: Array<{
914
+ domain: string;
915
+ options: {
916
+ type: string;
917
+ description?: string;
918
+ tags?: string[];
919
+ evidenceBead?: string;
920
+ };
921
+ }> = [];
922
+
923
+ const mockMulchClient = createMockMulchClient(async (domain, options) => {
924
+ recordCalls.push({
925
+ domain,
926
+ options: options as {
927
+ type: string;
928
+ description?: string;
929
+ tags?: string[];
930
+ evidenceBead?: string;
931
+ },
932
+ });
933
+ });
934
+
935
+ // Mock claude to succeed
936
+ const originalSpawn = Bun.spawn;
937
+ const selectiveMock = (...args: unknown[]): unknown => {
938
+ const cmd = args[0] as string[];
939
+ if (cmd?.[0] === "claude") {
940
+ return mockSpawnResult("resolved content from AI\n", "", 0);
941
+ }
942
+ return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
943
+ };
944
+
945
+ const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
946
+
947
+ try {
948
+ const resolver = createMergeResolver({
949
+ aiResolveEnabled: true,
950
+ reimagineEnabled: false,
951
+ mulchClient: mockMulchClient,
952
+ });
953
+
954
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
955
+
956
+ expect(result.success).toBe(true);
957
+ expect(result.tier).toBe("ai-resolve");
958
+
959
+ // Verify record was called
960
+ expect(recordCalls.length).toBe(1);
961
+ const call = recordCalls[0];
962
+ expect(call?.domain).toBe("architecture");
963
+ expect(call?.options.evidenceBead).toBe("bead-ai-789");
964
+
965
+ const desc = call?.options.description ?? "";
966
+ expect(desc).toContain("resolved");
967
+ expect(desc).toContain("ai-resolve");
968
+ } finally {
969
+ spawnSpy.mockRestore();
970
+ }
971
+ } finally {
972
+ await cleanupTempDir(repoDir);
973
+ }
974
+ });
975
+
976
+ test("records pattern on tier 4 reimagine success", async () => {
977
+ const repoDir = await createTempGitRepo();
978
+ try {
979
+ const defaultBranch = await getDefaultBranch(repoDir);
980
+ await setupReimagineScenario(repoDir, defaultBranch);
981
+
982
+ const entry = makeTestEntry({
983
+ branchName: "feature-branch",
984
+ beadId: "bead-reimagine-xyz",
985
+ filesModified: ["src/reimagine-target.ts"],
986
+ });
987
+
988
+ const recordCalls: Array<{
989
+ domain: string;
990
+ options: {
991
+ type: string;
992
+ description?: string;
993
+ tags?: string[];
994
+ evidenceBead?: string;
995
+ };
996
+ }> = [];
997
+
998
+ const mockMulchClient = createMockMulchClient(async (domain, options) => {
999
+ recordCalls.push({
1000
+ domain,
1001
+ options: options as {
1002
+ type: string;
1003
+ description?: string;
1004
+ tags?: string[];
1005
+ evidenceBead?: string;
1006
+ },
1007
+ });
1008
+ });
1009
+
1010
+ // Mock claude to succeed
1011
+ const originalSpawn = Bun.spawn;
1012
+ const selectiveMock = (...args: unknown[]): unknown => {
1013
+ const cmd = args[0] as string[];
1014
+ if (cmd?.[0] === "claude") {
1015
+ return mockSpawnResult("reimagined content\n", "", 0);
1016
+ }
1017
+ return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
1018
+ };
1019
+
1020
+ const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
1021
+
1022
+ try {
1023
+ const resolver = createMergeResolver({
1024
+ aiResolveEnabled: false,
1025
+ reimagineEnabled: true,
1026
+ mulchClient: mockMulchClient,
1027
+ });
1028
+
1029
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1030
+
1031
+ expect(result.success).toBe(true);
1032
+ expect(result.tier).toBe("reimagine");
1033
+
1034
+ // Verify record was called
1035
+ expect(recordCalls.length).toBe(1);
1036
+ const call = recordCalls[0];
1037
+ expect(call?.domain).toBe("architecture");
1038
+ expect(call?.options.evidenceBead).toBe("bead-reimagine-xyz");
1039
+
1040
+ const desc = call?.options.description ?? "";
1041
+ expect(desc).toContain("resolved");
1042
+ expect(desc).toContain("reimagine");
1043
+ } finally {
1044
+ spawnSpy.mockRestore();
1045
+ }
1046
+ } finally {
1047
+ await cleanupTempDir(repoDir);
1048
+ }
1049
+ });
1050
+
1051
+ test("no recording on tier 1 clean merge (no conflict)", async () => {
1052
+ const repoDir = await createTempGitRepo();
1053
+ try {
1054
+ const defaultBranch = await getDefaultBranch(repoDir);
1055
+ await setupCleanMerge(repoDir, defaultBranch);
1056
+
1057
+ const entry = makeTestEntry({
1058
+ branchName: "feature-branch",
1059
+ filesModified: ["src/feature-file.ts"],
1060
+ });
1061
+
1062
+ const recordCalls: Array<unknown> = [];
1063
+
1064
+ const mockMulchClient = createMockMulchClient(async (domain, options) => {
1065
+ recordCalls.push({ domain, options });
1066
+ });
1067
+
1068
+ const resolver = createMergeResolver({
1069
+ aiResolveEnabled: false,
1070
+ reimagineEnabled: false,
1071
+ mulchClient: mockMulchClient,
1072
+ });
1073
+
1074
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1075
+
1076
+ expect(result.success).toBe(true);
1077
+ expect(result.tier).toBe("clean-merge");
1078
+
1079
+ // No recording on clean merge (no conflict occurred)
1080
+ expect(recordCalls.length).toBe(0);
1081
+ } finally {
1082
+ await cleanupTempDir(repoDir);
1083
+ }
1084
+ });
1085
+ });
1086
+
1087
+ describe("parseConflictPatterns", () => {
1088
+ test("parses successful resolution pattern", () => {
1089
+ const input =
1090
+ "Merge conflict resolved at tier auto-resolve. Branch: feature-branch. Agent: test-builder. Conflicting files: src/test.ts.";
1091
+ const patterns = parseConflictPatterns(input);
1092
+ expect(patterns.length).toBe(1);
1093
+ expect(patterns[0]?.tier).toBe("auto-resolve");
1094
+ expect(patterns[0]?.success).toBe(true);
1095
+ expect(patterns[0]?.files).toEqual(["src/test.ts"]);
1096
+ expect(patterns[0]?.agent).toBe("test-builder");
1097
+ expect(patterns[0]?.branch).toBe("feature-branch");
1098
+ });
1099
+
1100
+ test("parses failed resolution pattern", () => {
1101
+ const input =
1102
+ "Merge conflict failed at tier ai-resolve. Branch: other-branch. Agent: my-agent. Conflicting files: src/foo.ts, src/bar.ts.";
1103
+ const patterns = parseConflictPatterns(input);
1104
+ expect(patterns.length).toBe(1);
1105
+ expect(patterns[0]?.tier).toBe("ai-resolve");
1106
+ expect(patterns[0]?.success).toBe(false);
1107
+ expect(patterns[0]?.files).toEqual(["src/foo.ts", "src/bar.ts"]);
1108
+ });
1109
+
1110
+ test("parses multiple patterns from search output", () => {
1111
+ const input = [
1112
+ "Some mulch header text",
1113
+ "Merge conflict resolved at tier auto-resolve. Branch: b1. Agent: a1. Conflicting files: src/a.ts.",
1114
+ "Other text in between",
1115
+ "Merge conflict failed at tier reimagine. Branch: b2. Agent: a2. Conflicting files: src/b.ts, src/c.ts.",
1116
+ ].join("\n");
1117
+ const patterns = parseConflictPatterns(input);
1118
+ expect(patterns.length).toBe(2);
1119
+ });
1120
+
1121
+ test("returns empty array for no matches", () => {
1122
+ expect(parseConflictPatterns("")).toEqual([]);
1123
+ expect(parseConflictPatterns("no patterns here")).toEqual([]);
1124
+ });
1125
+ });
1126
+
1127
+ describe("buildConflictHistory", () => {
1128
+ test("returns empty history when no patterns match entry files", () => {
1129
+ const patterns: ParsedConflictPattern[] = [
1130
+ {
1131
+ tier: "auto-resolve",
1132
+ success: true,
1133
+ files: ["unrelated.ts"],
1134
+ agent: "a",
1135
+ branch: "b",
1136
+ },
1137
+ ];
1138
+ const history = buildConflictHistory(patterns, ["src/test.ts"]);
1139
+ expect(history.skipTiers).toEqual([]);
1140
+ expect(history.pastResolutions).toEqual([]);
1141
+ expect(history.predictedConflictFiles).toEqual([]);
1142
+ });
1143
+
1144
+ test("builds skip tier list when tier fails >= 2 times with no successes", () => {
1145
+ const patterns: ParsedConflictPattern[] = [
1146
+ {
1147
+ tier: "ai-resolve",
1148
+ success: false,
1149
+ files: ["src/test.ts"],
1150
+ agent: "a",
1151
+ branch: "b1",
1152
+ },
1153
+ {
1154
+ tier: "ai-resolve",
1155
+ success: false,
1156
+ files: ["src/test.ts"],
1157
+ agent: "a",
1158
+ branch: "b2",
1159
+ },
1160
+ ];
1161
+ const history = buildConflictHistory(patterns, ["src/test.ts"]);
1162
+ expect(history.skipTiers).toContain("ai-resolve");
1163
+ });
1164
+
1165
+ test("does not skip tier if it has any successes", () => {
1166
+ const patterns: ParsedConflictPattern[] = [
1167
+ {
1168
+ tier: "ai-resolve",
1169
+ success: false,
1170
+ files: ["src/test.ts"],
1171
+ agent: "a",
1172
+ branch: "b1",
1173
+ },
1174
+ {
1175
+ tier: "ai-resolve",
1176
+ success: false,
1177
+ files: ["src/test.ts"],
1178
+ agent: "a",
1179
+ branch: "b2",
1180
+ },
1181
+ {
1182
+ tier: "ai-resolve",
1183
+ success: true,
1184
+ files: ["src/test.ts"],
1185
+ agent: "a",
1186
+ branch: "b3",
1187
+ },
1188
+ ];
1189
+ const history = buildConflictHistory(patterns, ["src/test.ts"]);
1190
+ expect(history.skipTiers).not.toContain("ai-resolve");
1191
+ });
1192
+
1193
+ test("does not skip tier with only 1 failure", () => {
1194
+ const patterns: ParsedConflictPattern[] = [
1195
+ {
1196
+ tier: "reimagine",
1197
+ success: false,
1198
+ files: ["src/test.ts"],
1199
+ agent: "a",
1200
+ branch: "b1",
1201
+ },
1202
+ ];
1203
+ const history = buildConflictHistory(patterns, ["src/test.ts"]);
1204
+ expect(history.skipTiers).not.toContain("reimagine");
1205
+ });
1206
+
1207
+ test("collects past successful resolutions", () => {
1208
+ const patterns: ParsedConflictPattern[] = [
1209
+ {
1210
+ tier: "auto-resolve",
1211
+ success: true,
1212
+ files: ["src/test.ts"],
1213
+ agent: "a",
1214
+ branch: "b1",
1215
+ },
1216
+ {
1217
+ tier: "ai-resolve",
1218
+ success: true,
1219
+ files: ["src/test.ts", "src/other.ts"],
1220
+ agent: "b",
1221
+ branch: "b2",
1222
+ },
1223
+ ];
1224
+ const history = buildConflictHistory(patterns, ["src/test.ts"]);
1225
+ expect(history.pastResolutions.length).toBe(2);
1226
+ expect(history.pastResolutions[0]).toContain("auto-resolve");
1227
+ expect(history.pastResolutions[1]).toContain("ai-resolve");
1228
+ });
1229
+
1230
+ test("predicts conflict files from historical patterns", () => {
1231
+ const patterns: ParsedConflictPattern[] = [
1232
+ {
1233
+ tier: "auto-resolve",
1234
+ success: true,
1235
+ files: ["src/test.ts", "src/utils.ts"],
1236
+ agent: "a",
1237
+ branch: "b1",
1238
+ },
1239
+ ];
1240
+ const history = buildConflictHistory(patterns, ["src/test.ts"]);
1241
+ expect(history.predictedConflictFiles).toContain("src/test.ts");
1242
+ expect(history.predictedConflictFiles).toContain("src/utils.ts");
1243
+ });
1244
+
1245
+ test("returns empty history for empty patterns array", () => {
1246
+ const history = buildConflictHistory([], ["src/test.ts"]);
1247
+ expect(history.skipTiers).toEqual([]);
1248
+ expect(history.pastResolutions).toEqual([]);
1249
+ expect(history.predictedConflictFiles).toEqual([]);
1250
+ });
1251
+ });
1252
+
1253
+ describe("Conflict history tier skipping", () => {
1254
+ test("skips auto-resolve tier when history says it always fails for these files", async () => {
1255
+ const repoDir = await createTempGitRepo();
1256
+ try {
1257
+ const defaultBranch = await getDefaultBranch(repoDir);
1258
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
1259
+
1260
+ const entry = makeTestEntry({
1261
+ branchName: "feature-branch",
1262
+ filesModified: ["src/test.ts"],
1263
+ });
1264
+
1265
+ // Mock mulchClient that returns history showing auto-resolve always fails
1266
+ const mockMulchClient = createMockMulchClient();
1267
+ mockMulchClient.search = async () => {
1268
+ return [
1269
+ "Merge conflict failed at tier auto-resolve. Branch: b1. Agent: a1. Conflicting files: src/test.ts.",
1270
+ "Merge conflict failed at tier auto-resolve. Branch: b2. Agent: a2. Conflicting files: src/test.ts.",
1271
+ ].join("\n");
1272
+ };
1273
+
1274
+ // AI and reimagine disabled, auto-resolve should be skipped -> fails immediately
1275
+ const resolver = createMergeResolver({
1276
+ aiResolveEnabled: false,
1277
+ reimagineEnabled: false,
1278
+ mulchClient: mockMulchClient,
1279
+ });
1280
+
1281
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1282
+
1283
+ // Should fail, and the last tier should NOT be auto-resolve (it was skipped)
1284
+ expect(result.success).toBe(false);
1285
+ } finally {
1286
+ await cleanupTempDir(repoDir);
1287
+ }
1288
+ });
1289
+ });
1290
+
1291
+ describe("AI-resolve with history context", () => {
1292
+ test("includes historical context in AI prompt when available", async () => {
1293
+ const repoDir = await createTempGitRepo();
1294
+ try {
1295
+ const defaultBranch = await getDefaultBranch(repoDir);
1296
+ await setupDeleteModifyConflict(repoDir, defaultBranch);
1297
+
1298
+ const entry = makeTestEntry({
1299
+ branchName: "feature-branch",
1300
+ filesModified: ["src/test.ts"],
1301
+ });
1302
+
1303
+ // Mock mulchClient that returns successful resolution history
1304
+ const mockMulchClient = createMockMulchClient();
1305
+ mockMulchClient.search = async () => {
1306
+ return "Merge conflict resolved at tier ai-resolve. Branch: old-branch. Agent: old-agent. Conflicting files: src/test.ts.";
1307
+ };
1308
+
1309
+ // Capture the prompt sent to claude
1310
+ let capturedPrompt = "";
1311
+ const originalSpawn = Bun.spawn;
1312
+ const selectiveMock = (...args: unknown[]): unknown => {
1313
+ const cmd = args[0] as string[];
1314
+ if (cmd?.[0] === "claude") {
1315
+ capturedPrompt = cmd[3] ?? "";
1316
+ return mockSpawnResult("resolved content\n", "", 0);
1317
+ }
1318
+ return originalSpawn.apply(Bun, args as Parameters<typeof Bun.spawn>);
1319
+ };
1320
+
1321
+ const spawnSpy = spyOn(Bun, "spawn").mockImplementation(selectiveMock as typeof Bun.spawn);
1322
+
1323
+ try {
1324
+ const resolver = createMergeResolver({
1325
+ aiResolveEnabled: true,
1326
+ reimagineEnabled: false,
1327
+ mulchClient: mockMulchClient,
1328
+ });
1329
+
1330
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1331
+
1332
+ expect(result.success).toBe(true);
1333
+ expect(result.tier).toBe("ai-resolve");
1334
+ // Verify historical context was included in the prompt
1335
+ expect(capturedPrompt).toContain("Historical context");
1336
+ expect(capturedPrompt).toContain("ai-resolve");
1337
+ } finally {
1338
+ spawnSpy.mockRestore();
1339
+ }
1340
+ } finally {
1341
+ await cleanupTempDir(repoDir);
1342
+ }
1343
+ });
1344
+ });
1345
+ });