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