@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,773 @@
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 { MailError } from "../errors.ts";
6
+ import type { WorkerDonePayload } from "../types.ts";
7
+ import { createMailClient, type MailClient, parsePayload } from "./client.ts";
8
+ import { createMailStore, type MailStore } from "./store.ts";
9
+
10
+ describe("createMailClient", () => {
11
+ let tempDir: string;
12
+ let store: MailStore;
13
+ let client: MailClient;
14
+
15
+ beforeEach(async () => {
16
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-mail-client-test-"));
17
+ store = createMailStore(join(tempDir, "mail.db"));
18
+ client = createMailClient(store);
19
+ });
20
+
21
+ afterEach(async () => {
22
+ client.close();
23
+ await rm(tempDir, { recursive: true, force: true });
24
+ });
25
+
26
+ describe("send", () => {
27
+ test("returns a message ID", () => {
28
+ const id = client.send({
29
+ from: "agent-a",
30
+ to: "orchestrator",
31
+ subject: "Status update",
32
+ body: "All tests passing",
33
+ });
34
+
35
+ expect(id).toMatch(/^msg-[a-z0-9]{12}$/);
36
+ });
37
+
38
+ test("defaults type to 'status' when not provided", () => {
39
+ const id = client.send({
40
+ from: "agent-a",
41
+ to: "orchestrator",
42
+ subject: "Update",
43
+ body: "Done",
44
+ });
45
+
46
+ const msg = store.getById(id);
47
+ expect(msg).not.toBeNull();
48
+ expect(msg?.type).toBe("status");
49
+ });
50
+
51
+ test("defaults priority to 'normal' when not provided", () => {
52
+ const id = client.send({
53
+ from: "agent-a",
54
+ to: "orchestrator",
55
+ subject: "Update",
56
+ body: "Done",
57
+ });
58
+
59
+ const msg = store.getById(id);
60
+ expect(msg).not.toBeNull();
61
+ expect(msg?.priority).toBe("normal");
62
+ });
63
+
64
+ test("uses provided type and priority", () => {
65
+ const id = client.send({
66
+ from: "agent-a",
67
+ to: "orchestrator",
68
+ subject: "Help needed",
69
+ body: "Blocked on dependency",
70
+ type: "question",
71
+ priority: "high",
72
+ });
73
+
74
+ const msg = store.getById(id);
75
+ expect(msg).not.toBeNull();
76
+ expect(msg?.type).toBe("question");
77
+ expect(msg?.priority).toBe("high");
78
+ });
79
+
80
+ test("stores all message fields correctly", () => {
81
+ const id = client.send({
82
+ from: "builder-1",
83
+ to: "lead-1",
84
+ subject: "Task complete",
85
+ body: "Implementation finished",
86
+ type: "result",
87
+ priority: "low",
88
+ threadId: "thread-abc",
89
+ });
90
+
91
+ const msg = store.getById(id);
92
+ expect(msg).not.toBeNull();
93
+ expect(msg?.from).toBe("builder-1");
94
+ expect(msg?.to).toBe("lead-1");
95
+ expect(msg?.subject).toBe("Task complete");
96
+ expect(msg?.body).toBe("Implementation finished");
97
+ expect(msg?.threadId).toBe("thread-abc");
98
+ expect(msg?.read).toBe(false);
99
+ });
100
+ });
101
+
102
+ describe("check", () => {
103
+ test("returns unread messages for the agent", () => {
104
+ client.send({
105
+ from: "agent-a",
106
+ to: "orchestrator",
107
+ subject: "msg1",
108
+ body: "body1",
109
+ });
110
+ client.send({
111
+ from: "agent-b",
112
+ to: "orchestrator",
113
+ subject: "msg2",
114
+ body: "body2",
115
+ });
116
+
117
+ const messages = client.check("orchestrator");
118
+ expect(messages).toHaveLength(2);
119
+ expect(messages[0]?.subject).toBe("msg1");
120
+ expect(messages[1]?.subject).toBe("msg2");
121
+ });
122
+
123
+ test("marks returned messages as read", () => {
124
+ client.send({
125
+ from: "agent-a",
126
+ to: "orchestrator",
127
+ subject: "msg1",
128
+ body: "body1",
129
+ });
130
+
131
+ const firstCheck = client.check("orchestrator");
132
+ expect(firstCheck).toHaveLength(1);
133
+
134
+ // Second check should return empty since messages are now read
135
+ const secondCheck = client.check("orchestrator");
136
+ expect(secondCheck).toHaveLength(0);
137
+ });
138
+
139
+ test("returns empty array when no unread messages", () => {
140
+ const messages = client.check("orchestrator");
141
+ expect(messages).toHaveLength(0);
142
+ });
143
+
144
+ test("only returns messages addressed to the specified agent", () => {
145
+ client.send({
146
+ from: "agent-a",
147
+ to: "orchestrator",
148
+ subject: "for-orch",
149
+ body: "body",
150
+ });
151
+ client.send({
152
+ from: "agent-a",
153
+ to: "agent-b",
154
+ subject: "for-b",
155
+ body: "body",
156
+ });
157
+
158
+ const messages = client.check("orchestrator");
159
+ expect(messages).toHaveLength(1);
160
+ expect(messages[0]?.subject).toBe("for-orch");
161
+ });
162
+ });
163
+
164
+ describe("checkInject", () => {
165
+ test("returns empty string when no unread messages", () => {
166
+ const result = client.checkInject("orchestrator");
167
+ expect(result).toBe("");
168
+ });
169
+
170
+ test("formats single message with count of 1", () => {
171
+ client.send({
172
+ from: "agent-a",
173
+ to: "orchestrator",
174
+ subject: "Build complete",
175
+ body: "All 42 tests pass",
176
+ });
177
+
178
+ const result = client.checkInject("orchestrator");
179
+ expect(result).toContain("1 new message");
180
+ expect(result).not.toContain("messages:");
181
+ });
182
+
183
+ test("includes sender name in formatted output", () => {
184
+ client.send({
185
+ from: "builder-1",
186
+ to: "orchestrator",
187
+ subject: "Done",
188
+ body: "Finished implementation",
189
+ });
190
+
191
+ const result = client.checkInject("orchestrator");
192
+ expect(result).toContain("From: builder-1");
193
+ });
194
+
195
+ test("includes subject in formatted output", () => {
196
+ client.send({
197
+ from: "agent-a",
198
+ to: "orchestrator",
199
+ subject: "Important Update",
200
+ body: "Details here",
201
+ });
202
+
203
+ const result = client.checkInject("orchestrator");
204
+ expect(result).toContain("Subject: Important Update");
205
+ });
206
+
207
+ test("includes message body in formatted output", () => {
208
+ client.send({
209
+ from: "agent-a",
210
+ to: "orchestrator",
211
+ subject: "Update",
212
+ body: "The implementation is complete and all tests pass.",
213
+ });
214
+
215
+ const result = client.checkInject("orchestrator");
216
+ expect(result).toContain("The implementation is complete and all tests pass.");
217
+ });
218
+
219
+ test("includes reply command with message id", () => {
220
+ const id = client.send({
221
+ from: "agent-a",
222
+ to: "orchestrator",
223
+ subject: "Question",
224
+ body: "Need clarification",
225
+ });
226
+
227
+ const result = client.checkInject("orchestrator");
228
+ expect(result).toContain(`overstory mail reply ${id}`);
229
+ });
230
+
231
+ test("formats multiple messages with correct count", () => {
232
+ client.send({
233
+ from: "agent-a",
234
+ to: "orchestrator",
235
+ subject: "msg1",
236
+ body: "body1",
237
+ });
238
+ client.send({
239
+ from: "agent-b",
240
+ to: "orchestrator",
241
+ subject: "msg2",
242
+ body: "body2",
243
+ });
244
+ client.send({
245
+ from: "agent-c",
246
+ to: "orchestrator",
247
+ subject: "msg3",
248
+ body: "body3",
249
+ });
250
+
251
+ const result = client.checkInject("orchestrator");
252
+ expect(result).toContain("3 new messages");
253
+ expect(result).toContain("From: agent-a");
254
+ expect(result).toContain("From: agent-b");
255
+ expect(result).toContain("From: agent-c");
256
+ });
257
+
258
+ test("shows priority tag for high priority", () => {
259
+ client.send({
260
+ from: "agent-a",
261
+ to: "orchestrator",
262
+ subject: "Urgent matter",
263
+ body: "Need help now",
264
+ priority: "high",
265
+ });
266
+
267
+ const result = client.checkInject("orchestrator");
268
+ expect(result).toContain("[HIGH]");
269
+ });
270
+
271
+ test("shows priority tag for urgent priority", () => {
272
+ client.send({
273
+ from: "agent-a",
274
+ to: "orchestrator",
275
+ subject: "Critical failure",
276
+ body: "Build broken",
277
+ priority: "urgent",
278
+ });
279
+
280
+ const result = client.checkInject("orchestrator");
281
+ expect(result).toContain("[URGENT]");
282
+ });
283
+
284
+ test("shows priority tag for low priority", () => {
285
+ client.send({
286
+ from: "agent-a",
287
+ to: "orchestrator",
288
+ subject: "FYI",
289
+ body: "Minor note",
290
+ priority: "low",
291
+ });
292
+
293
+ const result = client.checkInject("orchestrator");
294
+ expect(result).toContain("[LOW]");
295
+ });
296
+
297
+ test("does not show priority tag for normal priority", () => {
298
+ client.send({
299
+ from: "agent-a",
300
+ to: "orchestrator",
301
+ subject: "Update",
302
+ body: "Regular update",
303
+ priority: "normal",
304
+ });
305
+
306
+ const result = client.checkInject("orchestrator");
307
+ expect(result).not.toContain("[NORMAL]");
308
+ });
309
+
310
+ test("marks messages as read after injection", () => {
311
+ client.send({
312
+ from: "agent-a",
313
+ to: "orchestrator",
314
+ subject: "msg1",
315
+ body: "body1",
316
+ });
317
+
318
+ const first = client.checkInject("orchestrator");
319
+ expect(first).not.toBe("");
320
+
321
+ // Second call should return empty since messages are marked read
322
+ const second = client.checkInject("orchestrator");
323
+ expect(second).toBe("");
324
+ });
325
+ });
326
+
327
+ describe("list", () => {
328
+ test("returns all messages without filters", () => {
329
+ client.send({
330
+ from: "agent-a",
331
+ to: "orchestrator",
332
+ subject: "msg1",
333
+ body: "body1",
334
+ });
335
+ client.send({
336
+ from: "agent-b",
337
+ to: "agent-c",
338
+ subject: "msg2",
339
+ body: "body2",
340
+ });
341
+
342
+ const messages = client.list();
343
+ expect(messages).toHaveLength(2);
344
+ });
345
+
346
+ test("filters by from", () => {
347
+ client.send({
348
+ from: "agent-a",
349
+ to: "orchestrator",
350
+ subject: "msg1",
351
+ body: "body1",
352
+ });
353
+ client.send({
354
+ from: "agent-b",
355
+ to: "orchestrator",
356
+ subject: "msg2",
357
+ body: "body2",
358
+ });
359
+
360
+ const messages = client.list({ from: "agent-a" });
361
+ expect(messages).toHaveLength(1);
362
+ expect(messages[0]?.from).toBe("agent-a");
363
+ });
364
+
365
+ test("filters by to", () => {
366
+ client.send({
367
+ from: "agent-a",
368
+ to: "orchestrator",
369
+ subject: "msg1",
370
+ body: "body1",
371
+ });
372
+ client.send({
373
+ from: "agent-a",
374
+ to: "agent-b",
375
+ subject: "msg2",
376
+ body: "body2",
377
+ });
378
+
379
+ const messages = client.list({ to: "agent-b" });
380
+ expect(messages).toHaveLength(1);
381
+ expect(messages[0]?.to).toBe("agent-b");
382
+ });
383
+
384
+ test("filters by unread status", () => {
385
+ client.send({
386
+ from: "agent-a",
387
+ to: "orchestrator",
388
+ subject: "msg1",
389
+ body: "body1",
390
+ });
391
+ const id2 = client.send({
392
+ from: "agent-b",
393
+ to: "orchestrator",
394
+ subject: "msg2",
395
+ body: "body2",
396
+ });
397
+ client.markRead(id2);
398
+
399
+ const unread = client.list({ unread: true });
400
+ expect(unread).toHaveLength(1);
401
+ expect(unread[0]?.subject).toBe("msg1");
402
+ });
403
+ });
404
+
405
+ describe("markRead", () => {
406
+ test("marks a message as read", () => {
407
+ const id = client.send({
408
+ from: "agent-a",
409
+ to: "orchestrator",
410
+ subject: "test",
411
+ body: "body",
412
+ });
413
+
414
+ client.markRead(id);
415
+
416
+ const msg = store.getById(id);
417
+ expect(msg).not.toBeNull();
418
+ expect(msg?.read).toBe(true);
419
+ });
420
+
421
+ test("throws MailError when message does not exist", () => {
422
+ expect(() => client.markRead("nonexistent-id")).toThrow(MailError);
423
+ });
424
+
425
+ test("MailError includes the missing message ID", () => {
426
+ try {
427
+ client.markRead("bad-msg-id");
428
+ expect(true).toBe(false);
429
+ } catch (err) {
430
+ expect(err).toBeInstanceOf(MailError);
431
+ expect((err as MailError).message).toContain("bad-msg-id");
432
+ }
433
+ });
434
+ });
435
+
436
+ describe("reply", () => {
437
+ test("creates a reply addressed to original sender", () => {
438
+ const originalId = client.send({
439
+ from: "agent-a",
440
+ to: "orchestrator",
441
+ subject: "Question about API",
442
+ body: "How do I use the merge endpoint?",
443
+ type: "question",
444
+ priority: "normal",
445
+ });
446
+
447
+ const replyId = client.reply(originalId, "Use POST /merge with branch param", "orchestrator");
448
+
449
+ const replyMsg = store.getById(replyId);
450
+ expect(replyMsg).not.toBeNull();
451
+ expect(replyMsg?.from).toBe("orchestrator");
452
+ expect(replyMsg?.to).toBe("agent-a");
453
+ });
454
+
455
+ test("sets subject to 'Re: {original subject}'", () => {
456
+ const originalId = client.send({
457
+ from: "agent-a",
458
+ to: "orchestrator",
459
+ subject: "Build Status",
460
+ body: "Tests failing",
461
+ });
462
+
463
+ const replyId = client.reply(originalId, "Looking into it", "orchestrator");
464
+
465
+ const replyMsg = store.getById(replyId);
466
+ expect(replyMsg).not.toBeNull();
467
+ expect(replyMsg?.subject).toBe("Re: Build Status");
468
+ });
469
+
470
+ test("uses original message id as threadId when original has no threadId", () => {
471
+ const originalId = client.send({
472
+ from: "agent-a",
473
+ to: "orchestrator",
474
+ subject: "New thread",
475
+ body: "Starting conversation",
476
+ });
477
+
478
+ const replyId = client.reply(originalId, "Reply here", "orchestrator");
479
+
480
+ const replyMsg = store.getById(replyId);
481
+ expect(replyMsg).not.toBeNull();
482
+ expect(replyMsg?.threadId).toBe(originalId);
483
+ });
484
+
485
+ test("preserves threadId from original message when present", () => {
486
+ const originalId = client.send({
487
+ from: "agent-a",
488
+ to: "orchestrator",
489
+ subject: "In-thread message",
490
+ body: "Part of existing thread",
491
+ threadId: "thread-root-123",
492
+ });
493
+
494
+ const replyId = client.reply(originalId, "Continuing thread", "orchestrator");
495
+
496
+ const replyMsg = store.getById(replyId);
497
+ expect(replyMsg).not.toBeNull();
498
+ expect(replyMsg?.threadId).toBe("thread-root-123");
499
+ });
500
+
501
+ test("preserves original message type in reply", () => {
502
+ const originalId = client.send({
503
+ from: "agent-a",
504
+ to: "orchestrator",
505
+ subject: "Error report",
506
+ body: "Something broke",
507
+ type: "error",
508
+ });
509
+
510
+ const replyId = client.reply(originalId, "Fixed", "orchestrator");
511
+
512
+ const replyMsg = store.getById(replyId);
513
+ expect(replyMsg).not.toBeNull();
514
+ expect(replyMsg?.type).toBe("error");
515
+ });
516
+
517
+ test("preserves original priority in reply", () => {
518
+ const originalId = client.send({
519
+ from: "agent-a",
520
+ to: "orchestrator",
521
+ subject: "Urgent",
522
+ body: "Need help",
523
+ priority: "urgent",
524
+ });
525
+
526
+ const replyId = client.reply(originalId, "On it", "orchestrator");
527
+
528
+ const replyMsg = store.getById(replyId);
529
+ expect(replyMsg).not.toBeNull();
530
+ expect(replyMsg?.priority).toBe("urgent");
531
+ });
532
+
533
+ test("returns the reply message ID", () => {
534
+ const originalId = client.send({
535
+ from: "agent-a",
536
+ to: "orchestrator",
537
+ subject: "Test",
538
+ body: "Test body",
539
+ });
540
+
541
+ const replyId = client.reply(originalId, "Reply body", "orchestrator");
542
+ expect(replyId).toMatch(/^msg-[a-z0-9]{12}$/);
543
+ });
544
+
545
+ test("throws MailError when original message not found", () => {
546
+ expect(() => client.reply("nonexistent-id", "reply body", "orchestrator")).toThrow(MailError);
547
+ });
548
+
549
+ test("MailError includes the missing message ID", () => {
550
+ try {
551
+ client.reply("bad-msg-id", "reply body", "orchestrator");
552
+ expect(true).toBe(false);
553
+ } catch (err) {
554
+ expect(err).toBeInstanceOf(MailError);
555
+ expect((err as MailError).message).toContain("bad-msg-id");
556
+ }
557
+ });
558
+
559
+ test("reply to own sent message goes to original recipient, not back to sender", () => {
560
+ // Scenario: orchestrator sends to status-builder, then replies to that same message
561
+ const originalId = client.send({
562
+ from: "orchestrator",
563
+ to: "status-builder",
564
+ subject: "Task assignment",
565
+ body: "Please implement feature X",
566
+ });
567
+
568
+ // Orchestrator replies to their own sent message
569
+ const replyId = client.reply(originalId, "Actually, also do Y", "orchestrator");
570
+
571
+ const replyMsg = store.getById(replyId);
572
+ expect(replyMsg).not.toBeNull();
573
+ expect(replyMsg?.from).toBe("orchestrator");
574
+ // Reply should go to status-builder (original.to), not orchestrator (original.from)
575
+ expect(replyMsg?.to).toBe("status-builder");
576
+ });
577
+
578
+ test("reply from a third party goes to original sender", () => {
579
+ // Scenario: agent-a sends to agent-b, but agent-c replies (edge case)
580
+ const originalId = client.send({
581
+ from: "agent-a",
582
+ to: "agent-b",
583
+ subject: "Question",
584
+ body: "Need info",
585
+ });
586
+
587
+ // agent-c is neither sender nor recipient of original
588
+ const replyId = client.reply(originalId, "I can help", "agent-c");
589
+
590
+ const replyMsg = store.getById(replyId);
591
+ expect(replyMsg).not.toBeNull();
592
+ expect(replyMsg?.from).toBe("agent-c");
593
+ // Third-party reply goes to original sender
594
+ expect(replyMsg?.to).toBe("agent-a");
595
+ });
596
+ });
597
+
598
+ describe("sendProtocol", () => {
599
+ test("sends a worker_done message with serialized payload", () => {
600
+ const payload: WorkerDonePayload = {
601
+ beadId: "beads-abc",
602
+ branch: "agent/builder-1",
603
+ exitCode: 0,
604
+ filesModified: ["src/foo.ts", "src/bar.ts"],
605
+ };
606
+ const id = client.sendProtocol({
607
+ from: "builder-1",
608
+ to: "lead-1",
609
+ subject: "Task complete",
610
+ body: "Implementation finished, all tests pass",
611
+ type: "worker_done",
612
+ payload,
613
+ });
614
+
615
+ const msg = store.getById(id);
616
+ expect(msg).not.toBeNull();
617
+ expect(msg?.type).toBe("worker_done");
618
+ expect(msg?.payload).toBe(JSON.stringify(payload));
619
+ });
620
+
621
+ test("defaults priority to normal", () => {
622
+ const id = client.sendProtocol({
623
+ from: "merger-1",
624
+ to: "lead-1",
625
+ subject: "Merged",
626
+ body: "Branch merged",
627
+ type: "merged",
628
+ payload: { branch: "agent/b1", beadId: "beads-xyz", tier: "clean-merge" as const },
629
+ });
630
+
631
+ const msg = store.getById(id);
632
+ expect(msg?.priority).toBe("normal");
633
+ });
634
+
635
+ test("respects provided priority", () => {
636
+ const id = client.sendProtocol({
637
+ from: "builder-1",
638
+ to: "orchestrator",
639
+ subject: "Escalation",
640
+ body: "Build failing",
641
+ type: "escalation",
642
+ priority: "urgent",
643
+ payload: { severity: "critical" as const, beadId: null, context: "OOM" },
644
+ });
645
+
646
+ const msg = store.getById(id);
647
+ expect(msg?.priority).toBe("urgent");
648
+ });
649
+
650
+ test("preserves threadId", () => {
651
+ const id = client.sendProtocol({
652
+ from: "lead-1",
653
+ to: "builder-1",
654
+ subject: "Assign task",
655
+ body: "Please implement feature X",
656
+ type: "assign",
657
+ threadId: "thread-dispatch-1",
658
+ payload: {
659
+ beadId: "beads-123",
660
+ specPath: ".overstory/specs/beads-123.md",
661
+ workerName: "builder-1",
662
+ branch: "agent/builder-1",
663
+ },
664
+ });
665
+
666
+ const msg = store.getById(id);
667
+ expect(msg?.threadId).toBe("thread-dispatch-1");
668
+ });
669
+ });
670
+
671
+ describe("parsePayload", () => {
672
+ test("parses a valid JSON payload", () => {
673
+ const payload: WorkerDonePayload = {
674
+ beadId: "beads-abc",
675
+ branch: "agent/builder-1",
676
+ exitCode: 0,
677
+ filesModified: ["src/foo.ts"],
678
+ };
679
+ const id = client.sendProtocol({
680
+ from: "builder-1",
681
+ to: "lead-1",
682
+ subject: "Done",
683
+ body: "Done",
684
+ type: "worker_done",
685
+ payload,
686
+ });
687
+
688
+ const msg = store.getById(id);
689
+ if (msg === null) throw new Error("expected message");
690
+ const parsed = parsePayload(msg, "worker_done");
691
+ expect(parsed).toEqual(payload);
692
+ });
693
+
694
+ test("returns null for message with no payload", () => {
695
+ const id = client.send({
696
+ from: "agent-a",
697
+ to: "orchestrator",
698
+ subject: "Status",
699
+ body: "All good",
700
+ });
701
+
702
+ const msg = store.getById(id);
703
+ if (msg === null) throw new Error("expected message");
704
+ const parsed = parsePayload(msg, "worker_done");
705
+ expect(parsed).toBeNull();
706
+ });
707
+
708
+ test("returns null for invalid JSON payload", () => {
709
+ // Manually insert a message with malformed payload via store
710
+ const msg = store.insert({
711
+ id: "msg-bad-json",
712
+ from: "agent-a",
713
+ to: "orchestrator",
714
+ subject: "Bad",
715
+ body: "Bad payload",
716
+ type: "worker_done",
717
+ priority: "normal",
718
+ threadId: null,
719
+ payload: "not valid json{{{",
720
+ });
721
+
722
+ const parsed = parsePayload(msg, "worker_done");
723
+ expect(parsed).toBeNull();
724
+ });
725
+ });
726
+
727
+ describe("checkInject with protocol messages", () => {
728
+ test("includes payload in injection output for protocol messages", () => {
729
+ const payload: WorkerDonePayload = {
730
+ beadId: "beads-abc",
731
+ branch: "agent/builder-1",
732
+ exitCode: 0,
733
+ filesModified: ["src/foo.ts"],
734
+ };
735
+ client.sendProtocol({
736
+ from: "builder-1",
737
+ to: "orchestrator",
738
+ subject: "Task complete",
739
+ body: "Implementation done",
740
+ type: "worker_done",
741
+ payload,
742
+ });
743
+
744
+ const result = client.checkInject("orchestrator");
745
+ expect(result).toContain("worker_done");
746
+ expect(result).toContain("Payload:");
747
+ expect(result).toContain("beads-abc");
748
+ });
749
+
750
+ test("does not include payload line for semantic messages", () => {
751
+ client.send({
752
+ from: "agent-a",
753
+ to: "orchestrator",
754
+ subject: "Status",
755
+ body: "All good",
756
+ });
757
+
758
+ const result = client.checkInject("orchestrator");
759
+ expect(result).not.toContain("Payload:");
760
+ });
761
+ });
762
+
763
+ describe("close", () => {
764
+ test("closes without error", () => {
765
+ // Create a separate client/store to test close independently
766
+ const tempStore = createMailStore(join(tempDir, "mail-close-test.db"));
767
+ const tempClient = createMailClient(tempStore);
768
+
769
+ // Should not throw
770
+ tempClient.close();
771
+ });
772
+ });
773
+ });