@os-eco/overstory-cli 0.9.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +50 -19
  2. package/agents/builder.md +19 -9
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +204 -87
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +219 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/mail-poll-detect.test.ts +153 -0
  18. package/src/agents/mail-poll-detect.ts +73 -0
  19. package/src/agents/overlay.test.ts +60 -4
  20. package/src/agents/overlay.ts +63 -8
  21. package/src/agents/scope-detect.test.ts +190 -0
  22. package/src/agents/scope-detect.ts +146 -0
  23. package/src/agents/turn-lock.test.ts +181 -0
  24. package/src/agents/turn-lock.ts +235 -0
  25. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  26. package/src/agents/turn-runner-dispatch.ts +105 -0
  27. package/src/agents/turn-runner.test.ts +2312 -0
  28. package/src/agents/turn-runner.ts +1383 -0
  29. package/src/commands/agents.ts +9 -0
  30. package/src/commands/clean.ts +54 -0
  31. package/src/commands/coordinator.test.ts +254 -0
  32. package/src/commands/coordinator.ts +273 -8
  33. package/src/commands/dashboard.test.ts +188 -0
  34. package/src/commands/dashboard.ts +14 -4
  35. package/src/commands/doctor.ts +3 -1
  36. package/src/commands/group.test.ts +94 -0
  37. package/src/commands/group.ts +49 -20
  38. package/src/commands/init.test.ts +8 -0
  39. package/src/commands/init.ts +8 -1
  40. package/src/commands/log.test.ts +187 -11
  41. package/src/commands/log.ts +171 -71
  42. package/src/commands/mail.test.ts +162 -0
  43. package/src/commands/mail.ts +64 -9
  44. package/src/commands/merge.test.ts +230 -1
  45. package/src/commands/merge.ts +68 -12
  46. package/src/commands/nudge.test.ts +351 -4
  47. package/src/commands/nudge.ts +356 -34
  48. package/src/commands/run.test.ts +43 -7
  49. package/src/commands/serve/build.test.ts +202 -0
  50. package/src/commands/serve/build.ts +206 -0
  51. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  52. package/src/commands/serve/coordinator-actions.ts +408 -0
  53. package/src/commands/serve/dev.test.ts +168 -0
  54. package/src/commands/serve/dev.ts +117 -0
  55. package/src/commands/serve/mail-actions.test.ts +312 -0
  56. package/src/commands/serve/mail-actions.ts +167 -0
  57. package/src/commands/serve/rest.test.ts +1323 -0
  58. package/src/commands/serve/rest.ts +708 -0
  59. package/src/commands/serve/static.ts +51 -0
  60. package/src/commands/serve/ws.test.ts +361 -0
  61. package/src/commands/serve/ws.ts +332 -0
  62. package/src/commands/serve.test.ts +459 -0
  63. package/src/commands/serve.ts +565 -0
  64. package/src/commands/sling.test.ts +177 -1
  65. package/src/commands/sling.ts +243 -71
  66. package/src/commands/status.test.ts +9 -0
  67. package/src/commands/status.ts +12 -4
  68. package/src/commands/stop.test.ts +255 -1
  69. package/src/commands/stop.ts +107 -8
  70. package/src/commands/watch.test.ts +43 -0
  71. package/src/commands/watch.ts +153 -28
  72. package/src/config.ts +23 -0
  73. package/src/doctor/consistency.test.ts +106 -0
  74. package/src/doctor/consistency.ts +48 -1
  75. package/src/doctor/serve.test.ts +95 -0
  76. package/src/doctor/serve.ts +86 -0
  77. package/src/doctor/types.ts +2 -1
  78. package/src/doctor/watchdog.ts +57 -1
  79. package/src/events/tailer.test.ts +234 -1
  80. package/src/events/tailer.ts +90 -0
  81. package/src/index.ts +57 -6
  82. package/src/insights/quality-gates.test.ts +141 -0
  83. package/src/insights/quality-gates.ts +156 -0
  84. package/src/json.ts +29 -0
  85. package/src/logging/theme.ts +4 -0
  86. package/src/mail/client.ts +15 -2
  87. package/src/mail/store.test.ts +82 -0
  88. package/src/mail/store.ts +41 -4
  89. package/src/merge/lock.test.ts +149 -0
  90. package/src/merge/lock.ts +140 -0
  91. package/src/merge/predict.test.ts +387 -0
  92. package/src/merge/predict.ts +249 -0
  93. package/src/merge/resolver.ts +1 -1
  94. package/src/mulch/client.ts +3 -3
  95. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  96. package/src/runtimes/claude.test.ts +791 -1
  97. package/src/runtimes/claude.ts +323 -1
  98. package/src/runtimes/connections.test.ts +141 -1
  99. package/src/runtimes/connections.ts +73 -4
  100. package/src/runtimes/headless-connection.test.ts +264 -0
  101. package/src/runtimes/headless-connection.ts +158 -0
  102. package/src/runtimes/types.ts +10 -0
  103. package/src/schema-consistency.test.ts +1 -0
  104. package/src/sessions/store.test.ts +657 -29
  105. package/src/sessions/store.ts +286 -23
  106. package/src/test-setup.test.ts +31 -0
  107. package/src/test-setup.ts +28 -0
  108. package/src/types.ts +107 -2
  109. package/src/utils/pid.test.ts +85 -1
  110. package/src/utils/pid.ts +86 -1
  111. package/src/utils/process-scan.test.ts +53 -0
  112. package/src/utils/process-scan.ts +76 -0
  113. package/src/watchdog/daemon.test.ts +1607 -376
  114. package/src/watchdog/daemon.ts +462 -88
  115. package/src/watchdog/health.test.ts +282 -0
  116. package/src/watchdog/health.ts +126 -27
  117. package/src/worktree/manager.test.ts +218 -1
  118. package/src/worktree/manager.ts +55 -0
  119. package/src/worktree/process.test.ts +71 -0
  120. package/src/worktree/process.ts +25 -5
  121. package/src/worktree/tmux.test.ts +28 -0
  122. package/src/worktree/tmux.ts +27 -3
  123. package/templates/CLAUDE.md.tmpl +19 -8
  124. package/templates/overlay.md.tmpl +5 -2
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { MergeError } from "../errors.ts";
7
+ import { acquireMergeLock, mergeLockPath, sanitizeBranchForFilename } from "./lock.ts";
8
+
9
+ describe("sanitizeBranchForFilename", () => {
10
+ test("replaces forward slashes with dashes", () => {
11
+ expect(sanitizeBranchForFilename("feature/foo")).toBe("feature-foo");
12
+ expect(sanitizeBranchForFilename("a/b/c")).toBe("a-b-c");
13
+ });
14
+
15
+ test("replaces backslashes and colons", () => {
16
+ expect(sanitizeBranchForFilename("feature\\bar")).toBe("feature-bar");
17
+ expect(sanitizeBranchForFilename("ns:branch")).toBe("ns-branch");
18
+ });
19
+
20
+ test("leaves simple branch names alone", () => {
21
+ expect(sanitizeBranchForFilename("main")).toBe("main");
22
+ expect(sanitizeBranchForFilename("develop_2")).toBe("develop_2");
23
+ });
24
+ });
25
+
26
+ describe("mergeLockPath", () => {
27
+ test("composes path under .overstory/ with sanitized branch", () => {
28
+ expect(mergeLockPath("/tmp/.overstory", "feature/x")).toBe(
29
+ "/tmp/.overstory/merge-feature-x.lock",
30
+ );
31
+ });
32
+ });
33
+
34
+ describe("acquireMergeLock", () => {
35
+ let overstoryDir: string;
36
+
37
+ beforeEach(async () => {
38
+ overstoryDir = await mkdtemp(join(tmpdir(), "ov-merge-lock-"));
39
+ await mkdir(overstoryDir, { recursive: true });
40
+ });
41
+
42
+ afterEach(async () => {
43
+ await rm(overstoryDir, { recursive: true, force: true });
44
+ });
45
+
46
+ test("creates a lock file and returns a handle that removes it on release", () => {
47
+ const handle = acquireMergeLock(overstoryDir, "main");
48
+ expect(existsSync(handle.path)).toBe(true);
49
+
50
+ const payload = JSON.parse(readFileSync(handle.path, "utf8"));
51
+ expect(payload.pid).toBe(process.pid);
52
+ expect(payload.targetBranch).toBe("main");
53
+ expect(typeof payload.acquiredAt).toBe("string");
54
+
55
+ handle.release();
56
+ expect(existsSync(handle.path)).toBe(false);
57
+ });
58
+
59
+ test("release() is idempotent", () => {
60
+ const handle = acquireMergeLock(overstoryDir, "main");
61
+ handle.release();
62
+ handle.release(); // should not throw
63
+ expect(existsSync(handle.path)).toBe(false);
64
+ });
65
+
66
+ test("throws MergeError when lock is held by a live process", () => {
67
+ // Use this test process's own PID — it is guaranteed live.
68
+ const path = mergeLockPath(overstoryDir, "main");
69
+ writeFileSync(
70
+ path,
71
+ JSON.stringify({
72
+ pid: process.pid,
73
+ acquiredAt: new Date().toISOString(),
74
+ targetBranch: "main",
75
+ }),
76
+ );
77
+
78
+ try {
79
+ acquireMergeLock(overstoryDir, "main");
80
+ expect(true).toBe(false); // should not reach
81
+ } catch (err: unknown) {
82
+ expect(err).toBeInstanceOf(MergeError);
83
+ const msg = (err as MergeError).message;
84
+ expect(msg).toContain("Another ov merge is already running");
85
+ expect(msg).toContain(`pid ${process.pid}`);
86
+ expect(msg).toContain("main");
87
+ }
88
+
89
+ // Lock file is still on disk — we did not steal it.
90
+ expect(existsSync(path)).toBe(true);
91
+ });
92
+
93
+ test("steals a stale lock whose PID is not alive", () => {
94
+ const path = mergeLockPath(overstoryDir, "main");
95
+ // PID 2147483647 is INT_MAX — extremely unlikely to be in use.
96
+ writeFileSync(
97
+ path,
98
+ JSON.stringify({
99
+ pid: 2147483647,
100
+ acquiredAt: new Date(Date.now() - 60_000).toISOString(),
101
+ targetBranch: "main",
102
+ }),
103
+ );
104
+
105
+ const handle = acquireMergeLock(overstoryDir, "main");
106
+ const payload = JSON.parse(readFileSync(handle.path, "utf8"));
107
+ expect(payload.pid).toBe(process.pid);
108
+ handle.release();
109
+ });
110
+
111
+ test("steals an unparseable lock file", () => {
112
+ const path = mergeLockPath(overstoryDir, "main");
113
+ writeFileSync(path, "not json");
114
+
115
+ const handle = acquireMergeLock(overstoryDir, "main");
116
+ const payload = JSON.parse(readFileSync(handle.path, "utf8"));
117
+ expect(payload.pid).toBe(process.pid);
118
+ handle.release();
119
+ });
120
+
121
+ test("locks on different target branches are independent", () => {
122
+ const a = acquireMergeLock(overstoryDir, "main");
123
+ const b = acquireMergeLock(overstoryDir, "develop");
124
+ expect(existsSync(a.path)).toBe(true);
125
+ expect(existsSync(b.path)).toBe(true);
126
+ expect(a.path).not.toBe(b.path);
127
+ a.release();
128
+ b.release();
129
+ });
130
+
131
+ test("error message includes path so operator can manually clear", () => {
132
+ const path = mergeLockPath(overstoryDir, "main");
133
+ writeFileSync(
134
+ path,
135
+ JSON.stringify({
136
+ pid: process.pid,
137
+ acquiredAt: new Date().toISOString(),
138
+ targetBranch: "main",
139
+ }),
140
+ );
141
+
142
+ try {
143
+ acquireMergeLock(overstoryDir, "main");
144
+ expect(true).toBe(false);
145
+ } catch (err: unknown) {
146
+ expect((err as MergeError).message).toContain(path);
147
+ }
148
+ });
149
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Sentinel-file lock to prevent concurrent `ov merge` runs against the same
3
+ * canonical (target) branch.
4
+ *
5
+ * Two parallel merges into the same canonical branch can produce a misleading
6
+ * transient view: one merge runs the git operations while the second observes
7
+ * conflict markers mid-merge and reports a false failure. See seeds issue
8
+ * overstory-9610 for the original incident.
9
+ *
10
+ * The lock is a single JSON file at `.overstory/merge-{sanitized-target}.lock`
11
+ * created atomically with `writeFileSync(..., { flag: "wx" })`. If the file
12
+ * already exists, the holder PID is checked: live → fail fast, dead → take
13
+ * over. Released on exit via the returned handle.
14
+ */
15
+
16
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { MergeError } from "../errors.ts";
19
+ import { isProcessAlive } from "../worktree/tmux.ts";
20
+
21
+ export interface MergeLockHandle {
22
+ /** Path to the lock file on disk (useful for diagnostics / tests). */
23
+ readonly path: string;
24
+ /** Release the lock. Idempotent — safe to call multiple times. */
25
+ release(): void;
26
+ }
27
+
28
+ interface LockPayload {
29
+ pid: number;
30
+ acquiredAt: string;
31
+ targetBranch: string;
32
+ }
33
+
34
+ /**
35
+ * Sanitize a branch name for use in a filename.
36
+ * Replaces "/", "\\", and ":" with "-" so `feature/foo` becomes `feature-foo`.
37
+ */
38
+ export function sanitizeBranchForFilename(branch: string): string {
39
+ return branch.replace(/[/\\:]/g, "-");
40
+ }
41
+
42
+ /** Compute the lock file path for a given target branch. */
43
+ export function mergeLockPath(overstoryDir: string, targetBranch: string): string {
44
+ return join(overstoryDir, `merge-${sanitizeBranchForFilename(targetBranch)}.lock`);
45
+ }
46
+
47
+ /**
48
+ * Acquire the merge lock for a given target branch. Throws `MergeError` if
49
+ * another live `ov merge` is already running against this target. Stale locks
50
+ * (PID no longer alive) are taken over automatically.
51
+ *
52
+ * The caller MUST call `release()` on the returned handle when done.
53
+ */
54
+ export function acquireMergeLock(overstoryDir: string, targetBranch: string): MergeLockHandle {
55
+ const path = mergeLockPath(overstoryDir, targetBranch);
56
+ const payload: LockPayload = {
57
+ pid: process.pid,
58
+ acquiredAt: new Date().toISOString(),
59
+ targetBranch,
60
+ };
61
+ const serialized = JSON.stringify(payload);
62
+
63
+ const tryCreate = (): boolean => {
64
+ try {
65
+ writeFileSync(path, serialized, { flag: "wx" });
66
+ return true;
67
+ } catch (err: unknown) {
68
+ const code = (err as NodeJS.ErrnoException).code;
69
+ if (code === "EEXIST") return false;
70
+ throw err;
71
+ }
72
+ };
73
+
74
+ if (tryCreate()) {
75
+ return makeHandle(path);
76
+ }
77
+
78
+ // Lock file exists. Inspect the holder before failing.
79
+ const existing = readLockPayload(path);
80
+ const holderPid = existing?.pid;
81
+ const holderAlive = typeof holderPid === "number" && isProcessAlive(holderPid);
82
+
83
+ if (holderAlive) {
84
+ const since = existing?.acquiredAt ?? "unknown time";
85
+ throw new MergeError(
86
+ `Another ov merge is already running for "${targetBranch}" (pid ${holderPid}, acquired ${since}). Wait for it to finish, or remove ${path} if you are sure it is stale.`,
87
+ { branchName: targetBranch },
88
+ );
89
+ }
90
+
91
+ // Stale or unparseable lock — remove and retry once. If a third process
92
+ // won the race in between, surface that as a clear retry-soon error.
93
+ try {
94
+ unlinkSync(path);
95
+ } catch {
96
+ // File may have just been removed by another cleanup — fine.
97
+ }
98
+ if (tryCreate()) {
99
+ return makeHandle(path);
100
+ }
101
+
102
+ throw new MergeError(
103
+ `Another ov merge raced to acquire the lock for "${targetBranch}". Retry shortly.`,
104
+ { branchName: targetBranch },
105
+ );
106
+ }
107
+
108
+ function readLockPayload(path: string): LockPayload | null {
109
+ try {
110
+ const content = readFileSync(path, "utf8");
111
+ const parsed = JSON.parse(content) as unknown;
112
+ if (
113
+ parsed !== null &&
114
+ typeof parsed === "object" &&
115
+ "pid" in parsed &&
116
+ typeof (parsed as { pid: unknown }).pid === "number"
117
+ ) {
118
+ return parsed as LockPayload;
119
+ }
120
+ return null;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ function makeHandle(path: string): MergeLockHandle {
127
+ let released = false;
128
+ return {
129
+ path,
130
+ release(): void {
131
+ if (released) return;
132
+ released = true;
133
+ try {
134
+ unlinkSync(path);
135
+ } catch {
136
+ // File may already be gone — not an error.
137
+ }
138
+ },
139
+ };
140
+ }
@@ -0,0 +1,387 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { MergeError } from "../errors.ts";
3
+ import type { MulchClient } from "../mulch/client.ts";
4
+ import {
5
+ cleanupTempDir,
6
+ commitFile,
7
+ createTempGitRepo,
8
+ getDefaultBranch,
9
+ runGitInDir,
10
+ } from "../test-helpers.ts";
11
+ import type { MergeEntry } from "../types.ts";
12
+ import { predictConflicts } from "./predict.ts";
13
+
14
+ function makeTestEntry(overrides?: Partial<MergeEntry>): MergeEntry {
15
+ return {
16
+ branchName: overrides?.branchName ?? "feature-branch",
17
+ taskId: overrides?.taskId ?? "bead-123",
18
+ agentName: overrides?.agentName ?? "test-agent",
19
+ filesModified: overrides?.filesModified ?? ["src/test.ts"],
20
+ enqueuedAt: overrides?.enqueuedAt ?? new Date().toISOString(),
21
+ status: overrides?.status ?? "pending",
22
+ resolvedTier: overrides?.resolvedTier ?? null,
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Real mulch search output emulating two failed ai-resolve attempts on a file.
28
+ * Format must match the regex in `parseConflictPatterns` (resolver.ts).
29
+ */
30
+ function buildHistoricalFailureSearchOutput(file: string): string {
31
+ const recordTemplate = (branch: string, agent: string) =>
32
+ `Merge conflict failed at tier ai-resolve. Branch: ${branch}. Agent: ${agent}. Conflicting files: ${file}.`;
33
+ return [
34
+ recordTemplate("overstory/agent-a/bead-1", "agent-a"),
35
+ recordTemplate("overstory/agent-b/bead-2", "agent-b"),
36
+ ].join("\n");
37
+ }
38
+
39
+ /**
40
+ * Minimal MulchClient stub. Only `search` is exercised by predictConflicts.
41
+ * The other methods throw to make accidental use loud during testing.
42
+ */
43
+ function createMulchSearchStub(searchOutput: string): MulchClient {
44
+ return {
45
+ async prime() {
46
+ throw new Error("prime() not used by predictConflicts");
47
+ },
48
+ async status() {
49
+ throw new Error("status() not used by predictConflicts");
50
+ },
51
+ async record() {
52
+ throw new Error("record() not used by predictConflicts");
53
+ },
54
+ async query() {
55
+ throw new Error("query() not used by predictConflicts");
56
+ },
57
+ async search() {
58
+ return searchOutput;
59
+ },
60
+ async diff() {
61
+ throw new Error("diff() not used by predictConflicts");
62
+ },
63
+ async learn() {
64
+ throw new Error("learn() not used by predictConflicts");
65
+ },
66
+ async prune() {
67
+ throw new Error("prune() not used by predictConflicts");
68
+ },
69
+ async doctor() {
70
+ throw new Error("doctor() not used by predictConflicts");
71
+ },
72
+ async ready() {
73
+ throw new Error("ready() not used by predictConflicts");
74
+ },
75
+ async compact() {
76
+ throw new Error("compact() not used by predictConflicts");
77
+ },
78
+ async appendOutcome() {
79
+ throw new Error("appendOutcome() not used by predictConflicts");
80
+ },
81
+ };
82
+ }
83
+
84
+ describe("predictConflicts", () => {
85
+ test("clean-merge: branch adds a new file", async () => {
86
+ const repoDir = await createTempGitRepo();
87
+ try {
88
+ const defaultBranch = await getDefaultBranch(repoDir);
89
+ await commitFile(repoDir, "src/main.ts", "main content\n");
90
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
91
+ await commitFile(repoDir, "src/feature.ts", "feature\n");
92
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
93
+
94
+ const entry = makeTestEntry({
95
+ branchName: "feature-branch",
96
+ filesModified: ["src/feature.ts"],
97
+ });
98
+
99
+ const prediction = await predictConflicts(entry, defaultBranch, repoDir);
100
+
101
+ expect(prediction.predictedTier).toBe("clean-merge");
102
+ expect(prediction.conflictFiles).toEqual([]);
103
+ expect(prediction.wouldRequireAgent).toBe(false);
104
+ expect(prediction.reason).toContain("clean");
105
+ } finally {
106
+ await cleanupTempDir(repoDir);
107
+ }
108
+ });
109
+
110
+ test("clean-merge: branch is an ancestor of canonical (already merged)", async () => {
111
+ const repoDir = await createTempGitRepo();
112
+ try {
113
+ const defaultBranch = await getDefaultBranch(repoDir);
114
+ await commitFile(repoDir, "src/main.ts", "v1\n");
115
+ // Create a feature branch at the current tip — branch is an ancestor of canonical.
116
+ await runGitInDir(repoDir, ["branch", "feature-branch"]);
117
+ // Advance canonical past the branch.
118
+ await commitFile(repoDir, "src/main.ts", "v2\n");
119
+
120
+ const entry = makeTestEntry({
121
+ branchName: "feature-branch",
122
+ filesModified: ["src/main.ts"],
123
+ });
124
+
125
+ const prediction = await predictConflicts(entry, defaultBranch, repoDir);
126
+
127
+ expect(prediction.predictedTier).toBe("clean-merge");
128
+ expect(prediction.conflictFiles).toEqual([]);
129
+ expect(prediction.wouldRequireAgent).toBe(false);
130
+ expect(prediction.reason).toContain("ancestor");
131
+ } finally {
132
+ await cleanupTempDir(repoDir);
133
+ }
134
+ });
135
+
136
+ test("auto-resolve: whitespace-only canonical (empty HEAD side)", async () => {
137
+ const repoDir = await createTempGitRepo();
138
+ try {
139
+ const defaultBranch = await getDefaultBranch(repoDir);
140
+ // Common ancestor.
141
+ await commitFile(repoDir, "src/test.ts", "line1\nshared line\nline3\n");
142
+ // Feature replaces "shared line".
143
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
144
+ await commitFile(repoDir, "src/test.ts", "line1\nnew content\nline3\n");
145
+ // Main deletes "shared line" — produces a conflict where HEAD side is empty.
146
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
147
+ await commitFile(repoDir, "src/test.ts", "line1\nline3\n");
148
+
149
+ const entry = makeTestEntry({
150
+ branchName: "feature-branch",
151
+ filesModified: ["src/test.ts"],
152
+ });
153
+
154
+ const prediction = await predictConflicts(entry, defaultBranch, repoDir);
155
+
156
+ expect(prediction.predictedTier).toBe("auto-resolve");
157
+ expect(prediction.wouldRequireAgent).toBe(false);
158
+ expect(prediction.conflictFiles).toContain("src/test.ts");
159
+ } finally {
160
+ await cleanupTempDir(repoDir);
161
+ }
162
+ });
163
+
164
+ test("merge=union files do not require an agent", async () => {
165
+ const repoDir = await createTempGitRepo();
166
+ try {
167
+ const defaultBranch = await getDefaultBranch(repoDir);
168
+ // Same-line divergence on a merge=union file. With .gitattributes
169
+ // available, git's union driver may resolve cleanly at the tree
170
+ // level (-> clean-merge); without it, merge-tree surfaces a
171
+ // conflict that our predictor classifies via checkMergeUnion
172
+ // (-> auto-resolve). Either way, no merger agent is required.
173
+ await commitFile(repoDir, "data.jsonl", '{"id":"shared"}\n');
174
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
175
+ await commitFile(repoDir, "data.jsonl", '{"id":"branch-side"}\n');
176
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
177
+ await commitFile(repoDir, "data.jsonl", '{"id":"main-side"}\n');
178
+ await Bun.write(`${repoDir}/.gitattributes`, "*.jsonl merge=union\n");
179
+
180
+ const entry = makeTestEntry({
181
+ branchName: "feature-branch",
182
+ filesModified: ["data.jsonl"],
183
+ });
184
+
185
+ const prediction = await predictConflicts(entry, defaultBranch, repoDir);
186
+
187
+ expect(prediction.wouldRequireAgent).toBe(false);
188
+ expect(["clean-merge", "auto-resolve"]).toContain(prediction.predictedTier);
189
+ } finally {
190
+ await cleanupTempDir(repoDir);
191
+ }
192
+ });
193
+
194
+ test("auto-resolve: synthetic conflict where every file is union", async () => {
195
+ // Forces the conflict-classification branch by simulating a merge-tree
196
+ // output that reports a conflict, then verifying checkMergeUnion sends
197
+ // us to auto-resolve. We use a contentful conflict and write a working-
198
+ // tree .gitattributes that marks the file union AFTER merge-tree's tree
199
+ // pass has already produced a conflict for it. To pull that off we use
200
+ // a file extension git won't auto-merge, plus a merge.driver-less repo.
201
+ const repoDir = await createTempGitRepo();
202
+ try {
203
+ const defaultBranch = await getDefaultBranch(repoDir);
204
+ await commitFile(repoDir, "src/test.ts", "original\n");
205
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
206
+ await commitFile(repoDir, "src/test.ts", "feature\n");
207
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
208
+ await commitFile(repoDir, "src/test.ts", "main\n");
209
+ // Mark the conflicting file union via working-tree .gitattributes.
210
+ // Git's tree-merge resolved the .ts file with conflict markers
211
+ // (default driver — no committed attributes), and check-attr sees
212
+ // the working-tree directive.
213
+ await Bun.write(`${repoDir}/.gitattributes`, "src/test.ts merge=union\n");
214
+
215
+ const entry = makeTestEntry({
216
+ branchName: "feature-branch",
217
+ filesModified: ["src/test.ts"],
218
+ });
219
+
220
+ const prediction = await predictConflicts(entry, defaultBranch, repoDir);
221
+
222
+ expect(prediction.wouldRequireAgent).toBe(false);
223
+ // Either auto-resolve (conflict but union-resolvable) or clean-merge
224
+ // if git applied the working-tree attribute at tree-merge time.
225
+ expect(["clean-merge", "auto-resolve"]).toContain(prediction.predictedTier);
226
+ } finally {
227
+ await cleanupTempDir(repoDir);
228
+ }
229
+ });
230
+
231
+ test("ai-resolve: contentful canonical", async () => {
232
+ const repoDir = await createTempGitRepo();
233
+ try {
234
+ const defaultBranch = await getDefaultBranch(repoDir);
235
+ await commitFile(repoDir, "src/test.ts", "original content\n");
236
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
237
+ await commitFile(repoDir, "src/test.ts", "feature content\n");
238
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
239
+ await commitFile(repoDir, "src/test.ts", "main modified content\n");
240
+
241
+ const entry = makeTestEntry({
242
+ branchName: "feature-branch",
243
+ filesModified: ["src/test.ts"],
244
+ });
245
+
246
+ const prediction = await predictConflicts(entry, defaultBranch, repoDir);
247
+
248
+ expect(prediction.predictedTier).toBe("ai-resolve");
249
+ expect(prediction.wouldRequireAgent).toBe(true);
250
+ expect(prediction.conflictFiles).toContain("src/test.ts");
251
+ expect(prediction.reason).toContain("src/test.ts");
252
+ } finally {
253
+ await cleanupTempDir(repoDir);
254
+ }
255
+ });
256
+
257
+ test("missing branch throws MergeError with branch name", async () => {
258
+ const repoDir = await createTempGitRepo();
259
+ try {
260
+ const defaultBranch = await getDefaultBranch(repoDir);
261
+ await commitFile(repoDir, "src/main.ts", "content\n");
262
+
263
+ const entry = makeTestEntry({ branchName: "does-not-exist" });
264
+
265
+ await expect(predictConflicts(entry, defaultBranch, repoDir)).rejects.toThrow(MergeError);
266
+ } finally {
267
+ await cleanupTempDir(repoDir);
268
+ }
269
+ });
270
+
271
+ test("mulch skip-tier history bumps ai-resolve to reimagine", async () => {
272
+ const repoDir = await createTempGitRepo();
273
+ try {
274
+ const defaultBranch = await getDefaultBranch(repoDir);
275
+ await commitFile(repoDir, "src/test.ts", "original\n");
276
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
277
+ await commitFile(repoDir, "src/test.ts", "feature\n");
278
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
279
+ await commitFile(repoDir, "src/test.ts", "main\n");
280
+
281
+ const entry = makeTestEntry({
282
+ branchName: "feature-branch",
283
+ filesModified: ["src/test.ts"],
284
+ });
285
+
286
+ const mulchClient = createMulchSearchStub(buildHistoricalFailureSearchOutput("src/test.ts"));
287
+ const prediction = await predictConflicts(entry, defaultBranch, repoDir, mulchClient);
288
+
289
+ expect(prediction.predictedTier).toBe("reimagine");
290
+ expect(prediction.wouldRequireAgent).toBe(true);
291
+ expect(prediction.reason).toContain("historical");
292
+ } finally {
293
+ await cleanupTempDir(repoDir);
294
+ }
295
+ });
296
+
297
+ test("mulch absent: ai-resolve stays ai-resolve (history check is optional)", async () => {
298
+ const repoDir = await createTempGitRepo();
299
+ try {
300
+ const defaultBranch = await getDefaultBranch(repoDir);
301
+ await commitFile(repoDir, "src/test.ts", "original\n");
302
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
303
+ await commitFile(repoDir, "src/test.ts", "feature\n");
304
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
305
+ await commitFile(repoDir, "src/test.ts", "main\n");
306
+
307
+ const entry = makeTestEntry({
308
+ branchName: "feature-branch",
309
+ filesModified: ["src/test.ts"],
310
+ });
311
+
312
+ // No mulch client passed — verifies the history check is genuinely optional.
313
+ const prediction = await predictConflicts(entry, defaultBranch, repoDir);
314
+ expect(prediction.predictedTier).toBe("ai-resolve");
315
+ expect(prediction.wouldRequireAgent).toBe(true);
316
+ } finally {
317
+ await cleanupTempDir(repoDir);
318
+ }
319
+ });
320
+
321
+ test("mulch search failure does not block prediction", async () => {
322
+ const repoDir = await createTempGitRepo();
323
+ try {
324
+ const defaultBranch = await getDefaultBranch(repoDir);
325
+ await commitFile(repoDir, "src/test.ts", "original\n");
326
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
327
+ await commitFile(repoDir, "src/test.ts", "feature\n");
328
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
329
+ await commitFile(repoDir, "src/test.ts", "main\n");
330
+
331
+ const entry = makeTestEntry({
332
+ branchName: "feature-branch",
333
+ filesModified: ["src/test.ts"],
334
+ });
335
+
336
+ const failingMulch: MulchClient = {
337
+ ...createMulchSearchStub(""),
338
+ async search() {
339
+ throw new Error("mulch unreachable");
340
+ },
341
+ };
342
+
343
+ const prediction = await predictConflicts(entry, defaultBranch, repoDir, failingMulch);
344
+ expect(prediction.predictedTier).toBe("ai-resolve");
345
+ expect(prediction.wouldRequireAgent).toBe(true);
346
+ } finally {
347
+ await cleanupTempDir(repoDir);
348
+ }
349
+ });
350
+
351
+ test("does not mutate the working tree, HEAD, or current branch", async () => {
352
+ const repoDir = await createTempGitRepo();
353
+ try {
354
+ const defaultBranch = await getDefaultBranch(repoDir);
355
+ await commitFile(repoDir, "src/test.ts", "original\n");
356
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
357
+ await commitFile(repoDir, "src/test.ts", "feature\n");
358
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
359
+ await commitFile(repoDir, "src/test.ts", "main\n");
360
+
361
+ const headBefore = (await runGitInDir(repoDir, ["rev-parse", "HEAD"])).trim();
362
+ const branchBefore = (await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"])).trim();
363
+ const fileBefore = await Bun.file(`${repoDir}/src/test.ts`).text();
364
+
365
+ await predictConflicts(
366
+ makeTestEntry({
367
+ branchName: "feature-branch",
368
+ filesModified: ["src/test.ts"],
369
+ }),
370
+ defaultBranch,
371
+ repoDir,
372
+ );
373
+
374
+ const headAfter = (await runGitInDir(repoDir, ["rev-parse", "HEAD"])).trim();
375
+ const branchAfter = (await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"])).trim();
376
+ const fileAfter = await Bun.file(`${repoDir}/src/test.ts`).text();
377
+ const status = await runGitInDir(repoDir, ["status", "--porcelain"]);
378
+
379
+ expect(headAfter).toBe(headBefore);
380
+ expect(branchAfter).toBe(branchBefore);
381
+ expect(fileAfter).toBe(fileBefore);
382
+ expect(status.trim()).toBe("");
383
+ } finally {
384
+ await cleanupTempDir(repoDir);
385
+ }
386
+ });
387
+ });