@os-eco/overstory-cli 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,670 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { ValidationError } from "../errors.ts";
5
+ import { createMergeQueue } from "../merge/queue.ts";
6
+ import {
7
+ cleanupTempDir,
8
+ commitFile,
9
+ createTempGitRepo,
10
+ getDefaultBranch,
11
+ runGitInDir,
12
+ } from "../test-helpers.ts";
13
+ import { mergeCommand } from "./merge.ts";
14
+
15
+ describe("mergeCommand", () => {
16
+ let repoDir: string;
17
+ let defaultBranch: string;
18
+ let originalCwd: string;
19
+
20
+ beforeEach(async () => {
21
+ originalCwd = process.cwd();
22
+ repoDir = await createTempGitRepo();
23
+ defaultBranch = await getDefaultBranch(repoDir);
24
+ process.chdir(repoDir);
25
+ });
26
+
27
+ afterEach(async () => {
28
+ process.chdir(originalCwd);
29
+ await cleanupTempDir(repoDir);
30
+ });
31
+
32
+ /**
33
+ * Setup helper: Create .overstory/ dir and write config.yaml with project canonicalBranch.
34
+ */
35
+ async function setupProject(dir: string, canonicalBranch: string): Promise<void> {
36
+ const overstoryDir = join(dir, ".overstory");
37
+ await mkdir(overstoryDir);
38
+
39
+ const configYaml = `project:
40
+ canonicalBranch: ${canonicalBranch}
41
+ root: ${dir}
42
+
43
+ merge:
44
+ aiResolveEnabled: false
45
+ reimagineEnabled: false
46
+ `;
47
+ await Bun.write(join(overstoryDir, "config.yaml"), configYaml);
48
+ }
49
+
50
+ /**
51
+ * Setup helper: Create a clean feature branch with a committed file.
52
+ * Commits a base file (if not exists), creates a new branch, commits a feature file, then switches back to defaultBranch.
53
+ */
54
+ async function createCleanFeatureBranch(dir: string, branchName: string): Promise<void> {
55
+ // Only commit base file if it doesn't exist
56
+ const baseFilePath = join(dir, "src/base.ts");
57
+ const baseFileExists = await Bun.file(baseFilePath).exists();
58
+ if (!baseFileExists) {
59
+ await commitFile(dir, "src/base.ts", "base content");
60
+ }
61
+ await runGitInDir(dir, ["checkout", "-b", branchName]);
62
+ await commitFile(dir, `src/${branchName}.ts`, "feature content");
63
+ await runGitInDir(dir, ["checkout", defaultBranch]);
64
+ }
65
+
66
+ describe("help and validation", () => {
67
+ test("--help prints help containing 'overstory merge', '--branch', '--all', '--dry-run', '--into'", async () => {
68
+ let output = "";
69
+ const originalWrite = process.stdout.write.bind(process.stdout);
70
+ process.stdout.write = (chunk: unknown): boolean => {
71
+ output += String(chunk);
72
+ return true;
73
+ };
74
+
75
+ try {
76
+ await mergeCommand(["--help"]);
77
+ } finally {
78
+ process.stdout.write = originalWrite;
79
+ }
80
+
81
+ expect(output).toContain("overstory merge");
82
+ expect(output).toContain("--branch");
83
+ expect(output).toContain("--all");
84
+ expect(output).toContain("--dry-run");
85
+ expect(output).toContain("--into");
86
+ });
87
+
88
+ test("-h prints help", async () => {
89
+ let output = "";
90
+ const originalWrite = process.stdout.write.bind(process.stdout);
91
+ process.stdout.write = (chunk: unknown): boolean => {
92
+ output += String(chunk);
93
+ return true;
94
+ };
95
+
96
+ try {
97
+ await mergeCommand(["-h"]);
98
+ } finally {
99
+ process.stdout.write = originalWrite;
100
+ }
101
+
102
+ expect(output).toContain("overstory merge");
103
+ });
104
+
105
+ test("no flags throws ValidationError mentioning '--branch' and '--all'", async () => {
106
+ await setupProject(repoDir, defaultBranch);
107
+
108
+ try {
109
+ await mergeCommand([]);
110
+ expect(true).toBe(false); // Should not reach here
111
+ } catch (err: unknown) {
112
+ expect(err).toBeInstanceOf(ValidationError);
113
+ const validationErr = err as ValidationError;
114
+ expect(validationErr.message).toContain("--branch");
115
+ expect(validationErr.message).toContain("--all");
116
+ }
117
+ });
118
+ });
119
+
120
+ describe("--branch with real git repo", () => {
121
+ test("nonexistent branch throws ValidationError", async () => {
122
+ await setupProject(repoDir, defaultBranch);
123
+
124
+ try {
125
+ await mergeCommand(["--branch", "nonexistent-branch"]);
126
+ expect(true).toBe(false); // Should not reach here
127
+ } catch (err: unknown) {
128
+ expect(err).toBeInstanceOf(ValidationError);
129
+ const validationErr = err as ValidationError;
130
+ expect(validationErr.message).toContain("nonexistent-branch");
131
+ }
132
+ });
133
+
134
+ test("--dry-run shows branch info without merging (verify still on defaultBranch after)", async () => {
135
+ await setupProject(repoDir, defaultBranch);
136
+ const branchName = "overstory/test-agent/bead-123";
137
+ await createCleanFeatureBranch(repoDir, branchName);
138
+
139
+ let output = "";
140
+ const originalWrite = process.stdout.write.bind(process.stdout);
141
+ process.stdout.write = (chunk: unknown): boolean => {
142
+ output += String(chunk);
143
+ return true;
144
+ };
145
+
146
+ try {
147
+ await mergeCommand(["--branch", branchName, "--dry-run"]);
148
+ } finally {
149
+ process.stdout.write = originalWrite;
150
+ }
151
+
152
+ expect(output).toContain(branchName);
153
+ expect(output).toContain("pending");
154
+
155
+ // Verify still on defaultBranch
156
+ const currentBranch = await getDefaultBranch(repoDir);
157
+ expect(currentBranch).toBe(defaultBranch);
158
+ });
159
+
160
+ test("--dry-run --json outputs JSON with branchName and status:pending", async () => {
161
+ await setupProject(repoDir, defaultBranch);
162
+ const branchName = "overstory/test-agent/bead-456";
163
+ await createCleanFeatureBranch(repoDir, branchName);
164
+
165
+ let output = "";
166
+ const originalWrite = process.stdout.write.bind(process.stdout);
167
+ process.stdout.write = (chunk: unknown): boolean => {
168
+ output += String(chunk);
169
+ return true;
170
+ };
171
+
172
+ try {
173
+ await mergeCommand(["--branch", branchName, "--dry-run", "--json"]);
174
+ } finally {
175
+ process.stdout.write = originalWrite;
176
+ }
177
+
178
+ const parsed = JSON.parse(output);
179
+ expect(parsed.branchName).toBe(branchName);
180
+ expect(parsed.status).toBe("pending");
181
+ });
182
+
183
+ test("merges a clean branch successfully (verify feature file exists after)", async () => {
184
+ await setupProject(repoDir, defaultBranch);
185
+ const branchName = "overstory/builder/bead-789";
186
+ await createCleanFeatureBranch(repoDir, branchName);
187
+
188
+ const originalWrite = process.stdout.write.bind(process.stdout);
189
+ process.stdout.write = (): boolean => {
190
+ return true;
191
+ };
192
+
193
+ try {
194
+ await mergeCommand(["--branch", branchName]);
195
+ } finally {
196
+ process.stdout.write = originalWrite;
197
+ }
198
+
199
+ // Verify feature file exists after merge
200
+ const featureFilePath = join(repoDir, `src/${branchName}.ts`);
201
+ const featureFile = await Bun.file(featureFilePath).text();
202
+ expect(featureFile).toBe("feature content");
203
+ });
204
+
205
+ test("--json outputs JSON with success:true and tier:clean-merge", async () => {
206
+ await setupProject(repoDir, defaultBranch);
207
+ const branchName = "overstory/builder/bead-abc";
208
+ await createCleanFeatureBranch(repoDir, branchName);
209
+
210
+ let output = "";
211
+ const originalWrite = process.stdout.write.bind(process.stdout);
212
+ process.stdout.write = (chunk: unknown): boolean => {
213
+ output += String(chunk);
214
+ return true;
215
+ };
216
+
217
+ try {
218
+ await mergeCommand(["--branch", branchName, "--json"]);
219
+ } finally {
220
+ process.stdout.write = originalWrite;
221
+ }
222
+
223
+ const parsed = JSON.parse(output);
224
+ expect(parsed.success).toBe(true);
225
+ expect(parsed.tier).toBe("clean-merge");
226
+ });
227
+
228
+ test("parses agent name from overstory/my-builder/bead-abc convention (use --dry-run)", async () => {
229
+ await setupProject(repoDir, defaultBranch);
230
+ const branchName = "overstory/my-builder/bead-xyz";
231
+ await createCleanFeatureBranch(repoDir, branchName);
232
+
233
+ let output = "";
234
+ const originalWrite = process.stdout.write.bind(process.stdout);
235
+ process.stdout.write = (chunk: unknown): boolean => {
236
+ output += String(chunk);
237
+ return true;
238
+ };
239
+
240
+ try {
241
+ await mergeCommand(["--branch", branchName, "--dry-run", "--json"]);
242
+ } finally {
243
+ process.stdout.write = originalWrite;
244
+ }
245
+
246
+ const parsed = JSON.parse(output);
247
+ expect(parsed.agentName).toBe("my-builder");
248
+ expect(parsed.beadId).toBe("bead-xyz");
249
+ });
250
+ });
251
+
252
+ describe("--all with real git repo", () => {
253
+ test("prints 'No pending' when queue empty", async () => {
254
+ await setupProject(repoDir, defaultBranch);
255
+
256
+ let output = "";
257
+ const originalWrite = process.stdout.write.bind(process.stdout);
258
+ process.stdout.write = (chunk: unknown): boolean => {
259
+ output += String(chunk);
260
+ return true;
261
+ };
262
+
263
+ try {
264
+ await mergeCommand(["--all"]);
265
+ } finally {
266
+ process.stdout.write = originalWrite;
267
+ }
268
+
269
+ expect(output).toContain("No pending");
270
+ });
271
+
272
+ test("--json shows empty results", async () => {
273
+ await setupProject(repoDir, defaultBranch);
274
+
275
+ let output = "";
276
+ const originalWrite = process.stdout.write.bind(process.stdout);
277
+ process.stdout.write = (chunk: unknown): boolean => {
278
+ output += String(chunk);
279
+ return true;
280
+ };
281
+
282
+ try {
283
+ await mergeCommand(["--all", "--json"]);
284
+ } finally {
285
+ process.stdout.write = originalWrite;
286
+ }
287
+
288
+ const parsed = JSON.parse(output);
289
+ expect(parsed.results).toEqual([]);
290
+ expect(parsed.count).toBe(0);
291
+ });
292
+
293
+ test("--all --dry-run lists pending entries from merge-queue.json", async () => {
294
+ await setupProject(repoDir, defaultBranch);
295
+ const branch1 = "overstory/agent1/bead-001";
296
+ const branch2 = "overstory/agent2/bead-002";
297
+ await createCleanFeatureBranch(repoDir, branch1);
298
+ await createCleanFeatureBranch(repoDir, branch2);
299
+
300
+ // Enqueue entries via createMergeQueue
301
+ const queuePath = join(repoDir, ".overstory", "merge-queue.db");
302
+ const queue = createMergeQueue(queuePath);
303
+ queue.enqueue({
304
+ branchName: branch1,
305
+ beadId: "bead-001",
306
+ agentName: "agent1",
307
+ filesModified: [`src/${branch1}.ts`],
308
+ });
309
+ queue.enqueue({
310
+ branchName: branch2,
311
+ beadId: "bead-002",
312
+ agentName: "agent2",
313
+ filesModified: [`src/${branch2}.ts`],
314
+ });
315
+ queue.close();
316
+
317
+ let output = "";
318
+ const originalWrite = process.stdout.write.bind(process.stdout);
319
+ process.stdout.write = (chunk: unknown): boolean => {
320
+ output += String(chunk);
321
+ return true;
322
+ };
323
+
324
+ try {
325
+ await mergeCommand(["--all", "--dry-run"]);
326
+ } finally {
327
+ process.stdout.write = originalWrite;
328
+ }
329
+
330
+ expect(output).toContain("2 pending");
331
+ expect(output).toContain(branch1);
332
+ expect(output).toContain(branch2);
333
+ });
334
+
335
+ test("--all merges multiple pending entries (write merge-queue.json with entries, verify counts)", async () => {
336
+ await setupProject(repoDir, defaultBranch);
337
+ const branch1 = "overstory/builder1/bead-100";
338
+ const branch2 = "overstory/builder2/bead-200";
339
+ await createCleanFeatureBranch(repoDir, branch1);
340
+ await createCleanFeatureBranch(repoDir, branch2);
341
+
342
+ // Enqueue entries via createMergeQueue
343
+ const queuePath = join(repoDir, ".overstory", "merge-queue.db");
344
+ const queue = createMergeQueue(queuePath);
345
+ queue.enqueue({
346
+ branchName: branch1,
347
+ beadId: "bead-100",
348
+ agentName: "builder1",
349
+ filesModified: [`src/${branch1}.ts`],
350
+ });
351
+ queue.enqueue({
352
+ branchName: branch2,
353
+ beadId: "bead-200",
354
+ agentName: "builder2",
355
+ filesModified: [`src/${branch2}.ts`],
356
+ });
357
+ queue.close();
358
+
359
+ let output = "";
360
+ const originalWrite = process.stdout.write.bind(process.stdout);
361
+ process.stdout.write = (chunk: unknown): boolean => {
362
+ output += String(chunk);
363
+ return true;
364
+ };
365
+
366
+ try {
367
+ await mergeCommand(["--all"]);
368
+ } finally {
369
+ process.stdout.write = originalWrite;
370
+ }
371
+
372
+ expect(output).toContain("Done");
373
+ expect(output).toContain("2 merged");
374
+
375
+ // Verify both feature files exist after merge
376
+ const file1 = await Bun.file(join(repoDir, `src/${branch1}.ts`)).text();
377
+ const file2 = await Bun.file(join(repoDir, `src/${branch2}.ts`)).text();
378
+ expect(file1).toBe("feature content");
379
+ expect(file2).toBe("feature content");
380
+ });
381
+
382
+ test("--all --json reports successCount and failCount", async () => {
383
+ await setupProject(repoDir, defaultBranch);
384
+ const branch1 = "overstory/builder3/bead-300";
385
+ await createCleanFeatureBranch(repoDir, branch1);
386
+
387
+ // Enqueue entry via createMergeQueue
388
+ const queuePath = join(repoDir, ".overstory", "merge-queue.db");
389
+ const queue = createMergeQueue(queuePath);
390
+ queue.enqueue({
391
+ branchName: branch1,
392
+ beadId: "bead-300",
393
+ agentName: "builder3",
394
+ filesModified: [`src/${branch1}.ts`],
395
+ });
396
+ queue.close();
397
+
398
+ let output = "";
399
+ const originalWrite = process.stdout.write.bind(process.stdout);
400
+ process.stdout.write = (chunk: unknown): boolean => {
401
+ output += String(chunk);
402
+ return true;
403
+ };
404
+
405
+ try {
406
+ await mergeCommand(["--all", "--json"]);
407
+ } finally {
408
+ process.stdout.write = originalWrite;
409
+ }
410
+
411
+ const parsed = JSON.parse(output);
412
+ expect(parsed.successCount).toBe(1);
413
+ expect(parsed.failCount).toBe(0);
414
+ expect(parsed.count).toBe(1);
415
+ });
416
+ });
417
+
418
+ describe("--into flag", () => {
419
+ test("merges into a non-default target branch", async () => {
420
+ await setupProject(repoDir, defaultBranch);
421
+
422
+ // Create a target branch (not the default/canonical branch)
423
+ await commitFile(repoDir, "src/base.ts", "base content");
424
+ await runGitInDir(repoDir, ["checkout", "-b", "develop"]);
425
+ await commitFile(repoDir, "src/develop-marker.ts", "develop marker");
426
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
427
+
428
+ // Create a feature branch off defaultBranch
429
+ const branchName = "overstory/builder/bead-into-test";
430
+ await runGitInDir(repoDir, ["checkout", "-b", branchName]);
431
+ await commitFile(repoDir, `src/${branchName}.ts`, "feature for develop");
432
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
433
+
434
+ let output = "";
435
+ const originalWrite = process.stdout.write.bind(process.stdout);
436
+ process.stdout.write = (chunk: unknown): boolean => {
437
+ output += String(chunk);
438
+ return true;
439
+ };
440
+
441
+ try {
442
+ await mergeCommand(["--branch", branchName, "--into", "develop", "--json"]);
443
+ } finally {
444
+ process.stdout.write = originalWrite;
445
+ }
446
+
447
+ const parsed = JSON.parse(output);
448
+ expect(parsed.success).toBe(true);
449
+ expect(parsed.tier).toBe("clean-merge");
450
+
451
+ // Verify we ended up on the develop branch after merge
452
+ const currentBranch = await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"]);
453
+ expect(currentBranch.trim()).toBe("develop");
454
+
455
+ // Verify feature file exists on develop
456
+ const featureFile = await Bun.file(join(repoDir, `src/${branchName}.ts`)).text();
457
+ expect(featureFile).toBe("feature for develop");
458
+
459
+ // Verify defaultBranch was NOT modified (switch back and check)
460
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
461
+ const featureOnDefault = await Bun.file(join(repoDir, `src/${branchName}.ts`)).exists();
462
+ expect(featureOnDefault).toBe(false);
463
+ });
464
+
465
+ test("--into with --all merges all pending into target branch", async () => {
466
+ await setupProject(repoDir, defaultBranch);
467
+
468
+ // Create a target branch
469
+ await commitFile(repoDir, "src/base.ts", "base content");
470
+ await runGitInDir(repoDir, ["checkout", "-b", "staging"]);
471
+ await commitFile(repoDir, "src/staging-marker.ts", "staging marker");
472
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
473
+
474
+ // Create feature branches
475
+ const branch1 = "overstory/agent1/bead-into-all-1";
476
+ await runGitInDir(repoDir, ["checkout", "-b", branch1]);
477
+ await commitFile(repoDir, `src/${branch1}.ts`, "feature 1");
478
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
479
+
480
+ const branch2 = "overstory/agent2/bead-into-all-2";
481
+ await runGitInDir(repoDir, ["checkout", "-b", branch2]);
482
+ await commitFile(repoDir, `src/${branch2}.ts`, "feature 2");
483
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
484
+
485
+ // Enqueue entries
486
+ const queuePath = join(repoDir, ".overstory", "merge-queue.db");
487
+ const queue = createMergeQueue(queuePath);
488
+ queue.enqueue({
489
+ branchName: branch1,
490
+ beadId: "bead-into-all-1",
491
+ agentName: "agent1",
492
+ filesModified: [`src/${branch1}.ts`],
493
+ });
494
+ queue.enqueue({
495
+ branchName: branch2,
496
+ beadId: "bead-into-all-2",
497
+ agentName: "agent2",
498
+ filesModified: [`src/${branch2}.ts`],
499
+ });
500
+ queue.close();
501
+
502
+ let output = "";
503
+ const originalWrite = process.stdout.write.bind(process.stdout);
504
+ process.stdout.write = (chunk: unknown): boolean => {
505
+ output += String(chunk);
506
+ return true;
507
+ };
508
+
509
+ try {
510
+ await mergeCommand(["--all", "--into", "staging", "--json"]);
511
+ } finally {
512
+ process.stdout.write = originalWrite;
513
+ }
514
+
515
+ const parsed = JSON.parse(output);
516
+ expect(parsed.successCount).toBe(2);
517
+ expect(parsed.failCount).toBe(0);
518
+
519
+ // Verify we're on staging, not defaultBranch
520
+ const currentBranch = await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"]);
521
+ expect(currentBranch.trim()).toBe("staging");
522
+ });
523
+
524
+ test("defaults to canonicalBranch when --into and session-branch.txt are absent", async () => {
525
+ await setupProject(repoDir, defaultBranch);
526
+ const branchName = "overstory/builder/bead-default-target";
527
+ await createCleanFeatureBranch(repoDir, branchName);
528
+
529
+ let output = "";
530
+ const originalWrite = process.stdout.write.bind(process.stdout);
531
+ process.stdout.write = (chunk: unknown): boolean => {
532
+ output += String(chunk);
533
+ return true;
534
+ };
535
+
536
+ try {
537
+ await mergeCommand(["--branch", branchName, "--json"]);
538
+ } finally {
539
+ process.stdout.write = originalWrite;
540
+ }
541
+
542
+ const parsed = JSON.parse(output);
543
+ expect(parsed.success).toBe(true);
544
+
545
+ // Verify we ended up on the default branch (the canonical branch)
546
+ const currentBranch = await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"]);
547
+ expect(currentBranch.trim()).toBe(defaultBranch);
548
+ });
549
+
550
+ test("reads session-branch.txt as default when --into is not specified", async () => {
551
+ await setupProject(repoDir, defaultBranch);
552
+
553
+ // Create a target branch
554
+ await commitFile(repoDir, "src/base.ts", "base content");
555
+ await runGitInDir(repoDir, ["checkout", "-b", "feature/session-work"]);
556
+ await commitFile(repoDir, "src/session-marker.ts", "session marker");
557
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
558
+
559
+ // Write session-branch.txt pointing to the feature branch
560
+ await Bun.write(join(repoDir, ".overstory", "session-branch.txt"), "feature/session-work\n");
561
+
562
+ // Create a feature branch to merge
563
+ const branchName = "overstory/builder/bead-session-branch";
564
+ await runGitInDir(repoDir, ["checkout", "-b", branchName]);
565
+ await commitFile(repoDir, `src/${branchName}.ts`, "feature for session branch");
566
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
567
+
568
+ let output = "";
569
+ const originalWrite = process.stdout.write.bind(process.stdout);
570
+ process.stdout.write = (chunk: unknown): boolean => {
571
+ output += String(chunk);
572
+ return true;
573
+ };
574
+
575
+ try {
576
+ // No --into flag — should read session-branch.txt
577
+ await mergeCommand(["--branch", branchName, "--json"]);
578
+ } finally {
579
+ process.stdout.write = originalWrite;
580
+ }
581
+
582
+ const parsed = JSON.parse(output);
583
+ expect(parsed.success).toBe(true);
584
+
585
+ // Verify merge went to session branch, not defaultBranch
586
+ const currentBranch = await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"]);
587
+ expect(currentBranch.trim()).toBe("feature/session-work");
588
+
589
+ // Verify feature file exists on the session branch
590
+ const featureFile = await Bun.file(join(repoDir, `src/${branchName}.ts`)).text();
591
+ expect(featureFile).toBe("feature for session branch");
592
+ });
593
+
594
+ test("--into flag overrides session-branch.txt", async () => {
595
+ await setupProject(repoDir, defaultBranch);
596
+
597
+ // Create two target branches
598
+ await commitFile(repoDir, "src/base.ts", "base content");
599
+ await runGitInDir(repoDir, ["checkout", "-b", "session-branch-target"]);
600
+ await commitFile(repoDir, "src/session-marker.ts", "session marker");
601
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
602
+ await runGitInDir(repoDir, ["checkout", "-b", "explicit-target"]);
603
+ await commitFile(repoDir, "src/explicit-marker.ts", "explicit marker");
604
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
605
+
606
+ // Write session-branch.txt pointing to session-branch-target
607
+ await Bun.write(join(repoDir, ".overstory", "session-branch.txt"), "session-branch-target\n");
608
+
609
+ // Create a feature branch to merge
610
+ const branchName = "overstory/builder/bead-override-test";
611
+ await runGitInDir(repoDir, ["checkout", "-b", branchName]);
612
+ await commitFile(repoDir, `src/${branchName}.ts`, "feature content");
613
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
614
+
615
+ let output = "";
616
+ const originalWrite = process.stdout.write.bind(process.stdout);
617
+ process.stdout.write = (chunk: unknown): boolean => {
618
+ output += String(chunk);
619
+ return true;
620
+ };
621
+
622
+ try {
623
+ // --into overrides session-branch.txt
624
+ await mergeCommand(["--branch", branchName, "--into", "explicit-target", "--json"]);
625
+ } finally {
626
+ process.stdout.write = originalWrite;
627
+ }
628
+
629
+ const parsed = JSON.parse(output);
630
+ expect(parsed.success).toBe(true);
631
+
632
+ // Verify merge went to explicit-target, not session-branch-target
633
+ const currentBranch = await runGitInDir(repoDir, ["symbolic-ref", "--short", "HEAD"]);
634
+ expect(currentBranch.trim()).toBe("explicit-target");
635
+ });
636
+ });
637
+
638
+ describe("conflict handling", () => {
639
+ test("content conflict auto-resolves: same file modified on both branches, verify incoming content wins", async () => {
640
+ await setupProject(repoDir, defaultBranch);
641
+
642
+ // Create a conflict: modify same file on both branches
643
+ await commitFile(repoDir, "src/shared.ts", "base content");
644
+
645
+ // Modify on default branch
646
+ await commitFile(repoDir, "src/shared.ts", "default branch content");
647
+
648
+ // Create feature branch and modify the same file
649
+ const branchName = "overstory/builder-conflict/bead-999";
650
+ await runGitInDir(repoDir, ["checkout", "-b", branchName]);
651
+ await commitFile(repoDir, "src/shared.ts", "feature branch content");
652
+ await runGitInDir(repoDir, ["checkout", defaultBranch]);
653
+
654
+ const originalWrite = process.stdout.write.bind(process.stdout);
655
+ process.stdout.write = (): boolean => {
656
+ return true;
657
+ };
658
+
659
+ try {
660
+ await mergeCommand(["--branch", branchName]);
661
+ } finally {
662
+ process.stdout.write = originalWrite;
663
+ }
664
+
665
+ // Verify incoming (feature branch) content wins
666
+ const sharedFile = await Bun.file(join(repoDir, "src/shared.ts")).text();
667
+ expect(sharedFile).toBe("feature branch content");
668
+ });
669
+ });
670
+ });