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