@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,759 @@
1
+ /**
2
+ * Tiered conflict resolution for merging agent branches.
3
+ *
4
+ * Implements a 4-tier escalation strategy:
5
+ * 1. Clean merge — git merge with no conflicts
6
+ * 2. Auto-resolve — parse conflict markers, keep incoming (agent) changes
7
+ * 3. AI-resolve — use Claude to resolve remaining conflicts
8
+ * 4. Re-imagine — abort merge and reimplement changes from scratch
9
+ *
10
+ * Each tier is attempted in order. If a tier fails, the next is tried.
11
+ * Disabled tiers are skipped. Uses child_process.spawn for all subprocess calls.
12
+ */
13
+
14
+ import type { ChildProcess } from "node:child_process";
15
+ import * as cp from "node:child_process";
16
+ import { readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
17
+ import { MergeError } from "../errors.ts";
18
+ import type { MulchClient } from "../mulch/client.ts";
19
+ import type {
20
+ ConflictHistory,
21
+ MergeEntry,
22
+ MergeResult,
23
+ ParsedConflictPattern,
24
+ ResolutionTier,
25
+ } from "../types.ts";
26
+
27
+ interface MergeResolver {
28
+ /** Attempt to merge the entry's branch into the canonical branch with tiered resolution. */
29
+ resolve(entry: MergeEntry, canonicalBranch: string, repoRoot: string): Promise<MergeResult>;
30
+ }
31
+
32
+ /**
33
+ * Parse conflict markers in JSONL file content and resolve using a union-dedup-sort strategy.
34
+ *
35
+ * Collects all lines from both sides of each conflict block plus non-conflict content,
36
+ * parses each as JSON, deduplicates by `id` field (keeping the record with the later
37
+ * `recorded_at` value), and sorts the result by `recorded_at` ascending.
38
+ * Records without an `id` field are appended at the end.
39
+ *
40
+ * Returns null if:
41
+ * - No conflict markers are found
42
+ * - Any non-empty line fails JSON.parse (falls through to generic strategy)
43
+ * - Any parsed value is not a JSON object
44
+ */
45
+ export function resolveJsonlConflict(content: string): string | null {
46
+ const conflictPattern = /^<{7} .+\n([\s\S]*?)^={7}\n([\s\S]*?)^>{7} .+\n?/gm;
47
+
48
+ if (!conflictPattern.test(content)) {
49
+ return null;
50
+ }
51
+
52
+ // Reset regex lastIndex after test()
53
+ conflictPattern.lastIndex = 0;
54
+
55
+ const allLines: string[] = [];
56
+ let lastIndex = 0;
57
+ let match: RegExpExecArray | null;
58
+
59
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop pattern
60
+ while ((match = conflictPattern.exec(content)) !== null) {
61
+ // Collect non-conflict content before this match
62
+ const before = content.slice(lastIndex, match.index);
63
+ for (const line of before.split("\n")) {
64
+ const trimmed = line.trim();
65
+ if (trimmed.length > 0) allLines.push(trimmed);
66
+ }
67
+
68
+ // Collect HEAD (canonical) side
69
+ const canonical = match[1] ?? "";
70
+ for (const line of canonical.split("\n")) {
71
+ const trimmed = line.trim();
72
+ if (trimmed.length > 0) allLines.push(trimmed);
73
+ }
74
+
75
+ // Collect incoming side
76
+ const incoming = match[2] ?? "";
77
+ for (const line of incoming.split("\n")) {
78
+ const trimmed = line.trim();
79
+ if (trimmed.length > 0) allLines.push(trimmed);
80
+ }
81
+
82
+ lastIndex = match.index + match[0].length;
83
+ }
84
+
85
+ // Collect remaining non-conflict content after last match
86
+ const remaining = content.slice(lastIndex);
87
+ for (const line of remaining.split("\n")) {
88
+ const trimmed = line.trim();
89
+ if (trimmed.length > 0) allLines.push(trimmed);
90
+ }
91
+
92
+ // Parse each line as JSON and dedup by id field
93
+ const idMap = new Map<string, { recordedAt: string; raw: string }>();
94
+ const noIdLines: string[] = [];
95
+
96
+ for (const line of allLines) {
97
+ let parsed: unknown;
98
+ try {
99
+ parsed = JSON.parse(line);
100
+ } catch {
101
+ return null; // Invalid JSON — fall through to generic strategy
102
+ }
103
+
104
+ if (typeof parsed !== "object" || parsed === null) {
105
+ return null; // Not a JSON object — fall through
106
+ }
107
+
108
+ const record = parsed as Record<string, unknown>;
109
+
110
+ if (typeof record.id === "string" && typeof record.recorded_at === "string") {
111
+ const existing = idMap.get(record.id);
112
+ if (!existing || record.recorded_at > existing.recordedAt) {
113
+ idMap.set(record.id, { recordedAt: record.recorded_at, raw: line });
114
+ }
115
+ } else {
116
+ noIdLines.push(line);
117
+ }
118
+ }
119
+
120
+ // Sort id-bearing records by recorded_at ascending, append no-id records at the end
121
+ const sorted = [...idMap.values()]
122
+ .sort((a, b) => (a.recordedAt < b.recordedAt ? -1 : a.recordedAt > b.recordedAt ? 1 : 0))
123
+ .map((r) => r.raw);
124
+
125
+ return `${[...sorted, ...noIdLines].join("\n")}\n`;
126
+ }
127
+
128
+ /**
129
+ * Parse conflict markers in file content and keep the incoming (agent) changes.
130
+ *
131
+ * A conflict block looks like:
132
+ * ```
133
+ * <<<<<<< HEAD
134
+ * canonical content
135
+ * =======
136
+ * incoming content
137
+ * >>>>>>> branch
138
+ * ```
139
+ *
140
+ * This function replaces each conflict block with only the incoming content.
141
+ * Returns the resolved content, or null if no conflict markers were found.
142
+ */
143
+ function resolveConflictsKeepIncoming(content: string): string | null {
144
+ const conflictPattern = /^<{7} .+\n([\s\S]*?)^={7}\n([\s\S]*?)^>{7} .+\n?/gm;
145
+
146
+ if (!conflictPattern.test(content)) {
147
+ return null;
148
+ }
149
+
150
+ // Reset regex lastIndex after test()
151
+ conflictPattern.lastIndex = 0;
152
+
153
+ return content.replace(conflictPattern, (_match, _canonical: string, incoming: string) => {
154
+ return incoming;
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Read a file's content.
160
+ */
161
+ async function readFile(filePath: string): Promise<string> {
162
+ return fsReadFile(filePath, "utf-8");
163
+ }
164
+
165
+ /**
166
+ * Write content to a file.
167
+ */
168
+ async function writeFile(filePath: string, content: string): Promise<void> {
169
+ await fsWriteFile(filePath, content, "utf-8");
170
+ }
171
+
172
+ /**
173
+ * Check if text looks like conversational prose rather than code.
174
+ * Returns true if the output is likely prose from the LLM rather than resolved code.
175
+ */
176
+ export function looksLikeProse(text: string): boolean {
177
+ const trimmed = text.trim();
178
+ if (trimmed.length === 0) return true;
179
+
180
+ // Common conversational opening patterns from LLMs
181
+ const prosePatterns = [
182
+ /^(I |I'[a-z]+ |Here |Here's |The |This |Let me |Sure|Unfortunately|Apologies|Sorry)/i,
183
+ /^(To resolve|Looking at|Based on|After reviewing|The conflict)/i,
184
+ /^```/m, // Markdown fencing — the model wrapped the code
185
+ /I need permission/i,
186
+ /I cannot/i,
187
+ /I don't have/i,
188
+ ];
189
+
190
+ for (const pattern of prosePatterns) {
191
+ if (pattern.test(trimmed)) return true;
192
+ }
193
+
194
+ return false;
195
+ }
196
+
197
+ /**
198
+ * Parse mulch search output for conflict patterns.
199
+ * Extracts structured data from pattern descriptions recorded by recordConflictPattern().
200
+ */
201
+ export function parseConflictPatterns(searchOutput: string): ParsedConflictPattern[] {
202
+ const patterns: ParsedConflictPattern[] = [];
203
+ // Simple approach: match to end of line/sentence and manually strip trailing period
204
+ const regex =
205
+ /Merge conflict (resolved|failed) at tier (clean-merge|auto-resolve|ai-resolve|reimagine)\.\s*Branch:\s*(\S+)\.\s*Agent:\s*(\S+)\.\s*Conflicting files:\s*(.+?)(?=\.(?:\s|$))/g;
206
+
207
+ let match = regex.exec(searchOutput);
208
+ while (match !== null) {
209
+ const outcome = match[1];
210
+ const tier = match[2];
211
+ const branch = match[3];
212
+ const agent = match[4];
213
+ const filesStr = match[5];
214
+
215
+ if (!outcome || !tier || !branch || !agent || !filesStr) {
216
+ match = regex.exec(searchOutput);
217
+ continue;
218
+ }
219
+
220
+ patterns.push({
221
+ tier: tier as ResolutionTier,
222
+ success: outcome === "resolved",
223
+ files: filesStr
224
+ .split(",")
225
+ .map((f) => f.trim())
226
+ .filter((f) => f.length > 0),
227
+ agent: agent.trim(),
228
+ branch: branch.trim(),
229
+ });
230
+
231
+ match = regex.exec(searchOutput);
232
+ }
233
+
234
+ return patterns;
235
+ }
236
+
237
+ /**
238
+ * Build conflict history from parsed patterns, scoped to the files in the current merge entry.
239
+ *
240
+ * Skip-tier logic: if a tier has failed >= 2 times for any overlapping file
241
+ * and never succeeded for those files, add it to skipTiers.
242
+ *
243
+ * Past resolutions: collect descriptions of successful resolutions involving
244
+ * overlapping files to enrich AI prompts.
245
+ *
246
+ * Predicted conflicts: files from historical patterns that overlap with the
247
+ * current entry files.
248
+ */
249
+ export function buildConflictHistory(
250
+ patterns: ParsedConflictPattern[],
251
+ entryFiles: string[],
252
+ ): ConflictHistory {
253
+ const entryFileSet = new Set(entryFiles);
254
+
255
+ // Filter patterns to those that share files with the current entry
256
+ const relevantPatterns = patterns.filter((p) => p.files.some((f) => entryFileSet.has(f)));
257
+
258
+ if (relevantPatterns.length === 0) {
259
+ return { skipTiers: [], pastResolutions: [], predictedConflictFiles: [] };
260
+ }
261
+
262
+ // Build tier success/failure counts
263
+ const tierCounts = new Map<ResolutionTier, { successes: number; failures: number }>();
264
+ for (const p of relevantPatterns) {
265
+ const counts = tierCounts.get(p.tier) ?? { successes: 0, failures: 0 };
266
+ if (p.success) {
267
+ counts.successes++;
268
+ } else {
269
+ counts.failures++;
270
+ }
271
+ tierCounts.set(p.tier, counts);
272
+ }
273
+
274
+ // Skip tiers that have failed >= 2 times and never succeeded
275
+ const skipTiers: ResolutionTier[] = [];
276
+ for (const [tier, counts] of tierCounts) {
277
+ if (counts.failures >= 2 && counts.successes === 0) {
278
+ skipTiers.push(tier);
279
+ }
280
+ }
281
+
282
+ // Collect past successful resolutions
283
+ const pastResolutions: string[] = [];
284
+ for (const p of relevantPatterns) {
285
+ if (p.success) {
286
+ pastResolutions.push(
287
+ `Previously resolved at tier ${p.tier} for files: ${p.files.join(", ")}`,
288
+ );
289
+ }
290
+ }
291
+
292
+ // Predict conflict files: all files from relevant historical patterns
293
+ const predictedFileSet = new Set<string>();
294
+ for (const p of relevantPatterns) {
295
+ for (const f of p.files) {
296
+ predictedFileSet.add(f);
297
+ }
298
+ }
299
+ const predictedConflictFiles = [...predictedFileSet].sort();
300
+
301
+ return { skipTiers, pastResolutions, predictedConflictFiles };
302
+ }
303
+
304
+ /**
305
+ * Create a MergeResolver with configurable tier enablement.
306
+ *
307
+ * @param options.aiResolveEnabled - Enable tier 3 (AI-assisted resolution)
308
+ * @param options.reimagineEnabled - Enable tier 4 (full reimagine)
309
+ * @param options.mulchClient - Optional MulchClient for conflict pattern recording
310
+ * @param options._spawn - Injectable spawn function for testing (defaults to cp.spawn)
311
+ */
312
+ export function createMergeResolver(options: {
313
+ aiResolveEnabled: boolean;
314
+ reimagineEnabled: boolean;
315
+ mulchClient?: MulchClient;
316
+ _spawn?: (cmd: string, args: string[], opts: cp.SpawnOptions) => ChildProcess;
317
+ }): MergeResolver {
318
+ // Bind spawn so tests can inject a replacement without ESM namespace limitations
319
+ const spawnFn = options._spawn ?? cp.spawn.bind(cp);
320
+
321
+ /**
322
+ * Run a command as a subprocess. Returns stdout, stderr, and exit code.
323
+ * Closes over spawnFn so tests can inject a mock spawn.
324
+ */
325
+ function runCommand(
326
+ cmd: string[],
327
+ cwd?: string,
328
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
329
+ const [command, ...args] = cmd;
330
+ if (!command) return Promise.reject(new Error("Empty command"));
331
+ return new Promise((resolve, reject) => {
332
+ const proc = spawnFn(command, args, {
333
+ cwd,
334
+ stdio: ["ignore", "pipe", "pipe"],
335
+ });
336
+
337
+ if (proc.stdout === null || proc.stderr === null) {
338
+ reject(new Error("spawn failed to create stdio pipes"));
339
+ return;
340
+ }
341
+
342
+ const chunks: { stdout: Buffer[]; stderr: Buffer[] } = { stdout: [], stderr: [] };
343
+ proc.stdout.on("data", (data: Buffer) => chunks.stdout.push(data));
344
+ proc.stderr.on("data", (data: Buffer) => chunks.stderr.push(data));
345
+ proc.on("error", reject);
346
+ proc.on("close", (code) => {
347
+ resolve({
348
+ stdout: Buffer.concat(chunks.stdout).toString(),
349
+ stderr: Buffer.concat(chunks.stderr).toString(),
350
+ exitCode: code ?? 1,
351
+ });
352
+ });
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Run a git command in the given repo root. Returns stdout, stderr, and exit code.
358
+ */
359
+ function runGit(
360
+ repoRoot: string,
361
+ args: string[],
362
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
363
+ return runCommand(["git", ...args], repoRoot);
364
+ }
365
+
366
+ /**
367
+ * Get the list of conflicted files from `git diff --name-only --diff-filter=U`.
368
+ */
369
+ async function getConflictedFiles(repoRoot: string): Promise<string[]> {
370
+ const { stdout } = await runGit(repoRoot, ["diff", "--name-only", "--diff-filter=U"]);
371
+ return stdout
372
+ .trim()
373
+ .split("\n")
374
+ .filter((line) => line.length > 0);
375
+ }
376
+
377
+ /**
378
+ * Tier 1: Attempt a clean merge (git merge --no-edit).
379
+ * Returns true if the merge succeeds with no conflicts.
380
+ */
381
+ async function tryCleanMerge(
382
+ entry: MergeEntry,
383
+ repoRoot: string,
384
+ ): Promise<{ success: boolean; conflictFiles: string[] }> {
385
+ const { exitCode } = await runGit(repoRoot, ["merge", "--no-edit", entry.branchName]);
386
+
387
+ if (exitCode === 0) {
388
+ return { success: true, conflictFiles: [] };
389
+ }
390
+
391
+ // Merge failed — get the list of conflicted files
392
+ const conflictFiles = await getConflictedFiles(repoRoot);
393
+ return { success: false, conflictFiles };
394
+ }
395
+
396
+ /**
397
+ * Tier 2: Auto-resolve conflicts by keeping incoming (agent) changes.
398
+ * Parses conflict markers and keeps the content between ======= and >>>>>>>.
399
+ */
400
+ async function tryAutoResolve(
401
+ conflictFiles: string[],
402
+ repoRoot: string,
403
+ ): Promise<{ success: boolean; remainingConflicts: string[] }> {
404
+ const remainingConflicts: string[] = [];
405
+
406
+ for (const file of conflictFiles) {
407
+ const filePath = `${repoRoot}/${file}`;
408
+
409
+ try {
410
+ const content = await readFile(filePath);
411
+ const resolved = file.endsWith(".jsonl")
412
+ ? resolveJsonlConflict(content)
413
+ : resolveConflictsKeepIncoming(content);
414
+
415
+ if (resolved === null) {
416
+ // No conflict markers found (shouldn't happen but be defensive)
417
+ remainingConflicts.push(file);
418
+ continue;
419
+ }
420
+
421
+ await writeFile(filePath, resolved);
422
+ const { exitCode } = await runGit(repoRoot, ["add", file]);
423
+ if (exitCode !== 0) {
424
+ remainingConflicts.push(file);
425
+ }
426
+ } catch {
427
+ remainingConflicts.push(file);
428
+ }
429
+ }
430
+
431
+ if (remainingConflicts.length > 0) {
432
+ return { success: false, remainingConflicts };
433
+ }
434
+
435
+ // All files resolved — commit
436
+ const { exitCode } = await runGit(repoRoot, ["commit", "--no-edit"]);
437
+ return { success: exitCode === 0, remainingConflicts };
438
+ }
439
+
440
+ /**
441
+ * Tier 3: AI-assisted conflict resolution using Claude.
442
+ * Spawns `claude --print` for each conflicted file with the conflict content.
443
+ * Validates that output looks like code, not conversational prose.
444
+ */
445
+ async function tryAiResolve(
446
+ conflictFiles: string[],
447
+ repoRoot: string,
448
+ pastResolutions?: string[],
449
+ ): Promise<{ success: boolean; remainingConflicts: string[] }> {
450
+ const remainingConflicts: string[] = [];
451
+
452
+ for (const file of conflictFiles) {
453
+ const filePath = `${repoRoot}/${file}`;
454
+
455
+ try {
456
+ const content = await readFile(filePath);
457
+ const historyContext =
458
+ pastResolutions && pastResolutions.length > 0
459
+ ? `\n\nHistorical context from past merges:\n${pastResolutions.join("\n")}\n`
460
+ : "";
461
+ const prompt = [
462
+ "You are a merge conflict resolver. Output ONLY the resolved file content.",
463
+ "Rules: NO explanation, NO markdown fencing, NO conversation, NO preamble.",
464
+ "Output the raw file content as it should appear on disk.",
465
+ "Choose the best combination of both sides of this conflict:",
466
+ historyContext,
467
+ "\n\n",
468
+ content,
469
+ ].join(" ");
470
+
471
+ const { stdout: resolved, exitCode } = await runCommand(
472
+ ["claude", "--print", "-p", prompt],
473
+ repoRoot,
474
+ );
475
+
476
+ if (exitCode !== 0 || resolved.trim() === "") {
477
+ remainingConflicts.push(file);
478
+ continue;
479
+ }
480
+
481
+ // Validate output is code, not prose — fall back to next tier if not
482
+ if (looksLikeProse(resolved)) {
483
+ remainingConflicts.push(file);
484
+ continue;
485
+ }
486
+
487
+ await writeFile(filePath, resolved);
488
+ const { exitCode: addExitCode } = await runGit(repoRoot, ["add", file]);
489
+ if (addExitCode !== 0) {
490
+ remainingConflicts.push(file);
491
+ }
492
+ } catch {
493
+ remainingConflicts.push(file);
494
+ }
495
+ }
496
+
497
+ if (remainingConflicts.length > 0) {
498
+ return { success: false, remainingConflicts };
499
+ }
500
+
501
+ // All files resolved — commit
502
+ const { exitCode } = await runGit(repoRoot, ["commit", "--no-edit"]);
503
+ return { success: exitCode === 0, remainingConflicts };
504
+ }
505
+
506
+ /**
507
+ * Tier 4: Re-imagine — abort the merge and reimplement changes from scratch.
508
+ * Uses Claude to reimplement the agent's changes on top of the canonical version.
509
+ */
510
+ async function tryReimagine(
511
+ entry: MergeEntry,
512
+ canonicalBranch: string,
513
+ repoRoot: string,
514
+ ): Promise<{ success: boolean }> {
515
+ // Abort the current merge
516
+ await runGit(repoRoot, ["merge", "--abort"]);
517
+
518
+ for (const file of entry.filesModified) {
519
+ try {
520
+ // Get the canonical version
521
+ const { stdout: canonicalContent, exitCode: catCanonicalCode } = await runGit(repoRoot, [
522
+ "show",
523
+ `${canonicalBranch}:${file}`,
524
+ ]);
525
+
526
+ // Get the branch version
527
+ const { stdout: branchContent, exitCode: catBranchCode } = await runGit(repoRoot, [
528
+ "show",
529
+ `${entry.branchName}:${file}`,
530
+ ]);
531
+
532
+ if (catCanonicalCode !== 0 || catBranchCode !== 0) {
533
+ return { success: false };
534
+ }
535
+
536
+ const prompt = [
537
+ "You are a merge conflict resolver. Output ONLY the final file content.",
538
+ "Rules: NO explanation, NO markdown fencing, NO conversation, NO preamble.",
539
+ "Output the raw file content as it should appear on disk.",
540
+ "Reimplement the changes from the branch version onto the canonical version.",
541
+ `\n\n=== CANONICAL VERSION (${canonicalBranch}) ===\n`,
542
+ canonicalContent,
543
+ `\n\n=== BRANCH VERSION (${entry.branchName}) ===\n`,
544
+ branchContent,
545
+ ].join("");
546
+
547
+ const { stdout: reimagined, exitCode } = await runCommand(
548
+ ["claude", "--print", "-p", prompt],
549
+ repoRoot,
550
+ );
551
+
552
+ if (exitCode !== 0 || reimagined.trim() === "") {
553
+ return { success: false };
554
+ }
555
+
556
+ // Validate output is code, not prose
557
+ if (looksLikeProse(reimagined)) {
558
+ return { success: false };
559
+ }
560
+
561
+ const filePath = `${repoRoot}/${file}`;
562
+ await writeFile(filePath, reimagined);
563
+ const { exitCode: addExitCode } = await runGit(repoRoot, ["add", file]);
564
+ if (addExitCode !== 0) {
565
+ return { success: false };
566
+ }
567
+ } catch {
568
+ return { success: false };
569
+ }
570
+ }
571
+
572
+ // Commit the reimagined changes
573
+ const { exitCode } = await runGit(repoRoot, [
574
+ "commit",
575
+ "-m",
576
+ `Reimagine merge: ${entry.branchName} onto ${canonicalBranch}`,
577
+ ]);
578
+
579
+ return { success: exitCode === 0 };
580
+ }
581
+
582
+ /**
583
+ * Query mulch for historical conflict patterns related to the merge entry.
584
+ * Returns empty history if mulch is unavailable or search fails (fire-and-forget).
585
+ */
586
+ async function queryConflictHistory(
587
+ mulchClient: MulchClient,
588
+ entry: MergeEntry,
589
+ ): Promise<ConflictHistory> {
590
+ try {
591
+ const searchOutput = await mulchClient.search("merge-conflict");
592
+ const patterns = parseConflictPatterns(searchOutput);
593
+ return buildConflictHistory(patterns, entry.filesModified);
594
+ } catch {
595
+ return { skipTiers: [], pastResolutions: [], predictedConflictFiles: [] };
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Record a merge conflict pattern to mulch for future learning.
601
+ * Uses fire-and-forget (try/catch swallowing errors) so recording
602
+ * never blocks or fails the merge itself.
603
+ */
604
+ function recordConflictPattern(
605
+ mulchClient: MulchClient,
606
+ entry: MergeEntry,
607
+ tier: ResolutionTier,
608
+ conflictFiles: string[],
609
+ success: boolean,
610
+ ): void {
611
+ const outcome = success ? "resolved" : "failed";
612
+ const description = [
613
+ `Merge conflict ${outcome} at tier ${tier}.`,
614
+ `Branch: ${entry.branchName}.`,
615
+ `Agent: ${entry.agentName}.`,
616
+ `Conflicting files: ${conflictFiles.join(", ")}.`,
617
+ ].join(" ");
618
+
619
+ // Fire-and-forget per convention mx-09e10f
620
+ mulchClient
621
+ .record("architecture", {
622
+ type: "pattern",
623
+ description,
624
+ tags: ["merge-conflict"],
625
+ evidenceBead: entry.beadId,
626
+ })
627
+ .catch(() => {});
628
+ }
629
+
630
+ return {
631
+ async resolve(
632
+ entry: MergeEntry,
633
+ canonicalBranch: string,
634
+ repoRoot: string,
635
+ ): Promise<MergeResult> {
636
+ // Check current branch — skip checkout if already on canonical.
637
+ // Avoids "already checked out" error when worktrees exist.
638
+ const { stdout: currentRef, exitCode: refCode } = await runGit(repoRoot, [
639
+ "symbolic-ref",
640
+ "--short",
641
+ "HEAD",
642
+ ]);
643
+ const needsCheckout = refCode !== 0 || currentRef.trim() !== canonicalBranch;
644
+
645
+ if (needsCheckout) {
646
+ const { exitCode: checkoutCode, stderr: checkoutErr } = await runGit(repoRoot, [
647
+ "checkout",
648
+ canonicalBranch,
649
+ ]);
650
+ if (checkoutCode !== 0) {
651
+ throw new MergeError(`Failed to checkout ${canonicalBranch}: ${checkoutErr.trim()}`, {
652
+ branchName: canonicalBranch,
653
+ });
654
+ }
655
+ }
656
+
657
+ let lastTier: ResolutionTier = "clean-merge";
658
+ let conflictFiles: string[] = [];
659
+
660
+ // Tier 1: Clean merge
661
+ const cleanResult = await tryCleanMerge(entry, repoRoot);
662
+ if (cleanResult.success) {
663
+ return {
664
+ entry: { ...entry, status: "merged", resolvedTier: "clean-merge" },
665
+ success: true,
666
+ tier: "clean-merge",
667
+ conflictFiles: [],
668
+ errorMessage: null,
669
+ };
670
+ }
671
+ conflictFiles = cleanResult.conflictFiles;
672
+
673
+ // Query conflict history (if mulchClient available)
674
+ let history: ConflictHistory = {
675
+ skipTiers: [],
676
+ pastResolutions: [],
677
+ predictedConflictFiles: [],
678
+ };
679
+ if (options.mulchClient) {
680
+ history = await queryConflictHistory(options.mulchClient, entry);
681
+ }
682
+
683
+ // Tier 2: Auto-resolve (keep incoming)
684
+ if (!history.skipTiers.includes("auto-resolve")) {
685
+ lastTier = "auto-resolve";
686
+ const autoResult = await tryAutoResolve(conflictFiles, repoRoot);
687
+ if (autoResult.success) {
688
+ if (options.mulchClient) {
689
+ recordConflictPattern(options.mulchClient, entry, "auto-resolve", conflictFiles, true);
690
+ }
691
+ return {
692
+ entry: { ...entry, status: "merged", resolvedTier: "auto-resolve" },
693
+ success: true,
694
+ tier: "auto-resolve",
695
+ conflictFiles,
696
+ errorMessage: null,
697
+ };
698
+ }
699
+ conflictFiles = autoResult.remainingConflicts;
700
+ } // If skipped, fall through to next tier
701
+
702
+ // Tier 3: AI-resolve
703
+ if (options.aiResolveEnabled && !history.skipTiers.includes("ai-resolve")) {
704
+ lastTier = "ai-resolve";
705
+ const aiResult = await tryAiResolve(conflictFiles, repoRoot, history.pastResolutions);
706
+ if (aiResult.success) {
707
+ if (options.mulchClient) {
708
+ recordConflictPattern(options.mulchClient, entry, "ai-resolve", conflictFiles, true);
709
+ }
710
+ return {
711
+ entry: { ...entry, status: "merged", resolvedTier: "ai-resolve" },
712
+ success: true,
713
+ tier: "ai-resolve",
714
+ conflictFiles,
715
+ errorMessage: null,
716
+ };
717
+ }
718
+ conflictFiles = aiResult.remainingConflicts;
719
+ }
720
+
721
+ // Tier 4: Re-imagine
722
+ if (options.reimagineEnabled && !history.skipTiers.includes("reimagine")) {
723
+ lastTier = "reimagine";
724
+ const reimagineResult = await tryReimagine(entry, canonicalBranch, repoRoot);
725
+ if (reimagineResult.success) {
726
+ if (options.mulchClient) {
727
+ recordConflictPattern(options.mulchClient, entry, "reimagine", conflictFiles, true);
728
+ }
729
+ return {
730
+ entry: { ...entry, status: "merged", resolvedTier: "reimagine" },
731
+ success: true,
732
+ tier: "reimagine",
733
+ conflictFiles: [],
734
+ errorMessage: null,
735
+ };
736
+ }
737
+ }
738
+
739
+ // All enabled tiers failed — abort any in-progress merge
740
+ try {
741
+ await runGit(repoRoot, ["merge", "--abort"]);
742
+ } catch {
743
+ // merge --abort may fail if there's no merge in progress (e.g., after reimagine)
744
+ }
745
+
746
+ if (options.mulchClient) {
747
+ recordConflictPattern(options.mulchClient, entry, lastTier, conflictFiles, false);
748
+ }
749
+
750
+ return {
751
+ entry: { ...entry, status: "failed", resolvedTier: null },
752
+ success: false,
753
+ tier: lastTier,
754
+ conflictFiles,
755
+ errorMessage: `All enabled resolution tiers failed (last attempted: ${lastTier})`,
756
+ };
757
+ },
758
+ };
759
+ }