@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,1270 @@
1
+ /**
2
+ * Tests for the CLI mail command handlers.
3
+ *
4
+ * Tests CLI-level behavior like flag parsing and output formatting.
5
+ * Uses real SQLite databases in temp directories (no mocking).
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { createEventStore } from "../events/store.ts";
13
+ import { createMailClient } from "../mail/client.ts";
14
+ import { createMailStore } from "../mail/store.ts";
15
+ import type { StoredEvent } from "../types.ts";
16
+ import { mailCommand } from "./mail.ts";
17
+
18
+ describe("mailCommand", () => {
19
+ let tempDir: string;
20
+ let origCwd: string;
21
+ let origWrite: typeof process.stdout.write;
22
+ let origStderrWrite: typeof process.stderr.write;
23
+ let output: string;
24
+ let stderrOutput: string;
25
+
26
+ beforeEach(async () => {
27
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-mail-cmd-test-"));
28
+ await mkdir(join(tempDir, ".overstory"), { recursive: true });
29
+
30
+ // Seed some messages via the store directly
31
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
32
+ const client = createMailClient(store);
33
+ client.send({
34
+ from: "orchestrator",
35
+ to: "builder-1",
36
+ subject: "Build task",
37
+ body: "Implement feature X",
38
+ });
39
+ client.send({
40
+ from: "orchestrator",
41
+ to: "scout-1",
42
+ subject: "Explore API",
43
+ body: "Investigate endpoints",
44
+ });
45
+ client.close();
46
+
47
+ // Change cwd to temp dir so the command finds .overstory/mail.db
48
+ origCwd = process.cwd();
49
+ process.chdir(tempDir);
50
+
51
+ // Capture stdout
52
+ output = "";
53
+ origWrite = process.stdout.write;
54
+ process.stdout.write = ((chunk: string) => {
55
+ output += chunk;
56
+ return true;
57
+ }) as typeof process.stdout.write;
58
+
59
+ // Capture stderr
60
+ stderrOutput = "";
61
+ origStderrWrite = process.stderr.write;
62
+ process.stderr.write = ((chunk: string) => {
63
+ stderrOutput += chunk;
64
+ return true;
65
+ }) as typeof process.stderr.write;
66
+ });
67
+
68
+ afterEach(async () => {
69
+ process.stdout.write = origWrite;
70
+ process.stderr.write = origStderrWrite;
71
+ process.chdir(origCwd);
72
+ await rm(tempDir, { recursive: true, force: true });
73
+ });
74
+
75
+ describe("list", () => {
76
+ test("--unread shows all unread messages globally", async () => {
77
+ await mailCommand(["list", "--unread"]);
78
+ expect(output).toContain("Build task");
79
+ expect(output).toContain("Explore API");
80
+ expect(output).toContain("Total: 2 messages");
81
+ });
82
+
83
+ test("--agent filters by recipient (alias for --to)", async () => {
84
+ await mailCommand(["list", "--agent", "builder-1"]);
85
+ expect(output).toContain("Build task");
86
+ expect(output).not.toContain("Explore API");
87
+ expect(output).toContain("Total: 1 message");
88
+ });
89
+
90
+ test("--agent combined with --unread shows only unread for that agent", async () => {
91
+ // Mark builder-1's message as read
92
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
93
+ const client = createMailClient(store);
94
+ const msgs = client.list({ to: "builder-1" });
95
+ const msgId = msgs[0]?.id;
96
+ expect(msgId).toBeTruthy();
97
+ if (msgId) {
98
+ client.markRead(msgId);
99
+ }
100
+ client.close();
101
+
102
+ await mailCommand(["list", "--agent", "builder-1", "--unread"]);
103
+ expect(output).toContain("No messages found.");
104
+ });
105
+
106
+ test("--to takes precedence over --agent when both provided", async () => {
107
+ await mailCommand(["list", "--to", "scout-1", "--agent", "builder-1"]);
108
+ // --to is checked first via getFlag, so it should win
109
+ expect(output).toContain("Explore API");
110
+ expect(output).not.toContain("Build task");
111
+ });
112
+
113
+ test("list without filters shows all messages", async () => {
114
+ await mailCommand(["list"]);
115
+ expect(output).toContain("Build task");
116
+ expect(output).toContain("Explore API");
117
+ expect(output).toContain("Total: 2 messages");
118
+ });
119
+ });
120
+
121
+ describe("reply", () => {
122
+ test("reply to own sent message goes to original recipient", async () => {
123
+ // Get the message ID of the message orchestrator sent to builder-1
124
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
125
+ const client = createMailClient(store);
126
+ const msgs = client.list({ to: "builder-1" });
127
+ const originalId = msgs[0]?.id;
128
+ expect(originalId).toBeTruthy();
129
+ client.close();
130
+
131
+ if (!originalId) return;
132
+
133
+ // Reply as orchestrator (the original sender)
134
+ output = "";
135
+ await mailCommand(["reply", originalId, "--body", "Actually also do Y"]);
136
+
137
+ expect(output).toContain("Reply sent:");
138
+
139
+ // Verify the reply went to builder-1, not back to orchestrator
140
+ const store2 = createMailStore(join(tempDir, ".overstory", "mail.db"));
141
+ const client2 = createMailClient(store2);
142
+ const allMsgs = client2.list();
143
+ const replyMsg = allMsgs.find((m) => m.subject === "Re: Build task");
144
+ expect(replyMsg).toBeDefined();
145
+ expect(replyMsg?.from).toBe("orchestrator");
146
+ expect(replyMsg?.to).toBe("builder-1");
147
+ client2.close();
148
+ });
149
+
150
+ test("reply as recipient goes to original sender", async () => {
151
+ // Get the message ID
152
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
153
+ const client = createMailClient(store);
154
+ const msgs = client.list({ to: "builder-1" });
155
+ const originalId = msgs[0]?.id;
156
+ expect(originalId).toBeTruthy();
157
+ client.close();
158
+
159
+ if (!originalId) return;
160
+
161
+ // Reply as builder-1 (the recipient of the original)
162
+ output = "";
163
+ await mailCommand(["reply", originalId, "--body", "Done", "--agent", "builder-1"]);
164
+
165
+ expect(output).toContain("Reply sent:");
166
+
167
+ // Verify the reply went to orchestrator (original sender)
168
+ const store2 = createMailStore(join(tempDir, ".overstory", "mail.db"));
169
+ const client2 = createMailClient(store2);
170
+ const allMsgs = client2.list();
171
+ const replyMsg = allMsgs.find(
172
+ (m) => m.subject === "Re: Build task" && m.from === "builder-1",
173
+ );
174
+ expect(replyMsg).toBeDefined();
175
+ expect(replyMsg?.from).toBe("builder-1");
176
+ expect(replyMsg?.to).toBe("orchestrator");
177
+ client2.close();
178
+ });
179
+
180
+ test("reply with flags before positional ID extracts correct ID", async () => {
181
+ // Regression test for overstory-6nq: flags before the positional ID
182
+ // caused the flag VALUE (e.g. 'scout') to be treated as the message ID.
183
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
184
+ const client = createMailClient(store);
185
+ const msgs = client.list({ to: "builder-1" });
186
+ const originalId = msgs[0]?.id;
187
+ expect(originalId).toBeTruthy();
188
+ client.close();
189
+
190
+ if (!originalId) return;
191
+
192
+ // Put --agent and --body flags BEFORE the positional message ID
193
+ output = "";
194
+ await mailCommand(["reply", "--agent", "scout-1", "--body", "Got it", originalId]);
195
+
196
+ expect(output).toContain("Reply sent:");
197
+
198
+ // Verify the reply used the correct message ID (not 'scout-1' or 'Got it')
199
+ const store2 = createMailStore(join(tempDir, ".overstory", "mail.db"));
200
+ const client2 = createMailClient(store2);
201
+ const allMsgs = client2.list();
202
+ const replyMsg = allMsgs.find((m) => m.subject === "Re: Build task" && m.from === "scout-1");
203
+ expect(replyMsg).toBeDefined();
204
+ expect(replyMsg?.body).toBe("Got it");
205
+ client2.close();
206
+ });
207
+ });
208
+
209
+ describe("read", () => {
210
+ test("read with flags before positional ID extracts correct ID", async () => {
211
+ // Regression test for overstory-6nq: same fragile pattern existed in handleRead.
212
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
213
+ const client = createMailClient(store);
214
+ const msgs = client.list({ to: "builder-1" });
215
+ const originalId = msgs[0]?.id;
216
+ expect(originalId).toBeTruthy();
217
+ client.close();
218
+
219
+ if (!originalId) return;
220
+
221
+ // Although read doesn't currently use --agent, test that any unknown
222
+ // flags followed by values don't get treated as the positional ID
223
+ output = "";
224
+ await mailCommand(["read", originalId]);
225
+
226
+ expect(output).toContain(`Marked ${originalId} as read.`);
227
+ });
228
+
229
+ test("read marks message as read", async () => {
230
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
231
+ const client = createMailClient(store);
232
+ const msgs = client.list({ to: "builder-1" });
233
+ const originalId = msgs[0]?.id;
234
+ expect(originalId).toBeTruthy();
235
+ client.close();
236
+
237
+ if (!originalId) return;
238
+
239
+ output = "";
240
+ await mailCommand(["read", originalId]);
241
+ expect(output).toContain(`Marked ${originalId} as read.`);
242
+
243
+ // Reading again should show already read
244
+ output = "";
245
+ await mailCommand(["read", originalId]);
246
+ expect(output).toContain("already read");
247
+ });
248
+ });
249
+
250
+ describe("auto-nudge (pending nudge markers)", () => {
251
+ test("urgent message writes pending nudge marker instead of tmux keys", async () => {
252
+ await mailCommand([
253
+ "send",
254
+ "--to",
255
+ "builder-1",
256
+ "--subject",
257
+ "Fix NOW",
258
+ "--body",
259
+ "Production is down",
260
+ "--priority",
261
+ "urgent",
262
+ ]);
263
+
264
+ // Verify pending nudge marker was written
265
+ const markerPath = join(tempDir, ".overstory", "pending-nudges", "builder-1.json");
266
+ const file = Bun.file(markerPath);
267
+ expect(await file.exists()).toBe(true);
268
+
269
+ const marker = JSON.parse(await file.text());
270
+ expect(marker.from).toBe("orchestrator");
271
+ expect(marker.reason).toBe("urgent priority");
272
+ expect(marker.subject).toBe("Fix NOW");
273
+ expect(marker.messageId).toBeTruthy();
274
+ expect(marker.createdAt).toBeTruthy();
275
+
276
+ // Output should mention queued nudge, not direct delivery
277
+ expect(output).toContain("Queued nudge");
278
+ expect(output).toContain("delivered on next prompt");
279
+ });
280
+
281
+ test("high priority message writes pending nudge marker", async () => {
282
+ await mailCommand([
283
+ "send",
284
+ "--to",
285
+ "scout-1",
286
+ "--subject",
287
+ "Important task",
288
+ "--body",
289
+ "Please prioritize",
290
+ "--priority",
291
+ "high",
292
+ ]);
293
+
294
+ const markerPath = join(tempDir, ".overstory", "pending-nudges", "scout-1.json");
295
+ const file = Bun.file(markerPath);
296
+ expect(await file.exists()).toBe(true);
297
+
298
+ const marker = JSON.parse(await file.text());
299
+ expect(marker.reason).toBe("high priority");
300
+ });
301
+
302
+ test("worker_done type writes pending nudge marker regardless of priority", async () => {
303
+ await mailCommand([
304
+ "send",
305
+ "--to",
306
+ "orchestrator",
307
+ "--subject",
308
+ "Task complete",
309
+ "--body",
310
+ "Builder finished",
311
+ "--type",
312
+ "worker_done",
313
+ "--from",
314
+ "builder-1",
315
+ ]);
316
+
317
+ const markerPath = join(tempDir, ".overstory", "pending-nudges", "orchestrator.json");
318
+ const file = Bun.file(markerPath);
319
+ expect(await file.exists()).toBe(true);
320
+
321
+ const marker = JSON.parse(await file.text());
322
+ expect(marker.reason).toBe("worker_done");
323
+ expect(marker.from).toBe("builder-1");
324
+ });
325
+
326
+ test("normal priority non-protocol message does NOT write marker", async () => {
327
+ await mailCommand(["send", "--to", "builder-1", "--subject", "FYI", "--body", "Just a note"]);
328
+
329
+ const nudgeDir = join(tempDir, ".overstory", "pending-nudges");
330
+ try {
331
+ const files = await readdir(nudgeDir);
332
+ // No marker should exist for this normal-priority status message
333
+ expect(files.filter((f) => f === "builder-1.json")).toHaveLength(0);
334
+ } catch {
335
+ // Directory doesn't exist — that's fine, means no markers
336
+ }
337
+ });
338
+
339
+ test("mail check --inject surfaces pending nudge banner", async () => {
340
+ // Send an urgent message to create a pending nudge marker
341
+ await mailCommand([
342
+ "send",
343
+ "--to",
344
+ "builder-1",
345
+ "--subject",
346
+ "Critical fix",
347
+ "--body",
348
+ "Deploy hotfix",
349
+ "--priority",
350
+ "urgent",
351
+ ]);
352
+
353
+ // Now check as builder-1 with --inject
354
+ output = "";
355
+ await mailCommand(["check", "--inject", "--agent", "builder-1"]);
356
+
357
+ // Should contain the priority banner from the pending nudge
358
+ expect(output).toContain("PRIORITY");
359
+ expect(output).toContain("urgent priority");
360
+ expect(output).toContain("Critical fix");
361
+
362
+ // Should also contain the actual message (from mail check)
363
+ expect(output).toContain("Deploy hotfix");
364
+ });
365
+
366
+ test("pending nudge marker is cleared after mail check --inject", async () => {
367
+ // Send urgent message
368
+ await mailCommand([
369
+ "send",
370
+ "--to",
371
+ "builder-1",
372
+ "--subject",
373
+ "Fix it",
374
+ "--body",
375
+ "Broken",
376
+ "--priority",
377
+ "urgent",
378
+ ]);
379
+
380
+ // First check clears the marker
381
+ output = "";
382
+ await mailCommand(["check", "--inject", "--agent", "builder-1"]);
383
+ expect(output).toContain("PRIORITY");
384
+
385
+ // Second check should NOT have the priority banner
386
+ output = "";
387
+ await mailCommand(["check", "--inject", "--agent", "builder-1"]);
388
+ expect(output).not.toContain("PRIORITY");
389
+ });
390
+
391
+ test("json output for auto-nudge send does not include nudge banner", async () => {
392
+ await mailCommand([
393
+ "send",
394
+ "--to",
395
+ "builder-1",
396
+ "--subject",
397
+ "Urgent",
398
+ "--body",
399
+ "Fix",
400
+ "--priority",
401
+ "urgent",
402
+ "--json",
403
+ ]);
404
+
405
+ // JSON output should just have the message ID, not the nudge banner text
406
+ const parsed = JSON.parse(output.trim());
407
+ expect(parsed.id).toBeTruthy();
408
+ expect(output).not.toContain("Queued nudge");
409
+ });
410
+ });
411
+
412
+ describe("mail_sent event recording", () => {
413
+ test("mail send records mail_sent event to events.db", async () => {
414
+ await mailCommand([
415
+ "send",
416
+ "--to",
417
+ "builder-1",
418
+ "--subject",
419
+ "Test event",
420
+ "--body",
421
+ "Check events",
422
+ ]);
423
+
424
+ // Verify event was recorded
425
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
426
+ const store = createEventStore(eventsDbPath);
427
+ try {
428
+ const events: StoredEvent[] = store.getTimeline({
429
+ since: "2000-01-01T00:00:00Z",
430
+ });
431
+ const mailEvent = events.find((e) => e.eventType === "mail_sent");
432
+ expect(mailEvent).toBeDefined();
433
+ expect(mailEvent?.level).toBe("info");
434
+ expect(mailEvent?.agentName).toBe("orchestrator");
435
+
436
+ const data = JSON.parse(mailEvent?.data ?? "{}") as Record<string, unknown>;
437
+ expect(data.to).toBe("builder-1");
438
+ expect(data.subject).toBe("Test event");
439
+ expect(data.type).toBe("status");
440
+ expect(data.priority).toBe("normal");
441
+ expect(data.messageId).toBeTruthy();
442
+ } finally {
443
+ store.close();
444
+ }
445
+ });
446
+
447
+ test("mail send with custom --from records correct agentName", async () => {
448
+ await mailCommand([
449
+ "send",
450
+ "--to",
451
+ "orchestrator",
452
+ "--subject",
453
+ "Done",
454
+ "--body",
455
+ "Finished task",
456
+ "--from",
457
+ "builder-1",
458
+ "--type",
459
+ "worker_done",
460
+ ]);
461
+
462
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
463
+ const store = createEventStore(eventsDbPath);
464
+ try {
465
+ const events: StoredEvent[] = store.getTimeline({
466
+ since: "2000-01-01T00:00:00Z",
467
+ });
468
+ const mailEvent = events.find((e) => e.eventType === "mail_sent");
469
+ expect(mailEvent).toBeDefined();
470
+ expect(mailEvent?.agentName).toBe("builder-1");
471
+
472
+ const data = JSON.parse(mailEvent?.data ?? "{}") as Record<string, unknown>;
473
+ expect(data.to).toBe("orchestrator");
474
+ expect(data.type).toBe("worker_done");
475
+ } finally {
476
+ store.close();
477
+ }
478
+ });
479
+
480
+ test("mail send includes run_id when current-run.txt exists", async () => {
481
+ const runId = "run-test-mail-456";
482
+ await Bun.write(join(tempDir, ".overstory", "current-run.txt"), runId);
483
+
484
+ await mailCommand([
485
+ "send",
486
+ "--to",
487
+ "builder-1",
488
+ "--subject",
489
+ "With run ID",
490
+ "--body",
491
+ "Test",
492
+ ]);
493
+
494
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
495
+ const store = createEventStore(eventsDbPath);
496
+ try {
497
+ const events: StoredEvent[] = store.getTimeline({
498
+ since: "2000-01-01T00:00:00Z",
499
+ });
500
+ const mailEvent = events.find((e) => e.eventType === "mail_sent");
501
+ expect(mailEvent).toBeDefined();
502
+ expect(mailEvent?.runId).toBe(runId);
503
+ } finally {
504
+ store.close();
505
+ }
506
+ });
507
+
508
+ test("mail send without current-run.txt records null runId", async () => {
509
+ await mailCommand(["send", "--to", "builder-1", "--subject", "No run", "--body", "Test"]);
510
+
511
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
512
+ const store = createEventStore(eventsDbPath);
513
+ try {
514
+ const events: StoredEvent[] = store.getTimeline({
515
+ since: "2000-01-01T00:00:00Z",
516
+ });
517
+ const mailEvent = events.find((e) => e.eventType === "mail_sent");
518
+ expect(mailEvent).toBeDefined();
519
+ expect(mailEvent?.runId).toBeNull();
520
+ } finally {
521
+ store.close();
522
+ }
523
+ });
524
+ });
525
+
526
+ describe("mail check debounce", () => {
527
+ test("mail check without --debounce flag always executes", async () => {
528
+ // Send first message
529
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
530
+ const client = createMailClient(store);
531
+ client.send({
532
+ from: "orchestrator",
533
+ to: "test-agent",
534
+ subject: "Message 1",
535
+ body: "First message",
536
+ });
537
+ client.close();
538
+
539
+ // First check
540
+ output = "";
541
+ await mailCommand(["check", "--inject", "--agent", "test-agent"]);
542
+ const firstOutput = output;
543
+
544
+ // Send second message
545
+ const store2 = createMailStore(join(tempDir, ".overstory", "mail.db"));
546
+ const client2 = createMailClient(store2);
547
+ client2.send({
548
+ from: "orchestrator",
549
+ to: "test-agent",
550
+ subject: "Message 2",
551
+ body: "Second message",
552
+ });
553
+ client2.close();
554
+
555
+ // Second check immediately after
556
+ output = "";
557
+ await mailCommand(["check", "--inject", "--agent", "test-agent"]);
558
+ const secondOutput = output;
559
+
560
+ // Both should execute (no debouncing without flag)
561
+ expect(firstOutput).toContain("Message 1");
562
+ expect(secondOutput).toContain("Message 2");
563
+ });
564
+
565
+ test("mail check with --debounce 500 skips second check within window", async () => {
566
+ // First check with debounce
567
+ output = "";
568
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
569
+ expect(output).toContain("Build task");
570
+
571
+ // Second check immediately (within debounce window)
572
+ output = "";
573
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
574
+ // Should be skipped silently
575
+ expect(output).toBe("");
576
+ });
577
+
578
+ test("mail check with --debounce allows check after window expires", async () => {
579
+ // Send first message
580
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
581
+ const client = createMailClient(store);
582
+ client.send({
583
+ from: "orchestrator",
584
+ to: "debounce-test",
585
+ subject: "First",
586
+ body: "First check",
587
+ });
588
+ client.close();
589
+
590
+ // First check with debounce
591
+ output = "";
592
+ await mailCommand(["check", "--inject", "--agent", "debounce-test", "--debounce", "100"]);
593
+ expect(output).toContain("First check");
594
+
595
+ // Wait for debounce window to expire
596
+ await Bun.sleep(150);
597
+
598
+ // Send second message
599
+ const store2 = createMailStore(join(tempDir, ".overstory", "mail.db"));
600
+ const client2 = createMailClient(store2);
601
+ client2.send({
602
+ from: "orchestrator",
603
+ to: "debounce-test",
604
+ subject: "Second",
605
+ body: "Second check",
606
+ });
607
+ client2.close();
608
+
609
+ // Second check after debounce window
610
+ output = "";
611
+ await mailCommand(["check", "--inject", "--agent", "debounce-test", "--debounce", "100"]);
612
+ expect(output).toContain("Second check");
613
+ });
614
+
615
+ test("mail check with --debounce 0 disables debouncing", async () => {
616
+ // Send first message
617
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
618
+ const client = createMailClient(store);
619
+ client.send({
620
+ from: "orchestrator",
621
+ to: "zero-debounce",
622
+ subject: "Msg 1",
623
+ body: "Message one",
624
+ });
625
+ client.close();
626
+
627
+ // First check with --debounce 0
628
+ output = "";
629
+ await mailCommand(["check", "--inject", "--agent", "zero-debounce", "--debounce", "0"]);
630
+ expect(output).toContain("Message one");
631
+
632
+ // Send second message immediately
633
+ const store2 = createMailStore(join(tempDir, ".overstory", "mail.db"));
634
+ const client2 = createMailClient(store2);
635
+ client2.send({
636
+ from: "orchestrator",
637
+ to: "zero-debounce",
638
+ subject: "Msg 2",
639
+ body: "Message two",
640
+ });
641
+ client2.close();
642
+
643
+ // Second check immediately (should work with debounce 0)
644
+ output = "";
645
+ await mailCommand(["check", "--inject", "--agent", "zero-debounce", "--debounce", "0"]);
646
+ expect(output).toContain("Message two");
647
+ });
648
+
649
+ test("mail check debounce is per-agent", async () => {
650
+ // Check for builder-1 with debounce
651
+ output = "";
652
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
653
+ expect(output).toContain("Build task");
654
+
655
+ // Check for scout-1 immediately (different agent)
656
+ output = "";
657
+ await mailCommand(["check", "--agent", "scout-1", "--debounce", "500"]);
658
+ expect(output).toContain("Explore API");
659
+
660
+ // Check for builder-1 again (should be debounced)
661
+ output = "";
662
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
663
+ expect(output).toBe("");
664
+ });
665
+
666
+ test("mail check --debounce with invalid value throws ValidationError", async () => {
667
+ try {
668
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "invalid"]);
669
+ expect(true).toBe(false); // Should not reach here
670
+ } catch (err) {
671
+ expect(err).toBeInstanceOf(Error);
672
+ if (err instanceof Error) {
673
+ expect(err.message).toContain("must be a non-negative integer");
674
+ }
675
+ }
676
+ });
677
+
678
+ test("mail check --debounce with negative value throws ValidationError", async () => {
679
+ try {
680
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "-100"]);
681
+ expect(true).toBe(false);
682
+ } catch (err) {
683
+ expect(err).toBeInstanceOf(Error);
684
+ if (err instanceof Error) {
685
+ expect(err.message).toContain("must be a non-negative integer");
686
+ }
687
+ }
688
+ });
689
+
690
+ test("mail check --inject with --debounce skips check within window", async () => {
691
+ // First inject check with debounce
692
+ output = "";
693
+ await mailCommand(["check", "--inject", "--agent", "builder-1", "--debounce", "500"]);
694
+ expect(output).toContain("Build task");
695
+
696
+ // Second inject check immediately (should be debounced)
697
+ output = "";
698
+ await mailCommand(["check", "--inject", "--agent", "builder-1", "--debounce", "500"]);
699
+ expect(output).toBe("");
700
+ });
701
+
702
+ test("mail check debounce state persists across invocations", async () => {
703
+ // First check
704
+ output = "";
705
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
706
+ expect(output).toContain("Build task");
707
+
708
+ // Verify state file was created
709
+ const statePath = join(tempDir, ".overstory", "mail-check-state.json");
710
+ const file = Bun.file(statePath);
711
+ expect(await file.exists()).toBe(true);
712
+
713
+ const state = JSON.parse(await file.text()) as Record<string, number>;
714
+ expect(state["builder-1"]).toBeTruthy();
715
+ expect(typeof state["builder-1"]).toBe("number");
716
+ });
717
+
718
+ test("corrupted debounce state file is handled gracefully", async () => {
719
+ // Write corrupted state file
720
+ const statePath = join(tempDir, ".overstory", "mail-check-state.json");
721
+ await Bun.write(statePath, "not valid json");
722
+
723
+ // Should not throw, should treat as fresh state
724
+ output = "";
725
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
726
+ expect(output).toContain("Build task");
727
+
728
+ // State should be corrected
729
+ const state = JSON.parse(await Bun.file(statePath).text()) as Record<string, number>;
730
+ expect(state["builder-1"]).toBeTruthy();
731
+ });
732
+
733
+ test("mail check debounce only records timestamp when flag is provided", async () => {
734
+ const statePath = join(tempDir, ".overstory", "mail-check-state.json");
735
+
736
+ // Check without debounce flag
737
+ await mailCommand(["check", "--agent", "builder-1"]);
738
+
739
+ // State file should not be created
740
+ expect(await Bun.file(statePath).exists()).toBe(false);
741
+
742
+ // Check with debounce flag
743
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
744
+
745
+ // Now state file should exist
746
+ expect(await Bun.file(statePath).exists()).toBe(true);
747
+ });
748
+ });
749
+
750
+ describe("broadcast", () => {
751
+ // Helper to create active agent sessions for broadcast testing
752
+ async function seedActiveSessions(): Promise<void> {
753
+ const { createSessionStore } = await import("../sessions/store.ts");
754
+ const sessionsDbPath = join(tempDir, ".overstory", "sessions.db");
755
+ const sessionStore = createSessionStore(sessionsDbPath);
756
+
757
+ const sessions = [
758
+ {
759
+ id: "session-orchestrator",
760
+ agentName: "orchestrator",
761
+ capability: "coordinator",
762
+ worktreePath: "/worktrees/orchestrator",
763
+ branchName: "main",
764
+ beadId: "bead-001",
765
+ tmuxSession: "overstory-test-orchestrator",
766
+ state: "working" as const,
767
+ pid: 12345,
768
+ parentAgent: null,
769
+ depth: 0,
770
+ runId: "run-001",
771
+ startedAt: new Date().toISOString(),
772
+ lastActivity: new Date().toISOString(),
773
+ escalationLevel: 0,
774
+ stalledSince: null,
775
+ },
776
+ {
777
+ id: "session-builder-1",
778
+ agentName: "builder-1",
779
+ capability: "builder",
780
+ worktreePath: "/worktrees/builder-1",
781
+ branchName: "builder-1",
782
+ beadId: "bead-002",
783
+ tmuxSession: "overstory-test-builder-1",
784
+ state: "working" as const,
785
+ pid: 12346,
786
+ parentAgent: "orchestrator",
787
+ depth: 1,
788
+ runId: "run-001",
789
+ startedAt: new Date().toISOString(),
790
+ lastActivity: new Date().toISOString(),
791
+ escalationLevel: 0,
792
+ stalledSince: null,
793
+ },
794
+ {
795
+ id: "session-builder-2",
796
+ agentName: "builder-2",
797
+ capability: "builder",
798
+ worktreePath: "/worktrees/builder-2",
799
+ branchName: "builder-2",
800
+ beadId: "bead-003",
801
+ tmuxSession: "overstory-test-builder-2",
802
+ state: "working" as const,
803
+ pid: 12347,
804
+ parentAgent: "orchestrator",
805
+ depth: 1,
806
+ runId: "run-001",
807
+ startedAt: new Date().toISOString(),
808
+ lastActivity: new Date().toISOString(),
809
+ escalationLevel: 0,
810
+ stalledSince: null,
811
+ },
812
+ {
813
+ id: "session-scout-1",
814
+ agentName: "scout-1",
815
+ capability: "scout",
816
+ worktreePath: "/worktrees/scout-1",
817
+ branchName: "scout-1",
818
+ beadId: "bead-004",
819
+ tmuxSession: "overstory-test-scout-1",
820
+ state: "working" as const,
821
+ pid: 12348,
822
+ parentAgent: "orchestrator",
823
+ depth: 1,
824
+ runId: "run-001",
825
+ startedAt: new Date().toISOString(),
826
+ lastActivity: new Date().toISOString(),
827
+ escalationLevel: 0,
828
+ stalledSince: null,
829
+ },
830
+ ];
831
+
832
+ for (const session of sessions) {
833
+ sessionStore.upsert(session);
834
+ }
835
+
836
+ sessionStore.close();
837
+ }
838
+
839
+ test("@all broadcasts to all active agents except sender", async () => {
840
+ await seedActiveSessions();
841
+
842
+ output = "";
843
+ await mailCommand([
844
+ "send",
845
+ "--to",
846
+ "@all",
847
+ "--subject",
848
+ "Team update",
849
+ "--body",
850
+ "Important announcement",
851
+ ]);
852
+
853
+ expect(output).toContain("Broadcast sent to 3 recipients (@all)");
854
+ expect(output).toContain("→ builder-1");
855
+ expect(output).toContain("→ builder-2");
856
+ expect(output).toContain("→ scout-1");
857
+ expect(output).not.toContain("orchestrator"); // sender excluded
858
+
859
+ // Verify messages were actually stored
860
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
861
+ const client = createMailClient(store);
862
+ const messages = client.list();
863
+ const broadcastMsgs = messages.filter((m) => m.subject === "Team update");
864
+ expect(broadcastMsgs.length).toBe(3);
865
+ expect(broadcastMsgs.map((m) => m.to).sort()).toEqual(["builder-1", "builder-2", "scout-1"]);
866
+ client.close();
867
+ });
868
+
869
+ test("@builders broadcasts to all builder agents", async () => {
870
+ await seedActiveSessions();
871
+
872
+ output = "";
873
+ await mailCommand([
874
+ "send",
875
+ "--to",
876
+ "@builders",
877
+ "--subject",
878
+ "Builder update",
879
+ "--body",
880
+ "Build instructions",
881
+ ]);
882
+
883
+ expect(output).toContain("Broadcast sent to 2 recipients (@builders)");
884
+ expect(output).toContain("→ builder-1");
885
+ expect(output).toContain("→ builder-2");
886
+ expect(output).not.toContain("scout-1");
887
+
888
+ // Verify messages
889
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
890
+ const client = createMailClient(store);
891
+ const messages = client.list();
892
+ const broadcastMsgs = messages.filter((m) => m.subject === "Builder update");
893
+ expect(broadcastMsgs.length).toBe(2);
894
+ client.close();
895
+ });
896
+
897
+ test("@scouts broadcasts to all scout agents", async () => {
898
+ await seedActiveSessions();
899
+
900
+ output = "";
901
+ await mailCommand([
902
+ "send",
903
+ "--to",
904
+ "@scouts",
905
+ "--subject",
906
+ "Scout task",
907
+ "--body",
908
+ "Explore this area",
909
+ ]);
910
+
911
+ expect(output).toContain("Broadcast sent to 1 recipient (@scouts)");
912
+ expect(output).toContain("→ scout-1");
913
+
914
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
915
+ const client = createMailClient(store);
916
+ const messages = client.list();
917
+ const broadcastMsgs = messages.filter((m) => m.subject === "Scout task");
918
+ expect(broadcastMsgs.length).toBe(1);
919
+ expect(broadcastMsgs[0]?.to).toBe("scout-1");
920
+ client.close();
921
+ });
922
+
923
+ test("singular alias @builder works same as @builders", async () => {
924
+ await seedActiveSessions();
925
+
926
+ output = "";
927
+ await mailCommand([
928
+ "send",
929
+ "--to",
930
+ "@builder",
931
+ "--subject",
932
+ "Builder task",
933
+ "--body",
934
+ "Singular alias test",
935
+ ]);
936
+
937
+ expect(output).toContain("Broadcast sent to 2 recipients (@builder)");
938
+ expect(output).toContain("→ builder-1");
939
+ expect(output).toContain("→ builder-2");
940
+ });
941
+
942
+ test("sender is excluded from broadcast recipients", async () => {
943
+ await seedActiveSessions();
944
+
945
+ output = "";
946
+ await mailCommand([
947
+ "send",
948
+ "--to",
949
+ "@builders",
950
+ "--from",
951
+ "builder-1",
952
+ "--subject",
953
+ "Peer message",
954
+ "--body",
955
+ "Message from builder-1",
956
+ ]);
957
+
958
+ expect(output).toContain("Broadcast sent to 1 recipient (@builders)");
959
+ expect(output).toContain("→ builder-2");
960
+ expect(output).not.toContain("builder-1");
961
+
962
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
963
+ const client = createMailClient(store);
964
+ const messages = client.list();
965
+ const broadcastMsgs = messages.filter((m) => m.subject === "Peer message");
966
+ expect(broadcastMsgs.length).toBe(1);
967
+ expect(broadcastMsgs[0]?.to).toBe("builder-2");
968
+ client.close();
969
+ });
970
+
971
+ test("throws when group resolves to zero recipients", async () => {
972
+ await seedActiveSessions();
973
+
974
+ // @all from all agents (impossible — at least one agent needed)
975
+ // Instead, test a capability group with no members
976
+ let error: Error | null = null;
977
+ try {
978
+ await mailCommand(["send", "--to", "@reviewers", "--subject", "Test", "--body", "Body"]);
979
+ } catch (e) {
980
+ error = e as Error;
981
+ }
982
+
983
+ expect(error).toBeTruthy();
984
+ expect(error?.message).toContain("resolved to zero recipients");
985
+ });
986
+
987
+ test("throws when group is unknown", async () => {
988
+ await seedActiveSessions();
989
+
990
+ let error: Error | null = null;
991
+ try {
992
+ await mailCommand(["send", "--to", "@unknown", "--subject", "Test", "--body", "Body"]);
993
+ } catch (e) {
994
+ error = e as Error;
995
+ }
996
+
997
+ expect(error).toBeTruthy();
998
+ expect(error?.message).toContain("Unknown group address");
999
+ });
1000
+
1001
+ test("broadcast with --json outputs message IDs and recipient count", async () => {
1002
+ await seedActiveSessions();
1003
+
1004
+ output = "";
1005
+ await mailCommand([
1006
+ "send",
1007
+ "--to",
1008
+ "@builders",
1009
+ "--subject",
1010
+ "Test",
1011
+ "--body",
1012
+ "Body",
1013
+ "--json",
1014
+ ]);
1015
+
1016
+ const result = JSON.parse(output) as { messageIds: string[]; recipientCount: number };
1017
+ expect(result.messageIds).toBeInstanceOf(Array);
1018
+ expect(result.messageIds.length).toBe(2);
1019
+ expect(result.recipientCount).toBe(2);
1020
+ });
1021
+
1022
+ test("broadcast records event for each individual message", async () => {
1023
+ await seedActiveSessions();
1024
+
1025
+ const eventsDbPath = join(tempDir, ".overstory", "events.db");
1026
+ const eventStore = createEventStore(eventsDbPath);
1027
+ eventStore.close(); // Just to initialize the DB
1028
+
1029
+ output = "";
1030
+ await mailCommand(["send", "--to", "@builders", "--subject", "Test", "--body", "Body"]);
1031
+
1032
+ // Check events by agent (orchestrator is the sender)
1033
+ const eventStore2 = createEventStore(eventsDbPath);
1034
+ const events = eventStore2.getByAgent("orchestrator");
1035
+ eventStore2.close();
1036
+
1037
+ const mailSentEvents = events.filter((e) => e.eventType === "mail_sent");
1038
+ expect(mailSentEvents.length).toBe(2);
1039
+ for (const evt of mailSentEvents) {
1040
+ expect(evt.eventType).toBe("mail_sent");
1041
+ const data = JSON.parse(evt.data ?? "{}") as {
1042
+ to: string;
1043
+ broadcast: boolean;
1044
+ };
1045
+ expect(data.broadcast).toBe(true);
1046
+ expect(["builder-1", "builder-2"]).toContain(data.to);
1047
+ }
1048
+ });
1049
+
1050
+ test("broadcast with urgent priority writes pending nudge for each recipient", async () => {
1051
+ await seedActiveSessions();
1052
+
1053
+ output = "";
1054
+ await mailCommand([
1055
+ "send",
1056
+ "--to",
1057
+ "@builders",
1058
+ "--subject",
1059
+ "Urgent task",
1060
+ "--body",
1061
+ "Do this now",
1062
+ "--priority",
1063
+ "urgent",
1064
+ ]);
1065
+
1066
+ // Check pending nudge markers
1067
+ const nudgesDir = join(tempDir, ".overstory", "pending-nudges");
1068
+ const nudgeFiles = await readdir(nudgesDir);
1069
+ expect(nudgeFiles).toContain("builder-1.json");
1070
+ expect(nudgeFiles).toContain("builder-2.json");
1071
+
1072
+ // Verify nudge content
1073
+ const nudge1 = JSON.parse(await Bun.file(join(nudgesDir, "builder-1.json")).text()) as {
1074
+ reason: string;
1075
+ subject: string;
1076
+ };
1077
+ expect(nudge1.reason).toBe("urgent priority");
1078
+ expect(nudge1.subject).toBe("Urgent task");
1079
+ });
1080
+
1081
+ test("broadcast with auto-nudge type writes pending nudge for each recipient", async () => {
1082
+ await seedActiveSessions();
1083
+
1084
+ output = "";
1085
+ await mailCommand([
1086
+ "send",
1087
+ "--to",
1088
+ "@builders",
1089
+ "--subject",
1090
+ "Error occurred",
1091
+ "--body",
1092
+ "Something went wrong",
1093
+ "--type",
1094
+ "error",
1095
+ ]);
1096
+
1097
+ // Check pending nudge markers
1098
+ const nudgesDir = join(tempDir, ".overstory", "pending-nudges");
1099
+ const nudgeFiles = await readdir(nudgesDir);
1100
+ expect(nudgeFiles).toContain("builder-1.json");
1101
+ expect(nudgeFiles).toContain("builder-2.json");
1102
+
1103
+ const nudge1 = JSON.parse(await Bun.file(join(nudgesDir, "builder-1.json")).text()) as {
1104
+ reason: string;
1105
+ };
1106
+ expect(nudge1.reason).toBe("error");
1107
+ });
1108
+ });
1109
+
1110
+ describe("merge_ready reviewer validation", () => {
1111
+ // Helper to set up sessions in sessions.db
1112
+ async function seedSessions(
1113
+ sessions: Array<{
1114
+ agentName: string;
1115
+ capability: string;
1116
+ parentAgent: string | null;
1117
+ }>,
1118
+ ): Promise<void> {
1119
+ const { createSessionStore } = await import("../sessions/store.ts");
1120
+ const sessionsDbPath = join(tempDir, ".overstory", "sessions.db");
1121
+ const sessionStore = createSessionStore(sessionsDbPath);
1122
+
1123
+ for (const [idx, session] of sessions.entries()) {
1124
+ sessionStore.upsert({
1125
+ id: `session-${idx}`,
1126
+ agentName: session.agentName,
1127
+ capability: session.capability as
1128
+ | "builder"
1129
+ | "reviewer"
1130
+ | "scout"
1131
+ | "coordinator"
1132
+ | "lead"
1133
+ | "merger"
1134
+ | "supervisor"
1135
+ | "monitor",
1136
+ worktreePath: `/worktrees/${session.agentName}`,
1137
+ branchName: session.agentName,
1138
+ beadId: `bead-${idx}`,
1139
+ tmuxSession: `overstory-test-${session.agentName}`,
1140
+ state: "working" as const,
1141
+ pid: 10000 + idx,
1142
+ parentAgent: session.parentAgent,
1143
+ depth: 1,
1144
+ runId: "run-001",
1145
+ startedAt: new Date().toISOString(),
1146
+ lastActivity: new Date().toISOString(),
1147
+ escalationLevel: 0,
1148
+ stalledSince: null,
1149
+ });
1150
+ }
1151
+
1152
+ sessionStore.close();
1153
+ }
1154
+
1155
+ test("merge_ready with no reviewers emits warning", async () => {
1156
+ await seedSessions([
1157
+ { agentName: "builder-1", capability: "builder", parentAgent: "lead-1" },
1158
+ { agentName: "builder-2", capability: "builder", parentAgent: "lead-1" },
1159
+ ]);
1160
+
1161
+ output = "";
1162
+ stderrOutput = "";
1163
+ await mailCommand([
1164
+ "send",
1165
+ "--to",
1166
+ "coordinator",
1167
+ "--subject",
1168
+ "Ready to merge",
1169
+ "--body",
1170
+ "All builders complete",
1171
+ "--type",
1172
+ "merge_ready",
1173
+ "--from",
1174
+ "lead-1",
1175
+ ]);
1176
+
1177
+ // Verify warning on stderr
1178
+ expect(stderrOutput).toContain("WARNING");
1179
+ expect(stderrOutput).toContain("NO reviewer sessions found");
1180
+ expect(stderrOutput).toContain("lead-1");
1181
+ expect(stderrOutput).toContain("2 builder(s)");
1182
+ expect(stderrOutput).toContain("review-before-merge requirement");
1183
+ expect(stderrOutput).toContain("REVIEW_SKIP");
1184
+ });
1185
+
1186
+ test("merge_ready with partial reviewers emits note", async () => {
1187
+ await seedSessions([
1188
+ { agentName: "builder-1", capability: "builder", parentAgent: "lead-1" },
1189
+ { agentName: "builder-2", capability: "builder", parentAgent: "lead-1" },
1190
+ { agentName: "builder-3", capability: "builder", parentAgent: "lead-1" },
1191
+ { agentName: "reviewer-1", capability: "reviewer", parentAgent: "lead-1" },
1192
+ ]);
1193
+
1194
+ output = "";
1195
+ stderrOutput = "";
1196
+ await mailCommand([
1197
+ "send",
1198
+ "--to",
1199
+ "coordinator",
1200
+ "--subject",
1201
+ "Ready to merge",
1202
+ "--body",
1203
+ "Partial review complete",
1204
+ "--type",
1205
+ "merge_ready",
1206
+ "--from",
1207
+ "lead-1",
1208
+ ]);
1209
+
1210
+ // Verify note on stderr
1211
+ expect(stderrOutput).toContain("NOTE");
1212
+ expect(stderrOutput).toContain("Only 1 reviewer(s) for 3 builder(s)");
1213
+ expect(stderrOutput).toContain("review-verified");
1214
+ });
1215
+
1216
+ test("merge_ready with full coverage emits no warning", async () => {
1217
+ await seedSessions([
1218
+ { agentName: "builder-1", capability: "builder", parentAgent: "lead-1" },
1219
+ { agentName: "builder-2", capability: "builder", parentAgent: "lead-1" },
1220
+ { agentName: "reviewer-1", capability: "reviewer", parentAgent: "lead-1" },
1221
+ { agentName: "reviewer-2", capability: "reviewer", parentAgent: "lead-1" },
1222
+ ]);
1223
+
1224
+ output = "";
1225
+ stderrOutput = "";
1226
+ await mailCommand([
1227
+ "send",
1228
+ "--to",
1229
+ "coordinator",
1230
+ "--subject",
1231
+ "Ready to merge",
1232
+ "--body",
1233
+ "Full review complete",
1234
+ "--type",
1235
+ "merge_ready",
1236
+ "--from",
1237
+ "lead-1",
1238
+ ]);
1239
+
1240
+ // No warning should be emitted
1241
+ expect(stderrOutput).toBe("");
1242
+ });
1243
+
1244
+ test("non-merge_ready types skip reviewer check", async () => {
1245
+ await seedSessions([
1246
+ { agentName: "builder-1", capability: "builder", parentAgent: "lead-1" },
1247
+ { agentName: "builder-2", capability: "builder", parentAgent: "lead-1" },
1248
+ ]);
1249
+
1250
+ output = "";
1251
+ stderrOutput = "";
1252
+ await mailCommand([
1253
+ "send",
1254
+ "--to",
1255
+ "coordinator",
1256
+ "--subject",
1257
+ "Status update",
1258
+ "--body",
1259
+ "Work in progress",
1260
+ "--type",
1261
+ "status",
1262
+ "--from",
1263
+ "lead-1",
1264
+ ]);
1265
+
1266
+ // No warning should be emitted for non-merge_ready types
1267
+ expect(stderrOutput).toBe("");
1268
+ });
1269
+ });
1270
+ });