@os-eco/overstory-cli 0.6.4 → 0.6.5

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 (71) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -1
  3. package/src/agents/checkpoint.test.ts +2 -2
  4. package/src/agents/hooks-deployer.test.ts +37 -0
  5. package/src/agents/hooks-deployer.ts +15 -1
  6. package/src/agents/identity.test.ts +27 -27
  7. package/src/agents/identity.ts +10 -10
  8. package/src/agents/lifecycle.test.ts +6 -6
  9. package/src/agents/lifecycle.ts +2 -2
  10. package/src/agents/overlay.test.ts +9 -9
  11. package/src/agents/overlay.ts +4 -4
  12. package/src/commands/agents.test.ts +5 -5
  13. package/src/commands/agents.ts +3 -3
  14. package/src/commands/clean.test.ts +5 -5
  15. package/src/commands/coordinator.test.ts +2 -2
  16. package/src/commands/coordinator.ts +1 -1
  17. package/src/commands/costs.test.ts +45 -45
  18. package/src/commands/dashboard.ts +3 -3
  19. package/src/commands/inspect.test.ts +16 -16
  20. package/src/commands/inspect.ts +1 -1
  21. package/src/commands/log.test.ts +21 -21
  22. package/src/commands/log.ts +7 -7
  23. package/src/commands/mail.test.ts +5 -5
  24. package/src/commands/merge.test.ts +8 -8
  25. package/src/commands/merge.ts +8 -8
  26. package/src/commands/metrics.test.ts +6 -6
  27. package/src/commands/metrics.ts +1 -1
  28. package/src/commands/monitor.ts +1 -1
  29. package/src/commands/nudge.test.ts +1 -1
  30. package/src/commands/prime.test.ts +4 -4
  31. package/src/commands/prime.ts +6 -6
  32. package/src/commands/run.test.ts +1 -1
  33. package/src/commands/sling.test.ts +5 -5
  34. package/src/commands/sling.ts +13 -10
  35. package/src/commands/spec.test.ts +2 -2
  36. package/src/commands/spec.ts +8 -8
  37. package/src/commands/status.test.ts +97 -1
  38. package/src/commands/status.ts +17 -16
  39. package/src/commands/stop.test.ts +1 -1
  40. package/src/commands/supervisor.test.ts +9 -9
  41. package/src/commands/supervisor.ts +11 -11
  42. package/src/commands/trace.test.ts +6 -6
  43. package/src/commands/trace.ts +6 -6
  44. package/src/commands/worktree.test.ts +205 -29
  45. package/src/commands/worktree.ts +47 -9
  46. package/src/doctor/consistency.test.ts +14 -14
  47. package/src/doctor/merge-queue.test.ts +4 -4
  48. package/src/e2e/init-sling-lifecycle.test.ts +2 -2
  49. package/src/errors.ts +1 -1
  50. package/src/index.ts +3 -3
  51. package/src/mail/broadcast.test.ts +1 -1
  52. package/src/mail/client.test.ts +6 -6
  53. package/src/mail/store.test.ts +3 -3
  54. package/src/merge/queue.test.ts +12 -12
  55. package/src/merge/queue.ts +2 -2
  56. package/src/merge/resolver.test.ts +159 -7
  57. package/src/merge/resolver.ts +46 -2
  58. package/src/metrics/store.test.ts +44 -44
  59. package/src/metrics/store.ts +2 -2
  60. package/src/metrics/summary.test.ts +35 -35
  61. package/src/mulch/client.test.ts +1 -1
  62. package/src/sessions/compat.test.ts +3 -3
  63. package/src/sessions/compat.ts +1 -1
  64. package/src/sessions/store.test.ts +4 -4
  65. package/src/sessions/store.ts +2 -2
  66. package/src/types.ts +14 -14
  67. package/src/watchdog/daemon.test.ts +10 -10
  68. package/src/watchdog/daemon.ts +1 -1
  69. package/src/watchdog/health.test.ts +1 -1
  70. package/src/worktree/manager.test.ts +20 -20
  71. package/src/worktree/manager.ts +120 -4
@@ -77,13 +77,13 @@ describe("checkMergeQueue", () => {
77
77
  const queue = createMergeQueue(dbPath);
78
78
  queue.enqueue({
79
79
  branchName: "feature/test",
80
- beadId: "beads-abc",
80
+ taskId: "beads-abc",
81
81
  agentName: "test-agent",
82
82
  filesModified: ["src/test.ts"],
83
83
  });
84
84
  queue.enqueue({
85
85
  branchName: "feature/another",
86
- beadId: "beads-def",
86
+ taskId: "beads-def",
87
87
  agentName: "another-agent",
88
88
  filesModified: ["src/another.ts"],
89
89
  });
@@ -194,13 +194,13 @@ describe("checkMergeQueue", () => {
194
194
  const queue = createMergeQueue(dbPath);
195
195
  queue.enqueue({
196
196
  branchName: "feature/duplicate",
197
- beadId: "beads-abc",
197
+ taskId: "beads-abc",
198
198
  agentName: "test-agent",
199
199
  filesModified: ["src/test.ts"],
200
200
  });
201
201
  queue.enqueue({
202
202
  branchName: "feature/duplicate",
203
- beadId: "beads-def",
203
+ taskId: "beads-def",
204
204
  agentName: "another-agent",
205
205
  filesModified: ["src/another.ts"],
206
206
  });
@@ -174,7 +174,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
174
174
 
175
175
  const overlayConfig: OverlayConfig = {
176
176
  agentName: "test-agent",
177
- beadId: "test-bead-001",
177
+ taskId: "test-bead-001",
178
178
  specPath: null,
179
179
  branchName: "overstory/test-agent/test-bead-001",
180
180
  worktreePath: join(tempDir, ".overstory", "worktrees", "test-agent"),
@@ -241,7 +241,7 @@ describe("E2E: init→sling lifecycle on external project", () => {
241
241
  const builderDef = await Bun.file(join(agentDefsDir, "builder.md")).text();
242
242
  const overlayConfig: OverlayConfig = {
243
243
  agentName: "lifecycle-builder",
244
- beadId: "lifecycle-001",
244
+ taskId: "lifecycle-001",
245
245
  specPath: join(tempDir, ".overstory", "specs", "lifecycle-001.md"),
246
246
  branchName: "overstory/lifecycle-builder/lifecycle-001",
247
247
  worktreePath: join(tempDir, ".overstory", "worktrees", "lifecycle-builder"),
package/src/errors.ts CHANGED
@@ -152,7 +152,7 @@ export class MergeError extends OverstoryError {
152
152
 
153
153
  /**
154
154
  * Raised when input validation fails.
155
- * Examples: invalid agent names, malformed beadIds, bad CLI arguments.
155
+ * Examples: invalid agent names, malformed taskIds, bad CLI arguments.
156
156
  */
157
157
  export class ValidationError extends OverstoryError {
158
158
  readonly field: string | null;
package/src/index.ts CHANGED
@@ -42,7 +42,7 @@ import { createWorktreeCommand } from "./commands/worktree.ts";
42
42
  import { OverstoryError, WorktreeError } from "./errors.ts";
43
43
  import { setQuiet } from "./logging/color.ts";
44
44
 
45
- const VERSION = "0.6.4";
45
+ const VERSION = "0.6.5";
46
46
 
47
47
  const COMMANDS = [
48
48
  "agents",
@@ -180,8 +180,8 @@ specCmd
180
180
  .argument("<bead-id>", "Task ID for the spec file")
181
181
  .option("--body <content>", "Spec content (or pipe via stdin)")
182
182
  .option("--agent <name>", "Agent writing the spec (for attribution)")
183
- .action(async (beadId, opts) => {
184
- await specWriteCommand(beadId, opts);
183
+ .action(async (taskId, opts) => {
184
+ await specWriteCommand(taskId, opts);
185
185
  });
186
186
 
187
187
  program
@@ -30,7 +30,7 @@ describe("resolveGroupAddress", () => {
30
30
  capability,
31
31
  worktreePath: `/worktrees/${agentName}`,
32
32
  branchName: `branch-${agentName}`,
33
- beadId: "bead-001",
33
+ taskId: "bead-001",
34
34
  tmuxSession: `overstory-test-${agentName}`,
35
35
  state: "working",
36
36
  pid: 12345,
@@ -598,7 +598,7 @@ describe("createMailClient", () => {
598
598
  describe("sendProtocol", () => {
599
599
  test("sends a worker_done message with serialized payload", () => {
600
600
  const payload: WorkerDonePayload = {
601
- beadId: "beads-abc",
601
+ taskId: "beads-abc",
602
602
  branch: "agent/builder-1",
603
603
  exitCode: 0,
604
604
  filesModified: ["src/foo.ts", "src/bar.ts"],
@@ -625,7 +625,7 @@ describe("createMailClient", () => {
625
625
  subject: "Merged",
626
626
  body: "Branch merged",
627
627
  type: "merged",
628
- payload: { branch: "agent/b1", beadId: "beads-xyz", tier: "clean-merge" as const },
628
+ payload: { branch: "agent/b1", taskId: "beads-xyz", tier: "clean-merge" as const },
629
629
  });
630
630
 
631
631
  const msg = store.getById(id);
@@ -640,7 +640,7 @@ describe("createMailClient", () => {
640
640
  body: "Build failing",
641
641
  type: "escalation",
642
642
  priority: "urgent",
643
- payload: { severity: "critical" as const, beadId: null, context: "OOM" },
643
+ payload: { severity: "critical" as const, taskId: null, context: "OOM" },
644
644
  });
645
645
 
646
646
  const msg = store.getById(id);
@@ -656,7 +656,7 @@ describe("createMailClient", () => {
656
656
  type: "assign",
657
657
  threadId: "thread-dispatch-1",
658
658
  payload: {
659
- beadId: "beads-123",
659
+ taskId: "beads-123",
660
660
  specPath: ".overstory/specs/beads-123.md",
661
661
  workerName: "builder-1",
662
662
  branch: "agent/builder-1",
@@ -671,7 +671,7 @@ describe("createMailClient", () => {
671
671
  describe("parsePayload", () => {
672
672
  test("parses a valid JSON payload", () => {
673
673
  const payload: WorkerDonePayload = {
674
- beadId: "beads-abc",
674
+ taskId: "beads-abc",
675
675
  branch: "agent/builder-1",
676
676
  exitCode: 0,
677
677
  filesModified: ["src/foo.ts"],
@@ -727,7 +727,7 @@ describe("createMailClient", () => {
727
727
  describe("checkInject with protocol messages", () => {
728
728
  test("includes payload in injection output for protocol messages", () => {
729
729
  const payload: WorkerDonePayload = {
730
- beadId: "beads-abc",
730
+ taskId: "beads-abc",
731
731
  branch: "agent/builder-1",
732
732
  exitCode: 0,
733
733
  filesModified: ["src/foo.ts"],
@@ -638,7 +638,7 @@ describe("createMailStore", () => {
638
638
 
639
639
  test("stores JSON payload string", () => {
640
640
  const payload = JSON.stringify({
641
- beadId: "beads-abc",
641
+ taskId: "beads-abc",
642
642
  branch: "agent/builder-1",
643
643
  exitCode: 0,
644
644
  filesModified: ["src/foo.ts"],
@@ -661,7 +661,7 @@ describe("createMailStore", () => {
661
661
  });
662
662
 
663
663
  test("returns payload in getUnread results", () => {
664
- const payload = JSON.stringify({ severity: "critical", beadId: null, context: "OOM" });
664
+ const payload = JSON.stringify({ severity: "critical", taskId: null, context: "OOM" });
665
665
  store.insert({
666
666
  id: "msg-escalation",
667
667
  from: "builder-1",
@@ -682,7 +682,7 @@ describe("createMailStore", () => {
682
682
  test("returns payload in getAll results", () => {
683
683
  const payload = JSON.stringify({
684
684
  branch: "agent/b1",
685
- beadId: "beads-xyz",
685
+ taskId: "beads-xyz",
686
686
  tier: "clean-merge",
687
687
  });
688
688
  store.insert({
@@ -23,14 +23,14 @@ describe("createMergeQueue", () => {
23
23
  function makeInput(
24
24
  overrides?: Partial<{
25
25
  branchName: string;
26
- beadId: string;
26
+ taskId: string;
27
27
  agentName: string;
28
28
  filesModified: string[];
29
29
  }>,
30
30
  ) {
31
31
  return {
32
32
  branchName: overrides?.branchName ?? "overstory/test-agent/bead-123",
33
- beadId: overrides?.beadId ?? "bead-123",
33
+ taskId: overrides?.taskId ?? "bead-123",
34
34
  agentName: overrides?.agentName ?? "test-agent",
35
35
  filesModified: overrides?.filesModified ?? ["src/test.ts"],
36
36
  };
@@ -52,7 +52,7 @@ describe("createMergeQueue", () => {
52
52
  const after = new Date().toISOString();
53
53
 
54
54
  expect(entry.branchName).toBe("overstory/test-agent/bead-123");
55
- expect(entry.beadId).toBe("bead-123");
55
+ expect(entry.taskId).toBe("bead-123");
56
56
  expect(entry.agentName).toBe("test-agent");
57
57
  expect(entry.filesModified).toEqual(["src/test.ts"]);
58
58
  expect(entry.enqueuedAt).toBeDefined();
@@ -65,7 +65,7 @@ describe("createMergeQueue", () => {
65
65
  const queue = createMergeQueue(queuePath);
66
66
  const input = makeInput({
67
67
  branchName: "overstory/builder-1/bead-xyz",
68
- beadId: "bead-xyz",
68
+ taskId: "bead-xyz",
69
69
  agentName: "builder-1",
70
70
  filesModified: ["src/a.ts", "src/b.ts"],
71
71
  });
@@ -73,7 +73,7 @@ describe("createMergeQueue", () => {
73
73
  const entry = queue.enqueue(input);
74
74
 
75
75
  expect(entry.branchName).toBe("overstory/builder-1/bead-xyz");
76
- expect(entry.beadId).toBe("bead-xyz");
76
+ expect(entry.taskId).toBe("bead-xyz");
77
77
  expect(entry.agentName).toBe("builder-1");
78
78
  expect(entry.filesModified).toEqual(["src/a.ts", "src/b.ts"]);
79
79
  });
@@ -82,8 +82,8 @@ describe("createMergeQueue", () => {
82
82
  describe("dequeue", () => {
83
83
  test("returns first pending entry (FIFO)", () => {
84
84
  const queue = createMergeQueue(queuePath);
85
- queue.enqueue(makeInput({ branchName: "branch-a", beadId: "bead-a" }));
86
- queue.enqueue(makeInput({ branchName: "branch-b", beadId: "bead-b" }));
85
+ queue.enqueue(makeInput({ branchName: "branch-a", taskId: "bead-a" }));
86
+ queue.enqueue(makeInput({ branchName: "branch-b", taskId: "bead-b" }));
87
87
 
88
88
  const dequeued = queue.dequeue();
89
89
 
@@ -363,17 +363,17 @@ describe("createMergeQueue", () => {
363
363
  const entries = queue.list();
364
364
 
365
365
  expect(entries).toHaveLength(1);
366
- expect(entries[0]?.beadId).toBe("bead-1");
366
+ expect(entries[0]?.taskId).toBe("bead-1");
367
367
  expect(entries[0]?.branchName).toBe("overstory/test/bead-1");
368
368
 
369
369
  // New inserts should also work
370
370
  const newEntry = queue.enqueue({
371
371
  branchName: "overstory/test/bead-2",
372
- beadId: "bead-2",
372
+ taskId: "bead-2",
373
373
  agentName: "test",
374
374
  filesModified: ["src/b.ts"],
375
375
  });
376
- expect(newEntry.beadId).toBe("bead-2");
376
+ expect(newEntry.taskId).toBe("bead-2");
377
377
 
378
378
  queue.close();
379
379
  });
@@ -383,7 +383,7 @@ describe("createMergeQueue", () => {
383
383
  const queue1 = createMergeQueue(queuePath);
384
384
  queue1.enqueue({
385
385
  branchName: "overstory/test/bead-1",
386
- beadId: "bead-1",
386
+ taskId: "bead-1",
387
387
  agentName: "test",
388
388
  filesModified: [],
389
389
  });
@@ -394,7 +394,7 @@ describe("createMergeQueue", () => {
394
394
  const entries = queue2.list();
395
395
 
396
396
  expect(entries).toHaveLength(1);
397
- expect(entries[0]?.beadId).toBe("bead-1");
397
+ expect(entries[0]?.taskId).toBe("bead-1");
398
398
  queue2.close();
399
399
  });
400
400
  });
@@ -74,7 +74,7 @@ function rowToEntry(row: MergeQueueRow): MergeEntry {
74
74
 
75
75
  return {
76
76
  branchName: row.branch_name,
77
- beadId: row.task_id,
77
+ taskId: row.task_id,
78
78
  agentName: row.agent_name,
79
79
  filesModified,
80
80
  enqueuedAt: row.enqueued_at,
@@ -172,7 +172,7 @@ export function createMergeQueue(dbPath: string): MergeQueue {
172
172
 
173
173
  const row = insertStmt.get({
174
174
  $branch_name: input.branchName,
175
- $task_id: input.beadId,
175
+ $task_id: input.taskId,
176
176
  $agent_name: input.agentName,
177
177
  $files_modified: filesModifiedJson,
178
178
  $enqueued_at: enqueuedAt,
@@ -24,6 +24,7 @@ import {
24
24
  createMergeResolver,
25
25
  looksLikeProse,
26
26
  parseConflictPatterns,
27
+ resolveConflictsUnion,
27
28
  } from "./resolver.ts";
28
29
 
29
30
  /**
@@ -53,7 +54,7 @@ function mockSpawnResult(
53
54
  function makeTestEntry(overrides?: Partial<MergeEntry>): MergeEntry {
54
55
  return {
55
56
  branchName: overrides?.branchName ?? "feature-branch",
56
- beadId: overrides?.beadId ?? "bead-123",
57
+ taskId: overrides?.taskId ?? "bead-123",
57
58
  agentName: overrides?.agentName ?? "test-agent",
58
59
  filesModified: overrides?.filesModified ?? ["src/test.ts"],
59
60
  enqueuedAt: overrides?.enqueuedAt ?? new Date().toISOString(),
@@ -551,7 +552,7 @@ describe("createMergeResolver", () => {
551
552
 
552
553
  const entry = makeTestEntry({
553
554
  branchName: "overstory/my-agent/bead-xyz",
554
- beadId: "bead-xyz",
555
+ taskId: "bead-xyz",
555
556
  agentName: "my-agent",
556
557
  filesModified: ["src/test.ts"],
557
558
  });
@@ -564,7 +565,7 @@ describe("createMergeResolver", () => {
564
565
  const result = await resolver.resolve(entry, defaultBranch, repoDir);
565
566
 
566
567
  expect(result.entry.branchName).toBe("overstory/my-agent/bead-xyz");
567
- expect(result.entry.beadId).toBe("bead-xyz");
568
+ expect(result.entry.taskId).toBe("bead-xyz");
568
569
  expect(result.entry.agentName).toBe("my-agent");
569
570
  });
570
571
  });
@@ -744,7 +745,7 @@ describe("createMergeResolver", () => {
744
745
 
745
746
  const entry = makeTestEntry({
746
747
  branchName: "feature-branch",
747
- beadId: "bead-abc-123",
748
+ taskId: "bead-abc-123",
748
749
  agentName: "test-builder",
749
750
  filesModified: ["src/test.ts"],
750
751
  });
@@ -811,7 +812,7 @@ describe("createMergeResolver", () => {
811
812
 
812
813
  const entry = makeTestEntry({
813
814
  branchName: "feature-branch",
814
- beadId: "bead-fail-456",
815
+ taskId: "bead-fail-456",
815
816
  agentName: "test-agent",
816
817
  filesModified: ["src/test.ts"],
817
818
  });
@@ -906,7 +907,7 @@ describe("createMergeResolver", () => {
906
907
 
907
908
  const entry = makeTestEntry({
908
909
  branchName: "feature-branch",
909
- beadId: "bead-ai-789",
910
+ taskId: "bead-ai-789",
910
911
  filesModified: ["src/test.ts"],
911
912
  });
912
913
 
@@ -981,7 +982,7 @@ describe("createMergeResolver", () => {
981
982
 
982
983
  const entry = makeTestEntry({
983
984
  branchName: "feature-branch",
984
- beadId: "bead-reimagine-xyz",
985
+ taskId: "bead-reimagine-xyz",
985
986
  filesModified: ["src/reimagine-target.ts"],
986
987
  });
987
988
 
@@ -1288,6 +1289,157 @@ describe("createMergeResolver", () => {
1288
1289
  });
1289
1290
  });
1290
1291
 
1292
+ describe("resolveConflictsUnion", () => {
1293
+ test("returns null when no conflict markers are present", () => {
1294
+ expect(resolveConflictsUnion("no conflicts here\n")).toBeNull();
1295
+ expect(resolveConflictsUnion("")).toBeNull();
1296
+ });
1297
+
1298
+ test("keeps both canonical and incoming content for a single conflict", () => {
1299
+ const content = [
1300
+ "<<<<<<< HEAD\n",
1301
+ '{"id":"a"}\n',
1302
+ '{"id":"c"}\n',
1303
+ "=======\n",
1304
+ '{"id":"a"}\n',
1305
+ '{"id":"b"}\n',
1306
+ ">>>>>>> feature-branch\n",
1307
+ ].join("");
1308
+ const result = resolveConflictsUnion(content);
1309
+ expect(result).not.toBeNull();
1310
+ expect(result).toContain('{"id":"a"}\n{"id":"c"}\n');
1311
+ expect(result).toContain('{"id":"a"}\n{"id":"b"}\n');
1312
+ // No conflict markers remain
1313
+ expect(result).not.toContain("<<<<<<<");
1314
+ expect(result).not.toContain("=======");
1315
+ expect(result).not.toContain(">>>>>>>");
1316
+ });
1317
+
1318
+ test("resolves multiple conflict blocks with union strategy", () => {
1319
+ const block = (canonical: string, incoming: string): string =>
1320
+ `<<<<<<< HEAD\n${canonical}\n=======\n${incoming}\n>>>>>>> branch\n`;
1321
+ const content = `${block("line-a\n", "line-b\n")}middle\n${block("line-c\n", "line-d\n")}`;
1322
+ const result = resolveConflictsUnion(content);
1323
+ expect(result).not.toBeNull();
1324
+ expect(result).toContain("line-a\n");
1325
+ expect(result).toContain("line-b\n");
1326
+ expect(result).toContain("middle\n");
1327
+ expect(result).toContain("line-c\n");
1328
+ expect(result).toContain("line-d\n");
1329
+ expect(result).not.toContain("<<<<<<<");
1330
+ });
1331
+ });
1332
+
1333
+ describe("merge=union gitattribute support", () => {
1334
+ test("union strategy preserves lines from both sides (git handles cleanly or via auto-resolve)", async () => {
1335
+ const repoDir = await createTempGitRepo();
1336
+ try {
1337
+ const defaultBranch = await getDefaultBranch(repoDir);
1338
+
1339
+ // Set up .gitattributes with merge=union for *.jsonl files so that
1340
+ // both git's built-in union driver AND overstory's Tier 2 union path
1341
+ // are configured to keep all lines from both sides.
1342
+ await commitFile(repoDir, ".gitattributes", "*.jsonl merge=union\n");
1343
+ // Common ancestor: one line
1344
+ await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n');
1345
+
1346
+ // Feature branch: adds line b
1347
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
1348
+ await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"b"}\n');
1349
+
1350
+ // Back to main: adds line c (diverges from ancestor)
1351
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
1352
+ await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"c"}\n');
1353
+
1354
+ const entry = makeTestEntry({
1355
+ branchName: "feature-branch",
1356
+ filesModified: ["data.jsonl"],
1357
+ });
1358
+
1359
+ const resolver = createMergeResolver({
1360
+ aiResolveEnabled: false,
1361
+ reimagineEnabled: false,
1362
+ });
1363
+
1364
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1365
+
1366
+ // With merge=union, git either resolves cleanly (Tier 1) or
1367
+ // overstory's union path handles it (Tier 2). Either way, success
1368
+ // and both sides' content must be preserved.
1369
+ expect(result.success).toBe(true);
1370
+ expect(result.entry.status).toBe("merged");
1371
+
1372
+ // Both sides' lines must be present — no lines dropped
1373
+ const file = Bun.file(join(repoDir, "data.jsonl"));
1374
+ const content = await file.text();
1375
+ expect(content).toContain('{"id":"a"}');
1376
+ expect(content).toContain('{"id":"b"}');
1377
+ expect(content).toContain('{"id":"c"}');
1378
+ } finally {
1379
+ await cleanupTempDir(repoDir);
1380
+ }
1381
+ });
1382
+
1383
+ test("Tier 2 union auto-resolve keeps both sides when git produces conflict markers", async () => {
1384
+ // This test verifies the Tier 2 code path: when git produces conflict
1385
+ // markers for a file that has merge=union set in .gitattributes,
1386
+ // overstory resolves it by keeping both canonical and incoming content.
1387
+ // We produce conflict markers by doing a content conflict on a file whose
1388
+ // attribute is set to merge=union AFTER the conflict state exists, then
1389
+ // run only the auto-resolve path via a standalone resolver call.
1390
+ //
1391
+ // To force conflict markers despite merge=union: we DON'T commit
1392
+ // .gitattributes before the merge, so git uses the default driver and
1393
+ // produces conflict markers. Then we write .gitattributes to the working
1394
+ // tree (not committed) so git check-attr sees it and our code detects union.
1395
+ const repoDir = await createTempGitRepo();
1396
+ try {
1397
+ const defaultBranch = await getDefaultBranch(repoDir);
1398
+
1399
+ // Common ancestor
1400
+ await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n');
1401
+
1402
+ // Feature branch: adds line b (also appends to same position as main)
1403
+ await runGitInDir(repoDir, ["checkout", "-b", "feature-branch"]);
1404
+ await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"b"}\n');
1405
+
1406
+ // Back to main: adds line c — diverges from ancestor at same position
1407
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
1408
+ await commitFile(repoDir, "data.jsonl", '{"id":"a"}\n{"id":"c"}\n');
1409
+
1410
+ // Now WRITE .gitattributes to working tree (not committed).
1411
+ // git check-attr reads from the working tree during Tier 2.
1412
+ await Bun.write(`${repoDir}/.gitattributes`, "*.jsonl merge=union\n");
1413
+
1414
+ // Attempt merge — git uses default driver (no committed .gitattributes)
1415
+ // so it WILL produce conflict markers if branches diverge at same position.
1416
+ // (If git resolves cleanly, the test still passes because content is preserved.)
1417
+ const entry = makeTestEntry({
1418
+ branchName: "feature-branch",
1419
+ filesModified: ["data.jsonl"],
1420
+ });
1421
+
1422
+ const resolver = createMergeResolver({
1423
+ aiResolveEnabled: false,
1424
+ reimagineEnabled: false,
1425
+ });
1426
+
1427
+ const result = await resolver.resolve(entry, defaultBranch, repoDir);
1428
+
1429
+ expect(result.success).toBe(true);
1430
+ expect(result.entry.status).toBe("merged");
1431
+
1432
+ const file = Bun.file(join(repoDir, "data.jsonl"));
1433
+ const content = await file.text();
1434
+ expect(content).toContain('{"id":"a"}');
1435
+ expect(content).toContain('{"id":"b"}');
1436
+ expect(content).toContain('{"id":"c"}');
1437
+ } finally {
1438
+ await cleanupTempDir(repoDir);
1439
+ }
1440
+ });
1441
+ });
1442
+
1291
1443
  describe("AI-resolve with history context", () => {
1292
1444
  test("includes historical context in AI prompt when available", async () => {
1293
1445
  const repoDir = await createTempGitRepo();
@@ -89,6 +89,47 @@ function resolveConflictsKeepIncoming(content: string): string | null {
89
89
  });
90
90
  }
91
91
 
92
+ /**
93
+ * Parse conflict markers in file content and keep ALL lines from both sides.
94
+ * Used when the file has `merge=union` gitattribute — dedup-on-read handles duplicates.
95
+ *
96
+ * A conflict block looks like:
97
+ * ```
98
+ * <<<<<<< HEAD
99
+ * canonical content
100
+ * =======
101
+ * incoming content
102
+ * >>>>>>> branch
103
+ * ```
104
+ *
105
+ * This function replaces each conflict block with canonical + incoming content concatenated.
106
+ * Returns the resolved content, or null if no conflict markers were found.
107
+ */
108
+ export function resolveConflictsUnion(content: string): string | null {
109
+ const conflictPattern = /^<{7} .+\n([\s\S]*?)^={7}\n([\s\S]*?)^>{7} .+\n?/gm;
110
+
111
+ if (!conflictPattern.test(content)) {
112
+ return null;
113
+ }
114
+
115
+ // Reset regex lastIndex after test()
116
+ conflictPattern.lastIndex = 0;
117
+
118
+ return content.replace(conflictPattern, (_match, canonical: string, incoming: string) => {
119
+ return canonical + incoming;
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Check if a file has the `merge=union` gitattribute set.
125
+ * Returns true if `git check-attr merge -- <file>` ends with ": merge: union".
126
+ */
127
+ async function checkMergeUnion(repoRoot: string, filePath: string): Promise<boolean> {
128
+ const { stdout, exitCode } = await runGit(repoRoot, ["check-attr", "merge", "--", filePath]);
129
+ if (exitCode !== 0) return false;
130
+ return stdout.trim().endsWith(": merge: union");
131
+ }
132
+
92
133
  /**
93
134
  * Read a file's content using Bun.file().
94
135
  */
@@ -138,7 +179,10 @@ async function tryAutoResolve(
138
179
 
139
180
  try {
140
181
  const content = await readFile(filePath);
141
- const resolved = resolveConflictsKeepIncoming(content);
182
+ const isUnion = await checkMergeUnion(repoRoot, file);
183
+ const resolved = isUnion
184
+ ? resolveConflictsUnion(content)
185
+ : resolveConflictsKeepIncoming(content);
142
186
 
143
187
  if (resolved === null) {
144
188
  // No conflict markers found (shouldn't happen but be defensive)
@@ -496,7 +540,7 @@ function recordConflictPattern(
496
540
  type: "pattern",
497
541
  description,
498
542
  tags: ["merge-conflict"],
499
- evidenceBead: entry.beadId,
543
+ evidenceBead: entry.taskId,
500
544
  })
501
545
  .catch(() => {});
502
546
  }