@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,359 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { MergeError } from "../errors.ts";
6
+ import { createMergeQueue } from "./queue.ts";
7
+
8
+ describe("createMergeQueue", () => {
9
+ let tempDir: string;
10
+ let queuePath: string;
11
+
12
+ beforeEach(async () => {
13
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-merge-queue-test-"));
14
+ // The database file should NOT exist initially — createMergeQueue handles this
15
+ queuePath = join(tempDir, "merge-queue.db");
16
+ });
17
+
18
+ afterEach(async () => {
19
+ await rm(tempDir, { recursive: true, force: true });
20
+ });
21
+
22
+ function makeInput(
23
+ overrides?: Partial<{
24
+ branchName: string;
25
+ beadId: string;
26
+ agentName: string;
27
+ filesModified: string[];
28
+ }>,
29
+ ) {
30
+ return {
31
+ branchName: overrides?.branchName ?? "overstory/test-agent/bead-123",
32
+ beadId: overrides?.beadId ?? "bead-123",
33
+ agentName: overrides?.agentName ?? "test-agent",
34
+ filesModified: overrides?.filesModified ?? ["src/test.ts"],
35
+ };
36
+ }
37
+
38
+ describe("enqueue", () => {
39
+ test("adds entry with pending status and null resolvedTier", () => {
40
+ const queue = createMergeQueue(queuePath);
41
+ const entry = queue.enqueue(makeInput());
42
+
43
+ expect(entry.status).toBe("pending");
44
+ expect(entry.resolvedTier).toBeNull();
45
+ });
46
+
47
+ test("returns the created entry with enqueuedAt timestamp", () => {
48
+ const queue = createMergeQueue(queuePath);
49
+ const before = new Date().toISOString();
50
+ const entry = queue.enqueue(makeInput());
51
+ const after = new Date().toISOString();
52
+
53
+ expect(entry.branchName).toBe("overstory/test-agent/bead-123");
54
+ expect(entry.beadId).toBe("bead-123");
55
+ expect(entry.agentName).toBe("test-agent");
56
+ expect(entry.filesModified).toEqual(["src/test.ts"]);
57
+ expect(entry.enqueuedAt).toBeDefined();
58
+ // enqueuedAt should be between before and after
59
+ expect(entry.enqueuedAt >= before).toBe(true);
60
+ expect(entry.enqueuedAt <= after).toBe(true);
61
+ });
62
+
63
+ test("preserves all input fields on the returned entry", () => {
64
+ const queue = createMergeQueue(queuePath);
65
+ const input = makeInput({
66
+ branchName: "overstory/builder-1/bead-xyz",
67
+ beadId: "bead-xyz",
68
+ agentName: "builder-1",
69
+ filesModified: ["src/a.ts", "src/b.ts"],
70
+ });
71
+
72
+ const entry = queue.enqueue(input);
73
+
74
+ expect(entry.branchName).toBe("overstory/builder-1/bead-xyz");
75
+ expect(entry.beadId).toBe("bead-xyz");
76
+ expect(entry.agentName).toBe("builder-1");
77
+ expect(entry.filesModified).toEqual(["src/a.ts", "src/b.ts"]);
78
+ });
79
+ });
80
+
81
+ describe("dequeue", () => {
82
+ test("returns first pending entry (FIFO)", () => {
83
+ const queue = createMergeQueue(queuePath);
84
+ queue.enqueue(makeInput({ branchName: "branch-a", beadId: "bead-a" }));
85
+ queue.enqueue(makeInput({ branchName: "branch-b", beadId: "bead-b" }));
86
+
87
+ const dequeued = queue.dequeue();
88
+
89
+ expect(dequeued).not.toBeNull();
90
+ expect(dequeued?.branchName).toBe("branch-a");
91
+ });
92
+
93
+ test("removes the entry from the queue", () => {
94
+ const queue = createMergeQueue(queuePath);
95
+ queue.enqueue(makeInput({ branchName: "branch-a" }));
96
+ queue.enqueue(makeInput({ branchName: "branch-b" }));
97
+
98
+ queue.dequeue();
99
+ const all = queue.list();
100
+
101
+ expect(all).toHaveLength(1);
102
+ expect(all[0]?.branchName).toBe("branch-b");
103
+ });
104
+
105
+ test("returns null on empty queue", () => {
106
+ const queue = createMergeQueue(queuePath);
107
+ const result = queue.dequeue();
108
+
109
+ expect(result).toBeNull();
110
+ });
111
+
112
+ test("skips non-pending entries", () => {
113
+ const queue = createMergeQueue(queuePath);
114
+ queue.enqueue(makeInput({ branchName: "branch-a" }));
115
+ queue.enqueue(makeInput({ branchName: "branch-b" }));
116
+
117
+ // Mark the first entry as "merging" so it's no longer pending
118
+ queue.updateStatus("branch-a", "merging");
119
+
120
+ const dequeued = queue.dequeue();
121
+
122
+ expect(dequeued).not.toBeNull();
123
+ expect(dequeued?.branchName).toBe("branch-b");
124
+ });
125
+
126
+ test("returns null when all entries are non-pending", () => {
127
+ const queue = createMergeQueue(queuePath);
128
+ queue.enqueue(makeInput({ branchName: "branch-a" }));
129
+ queue.updateStatus("branch-a", "merged", "clean-merge");
130
+
131
+ const result = queue.dequeue();
132
+
133
+ expect(result).toBeNull();
134
+ });
135
+ });
136
+
137
+ describe("peek", () => {
138
+ test("returns first pending entry without removing it", () => {
139
+ const queue = createMergeQueue(queuePath);
140
+ queue.enqueue(makeInput({ branchName: "branch-a" }));
141
+ queue.enqueue(makeInput({ branchName: "branch-b" }));
142
+
143
+ const peeked = queue.peek();
144
+
145
+ expect(peeked).not.toBeNull();
146
+ expect(peeked?.branchName).toBe("branch-a");
147
+
148
+ // Entry should still be in the queue
149
+ const all = queue.list();
150
+ expect(all).toHaveLength(2);
151
+ });
152
+
153
+ test("returns null on empty queue", () => {
154
+ const queue = createMergeQueue(queuePath);
155
+ const result = queue.peek();
156
+
157
+ expect(result).toBeNull();
158
+ });
159
+
160
+ test("skips non-pending entries", () => {
161
+ const queue = createMergeQueue(queuePath);
162
+ queue.enqueue(makeInput({ branchName: "branch-a" }));
163
+ queue.enqueue(makeInput({ branchName: "branch-b" }));
164
+ queue.updateStatus("branch-a", "merged", "clean-merge");
165
+
166
+ const peeked = queue.peek();
167
+
168
+ expect(peeked).not.toBeNull();
169
+ expect(peeked?.branchName).toBe("branch-b");
170
+ });
171
+ });
172
+
173
+ describe("list", () => {
174
+ test("returns all entries when called without arguments", () => {
175
+ const queue = createMergeQueue(queuePath);
176
+ queue.enqueue(makeInput({ branchName: "branch-a" }));
177
+ queue.enqueue(makeInput({ branchName: "branch-b" }));
178
+ queue.updateStatus("branch-a", "merged", "clean-merge");
179
+
180
+ const all = queue.list();
181
+
182
+ expect(all).toHaveLength(2);
183
+ });
184
+
185
+ test("returns empty array on empty queue", () => {
186
+ const queue = createMergeQueue(queuePath);
187
+ const all = queue.list();
188
+
189
+ expect(all).toEqual([]);
190
+ });
191
+
192
+ test("filters by status when status argument is provided", () => {
193
+ const queue = createMergeQueue(queuePath);
194
+ queue.enqueue(makeInput({ branchName: "branch-a" }));
195
+ queue.enqueue(makeInput({ branchName: "branch-b" }));
196
+ queue.enqueue(makeInput({ branchName: "branch-c" }));
197
+ queue.updateStatus("branch-a", "merged", "clean-merge");
198
+ queue.updateStatus("branch-b", "failed");
199
+
200
+ const pending = queue.list("pending");
201
+ expect(pending).toHaveLength(1);
202
+ expect(pending[0]?.branchName).toBe("branch-c");
203
+
204
+ const merged = queue.list("merged");
205
+ expect(merged).toHaveLength(1);
206
+ expect(merged[0]?.branchName).toBe("branch-a");
207
+
208
+ const failed = queue.list("failed");
209
+ expect(failed).toHaveLength(1);
210
+ expect(failed[0]?.branchName).toBe("branch-b");
211
+ });
212
+ });
213
+
214
+ describe("updateStatus", () => {
215
+ test("changes status of an existing entry", () => {
216
+ const queue = createMergeQueue(queuePath);
217
+ queue.enqueue(makeInput({ branchName: "branch-a" }));
218
+
219
+ queue.updateStatus("branch-a", "merging");
220
+
221
+ const all = queue.list();
222
+ expect(all[0]?.status).toBe("merging");
223
+ expect(all[0]?.resolvedTier).toBeNull();
224
+ });
225
+
226
+ test("changes status and tier when tier is provided", () => {
227
+ const queue = createMergeQueue(queuePath);
228
+ queue.enqueue(makeInput({ branchName: "branch-a" }));
229
+
230
+ queue.updateStatus("branch-a", "merged", "auto-resolve");
231
+
232
+ const all = queue.list();
233
+ expect(all[0]?.status).toBe("merged");
234
+ expect(all[0]?.resolvedTier).toBe("auto-resolve");
235
+ });
236
+
237
+ test("throws MergeError for unknown branch", () => {
238
+ const queue = createMergeQueue(queuePath);
239
+ queue.enqueue(makeInput({ branchName: "branch-a" }));
240
+
241
+ expect(() => queue.updateStatus("nonexistent-branch", "merging")).toThrow(MergeError);
242
+ });
243
+
244
+ test("MergeError includes the unknown branch name in message", () => {
245
+ const queue = createMergeQueue(queuePath);
246
+
247
+ try {
248
+ queue.updateStatus("nonexistent-branch", "merging");
249
+ // Should not reach here
250
+ expect(true).toBe(false);
251
+ } catch (err: unknown) {
252
+ expect(err).toBeInstanceOf(MergeError);
253
+ const mergeErr = err as MergeError;
254
+ expect(mergeErr.message).toContain("nonexistent-branch");
255
+ }
256
+ });
257
+ });
258
+
259
+ describe("FIFO ordering", () => {
260
+ test("multiple enqueue/dequeue preserves FIFO order", () => {
261
+ const queue = createMergeQueue(queuePath);
262
+ queue.enqueue(makeInput({ branchName: "first" }));
263
+ queue.enqueue(makeInput({ branchName: "second" }));
264
+ queue.enqueue(makeInput({ branchName: "third" }));
265
+
266
+ const d1 = queue.dequeue();
267
+ const d2 = queue.dequeue();
268
+ const d3 = queue.dequeue();
269
+ const d4 = queue.dequeue();
270
+
271
+ expect(d1?.branchName).toBe("first");
272
+ expect(d2?.branchName).toBe("second");
273
+ expect(d3?.branchName).toBe("third");
274
+ expect(d4).toBeNull();
275
+ });
276
+
277
+ test("interleaved enqueue and dequeue preserves order", () => {
278
+ const queue = createMergeQueue(queuePath);
279
+ queue.enqueue(makeInput({ branchName: "first" }));
280
+ queue.enqueue(makeInput({ branchName: "second" }));
281
+
282
+ const d1 = queue.dequeue();
283
+ expect(d1?.branchName).toBe("first");
284
+
285
+ queue.enqueue(makeInput({ branchName: "third" }));
286
+
287
+ const d2 = queue.dequeue();
288
+ expect(d2?.branchName).toBe("second");
289
+
290
+ const d3 = queue.dequeue();
291
+ expect(d3?.branchName).toBe("third");
292
+ });
293
+ });
294
+
295
+ describe("persistence", () => {
296
+ test("queue survives across separate createMergeQueue calls", () => {
297
+ const queue1 = createMergeQueue(queuePath);
298
+ queue1.enqueue(makeInput({ branchName: "branch-a" }));
299
+ queue1.enqueue(makeInput({ branchName: "branch-b" }));
300
+
301
+ // Create a new queue instance pointing at the same file
302
+ const queue2 = createMergeQueue(queuePath);
303
+ const all = queue2.list();
304
+
305
+ expect(all).toHaveLength(2);
306
+ expect(all[0]?.branchName).toBe("branch-a");
307
+ expect(all[1]?.branchName).toBe("branch-b");
308
+ });
309
+
310
+ test("dequeue from one instance is visible from another", () => {
311
+ const queue1 = createMergeQueue(queuePath);
312
+ queue1.enqueue(makeInput({ branchName: "branch-a" }));
313
+ queue1.enqueue(makeInput({ branchName: "branch-b" }));
314
+
315
+ const queue2 = createMergeQueue(queuePath);
316
+ queue2.dequeue();
317
+
318
+ const all = queue1.list();
319
+ expect(all).toHaveLength(1);
320
+ expect(all[0]?.branchName).toBe("branch-b");
321
+ });
322
+
323
+ test("updateStatus from one instance is visible from another", () => {
324
+ const queue1 = createMergeQueue(queuePath);
325
+ queue1.enqueue(makeInput({ branchName: "branch-a" }));
326
+
327
+ const queue2 = createMergeQueue(queuePath);
328
+ queue2.updateStatus("branch-a", "merged", "clean-merge");
329
+
330
+ const all = queue1.list();
331
+ expect(all[0]?.status).toBe("merged");
332
+ expect(all[0]?.resolvedTier).toBe("clean-merge");
333
+ });
334
+ });
335
+
336
+ describe("close", () => {
337
+ test("closes the database connection", () => {
338
+ const queue = createMergeQueue(queuePath);
339
+ queue.enqueue(makeInput({ branchName: "branch-a" }));
340
+
341
+ // Should not throw
342
+ expect(() => queue.close()).not.toThrow();
343
+ });
344
+
345
+ test("database can be reopened after close", () => {
346
+ const queue1 = createMergeQueue(queuePath);
347
+ queue1.enqueue(makeInput({ branchName: "branch-a" }));
348
+ queue1.close();
349
+
350
+ // Create a new queue instance after closing the first one
351
+ const queue2 = createMergeQueue(queuePath);
352
+ const all = queue2.list();
353
+
354
+ expect(all).toHaveLength(1);
355
+ expect(all[0]?.branchName).toBe("branch-a");
356
+ queue2.close();
357
+ });
358
+ });
359
+ });
@@ -0,0 +1,231 @@
1
+ /**
2
+ * SQLite-backed FIFO merge queue for agent branches.
3
+ *
4
+ * Backed by a SQLite database with WAL mode for concurrent access.
5
+ * Uses bun:sqlite for zero-dependency, synchronous database access.
6
+ * FIFO ordering guaranteed via autoincrement id.
7
+ */
8
+
9
+ import { Database } from "bun:sqlite";
10
+ import { MergeError } from "../errors.ts";
11
+ import type { MergeEntry, ResolutionTier } from "../types.ts";
12
+
13
+ export interface MergeQueue {
14
+ /** Add a new entry to the end of the queue with pending status. */
15
+ enqueue(entry: Omit<MergeEntry, "enqueuedAt" | "status" | "resolvedTier">): MergeEntry;
16
+
17
+ /** Remove and return the first pending entry, or null if none. */
18
+ dequeue(): MergeEntry | null;
19
+
20
+ /** Return the first pending entry without removing it, or null if none. */
21
+ peek(): MergeEntry | null;
22
+
23
+ /** List entries, optionally filtered by status. */
24
+ list(status?: MergeEntry["status"]): MergeEntry[];
25
+
26
+ /** Update the status (and optional resolution tier) of an entry by branch name. */
27
+ updateStatus(branchName: string, status: MergeEntry["status"], tier?: ResolutionTier): void;
28
+
29
+ /** Close the database connection. */
30
+ close(): void;
31
+ }
32
+
33
+ /** Row shape as stored in SQLite (snake_case columns). */
34
+ interface MergeQueueRow {
35
+ id: number;
36
+ branch_name: string;
37
+ task_id: string;
38
+ agent_name: string;
39
+ files_modified: string; // JSON array stored as text
40
+ enqueued_at: string;
41
+ status: string;
42
+ resolved_tier: string | null;
43
+ }
44
+
45
+ const CREATE_TABLE = `
46
+ CREATE TABLE IF NOT EXISTS merge_queue (
47
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
48
+ branch_name TEXT NOT NULL,
49
+ task_id TEXT NOT NULL,
50
+ agent_name TEXT NOT NULL,
51
+ files_modified TEXT NOT NULL DEFAULT '[]',
52
+ enqueued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f','now')),
53
+ status TEXT NOT NULL DEFAULT 'pending'
54
+ CHECK(status IN ('pending','merging','merged','conflict','failed')),
55
+ resolved_tier TEXT
56
+ CHECK(resolved_tier IS NULL OR resolved_tier IN ('clean-merge','auto-resolve','ai-resolve','reimagine'))
57
+ )`;
58
+
59
+ const CREATE_INDEXES = `
60
+ CREATE INDEX IF NOT EXISTS idx_merge_queue_status ON merge_queue(status);
61
+ CREATE INDEX IF NOT EXISTS idx_merge_queue_branch ON merge_queue(branch_name)`;
62
+
63
+ /** Convert a database row (snake_case) to a MergeEntry object (camelCase). */
64
+ function rowToEntry(row: MergeQueueRow): MergeEntry {
65
+ // Parse files_modified from JSON string to array, with fallback to empty array
66
+ let filesModified: string[] = [];
67
+ try {
68
+ const parsed = JSON.parse(row.files_modified);
69
+ filesModified = Array.isArray(parsed) ? parsed : [];
70
+ } catch {
71
+ // Fallback to empty array on parse error
72
+ filesModified = [];
73
+ }
74
+
75
+ return {
76
+ branchName: row.branch_name,
77
+ beadId: row.task_id,
78
+ agentName: row.agent_name,
79
+ filesModified,
80
+ enqueuedAt: row.enqueued_at,
81
+ status: row.status as MergeEntry["status"],
82
+ resolvedTier: row.resolved_tier as ResolutionTier | null,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Create a new MergeQueue backed by a SQLite database at the given path.
88
+ *
89
+ * Initializes the database with WAL mode and a 5-second busy timeout.
90
+ * Creates the merge_queue table and indexes if they do not already exist.
91
+ */
92
+ export function createMergeQueue(dbPath: string): MergeQueue {
93
+ const db = new Database(dbPath);
94
+
95
+ // Configure for concurrent access from multiple agent processes
96
+ db.exec("PRAGMA journal_mode = WAL");
97
+ db.exec("PRAGMA synchronous = NORMAL");
98
+ db.exec("PRAGMA busy_timeout = 5000");
99
+
100
+ // Create schema
101
+ db.exec(CREATE_TABLE);
102
+ db.exec(CREATE_INDEXES);
103
+
104
+ // Prepare statements for frequent operations
105
+ const insertStmt = db.prepare<
106
+ MergeQueueRow,
107
+ {
108
+ $branch_name: string;
109
+ $task_id: string;
110
+ $agent_name: string;
111
+ $files_modified: string;
112
+ $enqueued_at: string;
113
+ }
114
+ >(`
115
+ INSERT INTO merge_queue (branch_name, task_id, agent_name, files_modified, enqueued_at)
116
+ VALUES ($branch_name, $task_id, $agent_name, $files_modified, $enqueued_at)
117
+ RETURNING *
118
+ `);
119
+
120
+ const getFirstPendingStmt = db.prepare<MergeQueueRow, Record<string, never>>(`
121
+ SELECT * FROM merge_queue WHERE status = 'pending' ORDER BY id ASC LIMIT 1
122
+ `);
123
+
124
+ const deleteByIdStmt = db.prepare<void, { $id: number }>(`
125
+ DELETE FROM merge_queue WHERE id = $id
126
+ `);
127
+
128
+ const listAllStmt = db.prepare<MergeQueueRow, Record<string, never>>(`
129
+ SELECT * FROM merge_queue ORDER BY id ASC
130
+ `);
131
+
132
+ const listByStatusStmt = db.prepare<MergeQueueRow, { $status: string }>(`
133
+ SELECT * FROM merge_queue WHERE status = $status ORDER BY id ASC
134
+ `);
135
+
136
+ const getByBranchStmt = db.prepare<MergeQueueRow, { $branch_name: string }>(`
137
+ SELECT * FROM merge_queue WHERE branch_name = $branch_name
138
+ `);
139
+
140
+ const updateStatusStmt = db.prepare<
141
+ void,
142
+ {
143
+ $branch_name: string;
144
+ $status: string;
145
+ $resolved_tier: string | null;
146
+ }
147
+ >(`
148
+ UPDATE merge_queue
149
+ SET status = $status, resolved_tier = $resolved_tier
150
+ WHERE branch_name = $branch_name
151
+ `);
152
+
153
+ return {
154
+ enqueue(input): MergeEntry {
155
+ const filesModifiedJson = JSON.stringify(input.filesModified);
156
+ const enqueuedAt = new Date().toISOString();
157
+
158
+ const row = insertStmt.get({
159
+ $branch_name: input.branchName,
160
+ $task_id: input.beadId,
161
+ $agent_name: input.agentName,
162
+ $files_modified: filesModifiedJson,
163
+ $enqueued_at: enqueuedAt,
164
+ });
165
+
166
+ if (row === null || row === undefined) {
167
+ throw new MergeError("Failed to insert entry into merge queue");
168
+ }
169
+
170
+ return rowToEntry(row);
171
+ },
172
+
173
+ dequeue(): MergeEntry | null {
174
+ const row = getFirstPendingStmt.get({});
175
+
176
+ if (row === null || row === undefined) {
177
+ return null;
178
+ }
179
+
180
+ // Delete the entry
181
+ deleteByIdStmt.run({ $id: row.id });
182
+
183
+ return rowToEntry(row);
184
+ },
185
+
186
+ peek(): MergeEntry | null {
187
+ const row = getFirstPendingStmt.get({});
188
+
189
+ if (row === null || row === undefined) {
190
+ return null;
191
+ }
192
+
193
+ return rowToEntry(row);
194
+ },
195
+
196
+ list(status?): MergeEntry[] {
197
+ let rows: MergeQueueRow[];
198
+
199
+ if (status === undefined) {
200
+ rows = listAllStmt.all({});
201
+ } else {
202
+ rows = listByStatusStmt.all({ $status: status });
203
+ }
204
+
205
+ return rows.map(rowToEntry);
206
+ },
207
+
208
+ updateStatus(branchName, status, tier?): void {
209
+ // Check if entry exists
210
+ const existing = getByBranchStmt.get({ $branch_name: branchName });
211
+
212
+ if (existing === null || existing === undefined) {
213
+ throw new MergeError(`No queue entry found for branch: ${branchName}`, {
214
+ branchName,
215
+ });
216
+ }
217
+
218
+ // Update the entry
219
+ updateStatusStmt.run({
220
+ $branch_name: branchName,
221
+ $status: status,
222
+ $resolved_tier: tier ?? null,
223
+ });
224
+ },
225
+
226
+ close(): void {
227
+ db.exec("PRAGMA wal_checkpoint(PASSIVE)");
228
+ db.close();
229
+ },
230
+ };
231
+ }