xab 1.0.0 → 2.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,2638 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+ var __defProp = Object.defineProperty;
4
+ var __returnValue = (v) => v;
5
+ function __exportSetter(name, newValue) {
6
+ this[name] = __returnValue.bind(null, newValue);
7
+ }
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, {
11
+ get: all[name],
12
+ enumerable: true,
13
+ configurable: true,
14
+ set: __exportSetter.bind(all, name)
15
+ });
16
+ };
17
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
+
19
+ // src/config.ts
20
+ import { existsSync, readFileSync } from "fs";
21
+ import { join } from "path";
22
+ function loadConfig(repoPath, explicitPath) {
23
+ const paths = explicitPath ? [explicitPath] : CONFIG_FILENAMES.map((f) => join(repoPath, f));
24
+ for (const p of paths) {
25
+ if (existsSync(p)) {
26
+ try {
27
+ const raw = readFileSync(p, "utf-8");
28
+ const parsed = JSON.parse(raw);
29
+ return { ...DEFAULT_CONFIG, ...parsed };
30
+ } catch (e) {
31
+ throw new Error(`Failed to parse config at ${p}: ${e.message}`);
32
+ }
33
+ }
34
+ }
35
+ return { ...DEFAULT_CONFIG };
36
+ }
37
+ var CONFIG_FILENAMES, DEFAULT_CONFIG;
38
+ var init_config = __esm(() => {
39
+ CONFIG_FILENAMES = [".backmerge.json", ".backmerge/config.json", "backmerge.config.json"];
40
+ DEFAULT_CONFIG = {
41
+ instructionFiles: [],
42
+ docRoutes: [],
43
+ promptHints: [],
44
+ pathRemaps: [],
45
+ reviewStrictness: "normal",
46
+ maxAttempts: 2,
47
+ commitPrefix: "backmerge:"
48
+ };
49
+ });
50
+
51
+ // src/context.ts
52
+ import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync } from "fs";
53
+ import { join as join2, relative } from "path";
54
+ function minimatch(path, pattern) {
55
+ let re = pattern;
56
+ re = re.replace(/\*\*\//g, "\x00GS\x00");
57
+ re = re.replace(/\*\*/g, "\x00GA\x00");
58
+ re = re.replace(/\*/g, "\x00S\x00");
59
+ re = re.replace(/\?/g, "\x00Q\x00");
60
+ re = re.replace(/[.+^${}()|[\]\\]/g, "\\$&");
61
+ re = re.replace(/\0GS\0/g, "(?:.*/)?");
62
+ re = re.replace(/\0GA\0/g, ".*");
63
+ re = re.replace(/\0S\0/g, "[^/]*");
64
+ re = re.replace(/\0Q\0/g, "[^/]");
65
+ return new RegExp(`^${re}$`).test(path);
66
+ }
67
+ function inferRepoStructure(repoPath) {
68
+ const entries = readdirSync(repoPath, { withFileTypes: true });
69
+ const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
70
+ const files = entries.filter((e) => e.isFile()).map((e) => e.name);
71
+ const configFiles = files.filter((f) => [
72
+ "package.json",
73
+ "tsconfig.json",
74
+ "Cargo.toml",
75
+ "go.mod",
76
+ "pyproject.toml",
77
+ "pnpm-workspace.yaml",
78
+ "turbo.json",
79
+ "nx.json",
80
+ "lerna.json",
81
+ "bun.lockb",
82
+ "yarn.lock",
83
+ "pnpm-lock.yaml",
84
+ "Makefile",
85
+ "foundry.toml",
86
+ "hardhat.config.ts",
87
+ "hardhat.config.js"
88
+ ].includes(f));
89
+ const monoSignals = ["apps", "packages", "libs", "modules", "services", "crates", "workspace"];
90
+ const hasMonoDir = dirs.some((d) => monoSignals.includes(d));
91
+ const hasWorkspaceConfig = ["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json"].some((f) => files.includes(f));
92
+ let hasWorkspacesField = false;
93
+ let packageManager;
94
+ const pkgPath = join2(repoPath, "package.json");
95
+ if (existsSync2(pkgPath)) {
96
+ try {
97
+ const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
98
+ hasWorkspacesField = Array.isArray(pkg.workspaces) || !!pkg.workspaces?.packages;
99
+ if (pkg.packageManager)
100
+ packageManager = pkg.packageManager;
101
+ else if (existsSync2(join2(repoPath, "bun.lockb")))
102
+ packageManager = "bun";
103
+ else if (existsSync2(join2(repoPath, "pnpm-lock.yaml")))
104
+ packageManager = "pnpm";
105
+ else if (existsSync2(join2(repoPath, "yarn.lock")))
106
+ packageManager = "yarn";
107
+ } catch {}
108
+ }
109
+ const componentDirs = dirs.filter((d) => {
110
+ const sub = join2(repoPath, d);
111
+ return existsSync2(join2(sub, "package.json")) || existsSync2(join2(sub, "go.mod")) || existsSync2(join2(sub, "Cargo.toml"));
112
+ });
113
+ const isComponentMonorepo = componentDirs.length >= 2;
114
+ const isMonorepo = hasMonoDir || hasWorkspaceConfig || hasWorkspacesField || isComponentMonorepo;
115
+ let apps;
116
+ let packages;
117
+ if (isMonorepo) {
118
+ for (const subdir of ["apps", "services"]) {
119
+ const p = join2(repoPath, subdir);
120
+ if (existsSync2(p)) {
121
+ apps = readdirSync(p, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => `${subdir}/${e.name}`);
122
+ }
123
+ }
124
+ for (const subdir of ["packages", "libs", "modules"]) {
125
+ const p = join2(repoPath, subdir);
126
+ if (existsSync2(p)) {
127
+ packages = readdirSync(p, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => `${subdir}/${e.name}`);
128
+ }
129
+ }
130
+ if (!apps && componentDirs.length >= 2) {
131
+ apps = componentDirs;
132
+ }
133
+ }
134
+ return {
135
+ type: isMonorepo ? "monorepo" : "single-project",
136
+ topLevelDirs: dirs,
137
+ apps,
138
+ packages,
139
+ configFiles,
140
+ packageManager
141
+ };
142
+ }
143
+ function discoverInstructionFiles(repoPath, config) {
144
+ const found = new Map;
145
+ for (const candidate of INSTRUCTION_FILE_CANDIDATES) {
146
+ const full = join2(repoPath, candidate);
147
+ if (existsSync2(full)) {
148
+ try {
149
+ const content = readFileSync2(full, "utf-8");
150
+ found.set(candidate, content.slice(0, 8000));
151
+ } catch {}
152
+ }
153
+ }
154
+ for (const f of config.instructionFiles ?? []) {
155
+ if (found.has(f))
156
+ continue;
157
+ const full = join2(repoPath, f);
158
+ if (existsSync2(full)) {
159
+ try {
160
+ found.set(f, readFileSync2(full, "utf-8").slice(0, 8000));
161
+ } catch {}
162
+ }
163
+ }
164
+ return found;
165
+ }
166
+ function discoverDocPaths(repoPath) {
167
+ const paths = [];
168
+ function walk(dir, globs) {
169
+ if (!existsSync2(dir))
170
+ return;
171
+ const entries = readdirSync(dir, { withFileTypes: true });
172
+ for (const entry of entries) {
173
+ const full = join2(dir, entry.name);
174
+ const rel = relative(repoPath, full);
175
+ if (entry.isDirectory()) {
176
+ walk(full, globs);
177
+ } else if (entry.isFile() && globs.some((g) => minimatch(rel, g))) {
178
+ paths.push(rel);
179
+ }
180
+ }
181
+ }
182
+ for (const glob of DOC_DIR_GLOBS) {
183
+ const baseDir = glob.split("/")[0];
184
+ walk(join2(repoPath, baseDir), [glob]);
185
+ }
186
+ try {
187
+ for (const entry of readdirSync(repoPath, { withFileTypes: true })) {
188
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
189
+ const readme = join2(repoPath, entry.name, "README.md");
190
+ if (existsSync2(readme)) {
191
+ paths.push(`${entry.name}/README.md`);
192
+ }
193
+ }
194
+ }
195
+ } catch {}
196
+ return [...new Set(paths)].sort();
197
+ }
198
+ function buildRepoContext(repoPath, config) {
199
+ const structure = inferRepoStructure(repoPath);
200
+ const instructions = discoverInstructionFiles(repoPath, config);
201
+ const docPaths = discoverDocPaths(repoPath);
202
+ return { structure, instructions, docPaths };
203
+ }
204
+ function buildCommitContext(repoPath, repoCtx, config, touchedPaths, commitMessage) {
205
+ const includedFiles = [];
206
+ const sections = [];
207
+ const rs = repoCtx.structure;
208
+ const structLines = [`Repository: ${rs.type}`];
209
+ if (rs.packageManager)
210
+ structLines.push(`Package manager: ${rs.packageManager}`);
211
+ if (rs.apps?.length)
212
+ structLines.push(`Components: ${rs.apps.join(", ")}`);
213
+ if (rs.packages?.length)
214
+ structLines.push(`Packages: ${rs.packages.join(", ")}`);
215
+ sections.push(structLines.join(`
216
+ `));
217
+ for (const [name, content] of repoCtx.instructions) {
218
+ sections.push(`--- ${name} ---
219
+ ${content}`);
220
+ includedFiles.push(name);
221
+ }
222
+ if (config.promptHints?.length) {
223
+ sections.push(`--- Merge hints ---
224
+ ${config.promptHints.join(`
225
+ `)}`);
226
+ }
227
+ if (config.pathRemaps?.length) {
228
+ const lines = config.pathRemaps.map((r) => ` ${r.source} \u2192 ${r.target}${r.note ? ` (${r.note})` : ""}`);
229
+ sections.push(`--- Path remaps ---
230
+ ${lines.join(`
231
+ `)}`);
232
+ }
233
+ const matchedDocs = new Set;
234
+ for (const route of config.docRoutes ?? []) {
235
+ if (routeMatches(route, touchedPaths, commitMessage)) {
236
+ for (const docGlob of route.docs) {
237
+ for (const docPath of repoCtx.docPaths) {
238
+ if (minimatch(docPath, docGlob)) {
239
+ matchedDocs.add(docPath);
240
+ }
241
+ }
242
+ }
243
+ }
244
+ }
245
+ const touchedTopDirs = new Set(touchedPaths.map((p) => p.split("/")[0]).filter(Boolean));
246
+ for (const docPath of repoCtx.docPaths) {
247
+ const docName = docPath.toLowerCase();
248
+ for (const dir of touchedTopDirs) {
249
+ if (docName.includes(dir.toLowerCase())) {
250
+ matchedDocs.add(docPath);
251
+ }
252
+ }
253
+ const msgLower = commitMessage.toLowerCase();
254
+ const docBasename = docPath.split("/").pop()?.replace(/\.md$/, "").toLowerCase() ?? "";
255
+ if (docBasename && msgLower.includes(docBasename)) {
256
+ matchedDocs.add(docPath);
257
+ }
258
+ }
259
+ let docBudget = 4000;
260
+ for (const docPath of matchedDocs) {
261
+ if (docBudget <= 0)
262
+ break;
263
+ const full = join2(repoPath, docPath);
264
+ if (existsSync2(full)) {
265
+ try {
266
+ const content = readFileSync2(full, "utf-8");
267
+ const chunk = content.slice(0, Math.min(2000, docBudget));
268
+ sections.push(`--- ${docPath} ---
269
+ ${chunk}`);
270
+ includedFiles.push(docPath);
271
+ docBudget -= chunk.length;
272
+ } catch {}
273
+ }
274
+ }
275
+ return {
276
+ promptBlock: sections.join(`
277
+
278
+ `),
279
+ includedFiles
280
+ };
281
+ }
282
+ function routeMatches(route, touchedPaths, commitMessage) {
283
+ for (const glob of route.pathGlobs) {
284
+ for (const path of touchedPaths) {
285
+ if (minimatch(path, glob))
286
+ return true;
287
+ }
288
+ }
289
+ if (route.keywords) {
290
+ const msgLower = commitMessage.toLowerCase();
291
+ for (const kw of route.keywords) {
292
+ if (msgLower.includes(kw.toLowerCase()))
293
+ return true;
294
+ }
295
+ }
296
+ return false;
297
+ }
298
+ var INSTRUCTION_FILE_CANDIDATES, DOC_DIR_GLOBS;
299
+ var init_context = __esm(() => {
300
+ INSTRUCTION_FILE_CANDIDATES = [
301
+ "AGENTS.md",
302
+ "CLAUDE.md",
303
+ "CONTRIBUTING.md",
304
+ ".cursorrules",
305
+ ".github/copilot-instructions.md"
306
+ ];
307
+ DOC_DIR_GLOBS = ["ai-docs/**/*.md", "docs/**/*.md", ".backmerge/docs/**/*.md"];
308
+ });
309
+
310
+ // src/decisions.ts
311
+ function validateDecision(d, headBefore, headAfter, isDryRun) {
312
+ const errors = [];
313
+ const headMoved = headBefore !== headAfter;
314
+ if (isDryRun) {
315
+ if (d.kind === "applied") {
316
+ errors.push("Dry-run produced 'applied' decision \u2014 must use 'would_apply'");
317
+ }
318
+ if (headMoved) {
319
+ errors.push(`Dry-run moved HEAD from ${headBefore.slice(0, 8)} to ${headAfter.slice(0, 8)}`);
320
+ }
321
+ }
322
+ switch (d.kind) {
323
+ case "applied":
324
+ if (!headMoved)
325
+ errors.push("Decision is 'applied' but HEAD did not move");
326
+ if (!d.newCommitHash)
327
+ errors.push("Decision is 'applied' but no newCommitHash");
328
+ break;
329
+ case "would_apply":
330
+ if (headMoved)
331
+ errors.push("Decision is 'would_apply' but HEAD moved");
332
+ if (!isDryRun)
333
+ errors.push("'would_apply' only valid in dry-run mode");
334
+ break;
335
+ case "already_applied":
336
+ case "skip":
337
+ if (headMoved)
338
+ errors.push(`Decision is '${d.kind}' but HEAD moved`);
339
+ break;
340
+ case "failed":
341
+ if (headMoved)
342
+ errors.push("Decision is 'failed' but HEAD moved (should have been reset)");
343
+ break;
344
+ }
345
+ return errors;
346
+ }
347
+ function emptyRunSummary() {
348
+ return { applied: 0, wouldApply: 0, alreadyApplied: 0, skipped: 0, failed: 0, total: 0, cherrySkipped: 0 };
349
+ }
350
+ function updateSummary(s, d) {
351
+ switch (d.kind) {
352
+ case "applied":
353
+ s.applied++;
354
+ break;
355
+ case "would_apply":
356
+ s.wouldApply++;
357
+ break;
358
+ case "already_applied":
359
+ s.alreadyApplied++;
360
+ break;
361
+ case "skip":
362
+ s.skipped++;
363
+ break;
364
+ case "failed":
365
+ s.failed++;
366
+ break;
367
+ }
368
+ }
369
+
370
+ // src/codex.ts
371
+ import { Codex } from "@openai/codex-sdk";
372
+ import { execSync } from "child_process";
373
+ function checkCodexInstalled() {
374
+ try {
375
+ execSync("codex --version", { stdio: "pipe" });
376
+ return true;
377
+ } catch {
378
+ return false;
379
+ }
380
+ }
381
+ function splitDiff(diff) {
382
+ if (diff.length <= CHUNK_SIZE)
383
+ return [diff];
384
+ const chunks = [];
385
+ const statEnd = diff.indexOf(`
386
+
387
+ `);
388
+ const firstEnd = statEnd > 0 ? Math.max(statEnd, CHUNK_SIZE) : CHUNK_SIZE;
389
+ chunks.push(diff.slice(0, Math.min(firstEnd, diff.length)));
390
+ let pos = chunks[0].length;
391
+ while (pos < diff.length) {
392
+ let end = pos + CHUNK_SIZE;
393
+ if (end < diff.length) {
394
+ const boundary = diff.lastIndexOf(`
395
+ diff --git `, end);
396
+ if (boundary > pos)
397
+ end = boundary;
398
+ }
399
+ chunks.push(diff.slice(pos, Math.min(end, diff.length)));
400
+ pos = end;
401
+ }
402
+ return chunks;
403
+ }
404
+ function truncateDiff(diff, maxLen) {
405
+ if (diff.length <= maxLen)
406
+ return diff;
407
+ const statEnd = diff.indexOf(`
408
+
409
+ `);
410
+ if (statEnd === -1 || statEnd > maxLen)
411
+ return diff.slice(0, maxLen) + `
412
+
413
+ ... [truncated]`;
414
+ const stat = diff.slice(0, statEnd);
415
+ const remaining = maxLen - stat.length - 50;
416
+ if (remaining <= 0)
417
+ return stat + `
418
+
419
+ ... [patch truncated]`;
420
+ return stat + diff.slice(statEnd, statEnd + remaining) + `
421
+
422
+ ... [patch truncated]`;
423
+ }
424
+ function parseJson(raw, fallback) {
425
+ try {
426
+ return JSON.parse(raw);
427
+ } catch {}
428
+ const m = raw.match(/\{[\s\S]*\}/);
429
+ if (m) {
430
+ try {
431
+ return JSON.parse(m[0]);
432
+ } catch {}
433
+ }
434
+ return fallback;
435
+ }
436
+ async function analyzeCommit(opts) {
437
+ const codex = new Codex;
438
+ const thread = codex.startThread({
439
+ workingDirectory: opts.worktreePath,
440
+ sandboxMode: "read-only",
441
+ model: "gpt-5.4",
442
+ modelReasoningEffort: "high"
443
+ });
444
+ const diffChunks = splitDiff(opts.commitDiff);
445
+ const firstPrompt = `${MERGE_PREAMBLE}
446
+ ${opts.repoContext ? `## Repository context
447
+ ${opts.repoContext}
448
+ ` : ""}
449
+ ## Source commit (from "${opts.sourceBranch}")
450
+ Hash: ${opts.commitHash}
451
+ Message: ${opts.commitMessage}
452
+
453
+ ### Diff${diffChunks.length > 1 ? ` (part 1/${diffChunks.length})` : ""}:
454
+ \`\`\`diff
455
+ ${diffChunks[0]}
456
+ \`\`\`
457
+
458
+ ## Latest state of source branch (for context):
459
+ \`\`\`diff
460
+ ${truncateDiff(opts.sourceLatestDiff, 1e4)}
461
+ \`\`\`
462
+
463
+ ${diffChunks.length > 1 ? "I will send the remaining diff parts next. Read them all before analyzing." : ""}
464
+
465
+ ## Your task
466
+ You are looking at a worktree based on the TARGET branch "${opts.targetBranch}".
467
+
468
+ 1. Read the target worktree files affected by this commit
469
+ 2. Summarize what the source commit does
470
+ 3. Determine if the target already has this functionality:
471
+ - "yes" = exact same functionality exists (check every affected file)
472
+ - "partial" = some parts exist
473
+ - "no" = missing
474
+ 4. If not fully present, describe a step-by-step strategy for applying cleanly
475
+ 5. List which top-level components/directories are affected
476
+ 6. Consider: does this change make sense for the target? Is it useful?`;
477
+ let turn;
478
+ if (diffChunks.length > 1) {
479
+ await thread.run(firstPrompt);
480
+ for (let i = 1;i < diffChunks.length - 1; i++) {
481
+ await thread.run(`### Diff (part ${i + 1}/${diffChunks.length}):
482
+ \`\`\`diff
483
+ ${diffChunks[i]}
484
+ \`\`\`
485
+
486
+ Continue reading. More parts coming.`);
487
+ }
488
+ const lastIdx = diffChunks.length - 1;
489
+ turn = await thread.run(`### Diff (part ${lastIdx + 1}/${diffChunks.length} \u2014 final):
490
+ \`\`\`diff
491
+ ${diffChunks[lastIdx]}
492
+ \`\`\`
493
+
494
+ You now have the complete diff. Analyze and produce your structured response.`, { outputSchema: analysisSchema });
495
+ } else {
496
+ turn = await thread.run(firstPrompt, { outputSchema: analysisSchema });
497
+ }
498
+ return parseJson(turn.finalResponse, {
499
+ summary: turn.finalResponse.slice(0, 500),
500
+ alreadyInTarget: "no",
501
+ reasoning: "Could not parse structured output",
502
+ applicationStrategy: "Manual review recommended",
503
+ affectedComponents: []
504
+ });
505
+ }
506
+ async function applyCommit(opts) {
507
+ const codex = new Codex;
508
+ const thread = codex.startThread({
509
+ workingDirectory: opts.worktreePath,
510
+ sandboxMode: "workspace-write",
511
+ model: "gpt-5.4",
512
+ modelReasoningEffort: "high"
513
+ });
514
+ const commitMsg = `${opts.commitPrefix} ${opts.commitMessage} (from ${opts.commitHash.slice(0, 8)})`;
515
+ const diffChunks = splitDiff(opts.commitDiff);
516
+ const instructions = `## Application strategy:
517
+ ${opts.applicationStrategy}
518
+
519
+ ## Instructions \u2014 clean curated merge
520
+ - Read the affected files in the worktree FIRST to understand the target's current state
521
+ - Apply changes so the result is clean, compiling, and conflict-free
522
+ - Adapt to the target's code style, imports, and architecture
523
+ - If the target already has a different version of the same logic, merge both intents
524
+ - Preserve the target's existing improvements \u2014 do not regress
525
+ - Create or modify files as needed; delete files if the source commit deleted them
526
+ - After applying, run exactly: git add -A && git commit -m "${commitMsg.replace(/"/g, "\\\"")}"
527
+ - You MUST create exactly ONE commit. Not zero, not two.
528
+ - No conflict markers, dead code, or TODO placeholders
529
+ - If impossible to apply cleanly, explain why in notes
530
+
531
+ Report what you did.`;
532
+ const firstPrompt = `${MERGE_PREAMBLE}
533
+ ${opts.repoContext ? `## Repository context
534
+ ${opts.repoContext}
535
+ ` : ""}
536
+ ## Source commit (from "${opts.sourceBranch}")
537
+ Hash: ${opts.commitHash}
538
+ Message: ${opts.commitMessage}
539
+
540
+ ### Diff${diffChunks.length > 1 ? ` (part 1/${diffChunks.length})` : ""}:
541
+ \`\`\`diff
542
+ ${diffChunks[0]}
543
+ \`\`\`
544
+
545
+ ${diffChunks.length > 1 ? "I will send the remaining diff parts next. Read them all before applying." : instructions}`;
546
+ let turn;
547
+ if (diffChunks.length > 1) {
548
+ await thread.run(firstPrompt);
549
+ for (let i = 1;i < diffChunks.length - 1; i++) {
550
+ await thread.run(`### Diff (part ${i + 1}/${diffChunks.length}):
551
+ \`\`\`diff
552
+ ${diffChunks[i]}
553
+ \`\`\`
554
+
555
+ Continue reading. More parts coming.`);
556
+ }
557
+ const lastIdx = diffChunks.length - 1;
558
+ turn = await thread.run(`### Diff (part ${lastIdx + 1}/${diffChunks.length} \u2014 final):
559
+ \`\`\`diff
560
+ ${diffChunks[lastIdx]}
561
+ \`\`\`
562
+
563
+ You now have the complete diff.
564
+
565
+ ${instructions}`, { outputSchema: applyResultSchema });
566
+ } else {
567
+ turn = await thread.run(firstPrompt, { outputSchema: applyResultSchema });
568
+ }
569
+ return parseJson(turn.finalResponse, {
570
+ applied: false,
571
+ filesChanged: [],
572
+ commitMessage: commitMsg,
573
+ notes: turn.finalResponse.slice(0, 1000),
574
+ adaptations: ""
575
+ });
576
+ }
577
+ async function fixFromReview(opts) {
578
+ const codex = new Codex;
579
+ const thread = codex.startThread({
580
+ workingDirectory: opts.worktreePath,
581
+ sandboxMode: "workspace-write",
582
+ model: "gpt-5.4",
583
+ modelReasoningEffort: "high"
584
+ });
585
+ const commitMsg = `${opts.commitPrefix} ${opts.commitMessage} (from ${opts.commitHash.slice(0, 8)})`;
586
+ const prompt = `A code reviewer found issues with your previous apply of commit ${opts.commitHash.slice(0, 8)} ("${opts.commitMessage}").
587
+
588
+ ${opts.repoContext ? `## Repository context
589
+ ${opts.repoContext}
590
+ ` : ""}
591
+
592
+ ## Review objections \u2014 you MUST fix ALL of these:
593
+ ${opts.reviewIssues.map((issue, i) => `${i + 1}. ${issue}`).join(`
594
+ `)}
595
+
596
+ ## Instructions
597
+ - Read the affected files to understand the current state
598
+ - Fix every issue the reviewer raised
599
+ - Do NOT introduce new problems while fixing
600
+ - After fixing, amend the commit: git add -A && git commit --amend -m "${commitMsg.replace(/"/g, "\\\"")}"
601
+ - The result must be a single clean commit with no issues
602
+
603
+ Report what you fixed.`;
604
+ const turn = await thread.run(prompt, { outputSchema: applyResultSchema });
605
+ return parseJson(turn.finalResponse, {
606
+ applied: false,
607
+ filesChanged: [],
608
+ commitMessage: commitMsg,
609
+ notes: turn.finalResponse.slice(0, 1000),
610
+ adaptations: ""
611
+ });
612
+ }
613
+ var analysisSchema, applyResultSchema, CHUNK_SIZE = 24000, MERGE_PREAMBLE = `## Important: this is a CURATED MERGE, not a blind cherry-pick
614
+
615
+ You are helping merge changes from a source branch into a target branch that may have diverged significantly.
616
+
617
+ Rules:
618
+ - Preserve the target branch's architecture, conventions, and improvements
619
+ - Merge the INTENT of the source commit, not its literal paths/code
620
+ - The target may have different file locations, naming, or implementations
621
+ - Only apply changes that are genuinely useful to the target
622
+ - Do not regress target-specific improvements or features
623
+ - Prefer adapting behavior into the target's existing code structure
624
+ - If the source commit doesn't make sense for the target, explain why
625
+ `;
626
+ var init_codex = __esm(() => {
627
+ analysisSchema = {
628
+ type: "object",
629
+ properties: {
630
+ summary: { type: "string", description: "Concise summary of what this commit does" },
631
+ alreadyInTarget: {
632
+ type: "string",
633
+ enum: ["yes", "no", "partial"],
634
+ description: "Whether the target branch already has this change. 'yes' = exact same functionality exists. 'partial' = some parts exist. 'no' = missing."
635
+ },
636
+ reasoning: { type: "string", description: "Evidence \u2014 list which files you checked and what you found" },
637
+ applicationStrategy: {
638
+ type: "string",
639
+ description: "Step-by-step strategy for applying. If already present, say 'skip'."
640
+ },
641
+ affectedComponents: {
642
+ type: "array",
643
+ items: { type: "string" },
644
+ description: "Top-level directories/components affected (e.g. 'api', 'frontend', 'contracts')"
645
+ }
646
+ },
647
+ required: ["summary", "alreadyInTarget", "reasoning", "applicationStrategy", "affectedComponents"],
648
+ additionalProperties: false
649
+ };
650
+ applyResultSchema = {
651
+ type: "object",
652
+ properties: {
653
+ applied: { type: "boolean", description: "Whether changes were applied and committed" },
654
+ filesChanged: { type: "array", items: { type: "string" }, description: "File paths created/modified/deleted" },
655
+ commitMessage: { type: "string", description: "The commit message used" },
656
+ notes: { type: "string", description: "Issues encountered or adaptations made" },
657
+ adaptations: { type: "string", description: "How the changes were adapted to fit the target codebase" }
658
+ },
659
+ required: ["applied", "filesChanged", "commitMessage", "notes", "adaptations"],
660
+ additionalProperties: false
661
+ };
662
+ });
663
+
664
+ // src/review.ts
665
+ import { query } from "@anthropic-ai/claude-agent-sdk";
666
+ function writeReviewPacket(audit, packet, attempt) {
667
+ const { commitHash } = packet;
668
+ audit.writeReviewContext(commitHash, attempt, {
669
+ commitHash: packet.commitHash,
670
+ commitMessage: packet.commitMessage,
671
+ sourceBranch: packet.sourceBranch,
672
+ targetBranch: packet.targetBranch,
673
+ analysis: packet.analysis,
674
+ reviewStrictness: packet.reviewStrictness
675
+ });
676
+ if (packet.relevantDocs) {
677
+ audit.writeRelevantDocs(commitHash, attempt, packet.relevantDocs);
678
+ }
679
+ if (packet.appliedDiffStat) {
680
+ audit.writeDiffStat(commitHash, attempt, packet.appliedDiffStat);
681
+ }
682
+ if (packet.appliedDiff) {
683
+ audit.writeAppliedPatch(commitHash, attempt, packet.appliedDiff);
684
+ }
685
+ }
686
+ async function reviewAppliedDiff(worktreePath, packet) {
687
+ const strictnessInstructions = {
688
+ strict: "Be very strict. Any questionable change should be rejected. Err on the side of caution.",
689
+ normal: "Be thorough but reasonable. Reject clear issues, accept minor style differences.",
690
+ lenient: "Focus on correctness and safety. Accept reasonable adaptations even if imperfect."
691
+ };
692
+ const prompt = `You are reviewing a curated merge. A commit from "${packet.sourceBranch}" was applied to a branch based on "${packet.targetBranch}".
693
+
694
+ ## Codex analysis of the source commit
695
+ Summary: ${packet.analysis.summary}
696
+ Decision: alreadyInTarget=${packet.analysis.alreadyInTarget}
697
+ Strategy: ${packet.analysis.applicationStrategy}
698
+ Affected components: ${packet.analysis.affectedComponents.join(", ")}
699
+
700
+ ## Source commit
701
+ Hash: ${packet.commitHash}
702
+ Message: ${packet.commitMessage}
703
+
704
+ ## Applied diff (what was actually committed):
705
+ \`\`\`diff
706
+ ${packet.appliedDiff.slice(0, 30000)}
707
+ \`\`\`
708
+
709
+ ## Diff stat:
710
+ ${packet.appliedDiffStat}
711
+
712
+ ${packet.repoContext ? `## Repository context
713
+ ${packet.repoContext}
714
+ ` : ""}
715
+ ${packet.relevantDocs ? `## Relevant documentation
716
+ ${packet.relevantDocs.slice(0, 5000)}
717
+ ` : ""}
718
+
719
+ ## Full code review \u2014 think critically
720
+
721
+ You are not just verifying the diff was applied. You are doing a FULL CODE REVIEW.
722
+
723
+ ### Correctness
724
+ 1. Did Codex make the RIGHT decision? Should this commit have been applied at all?
725
+ 2. Does the applied change actually achieve the intent of the source commit?
726
+ 3. Are there logic errors introduced by adapting the code?
727
+ 4. Does the change break any existing target functionality?
728
+ 5. Are edge cases handled correctly?
729
+
730
+ ### Cleanliness
731
+ 6. NO conflict markers (<<<<<<<, =======, >>>>>>>) anywhere
732
+ 7. NO dead code, commented-out old code, or TODO/FIXME placeholders
733
+ 8. Imports are correct \u2014 no broken references
734
+ 9. Code style matches the target branch's conventions
735
+ 10. No duplicate definitions or redundant code
736
+
737
+ ### Architecture
738
+ 11. Does the change respect the target's architecture?
739
+ 12. Were target-specific improvements preserved (not regressed)?
740
+ 13. If the target had a better implementation, was it kept?
741
+ 14. Are file paths and module boundaries correct for the target?
742
+
743
+ ### Completeness
744
+ 15. The applied changes actually match the stated strategy
745
+ 16. Nothing important was missed from the source commit
746
+ 17. Files created/deleted as needed
747
+
748
+ Scope your review to the diff introduced by this commit. Read the affected files in the worktree to understand the full context around the changed lines, but focus your judgment on what this commit changed.
749
+
750
+ ## Strictness: ${packet.reviewStrictness}
751
+ ${strictnessInstructions[packet.reviewStrictness]}
752
+
753
+ If you have ANY objections, be specific about what's wrong and how to fix it. Your issues will be sent back to the apply agent for correction.`;
754
+ const q = query({
755
+ prompt,
756
+ options: {
757
+ cwd: worktreePath,
758
+ model: "claude-opus-4-6",
759
+ maxTurns: 30,
760
+ permissionMode: "default",
761
+ outputFormat: reviewSchema,
762
+ tools: ["Read", "Glob", "Grep", "Bash"],
763
+ disallowedTools: ["Edit", "Write", "NotebookEdit"],
764
+ systemPrompt: `You are a senior code reviewer for curated merge operations. Do a full code review \u2014 think about correctness, architecture, and whether the change was the right call.
765
+
766
+ You can:
767
+ - Read files for full context
768
+ - Search with Glob/Grep
769
+ - Run tests, linters, type-checkers, and build commands via Bash to verify correctness
770
+ - Run any read-only shell command (cat, ls, git diff, git log, etc.)
771
+
772
+ You MUST NOT modify the worktree in any way. No file writes, no git commits, no destructive commands.
773
+
774
+ Run relevant tests if you can determine the test command from the repo. Your objections will be sent back to the apply agent for fixing, so be specific and actionable.`
775
+ }
776
+ });
777
+ let resultText = "";
778
+ for await (const message of q) {
779
+ if (message.type === "result") {
780
+ if ("result" in message) {
781
+ resultText = message.result;
782
+ }
783
+ break;
784
+ }
785
+ }
786
+ if (!resultText) {
787
+ return { approved: false, issues: ["Review produced no output"], summary: "Review failed", confidence: "low" };
788
+ }
789
+ try {
790
+ return JSON.parse(resultText);
791
+ } catch {
792
+ const m = resultText.match(/\{[\s\S]*\}/);
793
+ if (m) {
794
+ try {
795
+ return JSON.parse(m[0]);
796
+ } catch {}
797
+ }
798
+ return {
799
+ approved: false,
800
+ issues: [`Unparseable review output: ${resultText.slice(0, 200)}`],
801
+ summary: "Parse error",
802
+ confidence: "low"
803
+ };
804
+ }
805
+ }
806
+ async function verifyReviewIntegrity(wtGit, expectedHead) {
807
+ const currentHead = (await wtGit.raw(["rev-parse", "HEAD"])).trim();
808
+ if (currentHead !== expectedHead) {
809
+ return `Review mutated HEAD: expected ${expectedHead.slice(0, 8)}, got ${currentHead.slice(0, 8)}`;
810
+ }
811
+ const status = await wtGit.status();
812
+ const dirty = status.modified.length + status.created.length + status.deleted.length + status.not_added.length + status.conflicted.length;
813
+ if (dirty > 0) {
814
+ const parts = [];
815
+ if (status.modified.length)
816
+ parts.push(`${status.modified.length} modified`);
817
+ if (status.created.length)
818
+ parts.push(`${status.created.length} staged`);
819
+ if (status.not_added.length)
820
+ parts.push(`${status.not_added.length} untracked`);
821
+ if (status.deleted.length)
822
+ parts.push(`${status.deleted.length} deleted`);
823
+ return `Review left dirty worktree: ${parts.join(", ")}`;
824
+ }
825
+ return null;
826
+ }
827
+ var reviewSchema;
828
+ var init_review = __esm(() => {
829
+ reviewSchema = {
830
+ type: "json_schema",
831
+ schema: {
832
+ type: "object",
833
+ properties: {
834
+ approved: {
835
+ type: "boolean",
836
+ description: "true if changes are clean, correct, and safe. false if issues need fixing."
837
+ },
838
+ issues: {
839
+ type: "array",
840
+ items: { type: "string" },
841
+ description: "Specific issues found. Empty if approved."
842
+ },
843
+ summary: {
844
+ type: "string",
845
+ description: "Brief summary of what was reviewed and the verdict"
846
+ },
847
+ confidence: {
848
+ type: "string",
849
+ enum: ["high", "medium", "low"],
850
+ description: "Confidence in the review assessment"
851
+ }
852
+ },
853
+ required: ["approved", "issues", "summary", "confidence"],
854
+ additionalProperties: false
855
+ }
856
+ };
857
+ });
858
+
859
+ // src/audit.ts
860
+ import { mkdirSync, writeFileSync, appendFileSync, existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
861
+ import { join as join3 } from "path";
862
+
863
+ class AuditLog {
864
+ runDir;
865
+ commitsDir;
866
+ resultsFile;
867
+ constructor(baseDir, runId) {
868
+ this.runDir = join3(baseDir, ".backmerge", "runs", runId);
869
+ this.commitsDir = join3(this.runDir, "commits");
870
+ this.resultsFile = join3(this.runDir, "results.jsonl");
871
+ mkdirSync(this.commitsDir, { recursive: true });
872
+ }
873
+ get resultsPath() {
874
+ return this.resultsFile;
875
+ }
876
+ emit(event) {
877
+ appendFileSync(this.resultsFile, JSON.stringify(event) + `
878
+ `);
879
+ }
880
+ commitDir(hash) {
881
+ const dir = join3(this.commitsDir, hash.slice(0, 8));
882
+ mkdirSync(dir, { recursive: true });
883
+ return dir;
884
+ }
885
+ attemptDir(hash, attempt) {
886
+ const dir = join3(this.commitDir(hash), `attempt-${attempt}`);
887
+ mkdirSync(dir, { recursive: true });
888
+ return dir;
889
+ }
890
+ writeSourcePatch(hash, patch) {
891
+ const p = join3(this.commitDir(hash), "source.patch");
892
+ writeFileSync(p, patch);
893
+ return p;
894
+ }
895
+ writeAnalysis(hash, attempt, analysis) {
896
+ const p = join3(this.attemptDir(hash, attempt), "analysis.json");
897
+ writeFileSync(p, JSON.stringify(analysis, null, 2));
898
+ return p;
899
+ }
900
+ writeAppliedPatch(hash, attempt, patch) {
901
+ const p = join3(this.attemptDir(hash, attempt), "applied.patch");
902
+ writeFileSync(p, patch);
903
+ return p;
904
+ }
905
+ writeReviewContext(hash, attempt, context) {
906
+ const p = join3(this.attemptDir(hash, attempt), "review-context.json");
907
+ writeFileSync(p, JSON.stringify(context, null, 2));
908
+ return p;
909
+ }
910
+ writeReviewResult(hash, attempt, result) {
911
+ const p = join3(this.attemptDir(hash, attempt), "review-result.json");
912
+ writeFileSync(p, JSON.stringify(result, null, 2));
913
+ return p;
914
+ }
915
+ writeRelevantDocs(hash, attempt, docs) {
916
+ const p = join3(this.attemptDir(hash, attempt), "relevant-docs.txt");
917
+ writeFileSync(p, docs);
918
+ return p;
919
+ }
920
+ writeDiffStat(hash, attempt, stat) {
921
+ const p = join3(this.attemptDir(hash, attempt), "target-diff.stat.txt");
922
+ writeFileSync(p, stat);
923
+ return p;
924
+ }
925
+ runStart(meta) {
926
+ this.emit({ ts: new Date().toISOString(), event: "run_start", ...meta });
927
+ writeFileSync(join3(this.runDir, "metadata.json"), JSON.stringify(meta, null, 2));
928
+ }
929
+ runEnd(summary, decisions) {
930
+ this.emit({ ts: new Date().toISOString(), event: "run_end", summary });
931
+ writeFileSync(join3(this.runDir, "summary.json"), JSON.stringify({ summary, decisions }, null, 2));
932
+ }
933
+ commitStart(hash, message, index, total) {
934
+ this.emit({ ts: new Date().toISOString(), event: "commit_start", hash, message, index, total });
935
+ }
936
+ commitDecision(d) {
937
+ this.emit({ ts: new Date().toISOString(), event: "commit_decision", ...d });
938
+ }
939
+ cherrySkip(hash, message, reason) {
940
+ this.emit({ ts: new Date().toISOString(), event: "cherry_skip", hash, message, reason });
941
+ }
942
+ fetchReset(logs) {
943
+ this.emit({ ts: new Date().toISOString(), event: "fetch_reset", logs });
944
+ }
945
+ error(phase, hash, message, error) {
946
+ this.emit({ ts: new Date().toISOString(), event: "error", phase, hash, message, error });
947
+ }
948
+ get progressFile() {
949
+ return join3(this.runDir, "progress.json");
950
+ }
951
+ saveProgress(decisions, commitIndex) {
952
+ writeFileSync(this.progressFile, JSON.stringify({ decisions, lastCommitIndex: commitIndex, savedAt: new Date().toISOString() }, null, 2));
953
+ }
954
+ loadProgress() {
955
+ if (!existsSync3(this.progressFile))
956
+ return null;
957
+ try {
958
+ const raw = JSON.parse(readFileSync3(this.progressFile, "utf-8"));
959
+ return { decisions: raw.decisions ?? [], lastCommitIndex: raw.lastCommitIndex ?? -1 };
960
+ } catch {
961
+ return null;
962
+ }
963
+ }
964
+ }
965
+ function findResumableRun(baseDir, workBranch) {
966
+ const runsDir = join3(baseDir, ".backmerge", "runs");
967
+ if (!existsSync3(runsDir))
968
+ return null;
969
+ const runDirs = readdirSync2(runsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name.startsWith("run-")).map((e) => e.name).sort().reverse();
970
+ for (const runId of runDirs) {
971
+ const runDir = join3(runsDir, runId);
972
+ const metaFile = join3(runDir, "metadata.json");
973
+ const progressFile = join3(runDir, "progress.json");
974
+ const summaryFile = join3(runDir, "summary.json");
975
+ if (existsSync3(summaryFile))
976
+ continue;
977
+ if (!existsSync3(progressFile) || !existsSync3(metaFile))
978
+ continue;
979
+ try {
980
+ const meta = JSON.parse(readFileSync3(metaFile, "utf-8"));
981
+ if (meta.workBranch !== workBranch)
982
+ continue;
983
+ const progress = JSON.parse(readFileSync3(progressFile, "utf-8"));
984
+ return {
985
+ runId,
986
+ runDir,
987
+ decisions: progress.decisions ?? [],
988
+ lastCommitIndex: progress.lastCommitIndex ?? -1
989
+ };
990
+ } catch {
991
+ continue;
992
+ }
993
+ }
994
+ return null;
995
+ }
996
+ var init_audit = () => {};
997
+
998
+ // src/git.ts
999
+ import simpleGit from "simple-git";
1000
+ import { join as join4 } from "path";
1001
+ import { tmpdir } from "os";
1002
+ function createGit(cwd) {
1003
+ return simpleGit(cwd);
1004
+ }
1005
+ async function isGitRepo(git) {
1006
+ try {
1007
+ await git.raw(["rev-parse", "--git-dir"]);
1008
+ return true;
1009
+ } catch {
1010
+ return false;
1011
+ }
1012
+ }
1013
+ async function resolveRef(git, ref) {
1014
+ const result = await git.raw(["rev-parse", "--verify", ref]);
1015
+ return result.trim();
1016
+ }
1017
+ async function getHead(git) {
1018
+ const result = await git.raw(["rev-parse", "HEAD"]);
1019
+ return result.trim();
1020
+ }
1021
+ async function getBranches(git) {
1022
+ const summary = await git.branch();
1023
+ const local = summary.all.filter((b) => !b.startsWith("remotes/"));
1024
+ const remote = summary.all.filter((b) => b.startsWith("remotes/") && !b.endsWith("/HEAD")).map((b) => b.replace(/^remotes\//, ""));
1025
+ const localSet = new Set(local);
1026
+ const combined = [...local];
1027
+ for (const r of remote) {
1028
+ const shortName = r.replace(/^[^/]+\//, "");
1029
+ if (!localSet.has(shortName) && !localSet.has(r)) {
1030
+ combined.push(r);
1031
+ }
1032
+ }
1033
+ return combined;
1034
+ }
1035
+ async function getMergeBase(git, ref1, ref2) {
1036
+ const result = await git.raw(["merge-base", ref1, ref2]);
1037
+ return result.trim();
1038
+ }
1039
+ async function getCommitsSince(git, since, branch) {
1040
+ const log = await git.log({ from: since, to: branch });
1041
+ return log.all.map((c) => ({ hash: c.hash, message: c.message, date: c.date, author: c.author_name })).reverse();
1042
+ }
1043
+ async function getCommitDiff(git, hash) {
1044
+ return git.show([hash, "--stat", "--patch"]);
1045
+ }
1046
+ async function getCommitFiles(git, hash) {
1047
+ const raw = await git.raw(["diff-tree", "--no-commit-id", "--name-only", "-r", hash]);
1048
+ return raw.trim().split(`
1049
+ `).filter(Boolean);
1050
+ }
1051
+ async function getLatestCommitDiff(git, ref) {
1052
+ return git.show([ref, "--stat", "--patch"]);
1053
+ }
1054
+ async function getDescendantCommitsSince(git, since, ref) {
1055
+ const log = await git.log({ from: since, to: ref });
1056
+ return log.all.map((c) => ({ hash: c.hash, message: c.message, date: c.date, author: c.author_name }));
1057
+ }
1058
+ function generateWorktreePath(repoName) {
1059
+ const id = Math.random().toString(36).slice(2, 8);
1060
+ return join4(tmpdir(), `backmerge-${repoName}-${id}`);
1061
+ }
1062
+ async function createDetachedWorktree(git, path, ref) {
1063
+ await git.raw(["worktree", "add", "--detach", path, ref]);
1064
+ }
1065
+ async function advanceBranch(git, branch, newRef, expectedOld) {
1066
+ await git.raw(["update-ref", `refs/heads/${branch}`, newRef, expectedOld]);
1067
+ }
1068
+ async function branchExists(git, name) {
1069
+ try {
1070
+ await git.raw(["rev-parse", "--verify", `refs/heads/${name}`]);
1071
+ return true;
1072
+ } catch {
1073
+ return false;
1074
+ }
1075
+ }
1076
+ async function createBranch(git, name, startPoint) {
1077
+ await git.raw(["branch", name, startPoint]);
1078
+ }
1079
+ async function resetBranch(git, name, ref) {
1080
+ await git.raw(["update-ref", `refs/heads/${name}`, ref]);
1081
+ }
1082
+ async function ensureWorkBranch(git, name, targetRef, resetWorkBranch) {
1083
+ const exists = await branchExists(git, name);
1084
+ if (exists && resetWorkBranch) {
1085
+ const resolved2 = await resolveRef(git, targetRef);
1086
+ await resetBranch(git, name, resolved2);
1087
+ return { created: false, reset: true };
1088
+ }
1089
+ if (exists) {
1090
+ return { created: false, reset: false };
1091
+ }
1092
+ const resolved = await resolveRef(git, targetRef);
1093
+ await createBranch(git, name, resolved);
1094
+ return { created: true, reset: false };
1095
+ }
1096
+ async function findAlreadyCherryPicked(git, targetRef, sourceRef, mergeBase) {
1097
+ const needed = new Set;
1098
+ const skipped = new Map;
1099
+ try {
1100
+ const result = await git.raw(["cherry", targetRef, sourceRef, mergeBase]);
1101
+ for (const line of result.trim().split(`
1102
+ `)) {
1103
+ if (!line.trim())
1104
+ continue;
1105
+ const prefix = line[0];
1106
+ const hash = line.slice(2).trim();
1107
+ if (prefix === "-") {
1108
+ skipped.set(hash, "patch-id match (already cherry-picked)");
1109
+ } else {
1110
+ needed.add(hash);
1111
+ }
1112
+ }
1113
+ } catch {
1114
+ const commits = await getCommitsSince(git, mergeBase, sourceRef);
1115
+ for (const c of commits)
1116
+ needed.add(c.hash);
1117
+ }
1118
+ return { needed, skipped };
1119
+ }
1120
+ async function validateApply(worktreeGit, beforeHash) {
1121
+ const errors = [];
1122
+ let newCommitHash = null;
1123
+ let newCommitCount = 0;
1124
+ try {
1125
+ const log = await worktreeGit.raw(["log", "--format=%H", `${beforeHash}..HEAD`]);
1126
+ const newHashes = log.trim().split(`
1127
+ `).filter(Boolean);
1128
+ newCommitCount = newHashes.length;
1129
+ if (newCommitCount === 0) {
1130
+ errors.push("No new commit created");
1131
+ } else if (newCommitCount === 1) {
1132
+ newCommitHash = newHashes[0];
1133
+ } else {
1134
+ errors.push(`Expected 1 new commit, found ${newCommitCount}`);
1135
+ newCommitHash = newHashes[0];
1136
+ }
1137
+ } catch (e) {
1138
+ errors.push(`Failed to check commits: ${e.message}`);
1139
+ }
1140
+ const status = await worktreeGit.status();
1141
+ const worktreeClean = status.modified.length === 0 && status.created.length === 0 && status.deleted.length === 0 && status.conflicted.length === 0 && status.not_added.length === 0;
1142
+ if (!worktreeClean) {
1143
+ const parts = [];
1144
+ if (status.modified.length)
1145
+ parts.push(`${status.modified.length} modified`);
1146
+ if (status.not_added.length)
1147
+ parts.push(`${status.not_added.length} untracked`);
1148
+ if (status.deleted.length)
1149
+ parts.push(`${status.deleted.length} deleted`);
1150
+ if (status.conflicted.length)
1151
+ parts.push(`${status.conflicted.length} conflicted`);
1152
+ errors.push(`Working tree not clean: ${parts.join(", ")}`);
1153
+ }
1154
+ const conflictMarkers = [];
1155
+ if (newCommitHash) {
1156
+ try {
1157
+ const diffFiles = await worktreeGit.raw(["diff-tree", "--no-commit-id", "--name-only", "-r", newCommitHash]);
1158
+ for (const file of diffFiles.trim().split(`
1159
+ `).filter(Boolean)) {
1160
+ try {
1161
+ const content = await worktreeGit.raw(["show", `HEAD:${file}`]);
1162
+ if (/^<{7}\s|^>{7}\s|^={7}$/m.test(content)) {
1163
+ conflictMarkers.push(file);
1164
+ }
1165
+ } catch {}
1166
+ }
1167
+ } catch {}
1168
+ }
1169
+ if (conflictMarkers.length > 0) {
1170
+ errors.push(`Conflict markers in: ${conflictMarkers.join(", ")}`);
1171
+ }
1172
+ return { valid: errors.length === 0, newCommitHash, newCommitCount, worktreeClean, conflictMarkers, errors };
1173
+ }
1174
+ async function getAppliedDiff(worktreeGit, beforeHash) {
1175
+ return worktreeGit.raw(["diff", beforeHash, "HEAD"]);
1176
+ }
1177
+ async function getAppliedDiffStat(worktreeGit, beforeHash) {
1178
+ return worktreeGit.raw(["diff", "--stat", beforeHash, "HEAD"]);
1179
+ }
1180
+ async function resetHard(git, ref) {
1181
+ await git.raw(["reset", "--hard", ref]);
1182
+ await git.raw(["clean", "-fd"]);
1183
+ }
1184
+ async function fetchOrigin(git) {
1185
+ await git.fetch(["--all", "--prune"]);
1186
+ return "Fetched all remotes";
1187
+ }
1188
+ async function resetBranchToRemote(git, branch) {
1189
+ try {
1190
+ const remote = (await git.raw(["config", `branch.${branch}.remote`])).trim();
1191
+ const remoteBranch = `${remote}/${branch}`;
1192
+ await git.raw(["rev-parse", "--verify", remoteBranch]);
1193
+ await git.raw(["update-ref", `refs/heads/${branch}`, remoteBranch]);
1194
+ return `Reset ${branch} \u2192 ${remoteBranch}`;
1195
+ } catch {
1196
+ return `No remote tracking for ${branch}, skipped`;
1197
+ }
1198
+ }
1199
+ async function fetchAndReset(git, branches) {
1200
+ const logs = [];
1201
+ logs.push(await fetchOrigin(git));
1202
+ for (const branch of branches) {
1203
+ logs.push(await resetBranchToRemote(git, branch));
1204
+ }
1205
+ return logs;
1206
+ }
1207
+ var init_git = () => {};
1208
+
1209
+ // src/engine.ts
1210
+ async function runEngine(opts, cb) {
1211
+ const {
1212
+ repoPath,
1213
+ sourceRef,
1214
+ targetRef,
1215
+ dryRun = false,
1216
+ listOnly = false,
1217
+ fetch = false,
1218
+ review = true,
1219
+ autoSkip = true
1220
+ } = opts;
1221
+ const config = loadConfig(repoPath, opts.configPath);
1222
+ const effectiveMaxAttempts = opts.maxAttempts ?? config.maxAttempts ?? 2;
1223
+ const effectiveWorkBranch = opts.workBranch ?? config.workBranch;
1224
+ const commitPrefix = config.commitPrefix ?? "backmerge:";
1225
+ if (!checkCodexInstalled())
1226
+ throw new Error("codex CLI not found. Install: npm install -g @openai/codex");
1227
+ const git = createGit(repoPath);
1228
+ if (!await isGitRepo(git))
1229
+ throw new Error(`${repoPath} is not a git repository`);
1230
+ cb.onLog("codex CLI found", "green");
1231
+ if (fetch) {
1232
+ cb.onStatus("Fetching remotes...");
1233
+ const logs = await fetchAndReset(git, []);
1234
+ for (const l of logs)
1235
+ cb.onLog(` ${l}`, "gray");
1236
+ }
1237
+ cb.onStatus("Discovering repo structure & docs...");
1238
+ const repoCtx = buildRepoContext(repoPath, config);
1239
+ const instrCount = repoCtx.instructions.size;
1240
+ const docCount = repoCtx.docPaths.length;
1241
+ cb.onLog(`Repo: ${repoCtx.structure.type}, ${instrCount} instruction files, ${docCount} doc files`, "gray");
1242
+ if (instrCount > 0) {
1243
+ cb.onLog(` Instructions: ${[...repoCtx.instructions.keys()].join(", ")}`, "gray");
1244
+ }
1245
+ cb.onStatus("Resolving refs...");
1246
+ const resolvedSource = await resolveRef(git, sourceRef);
1247
+ const resolvedTarget = await resolveRef(git, targetRef);
1248
+ cb.onLog(`Source: ${sourceRef} \u2192 ${resolvedSource.slice(0, 8)}`, "gray");
1249
+ cb.onLog(`Target: ${targetRef} \u2192 ${resolvedTarget.slice(0, 8)}`, "gray");
1250
+ const mergeBase = await getMergeBase(git, resolvedTarget, resolvedSource);
1251
+ cb.onLog(`Merge base: ${mergeBase.slice(0, 8)}`, "gray");
1252
+ const allSourceCommits = await getCommitsSince(git, mergeBase, resolvedSource);
1253
+ const targetCommits = await getDescendantCommitsSince(git, mergeBase, resolvedTarget);
1254
+ cb.onLog(`${allSourceCommits.length} source commits, ${targetCommits.length} target commits since divergence`, "blue");
1255
+ if (allSourceCommits.length === 0) {
1256
+ cb.onLog("No source commits to process!", "green");
1257
+ return {
1258
+ summary: emptyRunSummary(),
1259
+ decisions: [],
1260
+ worktreePath: "",
1261
+ workBranch: "",
1262
+ auditDir: "",
1263
+ commits: []
1264
+ };
1265
+ }
1266
+ cb.onStatus("Detecting already cherry-picked commits...");
1267
+ const { needed, skipped: cherrySkipped } = await findAlreadyCherryPicked(git, resolvedTarget, resolvedSource, mergeBase);
1268
+ const summary = emptyRunSummary();
1269
+ const decisions = [];
1270
+ summary.cherrySkipped = cherrySkipped.size;
1271
+ let commitsToProcess = allSourceCommits.filter((c) => needed.has(c.hash));
1272
+ cb.onLog(`${cherrySkipped.size} cherry-picked (skipped), ${commitsToProcess.length} to process`, "blue");
1273
+ if (opts.startAfter) {
1274
+ const idx = commitsToProcess.findIndex((c) => c.hash.startsWith(opts.startAfter));
1275
+ if (idx >= 0) {
1276
+ commitsToProcess = commitsToProcess.slice(idx + 1);
1277
+ cb.onLog(`Starting after ${opts.startAfter}, ${commitsToProcess.length} remaining`, "gray");
1278
+ }
1279
+ }
1280
+ if (opts.limit && opts.limit < commitsToProcess.length) {
1281
+ commitsToProcess = commitsToProcess.slice(0, opts.limit);
1282
+ cb.onLog(`Limited to first ${opts.limit} commits`, "gray");
1283
+ }
1284
+ summary.total = commitsToProcess.length;
1285
+ if (listOnly) {
1286
+ for (let i = 0;i < commitsToProcess.length; i++) {
1287
+ const c = commitsToProcess[i];
1288
+ cb.onLog(` ${i + 1}. ${c.hash.slice(0, 8)} ${c.message}`, "white");
1289
+ }
1290
+ return {
1291
+ summary,
1292
+ decisions: [],
1293
+ worktreePath: "",
1294
+ workBranch: "",
1295
+ auditDir: "",
1296
+ commits: commitsToProcess
1297
+ };
1298
+ }
1299
+ const repoName = repoPath.split("/").pop() ?? "repo";
1300
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1301
+ let wbName;
1302
+ if (effectiveWorkBranch) {
1303
+ wbName = effectiveWorkBranch;
1304
+ const { created, reset } = await ensureWorkBranch(git, wbName, targetRef, opts.resetWorkBranch ?? false);
1305
+ if (created)
1306
+ cb.onLog(`Created work branch: ${wbName} from ${targetRef}`, "green");
1307
+ else if (reset)
1308
+ cb.onLog(`Reset work branch: ${wbName} to ${targetRef}`, "yellow");
1309
+ else
1310
+ cb.onLog(`Resuming work branch: ${wbName}`, "green");
1311
+ } else {
1312
+ wbName = `backmerge/${ts}`;
1313
+ await ensureWorkBranch(git, wbName, targetRef, false);
1314
+ cb.onLog(`New branch: ${wbName}`, "green");
1315
+ }
1316
+ const wbHead = await resolveRef(git, wbName);
1317
+ cb.onLog(`Work branch HEAD: ${wbHead.slice(0, 8)}`, "gray");
1318
+ const wtPath = generateWorktreePath(repoName);
1319
+ await createDetachedWorktree(git, wtPath, wbHead);
1320
+ cb.onLog(`Eval worktree (detached): ${wtPath}`, "green");
1321
+ const wtGit = createGit(wtPath);
1322
+ const runId = `run-${ts}`;
1323
+ const audit = new AuditLog(wtPath, runId);
1324
+ const runMeta = {
1325
+ runId,
1326
+ startedAt: new Date().toISOString(),
1327
+ sourceRef,
1328
+ targetRef,
1329
+ workBranch: wbName,
1330
+ mergeBase,
1331
+ worktreePath: wtPath,
1332
+ totalCandidates: commitsToProcess.length,
1333
+ cherrySkipped: cherrySkipped.size,
1334
+ dryRun,
1335
+ repoPath
1336
+ };
1337
+ audit.runStart(runMeta);
1338
+ for (const [hash, reason] of cherrySkipped) {
1339
+ const c = allSourceCommits.find((x) => x.hash === hash);
1340
+ if (c)
1341
+ audit.cherrySkip(hash, c.message, reason);
1342
+ }
1343
+ let resumeFromIndex = 0;
1344
+ if (opts.resume && effectiveWorkBranch) {
1345
+ const resumeInfo = findResumableRun(repoPath, effectiveWorkBranch);
1346
+ if (resumeInfo && resumeInfo.lastCommitIndex >= 0) {
1347
+ const prevHashes = new Set(resumeInfo.decisions.map((d) => d.commitHash));
1348
+ for (const d of resumeInfo.decisions) {
1349
+ decisions.push(d);
1350
+ updateSummary(summary, d);
1351
+ }
1352
+ resumeFromIndex = commitsToProcess.findIndex((c) => !prevHashes.has(c.hash));
1353
+ if (resumeFromIndex < 0)
1354
+ resumeFromIndex = commitsToProcess.length;
1355
+ cb.onLog(`Resuming from commit ${resumeFromIndex + 1}/${commitsToProcess.length} (${resumeInfo.decisions.length} already decided)`, "green");
1356
+ }
1357
+ }
1358
+ const sourceLatestDiff = await getLatestCommitDiff(git, resolvedSource);
1359
+ let currentBranchHead = wbHead;
1360
+ for (let i = resumeFromIndex;i < commitsToProcess.length; i++) {
1361
+ const commit = commitsToProcess[i];
1362
+ audit.commitStart(commit.hash, commit.message, i, commitsToProcess.length);
1363
+ cb.onCommitStart(commit, i, commitsToProcess.length);
1364
+ const decision = await processOneCommit({
1365
+ git,
1366
+ wtGit,
1367
+ wtPath,
1368
+ repoPath,
1369
+ commit,
1370
+ index: i,
1371
+ total: commitsToProcess.length,
1372
+ sourceRef,
1373
+ targetRef,
1374
+ sourceLatestDiff,
1375
+ config,
1376
+ repoCtx,
1377
+ audit,
1378
+ cb,
1379
+ dryRun,
1380
+ review,
1381
+ autoSkip,
1382
+ maxAttempts: effectiveMaxAttempts,
1383
+ commitPrefix,
1384
+ workBranch: wbName,
1385
+ workBranchHead: currentBranchHead
1386
+ });
1387
+ if (decision.kind === "applied" && decision.newCommitHash) {
1388
+ currentBranchHead = decision.newCommitHash;
1389
+ }
1390
+ decisions.push(decision);
1391
+ updateSummary(summary, decision);
1392
+ audit.commitDecision(decision);
1393
+ audit.saveProgress(decisions, i);
1394
+ cb.onDecision(commit, decision);
1395
+ }
1396
+ audit.runEnd(summary, decisions);
1397
+ return {
1398
+ summary,
1399
+ decisions,
1400
+ worktreePath: wtPath,
1401
+ workBranch: wbName,
1402
+ auditDir: audit.runDir,
1403
+ commits: commitsToProcess
1404
+ };
1405
+ }
1406
+ async function processOneCommit(o) {
1407
+ const start = Date.now();
1408
+ const { commit, audit, cb } = o;
1409
+ let touchedPaths = [];
1410
+ try {
1411
+ touchedPaths = await getCommitFiles(o.git, commit.hash);
1412
+ } catch {}
1413
+ const commitCtx = buildCommitContext(o.repoPath, o.repoCtx, o.config, touchedPaths, commit.message);
1414
+ if (commitCtx.includedFiles.length > 0) {
1415
+ audit.writeRelevantDocs(commit.hash, 0, commitCtx.includedFiles.join(`
1416
+ `));
1417
+ }
1418
+ let diff;
1419
+ try {
1420
+ diff = await getCommitDiff(o.git, commit.hash);
1421
+ audit.writeSourcePatch(commit.hash, diff);
1422
+ } catch (e) {
1423
+ return mkFailed(commit, "analysis", e.message, start);
1424
+ }
1425
+ cb.onStatus(`Analyzing ${commit.hash.slice(0, 8)}: ${commit.message.slice(0, 50)}`);
1426
+ let analysis;
1427
+ try {
1428
+ analysis = await analyzeCommit({
1429
+ worktreePath: o.wtPath,
1430
+ commitDiff: diff,
1431
+ commitMessage: commit.message,
1432
+ commitHash: commit.hash,
1433
+ sourceBranch: o.sourceRef,
1434
+ targetBranch: o.targetRef,
1435
+ sourceLatestDiff: o.sourceLatestDiff,
1436
+ repoContext: commitCtx.promptBlock
1437
+ });
1438
+ audit.writeAnalysis(commit.hash, 1, analysis);
1439
+ cb.onAnalysis(commit, analysis);
1440
+ } catch (e) {
1441
+ audit.error("analysis", commit.hash, commit.message, e.message);
1442
+ return mkFailed(commit, "analysis", e.message, start);
1443
+ }
1444
+ if (o.autoSkip && analysis.alreadyInTarget === "yes") {
1445
+ cb.onLog(`Auto-skip ${commit.hash.slice(0, 8)}: ${commit.message} (already present)`, "blue");
1446
+ return {
1447
+ kind: "already_applied",
1448
+ commitHash: commit.hash,
1449
+ commitMessage: commit.message,
1450
+ reason: `AI: ${analysis.reasoning.slice(0, 200)}`,
1451
+ durationMs: Date.now() - start
1452
+ };
1453
+ }
1454
+ if (o.dryRun) {
1455
+ const kind = analysis.alreadyInTarget === "partial" ? "would_apply" : "would_apply";
1456
+ return {
1457
+ kind,
1458
+ commitHash: commit.hash,
1459
+ commitMessage: commit.message,
1460
+ reason: analysis.applicationStrategy.slice(0, 300),
1461
+ durationMs: Date.now() - start
1462
+ };
1463
+ }
1464
+ if (o.cb.onAskApply) {
1465
+ const answer = await o.cb.onAskApply(commit, analysis);
1466
+ if (answer === "skip") {
1467
+ return {
1468
+ kind: "skip",
1469
+ commitHash: commit.hash,
1470
+ commitMessage: commit.message,
1471
+ reason: "user skipped",
1472
+ durationMs: Date.now() - start
1473
+ };
1474
+ }
1475
+ if (answer === "quit") {
1476
+ return {
1477
+ kind: "skip",
1478
+ commitHash: commit.hash,
1479
+ commitMessage: commit.message,
1480
+ reason: "user quit",
1481
+ durationMs: Date.now() - start
1482
+ };
1483
+ }
1484
+ }
1485
+ for (let attempt = 1;attempt <= o.maxAttempts; attempt++) {
1486
+ const headBefore = await getHead(o.wtGit);
1487
+ cb.onStatus(`Applying ${commit.hash.slice(0, 8)} (attempt ${attempt}/${o.maxAttempts})...`);
1488
+ let applyResult;
1489
+ try {
1490
+ applyResult = await applyCommit({
1491
+ worktreePath: o.wtPath,
1492
+ commitDiff: diff,
1493
+ commitMessage: commit.message,
1494
+ commitHash: commit.hash,
1495
+ applicationStrategy: analysis.applicationStrategy,
1496
+ sourceBranch: o.sourceRef,
1497
+ targetBranch: o.targetRef,
1498
+ repoContext: commitCtx.promptBlock,
1499
+ commitPrefix: o.commitPrefix
1500
+ });
1501
+ audit.writeAnalysis(commit.hash, attempt, { ...analysis, applyResult });
1502
+ } catch (e) {
1503
+ audit.error("apply", commit.hash, commit.message, e.message);
1504
+ await resetHard(o.wtGit, headBefore);
1505
+ if (attempt === o.maxAttempts)
1506
+ return mkFailed(commit, "apply", e.message, start);
1507
+ continue;
1508
+ }
1509
+ cb.onStatus(`Validating ${commit.hash.slice(0, 8)}...`);
1510
+ const validation = await validateApply(o.wtGit, headBefore);
1511
+ if (!validation.valid) {
1512
+ cb.onLog(`Validation failed: ${validation.errors.join("; ")}`, "red");
1513
+ await resetHard(o.wtGit, headBefore);
1514
+ if (attempt === o.maxAttempts)
1515
+ return mkFailed(commit, "validation", validation.errors.join("; "), start);
1516
+ continue;
1517
+ }
1518
+ if (o.review) {
1519
+ cb.onStatus(`Claude reviewing ${commit.hash.slice(0, 8)}...`);
1520
+ let reviewResult;
1521
+ let packet;
1522
+ try {
1523
+ const appliedDiff = await getAppliedDiff(o.wtGit, headBefore);
1524
+ const appliedDiffStat = await getAppliedDiffStat(o.wtGit, headBefore);
1525
+ packet = {
1526
+ commitHash: commit.hash,
1527
+ commitMessage: commit.message,
1528
+ sourceBranch: o.sourceRef,
1529
+ targetBranch: o.targetRef,
1530
+ analysis,
1531
+ appliedDiff,
1532
+ appliedDiffStat,
1533
+ sourcePatch: diff,
1534
+ relevantDocs: commitCtx.includedFiles.join(`
1535
+ `),
1536
+ repoContext: commitCtx.promptBlock,
1537
+ reviewStrictness: o.config.reviewStrictness ?? "normal"
1538
+ };
1539
+ writeReviewPacket(audit, packet, attempt);
1540
+ const headBeforeReview = await getHead(o.wtGit);
1541
+ reviewResult = await reviewAppliedDiff(o.wtPath, packet);
1542
+ audit.writeReviewResult(commit.hash, attempt, reviewResult);
1543
+ const mutation = await verifyReviewIntegrity(o.wtGit, headBeforeReview);
1544
+ if (mutation) {
1545
+ cb.onLog(`Review integrity violation: ${mutation} \u2014 resetting`, "red");
1546
+ audit.error("review_integrity", commit.hash, commit.message, mutation);
1547
+ await resetHard(o.wtGit, headBeforeReview);
1548
+ }
1549
+ cb.onReview?.(commit, reviewResult);
1550
+ } catch (e) {
1551
+ audit.error("review", commit.hash, commit.message, e.message);
1552
+ await resetHard(o.wtGit, headBefore);
1553
+ if (attempt === o.maxAttempts)
1554
+ return mkFailed(commit, "review", e.message, start);
1555
+ continue;
1556
+ }
1557
+ if (!reviewResult.approved) {
1558
+ cb.onLog(`Review rejected: ${reviewResult.issues.join("; ")}`, "red");
1559
+ const maxFixRounds = 2;
1560
+ let fixed = false;
1561
+ for (let fixRound = 1;fixRound <= maxFixRounds; fixRound++) {
1562
+ cb.onStatus(`Codex fixing review issues (round ${fixRound}/${maxFixRounds})...`);
1563
+ try {
1564
+ await fixFromReview({
1565
+ worktreePath: o.wtPath,
1566
+ commitHash: commit.hash,
1567
+ commitMessage: commit.message,
1568
+ reviewIssues: reviewResult.issues,
1569
+ sourceBranch: o.sourceRef,
1570
+ targetBranch: o.targetRef,
1571
+ repoContext: commitCtx.promptBlock,
1572
+ commitPrefix: o.commitPrefix
1573
+ });
1574
+ } catch (e) {
1575
+ cb.onLog(`Fix failed: ${e.message}`, "red");
1576
+ break;
1577
+ }
1578
+ const postFixValidation = await validateApply(o.wtGit, headBefore);
1579
+ if (!postFixValidation.valid) {
1580
+ cb.onLog(`Post-fix validation failed: ${postFixValidation.errors.join("; ")}`, "red");
1581
+ break;
1582
+ }
1583
+ cb.onStatus(`Claude re-reviewing after fix (round ${fixRound})...`);
1584
+ try {
1585
+ const fixedDiff = await getAppliedDiff(o.wtGit, headBefore);
1586
+ const fixedStat = await getAppliedDiffStat(o.wtGit, headBefore);
1587
+ const fixPacket = {
1588
+ ...packet,
1589
+ appliedDiff: fixedDiff,
1590
+ appliedDiffStat: fixedStat
1591
+ };
1592
+ const headBeforeReReview = await getHead(o.wtGit);
1593
+ reviewResult = await reviewAppliedDiff(o.wtPath, fixPacket);
1594
+ audit.writeReviewResult(commit.hash, attempt, {
1595
+ ...reviewResult,
1596
+ fixRound
1597
+ });
1598
+ const reReviewMutation = await verifyReviewIntegrity(o.wtGit, headBeforeReReview);
1599
+ if (reReviewMutation) {
1600
+ cb.onLog(`Re-review integrity violation: ${reReviewMutation} \u2014 resetting`, "red");
1601
+ await resetHard(o.wtGit, headBeforeReReview);
1602
+ }
1603
+ cb.onReview?.(commit, reviewResult);
1604
+ if (reviewResult.approved) {
1605
+ cb.onLog(`Review approved after fix round ${fixRound}`, "green");
1606
+ fixed = true;
1607
+ break;
1608
+ }
1609
+ cb.onLog(`Still rejected after fix round ${fixRound}: ${reviewResult.issues.join("; ")}`, "yellow");
1610
+ } catch (e) {
1611
+ cb.onLog(`Re-review failed: ${e.message}`, "red");
1612
+ break;
1613
+ }
1614
+ }
1615
+ if (!fixed) {
1616
+ await resetHard(o.wtGit, headBefore);
1617
+ if (o.cb.onReviewRejected) {
1618
+ const answer = await o.cb.onReviewRejected(commit, reviewResult);
1619
+ if (answer === "skip" || answer === "quit") {
1620
+ return {
1621
+ kind: "failed",
1622
+ commitHash: commit.hash,
1623
+ commitMessage: commit.message,
1624
+ reason: `Review rejected after ${maxFixRounds} fix rounds: ${reviewResult.issues.join("; ")}`,
1625
+ failedPhase: "review",
1626
+ reviewApproved: false,
1627
+ reviewIssues: reviewResult.issues,
1628
+ durationMs: Date.now() - start
1629
+ };
1630
+ }
1631
+ continue;
1632
+ }
1633
+ if (attempt === o.maxAttempts) {
1634
+ return {
1635
+ kind: "failed",
1636
+ commitHash: commit.hash,
1637
+ commitMessage: commit.message,
1638
+ reason: `Review rejected after ${maxFixRounds} fix rounds: ${reviewResult.issues.join("; ")}`,
1639
+ failedPhase: "review",
1640
+ reviewApproved: false,
1641
+ reviewIssues: reviewResult.issues,
1642
+ durationMs: Date.now() - start
1643
+ };
1644
+ }
1645
+ continue;
1646
+ }
1647
+ }
1648
+ cb.onLog(`Review approved (${reviewResult.confidence})`, "green");
1649
+ }
1650
+ const headAfter = await getHead(o.wtGit);
1651
+ const d = {
1652
+ kind: "applied",
1653
+ commitHash: commit.hash,
1654
+ commitMessage: commit.message,
1655
+ reason: applyResult.notes || "Applied successfully",
1656
+ newCommitHash: validation.newCommitHash ?? undefined,
1657
+ filesChanged: applyResult.filesChanged,
1658
+ reviewApproved: o.review ? true : undefined,
1659
+ durationMs: Date.now() - start
1660
+ };
1661
+ const invariantErrors = validateDecision(d, headBefore, headAfter, o.dryRun);
1662
+ if (invariantErrors.length > 0) {
1663
+ cb.onLog(`Decision invariant violated: ${invariantErrors.join("; ")}`, "red");
1664
+ audit.error("invariant", commit.hash, commit.message, invariantErrors.join("; "));
1665
+ await resetHard(o.wtGit, headBefore);
1666
+ return mkFailed(commit, "invariant", `Decision invariant violated: ${invariantErrors.join("; ")}`, start);
1667
+ }
1668
+ try {
1669
+ await advanceBranch(o.git, o.workBranch, headAfter, o.workBranchHead);
1670
+ cb.onLog(`Advanced ${o.workBranch} \u2192 ${headAfter.slice(0, 8)}`, "green");
1671
+ } catch (e) {
1672
+ cb.onLog(`Branch advance failed (concurrent move?): ${e.message}`, "red");
1673
+ audit.error("branch_advance", commit.hash, commit.message, e.message);
1674
+ await resetHard(o.wtGit, headBefore);
1675
+ return mkFailed(commit, "branch_advance", `CAS failed: ${e.message}`, start);
1676
+ }
1677
+ cb.onLog(`Applied ${commit.hash.slice(0, 8)}: ${commit.message}`, "green");
1678
+ if (applyResult.filesChanged.length > 0) {
1679
+ cb.onLog(` files: ${applyResult.filesChanged.join(", ")}`, "gray");
1680
+ }
1681
+ return d;
1682
+ }
1683
+ return mkFailed(commit, "apply", "Exhausted all attempts", start);
1684
+ }
1685
+ function mkFailed(commit, phase, error, start) {
1686
+ return {
1687
+ kind: "failed",
1688
+ commitHash: commit.hash,
1689
+ commitMessage: commit.message,
1690
+ reason: error,
1691
+ error,
1692
+ failedPhase: phase,
1693
+ durationMs: Date.now() - start
1694
+ };
1695
+ }
1696
+ var init_engine = __esm(() => {
1697
+ init_config();
1698
+ init_context();
1699
+ init_codex();
1700
+ init_review();
1701
+ init_audit();
1702
+ init_git();
1703
+ });
1704
+
1705
+ // src/batch.ts
1706
+ var exports_batch = {};
1707
+ __export(exports_batch, {
1708
+ runBatch: () => runBatch
1709
+ });
1710
+ function emit(obj) {
1711
+ process.stdout.write(JSON.stringify({ ...obj, ts: new Date().toISOString() }) + `
1712
+ `);
1713
+ }
1714
+ async function runBatch(opts) {
1715
+ const cb = {
1716
+ onLog(msg, color) {
1717
+ emit({ event: "log", msg, color });
1718
+ },
1719
+ onStatus(msg) {
1720
+ emit({ event: "status", msg });
1721
+ },
1722
+ onCommitStart(commit, index, total) {
1723
+ emit({ event: "commit_start", hash: commit.hash, message: commit.message, index, total });
1724
+ },
1725
+ onAnalysis(commit, analysis) {
1726
+ emit({ event: "analysis", hash: commit.hash, result: analysis });
1727
+ },
1728
+ onDecision(commit, decision) {
1729
+ emit({ event: "decision", hash: commit.hash, kind: decision.kind, reason: decision.reason });
1730
+ },
1731
+ onReview(commit, review) {
1732
+ emit({ event: "review", hash: commit.hash, approved: review.approved, issues: review.issues });
1733
+ }
1734
+ };
1735
+ try {
1736
+ const result = await runEngine(opts, cb);
1737
+ emit({
1738
+ event: "done",
1739
+ summary: result.summary,
1740
+ worktree: result.worktreePath,
1741
+ branch: result.workBranch,
1742
+ auditDir: result.auditDir
1743
+ });
1744
+ const { summary } = result;
1745
+ if (summary.failed > 0)
1746
+ return 2;
1747
+ return 0;
1748
+ } catch (e) {
1749
+ emit({ event: "fatal", error: e.message });
1750
+ return 1;
1751
+ }
1752
+ }
1753
+ var init_batch = __esm(() => {
1754
+ init_engine();
1755
+ });
1756
+
1757
+ // index.ts
1758
+ import React2 from "react";
1759
+ import { render } from "ink";
1760
+ import { resolve } from "path";
1761
+
1762
+ // src/app.tsx
1763
+ init_engine();
1764
+ init_git();
1765
+ init_codex();
1766
+ import { useState, useEffect, useCallback, useRef } from "react";
1767
+ import { Box, Text, useInput, useApp, Static, Newline } from "ink";
1768
+ import SelectInput from "ink-select-input";
1769
+ import Spinner from "ink-spinner";
1770
+ import { jsxDEV, Fragment } from "react/jsx-dev-runtime";
1771
+ function shortHash(h) {
1772
+ return h.slice(0, 8);
1773
+ }
1774
+ function progressBar(current, total, width = 30) {
1775
+ const ratio = Math.min(current / total, 1);
1776
+ const filled = Math.round(ratio * width);
1777
+ return `[${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}] ${current}/${total}`;
1778
+ }
1779
+ function Header({
1780
+ source,
1781
+ target,
1782
+ current,
1783
+ total,
1784
+ worktree
1785
+ }) {
1786
+ return /* @__PURE__ */ jsxDEV(Box, {
1787
+ flexDirection: "column",
1788
+ marginBottom: 1,
1789
+ children: [
1790
+ /* @__PURE__ */ jsxDEV(Box, {
1791
+ children: [
1792
+ /* @__PURE__ */ jsxDEV(Text, {
1793
+ bold: true,
1794
+ color: "cyan",
1795
+ children: "\u256D\u2500 backmerge"
1796
+ }, undefined, false, undefined, this),
1797
+ /* @__PURE__ */ jsxDEV(Text, {
1798
+ color: "gray",
1799
+ children: " \u2014 curated branch reconciliation"
1800
+ }, undefined, false, undefined, this)
1801
+ ]
1802
+ }, undefined, true, undefined, this),
1803
+ target && source && /* @__PURE__ */ jsxDEV(Box, {
1804
+ children: [
1805
+ /* @__PURE__ */ jsxDEV(Text, {
1806
+ color: "cyan",
1807
+ children: [
1808
+ "\u2502",
1809
+ " "
1810
+ ]
1811
+ }, undefined, true, undefined, this),
1812
+ /* @__PURE__ */ jsxDEV(Text, {
1813
+ color: "green",
1814
+ children: target
1815
+ }, undefined, false, undefined, this),
1816
+ /* @__PURE__ */ jsxDEV(Text, {
1817
+ color: "gray",
1818
+ children: " \u2190 "
1819
+ }, undefined, false, undefined, this),
1820
+ /* @__PURE__ */ jsxDEV(Text, {
1821
+ color: "magenta",
1822
+ children: source
1823
+ }, undefined, false, undefined, this),
1824
+ current !== undefined && total !== undefined && /* @__PURE__ */ jsxDEV(Fragment, {
1825
+ children: [
1826
+ /* @__PURE__ */ jsxDEV(Text, {
1827
+ color: "gray",
1828
+ children: " "
1829
+ }, undefined, false, undefined, this),
1830
+ /* @__PURE__ */ jsxDEV(Text, {
1831
+ children: progressBar(current, total)
1832
+ }, undefined, false, undefined, this)
1833
+ ]
1834
+ }, undefined, true, undefined, this)
1835
+ ]
1836
+ }, undefined, true, undefined, this),
1837
+ worktree && /* @__PURE__ */ jsxDEV(Box, {
1838
+ children: [
1839
+ /* @__PURE__ */ jsxDEV(Text, {
1840
+ color: "cyan",
1841
+ children: [
1842
+ "\u2502",
1843
+ " "
1844
+ ]
1845
+ }, undefined, true, undefined, this),
1846
+ /* @__PURE__ */ jsxDEV(Text, {
1847
+ dimColor: true,
1848
+ children: [
1849
+ "worktree: ",
1850
+ worktree
1851
+ ]
1852
+ }, undefined, true, undefined, this)
1853
+ ]
1854
+ }, undefined, true, undefined, this),
1855
+ /* @__PURE__ */ jsxDEV(Text, {
1856
+ color: "cyan",
1857
+ children: [
1858
+ "\u2570",
1859
+ "\u2500".repeat(60)
1860
+ ]
1861
+ }, undefined, true, undefined, this)
1862
+ ]
1863
+ }, undefined, true, undefined, this);
1864
+ }
1865
+ function ActionBar({ actions }) {
1866
+ return /* @__PURE__ */ jsxDEV(Box, {
1867
+ gap: 2,
1868
+ marginTop: 1,
1869
+ children: actions.map((a) => /* @__PURE__ */ jsxDEV(Box, {
1870
+ children: [
1871
+ /* @__PURE__ */ jsxDEV(Text, {
1872
+ color: "gray",
1873
+ children: "["
1874
+ }, undefined, false, undefined, this),
1875
+ /* @__PURE__ */ jsxDEV(Text, {
1876
+ bold: true,
1877
+ color: a.color ?? "cyan",
1878
+ children: a.key
1879
+ }, undefined, false, undefined, this),
1880
+ /* @__PURE__ */ jsxDEV(Text, {
1881
+ color: "gray",
1882
+ children: "] "
1883
+ }, undefined, false, undefined, this),
1884
+ /* @__PURE__ */ jsxDEV(Text, {
1885
+ children: a.label
1886
+ }, undefined, false, undefined, this)
1887
+ ]
1888
+ }, a.key, true, undefined, this))
1889
+ }, undefined, false, undefined, this);
1890
+ }
1891
+ function App({ repoPath, engineOpts }) {
1892
+ const { exit } = useApp();
1893
+ const [phase, setPhase] = useState("checking-prereqs");
1894
+ const [branches, setBranches] = useState([]);
1895
+ const [source, setSource] = useState(engineOpts.sourceRef ?? "");
1896
+ const [target, setTarget] = useState(engineOpts.targetRef ?? "");
1897
+ const [logs, setLogs] = useState([]);
1898
+ const [statusText, setStatusText] = useState("");
1899
+ const [error, setError] = useState("");
1900
+ const [currentCommit, setCurrentCommit] = useState(null);
1901
+ const [currentAnalysis, setCurrentAnalysis] = useState(null);
1902
+ const [currentReview, setCurrentReview] = useState(null);
1903
+ const [currentIdx, setCurrentIdx] = useState(0);
1904
+ const [totalCommits, setTotalCommits] = useState(0);
1905
+ const [worktreePath, setWorktreePath] = useState("");
1906
+ const [result, setResult] = useState(null);
1907
+ const askResolverRef = useRef(null);
1908
+ const reviewResolverRef = useRef(null);
1909
+ const addLog = useCallback((text, color) => {
1910
+ setLogs((prev) => [...prev, { id: `${Date.now()}-${Math.random()}`, text, color }]);
1911
+ }, []);
1912
+ useEffect(() => {
1913
+ if (phase !== "checking-prereqs")
1914
+ return;
1915
+ (async () => {
1916
+ if (!checkCodexInstalled()) {
1917
+ setError("codex CLI not found");
1918
+ setPhase("error");
1919
+ return;
1920
+ }
1921
+ const git = createGit(repoPath);
1922
+ if (!await isGitRepo(git)) {
1923
+ setError(`${repoPath} is not a git repo`);
1924
+ setPhase("error");
1925
+ return;
1926
+ }
1927
+ const b = await getBranches(git);
1928
+ setBranches(b);
1929
+ addLog(`Found ${b.length} branches`, "gray");
1930
+ if (source && target)
1931
+ setPhase("confirm");
1932
+ else if (target)
1933
+ setPhase("select-source");
1934
+ else
1935
+ setPhase("select-target");
1936
+ })().catch((e) => {
1937
+ setError(e.message);
1938
+ setPhase("error");
1939
+ });
1940
+ }, [phase, repoPath, addLog, source, target]);
1941
+ const startEngine = useCallback(async () => {
1942
+ setPhase("running");
1943
+ const opts = { repoPath, sourceRef: source, targetRef: target, ...engineOpts };
1944
+ const cb = {
1945
+ onLog: addLog,
1946
+ onStatus: setStatusText,
1947
+ onCommitStart(commit, index, total) {
1948
+ setCurrentCommit(commit);
1949
+ setCurrentIdx(index);
1950
+ setTotalCommits(total);
1951
+ },
1952
+ onAnalysis(_commit, analysis) {
1953
+ setCurrentAnalysis(analysis);
1954
+ },
1955
+ onDecision() {},
1956
+ onReview(_commit, review) {
1957
+ setCurrentReview(review);
1958
+ },
1959
+ async onAskApply(commit, analysis) {
1960
+ setCurrentCommit(commit);
1961
+ setCurrentAnalysis(analysis);
1962
+ setPhase("show-analysis");
1963
+ return new Promise((resolve) => {
1964
+ askResolverRef.current = resolve;
1965
+ });
1966
+ },
1967
+ async onReviewRejected(commit, review) {
1968
+ setCurrentCommit(commit);
1969
+ setCurrentReview(review);
1970
+ setPhase("review-result");
1971
+ return new Promise((resolve) => {
1972
+ reviewResolverRef.current = resolve;
1973
+ });
1974
+ }
1975
+ };
1976
+ try {
1977
+ const r = await runEngine(opts, cb);
1978
+ setResult(r);
1979
+ setWorktreePath(r.worktreePath);
1980
+ setPhase("done");
1981
+ } catch (e) {
1982
+ setError(e.message);
1983
+ setPhase("error");
1984
+ }
1985
+ }, [repoPath, source, target, engineOpts, addLog]);
1986
+ useInput((input, key) => {
1987
+ if (phase === "show-analysis" && askResolverRef.current) {
1988
+ if (input === "a" || input === "A") {
1989
+ askResolverRef.current("apply");
1990
+ askResolverRef.current = null;
1991
+ setPhase("running");
1992
+ } else if (input === "s" || input === "S") {
1993
+ askResolverRef.current("skip");
1994
+ askResolverRef.current = null;
1995
+ setPhase("running");
1996
+ } else if (input === "q" || input === "Q") {
1997
+ askResolverRef.current("quit");
1998
+ askResolverRef.current = null;
1999
+ setPhase("done");
2000
+ }
2001
+ } else if (phase === "review-result" && reviewResolverRef.current) {
2002
+ if (input === "r" || input === "R") {
2003
+ reviewResolverRef.current("retry");
2004
+ reviewResolverRef.current = null;
2005
+ setPhase("running");
2006
+ } else if (input === "s" || input === "S") {
2007
+ reviewResolverRef.current("skip");
2008
+ reviewResolverRef.current = null;
2009
+ setPhase("running");
2010
+ } else if (input === "q" || input === "Q") {
2011
+ reviewResolverRef.current("quit");
2012
+ reviewResolverRef.current = null;
2013
+ setPhase("done");
2014
+ }
2015
+ } else if (phase === "done" || phase === "error") {
2016
+ if (input === "q" || input === "Q" || key.escape || key.return)
2017
+ exit();
2018
+ } else if (phase === "confirm") {
2019
+ if (input === "y" || input === "Y" || key.return)
2020
+ startEngine();
2021
+ else if (input === "n" || input === "N" || key.escape)
2022
+ exit();
2023
+ }
2024
+ }, { isActive: ["show-analysis", "review-result", "done", "error", "confirm"].includes(phase) });
2025
+ const branchItems = branches.map((b) => ({ label: b, value: b }));
2026
+ return /* @__PURE__ */ jsxDEV(Box, {
2027
+ flexDirection: "column",
2028
+ paddingX: 1,
2029
+ children: [
2030
+ /* @__PURE__ */ jsxDEV(Header, {
2031
+ source: source || undefined,
2032
+ target: target || undefined,
2033
+ current: totalCommits > 0 ? currentIdx + 1 : undefined,
2034
+ total: totalCommits || undefined,
2035
+ worktree: worktreePath || undefined
2036
+ }, undefined, false, undefined, this),
2037
+ /* @__PURE__ */ jsxDEV(Static, {
2038
+ items: logs,
2039
+ children: (entry) => /* @__PURE__ */ jsxDEV(Text, {
2040
+ color: entry.color ?? "white",
2041
+ children: entry.text
2042
+ }, entry.id, false, undefined, this)
2043
+ }, undefined, false, undefined, this),
2044
+ phase === "checking-prereqs" && /* @__PURE__ */ jsxDEV(Box, {
2045
+ children: [
2046
+ /* @__PURE__ */ jsxDEV(Text, {
2047
+ color: "cyan",
2048
+ children: /* @__PURE__ */ jsxDEV(Spinner, {
2049
+ type: "dots"
2050
+ }, undefined, false, undefined, this)
2051
+ }, undefined, false, undefined, this),
2052
+ /* @__PURE__ */ jsxDEV(Text, {
2053
+ children: " Checking prerequisites..."
2054
+ }, undefined, false, undefined, this)
2055
+ ]
2056
+ }, undefined, true, undefined, this),
2057
+ phase === "select-target" && branches.length > 0 && /* @__PURE__ */ jsxDEV(Box, {
2058
+ flexDirection: "column",
2059
+ children: [
2060
+ /* @__PURE__ */ jsxDEV(Text, {
2061
+ bold: true,
2062
+ color: "green",
2063
+ children: "Select target branch (where changes merge INTO):"
2064
+ }, undefined, false, undefined, this),
2065
+ /* @__PURE__ */ jsxDEV(SelectInput, {
2066
+ items: branchItems,
2067
+ onSelect: (item) => {
2068
+ setTarget(item.value);
2069
+ setPhase("select-source");
2070
+ }
2071
+ }, undefined, false, undefined, this)
2072
+ ]
2073
+ }, undefined, true, undefined, this),
2074
+ phase === "select-source" && /* @__PURE__ */ jsxDEV(Box, {
2075
+ flexDirection: "column",
2076
+ children: [
2077
+ /* @__PURE__ */ jsxDEV(Text, {
2078
+ bold: true,
2079
+ color: "magenta",
2080
+ children: "Select source branch (where changes come FROM):"
2081
+ }, undefined, false, undefined, this),
2082
+ /* @__PURE__ */ jsxDEV(SelectInput, {
2083
+ items: branchItems.filter((b) => b.value !== target),
2084
+ onSelect: (item) => {
2085
+ setSource(item.value);
2086
+ setPhase("confirm");
2087
+ }
2088
+ }, undefined, false, undefined, this)
2089
+ ]
2090
+ }, undefined, true, undefined, this),
2091
+ phase === "confirm" && /* @__PURE__ */ jsxDEV(Box, {
2092
+ flexDirection: "column",
2093
+ children: [
2094
+ /* @__PURE__ */ jsxDEV(Box, {
2095
+ flexDirection: "column",
2096
+ borderStyle: "round",
2097
+ borderColor: "yellow",
2098
+ paddingX: 1,
2099
+ children: [
2100
+ /* @__PURE__ */ jsxDEV(Text, {
2101
+ bold: true,
2102
+ children: "Confirm backmerge"
2103
+ }, undefined, false, undefined, this),
2104
+ /* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
2105
+ /* @__PURE__ */ jsxDEV(Box, {
2106
+ children: [
2107
+ /* @__PURE__ */ jsxDEV(Text, {
2108
+ children: "Source: "
2109
+ }, undefined, false, undefined, this),
2110
+ /* @__PURE__ */ jsxDEV(Text, {
2111
+ bold: true,
2112
+ color: "magenta",
2113
+ children: source
2114
+ }, undefined, false, undefined, this)
2115
+ ]
2116
+ }, undefined, true, undefined, this),
2117
+ /* @__PURE__ */ jsxDEV(Box, {
2118
+ children: [
2119
+ /* @__PURE__ */ jsxDEV(Text, {
2120
+ children: "Target: "
2121
+ }, undefined, false, undefined, this),
2122
+ /* @__PURE__ */ jsxDEV(Text, {
2123
+ bold: true,
2124
+ color: "green",
2125
+ children: target
2126
+ }, undefined, false, undefined, this)
2127
+ ]
2128
+ }, undefined, true, undefined, this),
2129
+ engineOpts.workBranch && /* @__PURE__ */ jsxDEV(Box, {
2130
+ children: [
2131
+ /* @__PURE__ */ jsxDEV(Text, {
2132
+ children: "Work branch: "
2133
+ }, undefined, false, undefined, this),
2134
+ /* @__PURE__ */ jsxDEV(Text, {
2135
+ bold: true,
2136
+ color: "cyan",
2137
+ children: engineOpts.workBranch
2138
+ }, undefined, false, undefined, this)
2139
+ ]
2140
+ }, undefined, true, undefined, this),
2141
+ engineOpts.dryRun && /* @__PURE__ */ jsxDEV(Text, {
2142
+ color: "yellow",
2143
+ children: "DRY RUN"
2144
+ }, undefined, false, undefined, this)
2145
+ ]
2146
+ }, undefined, true, undefined, this),
2147
+ /* @__PURE__ */ jsxDEV(ActionBar, {
2148
+ actions: [
2149
+ { key: "y", label: "Proceed", color: "green" },
2150
+ { key: "n", label: "Cancel", color: "red" }
2151
+ ]
2152
+ }, undefined, false, undefined, this)
2153
+ ]
2154
+ }, undefined, true, undefined, this),
2155
+ phase === "running" && /* @__PURE__ */ jsxDEV(Box, {
2156
+ children: [
2157
+ /* @__PURE__ */ jsxDEV(Text, {
2158
+ color: "cyan",
2159
+ children: /* @__PURE__ */ jsxDEV(Spinner, {
2160
+ type: "dots"
2161
+ }, undefined, false, undefined, this)
2162
+ }, undefined, false, undefined, this),
2163
+ /* @__PURE__ */ jsxDEV(Text, {
2164
+ children: [
2165
+ " ",
2166
+ statusText
2167
+ ]
2168
+ }, undefined, true, undefined, this)
2169
+ ]
2170
+ }, undefined, true, undefined, this),
2171
+ phase === "show-analysis" && currentCommit && currentAnalysis && /* @__PURE__ */ jsxDEV(Box, {
2172
+ flexDirection: "column",
2173
+ children: [
2174
+ /* @__PURE__ */ jsxDEV(Box, {
2175
+ flexDirection: "column",
2176
+ borderStyle: "round",
2177
+ borderColor: "blue",
2178
+ paddingX: 1,
2179
+ marginBottom: 1,
2180
+ children: [
2181
+ /* @__PURE__ */ jsxDEV(Box, {
2182
+ children: [
2183
+ /* @__PURE__ */ jsxDEV(Text, {
2184
+ bold: true,
2185
+ color: "blue",
2186
+ children: [
2187
+ "Commit ",
2188
+ currentIdx + 1,
2189
+ "/",
2190
+ totalCommits
2191
+ ]
2192
+ }, undefined, true, undefined, this),
2193
+ /* @__PURE__ */ jsxDEV(Text, {
2194
+ color: "gray",
2195
+ children: " \u2022 "
2196
+ }, undefined, false, undefined, this),
2197
+ /* @__PURE__ */ jsxDEV(Text, {
2198
+ color: "yellow",
2199
+ children: shortHash(currentCommit.hash)
2200
+ }, undefined, false, undefined, this)
2201
+ ]
2202
+ }, undefined, true, undefined, this),
2203
+ /* @__PURE__ */ jsxDEV(Text, {
2204
+ bold: true,
2205
+ children: currentCommit.message
2206
+ }, undefined, false, undefined, this)
2207
+ ]
2208
+ }, undefined, true, undefined, this),
2209
+ /* @__PURE__ */ jsxDEV(Box, {
2210
+ flexDirection: "column",
2211
+ borderStyle: "round",
2212
+ borderColor: "magenta",
2213
+ paddingX: 1,
2214
+ marginBottom: 1,
2215
+ children: [
2216
+ /* @__PURE__ */ jsxDEV(Box, {
2217
+ children: [
2218
+ /* @__PURE__ */ jsxDEV(Text, {
2219
+ bold: true,
2220
+ color: "magenta",
2221
+ children: [
2222
+ "Analysis \u2502",
2223
+ " "
2224
+ ]
2225
+ }, undefined, true, undefined, this),
2226
+ /* @__PURE__ */ jsxDEV(Text, {
2227
+ bold: true,
2228
+ color: currentAnalysis.alreadyInTarget === "yes" ? "green" : currentAnalysis.alreadyInTarget === "partial" ? "yellow" : "red",
2229
+ children: currentAnalysis.alreadyInTarget === "yes" ? "ALREADY PRESENT" : currentAnalysis.alreadyInTarget === "partial" ? "PARTIAL" : "MISSING"
2230
+ }, undefined, false, undefined, this)
2231
+ ]
2232
+ }, undefined, true, undefined, this),
2233
+ /* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
2234
+ /* @__PURE__ */ jsxDEV(Text, {
2235
+ bold: true,
2236
+ children: "Summary:"
2237
+ }, undefined, false, undefined, this),
2238
+ /* @__PURE__ */ jsxDEV(Text, {
2239
+ wrap: "wrap",
2240
+ children: currentAnalysis.summary
2241
+ }, undefined, false, undefined, this),
2242
+ /* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
2243
+ /* @__PURE__ */ jsxDEV(Text, {
2244
+ bold: true,
2245
+ children: "Reasoning:"
2246
+ }, undefined, false, undefined, this),
2247
+ /* @__PURE__ */ jsxDEV(Text, {
2248
+ wrap: "wrap",
2249
+ dimColor: true,
2250
+ children: currentAnalysis.reasoning
2251
+ }, undefined, false, undefined, this),
2252
+ currentAnalysis.alreadyInTarget !== "yes" && /* @__PURE__ */ jsxDEV(Fragment, {
2253
+ children: [
2254
+ /* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
2255
+ /* @__PURE__ */ jsxDEV(Text, {
2256
+ bold: true,
2257
+ color: "yellow",
2258
+ children: "Strategy:"
2259
+ }, undefined, false, undefined, this),
2260
+ /* @__PURE__ */ jsxDEV(Text, {
2261
+ wrap: "wrap",
2262
+ children: currentAnalysis.applicationStrategy
2263
+ }, undefined, false, undefined, this)
2264
+ ]
2265
+ }, undefined, true, undefined, this)
2266
+ ]
2267
+ }, undefined, true, undefined, this),
2268
+ /* @__PURE__ */ jsxDEV(ActionBar, {
2269
+ actions: [
2270
+ ...currentAnalysis.alreadyInTarget !== "yes" ? [{ key: "a", label: "Apply", color: "green" }] : [],
2271
+ { key: "s", label: "Skip", color: "yellow" },
2272
+ { key: "q", label: "Quit", color: "red" }
2273
+ ]
2274
+ }, undefined, false, undefined, this)
2275
+ ]
2276
+ }, undefined, true, undefined, this),
2277
+ phase === "review-result" && currentCommit && currentReview && /* @__PURE__ */ jsxDEV(Box, {
2278
+ flexDirection: "column",
2279
+ children: [
2280
+ /* @__PURE__ */ jsxDEV(Box, {
2281
+ flexDirection: "column",
2282
+ borderStyle: "round",
2283
+ borderColor: "red",
2284
+ paddingX: 1,
2285
+ marginBottom: 1,
2286
+ children: [
2287
+ /* @__PURE__ */ jsxDEV(Text, {
2288
+ bold: true,
2289
+ color: "red",
2290
+ children: [
2291
+ "Review REJECTED \u2014 ",
2292
+ shortHash(currentCommit.hash)
2293
+ ]
2294
+ }, undefined, true, undefined, this),
2295
+ /* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
2296
+ /* @__PURE__ */ jsxDEV(Text, {
2297
+ wrap: "wrap",
2298
+ children: currentReview.summary
2299
+ }, undefined, false, undefined, this),
2300
+ currentReview.issues.map((issue, i) => /* @__PURE__ */ jsxDEV(Text, {
2301
+ color: "red",
2302
+ children: [
2303
+ " ",
2304
+ "\u2022 ",
2305
+ issue
2306
+ ]
2307
+ }, i, true, undefined, this))
2308
+ ]
2309
+ }, undefined, true, undefined, this),
2310
+ /* @__PURE__ */ jsxDEV(Text, {
2311
+ color: "yellow",
2312
+ children: "Changes rolled back."
2313
+ }, undefined, false, undefined, this),
2314
+ /* @__PURE__ */ jsxDEV(ActionBar, {
2315
+ actions: [
2316
+ { key: "r", label: "Retry", color: "cyan" },
2317
+ { key: "s", label: "Skip", color: "yellow" },
2318
+ { key: "q", label: "Quit", color: "red" }
2319
+ ]
2320
+ }, undefined, false, undefined, this)
2321
+ ]
2322
+ }, undefined, true, undefined, this),
2323
+ phase === "done" && result && /* @__PURE__ */ jsxDEV(Box, {
2324
+ flexDirection: "column",
2325
+ marginTop: 1,
2326
+ children: [
2327
+ /* @__PURE__ */ jsxDEV(Box, {
2328
+ flexDirection: "column",
2329
+ borderStyle: "double",
2330
+ borderColor: "green",
2331
+ paddingX: 2,
2332
+ paddingY: 1,
2333
+ children: [
2334
+ /* @__PURE__ */ jsxDEV(Text, {
2335
+ bold: true,
2336
+ color: "green",
2337
+ children: "Backmerge Complete"
2338
+ }, undefined, false, undefined, this),
2339
+ /* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
2340
+ /* @__PURE__ */ jsxDEV(Box, {
2341
+ children: [
2342
+ /* @__PURE__ */ jsxDEV(Text, {
2343
+ children: "Applied: "
2344
+ }, undefined, false, undefined, this),
2345
+ /* @__PURE__ */ jsxDEV(Text, {
2346
+ bold: true,
2347
+ color: "green",
2348
+ children: result.summary.applied
2349
+ }, undefined, false, undefined, this)
2350
+ ]
2351
+ }, undefined, true, undefined, this),
2352
+ /* @__PURE__ */ jsxDEV(Box, {
2353
+ children: [
2354
+ /* @__PURE__ */ jsxDEV(Text, {
2355
+ children: "Would apply: "
2356
+ }, undefined, false, undefined, this),
2357
+ /* @__PURE__ */ jsxDEV(Text, {
2358
+ bold: true,
2359
+ color: "cyan",
2360
+ children: result.summary.wouldApply
2361
+ }, undefined, false, undefined, this)
2362
+ ]
2363
+ }, undefined, true, undefined, this),
2364
+ /* @__PURE__ */ jsxDEV(Box, {
2365
+ children: [
2366
+ /* @__PURE__ */ jsxDEV(Text, {
2367
+ children: "Already applied: "
2368
+ }, undefined, false, undefined, this),
2369
+ /* @__PURE__ */ jsxDEV(Text, {
2370
+ bold: true,
2371
+ color: "blue",
2372
+ children: result.summary.alreadyApplied
2373
+ }, undefined, false, undefined, this)
2374
+ ]
2375
+ }, undefined, true, undefined, this),
2376
+ /* @__PURE__ */ jsxDEV(Box, {
2377
+ children: [
2378
+ /* @__PURE__ */ jsxDEV(Text, {
2379
+ children: "Skipped: "
2380
+ }, undefined, false, undefined, this),
2381
+ /* @__PURE__ */ jsxDEV(Text, {
2382
+ bold: true,
2383
+ color: "yellow",
2384
+ children: result.summary.skipped
2385
+ }, undefined, false, undefined, this)
2386
+ ]
2387
+ }, undefined, true, undefined, this),
2388
+ /* @__PURE__ */ jsxDEV(Box, {
2389
+ children: [
2390
+ /* @__PURE__ */ jsxDEV(Text, {
2391
+ children: "Cherry-skipped: "
2392
+ }, undefined, false, undefined, this),
2393
+ /* @__PURE__ */ jsxDEV(Text, {
2394
+ bold: true,
2395
+ color: "cyan",
2396
+ children: result.summary.cherrySkipped
2397
+ }, undefined, false, undefined, this)
2398
+ ]
2399
+ }, undefined, true, undefined, this),
2400
+ /* @__PURE__ */ jsxDEV(Box, {
2401
+ children: [
2402
+ /* @__PURE__ */ jsxDEV(Text, {
2403
+ children: "Failed: "
2404
+ }, undefined, false, undefined, this),
2405
+ /* @__PURE__ */ jsxDEV(Text, {
2406
+ bold: true,
2407
+ color: "red",
2408
+ children: result.summary.failed
2409
+ }, undefined, false, undefined, this)
2410
+ ]
2411
+ }, undefined, true, undefined, this),
2412
+ /* @__PURE__ */ jsxDEV(Newline, {}, undefined, false, undefined, this),
2413
+ result.worktreePath && /* @__PURE__ */ jsxDEV(Text, {
2414
+ dimColor: true,
2415
+ children: [
2416
+ "Worktree: ",
2417
+ result.worktreePath
2418
+ ]
2419
+ }, undefined, true, undefined, this),
2420
+ result.workBranch && /* @__PURE__ */ jsxDEV(Text, {
2421
+ dimColor: true,
2422
+ children: [
2423
+ "Branch: ",
2424
+ result.workBranch
2425
+ ]
2426
+ }, undefined, true, undefined, this),
2427
+ result.auditDir && /* @__PURE__ */ jsxDEV(Text, {
2428
+ dimColor: true,
2429
+ children: [
2430
+ "Audit: ",
2431
+ result.auditDir
2432
+ ]
2433
+ }, undefined, true, undefined, this)
2434
+ ]
2435
+ }, undefined, true, undefined, this),
2436
+ /* @__PURE__ */ jsxDEV(ActionBar, {
2437
+ actions: [{ key: "q", label: "Exit", color: "gray" }]
2438
+ }, undefined, false, undefined, this)
2439
+ ]
2440
+ }, undefined, true, undefined, this),
2441
+ phase === "error" && /* @__PURE__ */ jsxDEV(Box, {
2442
+ flexDirection: "column",
2443
+ marginTop: 1,
2444
+ children: [
2445
+ /* @__PURE__ */ jsxDEV(Box, {
2446
+ borderStyle: "round",
2447
+ borderColor: "red",
2448
+ paddingX: 1,
2449
+ children: /* @__PURE__ */ jsxDEV(Text, {
2450
+ color: "red",
2451
+ bold: true,
2452
+ children: [
2453
+ "Error: ",
2454
+ error
2455
+ ]
2456
+ }, undefined, true, undefined, this)
2457
+ }, undefined, false, undefined, this),
2458
+ /* @__PURE__ */ jsxDEV(ActionBar, {
2459
+ actions: [{ key: "q", label: "Exit", color: "gray" }]
2460
+ }, undefined, false, undefined, this)
2461
+ ]
2462
+ }, undefined, true, undefined, this)
2463
+ ]
2464
+ }, undefined, true, undefined, this);
2465
+ }
2466
+
2467
+ // index.ts
2468
+ init_config();
2469
+ var args = process.argv.slice(2);
2470
+ var repoPath = ".";
2471
+ var sourceRef = "";
2472
+ var targetRef = "";
2473
+ var workBranch = "";
2474
+ var resetWorkBranch = false;
2475
+ var dryRun = false;
2476
+ var listOnly = false;
2477
+ var doFetch = false;
2478
+ var review = true;
2479
+ var autoSkip = true;
2480
+ var maxAttempts = 2;
2481
+ var startAfter = "";
2482
+ var limit = 0;
2483
+ var configPath = "";
2484
+ var batch = false;
2485
+ var resume = true;
2486
+ var showHelp = false;
2487
+ for (let i = 0;i < args.length; i++) {
2488
+ const arg = args[i];
2489
+ if (arg === "--help" || arg === "-h")
2490
+ showHelp = true;
2491
+ else if (arg === "--batch" || arg === "-b")
2492
+ batch = true;
2493
+ else if (arg === "--dry-run")
2494
+ dryRun = true;
2495
+ else if (arg === "--list-only")
2496
+ listOnly = true;
2497
+ else if (arg === "--fetch" || arg === "-f")
2498
+ doFetch = true;
2499
+ else if (arg === "--no-fetch")
2500
+ doFetch = false;
2501
+ else if (arg === "--no-review")
2502
+ review = false;
2503
+ else if (arg === "--no-auto-skip")
2504
+ autoSkip = false;
2505
+ else if (arg === "--reset-work-branch") {
2506
+ resetWorkBranch = true;
2507
+ resume = false;
2508
+ } else if (arg === "--no-resume")
2509
+ resume = false;
2510
+ else if (arg === "--source-ref" && args[i + 1])
2511
+ sourceRef = args[++i];
2512
+ else if (arg === "--target-ref" && args[i + 1])
2513
+ targetRef = args[++i];
2514
+ else if (arg === "--work-branch" && args[i + 1])
2515
+ workBranch = args[++i];
2516
+ else if (arg === "--start-after" && args[i + 1])
2517
+ startAfter = args[++i];
2518
+ else if (arg === "--limit" && args[i + 1])
2519
+ limit = parseInt(args[++i], 10) || 0;
2520
+ else if (arg === "--max-attempts" && args[i + 1])
2521
+ maxAttempts = parseInt(args[++i], 10) || 2;
2522
+ else if (arg === "--config" && args[i + 1])
2523
+ configPath = args[++i];
2524
+ else if (!arg.startsWith("-"))
2525
+ repoPath = arg;
2526
+ }
2527
+ if (showHelp) {
2528
+ console.log(`
2529
+ xab \u2014 AI-powered curated branch reconciliation
2530
+
2531
+ Usage:
2532
+ xab [repo-path] [options]
2533
+
2534
+ Ref selection:
2535
+ --source-ref <ref> Source branch/ref (where changes come FROM)
2536
+ --target-ref <ref> Target branch/ref (where changes merge INTO)
2537
+ --work-branch <name> Persistent work branch (resumes if exists)
2538
+ --reset-work-branch Force-reset work branch to target ref
2539
+
2540
+ Modes:
2541
+ --batch, -b Unattended batch mode (JSONL to stdout)
2542
+ --dry-run Analyze only, never create commits
2543
+ --list-only List candidate commits and exit
2544
+
2545
+ Filtering:
2546
+ --start-after <sha> Skip commits up to and including this hash
2547
+ --limit <n> Process at most n commits
2548
+
2549
+ Behavior:
2550
+ --fetch, -f Fetch remotes before starting
2551
+ --no-fetch Skip fetch (default)
2552
+ --no-review Skip Claude review pass
2553
+ --no-auto-skip Don't auto-skip commits AI identifies as present
2554
+ --max-attempts <n> Max retries per commit (default: 2)
2555
+ --no-resume Don't resume from interrupted runs (default: auto-resume)
2556
+ --config <path> Path to config file (default: auto-discover)
2557
+ --help, -h Show this help
2558
+
2559
+ Config:
2560
+ Place .xab.json in the target repo for persistent settings:
2561
+ - sourceRef, targetRef, workBranch: default refs
2562
+ - instructionFiles: extra docs to include in AI context
2563
+ - docRoutes: map commit paths/keywords to relevant docs
2564
+ - promptHints: extra instructions for AI
2565
+ - pathRemaps: source\u2192target path mappings
2566
+ - reviewStrictness: "strict" | "normal" | "lenient"
2567
+ - maxAttempts, commitPrefix
2568
+
2569
+ Pipeline:
2570
+ 1. Discover repo structure, instruction files, docs
2571
+ 2. Resolve refs, compute merge base
2572
+ 3. Detect already cherry-picked commits (git patch-id)
2573
+ 4. For each remaining commit:
2574
+ a. Build per-commit context (relevant docs, repo hints)
2575
+ b. Codex (gpt-5.4, high) analyzes: present/missing/partial
2576
+ c. Auto-skip if already present
2577
+ d. Codex applies changes (curated merge, not cherry-pick)
2578
+ e. Validate: exactly 1 clean commit, no conflict markers
2579
+ f. Claude (opus 4.6, high) reviews applied diff
2580
+ g. Branch advances only after review approval
2581
+ 5. Audit log + artifacts written to .xab/runs/
2582
+
2583
+ Models:
2584
+ - Analysis/Apply: gpt-5.4 (high reasoning effort) via Codex SDK
2585
+ - Review: claude-opus-4-6 (high reasoning effort) via Claude Agent SDK
2586
+
2587
+ Exit codes (batch): 0=success, 1=fatal, 2=partial failure
2588
+
2589
+ Prerequisites:
2590
+ - codex CLI: npm install -g @openai/codex
2591
+ - Claude Code CLI: npm install -g @anthropic-ai/claude-code
2592
+ `);
2593
+ process.exit(0);
2594
+ }
2595
+ var resolvedPath = resolve(repoPath);
2596
+ var repoConfig = loadConfig(resolvedPath, configPath || undefined);
2597
+ if (!sourceRef && repoConfig.sourceRef)
2598
+ sourceRef = repoConfig.sourceRef;
2599
+ if (!targetRef && repoConfig.targetRef)
2600
+ targetRef = repoConfig.targetRef;
2601
+ if (!workBranch && repoConfig.workBranch)
2602
+ workBranch = repoConfig.workBranch;
2603
+ if (repoConfig.maxAttempts && maxAttempts === 2)
2604
+ maxAttempts = repoConfig.maxAttempts;
2605
+ var engineOpts = {
2606
+ ...sourceRef && { sourceRef },
2607
+ ...targetRef && { targetRef },
2608
+ ...workBranch && { workBranch },
2609
+ ...resetWorkBranch && { resetWorkBranch },
2610
+ ...dryRun && { dryRun },
2611
+ ...listOnly && { listOnly },
2612
+ ...doFetch && { fetch: doFetch },
2613
+ review,
2614
+ autoSkip,
2615
+ resume,
2616
+ maxAttempts,
2617
+ ...startAfter && { startAfter },
2618
+ ...limit > 0 && { limit },
2619
+ ...configPath && { configPath }
2620
+ };
2621
+ if (batch || listOnly) {
2622
+ if (!sourceRef || !targetRef) {
2623
+ console.error("Error: --batch/--list-only requires --source-ref and --target-ref (via flags or .xab.json)");
2624
+ process.exit(1);
2625
+ }
2626
+ const { runBatch: runBatch2 } = await Promise.resolve().then(() => (init_batch(), exports_batch));
2627
+ const exitCode = await runBatch2({
2628
+ repoPath: resolvedPath,
2629
+ sourceRef,
2630
+ targetRef,
2631
+ ...engineOpts
2632
+ });
2633
+ process.exit(exitCode);
2634
+ } else {
2635
+ console.clear();
2636
+ const { waitUntilExit } = render(React2.createElement(App, { repoPath: resolvedPath, engineOpts }));
2637
+ await waitUntilExit();
2638
+ }