@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,657 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { HierarchyError } from "../errors.ts";
3
+ import {
4
+ type BeaconOptions,
5
+ buildBeacon,
6
+ calculateStaggerDelay,
7
+ checkBeadLock,
8
+ checkRunSessionLimit,
9
+ inferDomainsFromFiles,
10
+ isRunningAsRoot,
11
+ parentHasScouts,
12
+ validateHierarchy,
13
+ } from "./sling.ts";
14
+
15
+ /**
16
+ * Tests for the stagger delay enforcement in the sling command (step 4b).
17
+ *
18
+ * The stagger delay logic prevents rapid-fire agent spawning by requiring
19
+ * a minimum delay between consecutive spawns. If the most recently started
20
+ * active session was spawned less than staggerDelayMs ago, the sling command
21
+ * sleeps for the remaining time.
22
+ *
23
+ * calculateStaggerDelay is a pure function that returns the number of
24
+ * milliseconds to sleep (0 if no delay is needed). The sling command calls
25
+ * Bun.sleep with the returned value if it's greater than 0.
26
+ */
27
+
28
+ // --- Helpers ---
29
+
30
+ function makeSession(startedAt: string): { startedAt: string } {
31
+ return { startedAt };
32
+ }
33
+
34
+ describe("calculateStaggerDelay", () => {
35
+ test("returns remaining delay when a recent session exists", () => {
36
+ const now = Date.now();
37
+ // Session started 500ms ago, stagger delay is 2000ms -> should return ~1500ms
38
+ const sessions = [makeSession(new Date(now - 500).toISOString())];
39
+
40
+ const delay = calculateStaggerDelay(2_000, sessions, now);
41
+
42
+ expect(delay).toBe(1_500);
43
+ });
44
+
45
+ test("returns 0 when staggerDelayMs is 0", () => {
46
+ const now = Date.now();
47
+ // Even with a very recent session, delay of 0 means no stagger
48
+ const sessions = [makeSession(new Date(now - 100).toISOString())];
49
+
50
+ const delay = calculateStaggerDelay(0, sessions, now);
51
+
52
+ expect(delay).toBe(0);
53
+ });
54
+
55
+ test("returns 0 when no active sessions exist", () => {
56
+ const now = Date.now();
57
+
58
+ const delay = calculateStaggerDelay(5_000, [], now);
59
+
60
+ expect(delay).toBe(0);
61
+ });
62
+
63
+ test("returns 0 when enough time has already elapsed", () => {
64
+ const now = Date.now();
65
+ // Session started 10 seconds ago, stagger delay is 2 seconds -> no delay
66
+ const sessions = [makeSession(new Date(now - 10_000).toISOString())];
67
+
68
+ const delay = calculateStaggerDelay(2_000, sessions, now);
69
+
70
+ expect(delay).toBe(0);
71
+ });
72
+
73
+ test("returns 0 when elapsed time exactly equals stagger delay", () => {
74
+ const now = Date.now();
75
+ // Session started exactly 2000ms ago, stagger delay is 2000ms -> remaining = 0
76
+ const sessions = [makeSession(new Date(now - 2_000).toISOString())];
77
+
78
+ const delay = calculateStaggerDelay(2_000, sessions, now);
79
+
80
+ expect(delay).toBe(0);
81
+ });
82
+
83
+ test("uses the most recent session for calculation with multiple sessions", () => {
84
+ const now = Date.now();
85
+ // Two sessions: one old (5s ago), one recent (200ms ago)
86
+ // With staggerDelayMs=2000, delay should be based on the 200ms-old session
87
+ const sessions = [
88
+ makeSession(new Date(now - 5_000).toISOString()),
89
+ makeSession(new Date(now - 200).toISOString()),
90
+ ];
91
+
92
+ const delay = calculateStaggerDelay(2_000, sessions, now);
93
+
94
+ expect(delay).toBe(1_800);
95
+ });
96
+
97
+ test("handles sessions in any order (most recent is not last)", () => {
98
+ const now = Date.now();
99
+ // Most recent session is first in the array
100
+ const sessions = [
101
+ makeSession(new Date(now - 300).toISOString()),
102
+ makeSession(new Date(now - 5_000).toISOString()),
103
+ makeSession(new Date(now - 10_000).toISOString()),
104
+ ];
105
+
106
+ const delay = calculateStaggerDelay(2_000, sessions, now);
107
+
108
+ expect(delay).toBe(1_700);
109
+ });
110
+
111
+ test("returns 0 when staggerDelayMs is negative", () => {
112
+ const now = Date.now();
113
+ const sessions = [makeSession(new Date(now - 100).toISOString())];
114
+
115
+ const delay = calculateStaggerDelay(-1_000, sessions, now);
116
+
117
+ expect(delay).toBe(0);
118
+ });
119
+
120
+ test("returns full delay when session was just started (elapsed ~0)", () => {
121
+ const now = Date.now();
122
+ // Session started at exactly now
123
+ const sessions = [makeSession(new Date(now).toISOString())];
124
+
125
+ const delay = calculateStaggerDelay(3_000, sessions, now);
126
+
127
+ expect(delay).toBe(3_000);
128
+ });
129
+
130
+ test("handles a single session correctly", () => {
131
+ const now = Date.now();
132
+ const sessions = [makeSession(new Date(now - 1_000).toISOString())];
133
+
134
+ const delay = calculateStaggerDelay(5_000, sessions, now);
135
+
136
+ expect(delay).toBe(4_000);
137
+ });
138
+
139
+ test("handles large stagger delay values", () => {
140
+ const now = Date.now();
141
+ const sessions = [makeSession(new Date(now - 1_000).toISOString())];
142
+
143
+ const delay = calculateStaggerDelay(60_000, sessions, now);
144
+
145
+ expect(delay).toBe(59_000);
146
+ });
147
+
148
+ test("all sessions old enough means no delay, regardless of count", () => {
149
+ const now = Date.now();
150
+ // Many sessions, but all started well before the stagger window
151
+ const sessions = [
152
+ makeSession(new Date(now - 30_000).toISOString()),
153
+ makeSession(new Date(now - 25_000).toISOString()),
154
+ makeSession(new Date(now - 20_000).toISOString()),
155
+ makeSession(new Date(now - 15_000).toISOString()),
156
+ ];
157
+
158
+ const delay = calculateStaggerDelay(5_000, sessions, now);
159
+
160
+ expect(delay).toBe(0);
161
+ });
162
+ });
163
+
164
+ /**
165
+ * Tests for parentHasScouts check.
166
+ *
167
+ * parentHasScouts is used during sling to detect when a lead agent spawns a
168
+ * builder without having previously spawned any scouts. This provides structural
169
+ * enforcement of the scout-first workflow (Phase 1: explore, Phase 2: build).
170
+ *
171
+ * The function is non-blocking — it only emits a warning to stderr, but does
172
+ * not prevent the spawn. This allows valid edge cases where scout-skip is
173
+ * justified, while surfacing the pattern so agents and operators can see it.
174
+ */
175
+
176
+ function makeAgentSession(
177
+ parentAgent: string | null,
178
+ capability: string,
179
+ ): { parentAgent: string | null; capability: string } {
180
+ return { parentAgent, capability };
181
+ }
182
+
183
+ describe("parentHasScouts", () => {
184
+ test("returns false when sessions is empty", () => {
185
+ expect(parentHasScouts([], "lead-alpha")).toBe(false);
186
+ });
187
+
188
+ test("returns false when parent has only builder children", () => {
189
+ const sessions = [
190
+ makeAgentSession("lead-alpha", "builder"),
191
+ makeAgentSession("lead-alpha", "builder"),
192
+ ];
193
+
194
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
195
+ });
196
+
197
+ test("returns true when parent has a scout child", () => {
198
+ const sessions = [makeAgentSession("lead-alpha", "scout")];
199
+
200
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
201
+ });
202
+
203
+ test("returns true when parent has scout + builder children", () => {
204
+ const sessions = [
205
+ makeAgentSession("lead-alpha", "scout"),
206
+ makeAgentSession("lead-alpha", "builder"),
207
+ ];
208
+
209
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
210
+ });
211
+
212
+ test("ignores scouts from other parents", () => {
213
+ const sessions = [
214
+ makeAgentSession("lead-beta", "scout"),
215
+ makeAgentSession("lead-gamma", "scout"),
216
+ makeAgentSession("lead-alpha", "builder"),
217
+ ];
218
+
219
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
220
+ });
221
+
222
+ test("returns false when parent has only reviewer children", () => {
223
+ const sessions = [
224
+ makeAgentSession("lead-alpha", "reviewer"),
225
+ makeAgentSession("lead-alpha", "reviewer"),
226
+ ];
227
+
228
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
229
+ });
230
+
231
+ test("returns true when parent has multiple scouts", () => {
232
+ const sessions = [
233
+ makeAgentSession("lead-alpha", "scout"),
234
+ makeAgentSession("lead-alpha", "scout"),
235
+ makeAgentSession("lead-alpha", "scout"),
236
+ ];
237
+
238
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
239
+ });
240
+
241
+ test("returns false when sessions contain null parents only", () => {
242
+ const sessions = [makeAgentSession(null, "scout"), makeAgentSession(null, "builder")];
243
+
244
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
245
+ });
246
+
247
+ test("differentiates between parent names (case-sensitive)", () => {
248
+ const sessions = [
249
+ makeAgentSession("lead-alpha", "scout"),
250
+ makeAgentSession("Lead-Alpha", "scout"),
251
+ ];
252
+
253
+ // Should only find the exact match
254
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
255
+ expect(parentHasScouts(sessions, "Lead-Alpha")).toBe(true);
256
+ expect(parentHasScouts(sessions, "lead-beta")).toBe(false);
257
+ });
258
+
259
+ test("works with mixed capability types", () => {
260
+ const sessions = [
261
+ makeAgentSession("lead-alpha", "builder"),
262
+ makeAgentSession("lead-alpha", "reviewer"),
263
+ makeAgentSession("lead-alpha", "merger"),
264
+ ];
265
+
266
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
267
+ });
268
+ });
269
+
270
+ /**
271
+ * Tests for hierarchy validation in sling.
272
+ *
273
+ * validateHierarchy enforces that the coordinator (no --parent flag) can only
274
+ * spawn lead agents. All other capabilities must be spawned by a lead or
275
+ * supervisor that passes --parent. This prevents the flat delegation anti-pattern
276
+ * where the coordinator short-circuits the hierarchy.
277
+ */
278
+
279
+ describe("validateHierarchy", () => {
280
+ test("rejects builder when parentAgent is null", () => {
281
+ expect(() => validateHierarchy(null, "builder", "test-builder", 0, false)).toThrow(
282
+ HierarchyError,
283
+ );
284
+ });
285
+
286
+ test("rejects scout when parentAgent is null", () => {
287
+ expect(() => validateHierarchy(null, "scout", "test-scout", 0, false)).toThrow(HierarchyError);
288
+ });
289
+
290
+ test("rejects reviewer when parentAgent is null", () => {
291
+ expect(() => validateHierarchy(null, "reviewer", "test-reviewer", 0, false)).toThrow(
292
+ HierarchyError,
293
+ );
294
+ });
295
+
296
+ test("rejects merger when parentAgent is null", () => {
297
+ expect(() => validateHierarchy(null, "merger", "test-merger", 0, false)).toThrow(
298
+ HierarchyError,
299
+ );
300
+ });
301
+
302
+ test("allows lead when parentAgent is null", () => {
303
+ expect(() => validateHierarchy(null, "lead", "test-lead", 0, false)).not.toThrow();
304
+ });
305
+
306
+ test("allows builder when parentAgent is provided", () => {
307
+ expect(() =>
308
+ validateHierarchy("lead-alpha", "builder", "test-builder", 1, false),
309
+ ).not.toThrow();
310
+ });
311
+
312
+ test("allows scout when parentAgent is provided", () => {
313
+ expect(() => validateHierarchy("lead-alpha", "scout", "test-scout", 1, false)).not.toThrow();
314
+ });
315
+
316
+ test("allows reviewer when parentAgent is provided", () => {
317
+ expect(() =>
318
+ validateHierarchy("lead-alpha", "reviewer", "test-reviewer", 1, false),
319
+ ).not.toThrow();
320
+ });
321
+
322
+ test("--force-hierarchy bypasses the check for builder", () => {
323
+ expect(() => validateHierarchy(null, "builder", "test-builder", 0, true)).not.toThrow();
324
+ });
325
+
326
+ test("--force-hierarchy bypasses the check for scout", () => {
327
+ expect(() => validateHierarchy(null, "scout", "test-scout", 0, true)).not.toThrow();
328
+ });
329
+
330
+ test("error has correct fields and code", () => {
331
+ try {
332
+ validateHierarchy(null, "builder", "my-builder", 0, false);
333
+ expect.unreachable("should have thrown");
334
+ } catch (err) {
335
+ expect(err).toBeInstanceOf(HierarchyError);
336
+ const he = err as HierarchyError;
337
+ expect(he.code).toBe("HIERARCHY_VIOLATION");
338
+ expect(he.agentName).toBe("my-builder");
339
+ expect(he.requestedCapability).toBe("builder");
340
+ expect(he.message).toContain("builder");
341
+ expect(he.message).toContain("lead");
342
+ }
343
+ });
344
+ });
345
+
346
+ /**
347
+ * Tests for the structured startup beacon sent to agents via tmux send-keys.
348
+ *
349
+ * buildBeacon is a pure function that constructs the first user message an
350
+ * agent sees. It includes identity context (name, capability, task ID),
351
+ * hierarchy info (depth, parent), and startup instructions.
352
+ *
353
+ * The beacon is a single-line string (parts joined by " — ") to prevent
354
+ * multiline tmux send-keys issues (overstory-y2ob, overstory-cczf).
355
+ */
356
+
357
+ function makeBeaconOpts(overrides?: Partial<BeaconOptions>): BeaconOptions {
358
+ return {
359
+ agentName: "test-builder",
360
+ capability: "builder",
361
+ taskId: "overstory-abc",
362
+ parentAgent: null,
363
+ depth: 0,
364
+ ...overrides,
365
+ };
366
+ }
367
+
368
+ describe("buildBeacon", () => {
369
+ test("is a single line (no newlines)", () => {
370
+ const beacon = buildBeacon(makeBeaconOpts());
371
+
372
+ expect(beacon).not.toContain("\n");
373
+ });
374
+
375
+ test("includes agent identity and task ID in header", () => {
376
+ const beacon = buildBeacon(makeBeaconOpts());
377
+
378
+ expect(beacon).toContain("[OVERSTORY] test-builder (builder) ");
379
+ expect(beacon).toContain("task:overstory-abc");
380
+ });
381
+
382
+ test("includes ISO timestamp", () => {
383
+ const beacon = buildBeacon(makeBeaconOpts());
384
+
385
+ expect(beacon).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
386
+ });
387
+
388
+ test("includes depth and parent info", () => {
389
+ const beacon = buildBeacon(makeBeaconOpts({ depth: 1, parentAgent: "lead-alpha" }));
390
+
391
+ expect(beacon).toContain("Depth: 1 | Parent: lead-alpha");
392
+ });
393
+
394
+ test("shows 'none' for parent when no parent agent", () => {
395
+ const beacon = buildBeacon(makeBeaconOpts({ parentAgent: null }));
396
+
397
+ expect(beacon).toContain("Depth: 0 | Parent: none");
398
+ });
399
+
400
+ test("includes startup instructions with agent name and task ID", () => {
401
+ const opts = makeBeaconOpts({ agentName: "scout-1", taskId: "overstory-xyz" });
402
+ const beacon = buildBeacon(opts);
403
+
404
+ expect(beacon).toContain("read .claude/CLAUDE.md");
405
+ expect(beacon).toContain("mulch prime");
406
+ expect(beacon).toContain("overstory mail check --agent scout-1");
407
+ expect(beacon).toContain("begin task overstory-xyz");
408
+ });
409
+
410
+ test("uses agent name in mail check command", () => {
411
+ const beacon = buildBeacon(makeBeaconOpts({ agentName: "reviewer-beta" }));
412
+
413
+ expect(beacon).toContain("overstory mail check --agent reviewer-beta");
414
+ });
415
+
416
+ test("reflects capability in header", () => {
417
+ const beacon = buildBeacon(makeBeaconOpts({ capability: "scout" }));
418
+
419
+ expect(beacon).toContain("(scout)");
420
+ });
421
+
422
+ test("works with hierarchy depth > 0 and parent", () => {
423
+ const beacon = buildBeacon(
424
+ makeBeaconOpts({
425
+ agentName: "worker-3",
426
+ capability: "builder",
427
+ taskId: "overstory-deep",
428
+ parentAgent: "lead-main",
429
+ depth: 2,
430
+ }),
431
+ );
432
+
433
+ expect(beacon).toContain("[OVERSTORY] worker-3 (builder)");
434
+ expect(beacon).toContain("task:overstory-deep");
435
+ expect(beacon).toContain("Depth: 2 | Parent: lead-main");
436
+ });
437
+ });
438
+
439
+ /**
440
+ * Tests for inferDomainsFromFiles.
441
+ *
442
+ * This pure function maps file paths to mulch domains using inferDomain(),
443
+ * deduplicates results, sorts them alphabetically, and falls back to
444
+ * configDomains when no paths produce a domain mapping.
445
+ */
446
+
447
+ describe("inferDomainsFromFiles", () => {
448
+ test("infers cli domain from src/commands/ files", () => {
449
+ const domains = inferDomainsFromFiles(["src/commands/sling.ts"], []);
450
+
451
+ expect(domains).toEqual(["cli"]);
452
+ });
453
+
454
+ test("infers messaging domain from src/mail/ files", () => {
455
+ const domains = inferDomainsFromFiles(["src/mail/store.ts"], []);
456
+
457
+ expect(domains).toEqual(["messaging"]);
458
+ });
459
+
460
+ test("infers typescript domain from general src/ files", () => {
461
+ const domains = inferDomainsFromFiles(["src/config.ts"], []);
462
+
463
+ expect(domains).toEqual(["typescript"]);
464
+ });
465
+
466
+ test("infers cli domain from .test.ts files in src/commands/ (commands check takes priority)", () => {
467
+ const domains = inferDomainsFromFiles(["src/commands/sling.test.ts"], []);
468
+
469
+ // src/commands/ check runs before .test.ts check in inferDomain
470
+ expect(domains).toEqual(["cli"]);
471
+ });
472
+
473
+ test("infers typescript domain from .test.ts files outside recognized directories", () => {
474
+ const domains = inferDomainsFromFiles(["src/config.test.ts"], []);
475
+
476
+ // src/ match triggers typescript (config.test.ts is not in a specific subdirectory)
477
+ expect(domains).toEqual(["typescript"]);
478
+ });
479
+
480
+ test("deduplicates domains across multiple files", () => {
481
+ const files = ["src/commands/sling.ts", "src/commands/init.ts", "src/commands/merge.ts"];
482
+ const domains = inferDomainsFromFiles(files, []);
483
+
484
+ expect(domains).toEqual(["cli"]);
485
+ });
486
+
487
+ test("returns multiple domains sorted alphabetically", () => {
488
+ const files = ["src/commands/sling.ts", "src/mail/store.ts"];
489
+ const domains = inferDomainsFromFiles(files, []);
490
+
491
+ expect(domains).toEqual(["cli", "messaging"]);
492
+ });
493
+
494
+ test("falls back to configDomains when no files match", () => {
495
+ const domains = inferDomainsFromFiles(["docs/README.md"], ["typescript", "cli"]);
496
+
497
+ expect(domains).toEqual(["typescript", "cli"]);
498
+ });
499
+
500
+ test("falls back to configDomains when files list is empty", () => {
501
+ const domains = inferDomainsFromFiles([], ["agents"]);
502
+
503
+ expect(domains).toEqual(["agents"]);
504
+ });
505
+
506
+ test("returns empty array when no files match and configDomains is empty", () => {
507
+ const domains = inferDomainsFromFiles(["docs/README.md"], []);
508
+
509
+ expect(domains).toEqual([]);
510
+ });
511
+
512
+ test("infers agents domain from src/agents/ files", () => {
513
+ const domains = inferDomainsFromFiles(["src/agents/manifest.ts"], []);
514
+
515
+ expect(domains).toEqual(["agents"]);
516
+ });
517
+
518
+ test("infers architecture domain from src/merge/ files", () => {
519
+ const domains = inferDomainsFromFiles(["src/merge/queue.ts"], []);
520
+
521
+ expect(domains).toEqual(["architecture"]);
522
+ });
523
+
524
+ test("infers architecture domain from src/worktree/ files", () => {
525
+ const domains = inferDomainsFromFiles(["src/worktree/manager.ts"], []);
526
+
527
+ expect(domains).toEqual(["architecture"]);
528
+ });
529
+
530
+ test("handles mixed file scopes producing multiple domains", () => {
531
+ const files = ["src/commands/sling.ts", "src/agents/manifest.ts", "src/mail/client.ts"];
532
+ const domains = inferDomainsFromFiles(files, []);
533
+
534
+ expect(domains).toEqual(["agents", "cli", "messaging"]);
535
+ });
536
+ });
537
+
538
+ describe("isRunningAsRoot", () => {
539
+ test("returns true when getuid returns 0", () => {
540
+ expect(isRunningAsRoot(() => 0)).toBe(true);
541
+ });
542
+
543
+ test("returns false when getuid returns non-zero UID", () => {
544
+ expect(isRunningAsRoot(() => 1000)).toBe(false);
545
+ });
546
+
547
+ test("returns false when getuid is undefined (platform without getuid)", () => {
548
+ expect(isRunningAsRoot(undefined)).toBe(false);
549
+ });
550
+ });
551
+
552
+ /**
553
+ * Tests for checkBeadLock.
554
+ *
555
+ * checkBeadLock prevents concurrent agents from working the same bead ID.
556
+ * It checks the active session list and returns the agent name that holds
557
+ * the lock (i.e., is already working on the bead), or null if the bead is free.
558
+ */
559
+
560
+ function makeBeadSession(agentName: string, beadId: string): { agentName: string; beadId: string } {
561
+ return { agentName, beadId };
562
+ }
563
+
564
+ describe("checkBeadLock", () => {
565
+ test("returns null when no sessions exist", () => {
566
+ expect(checkBeadLock([], "overstory-abc")).toBeNull();
567
+ });
568
+
569
+ test("returns null when no session matches the bead ID", () => {
570
+ const sessions = [
571
+ makeBeadSession("builder-1", "overstory-xyz"),
572
+ makeBeadSession("builder-2", "overstory-def"),
573
+ ];
574
+
575
+ expect(checkBeadLock(sessions, "overstory-abc")).toBeNull();
576
+ });
577
+
578
+ test("returns the agent name when a session matches", () => {
579
+ const sessions = [
580
+ makeBeadSession("builder-1", "overstory-abc"),
581
+ makeBeadSession("builder-2", "overstory-xyz"),
582
+ ];
583
+
584
+ expect(checkBeadLock(sessions, "overstory-abc")).toBe("builder-1");
585
+ });
586
+
587
+ test("returns the first matching agent when multiple sessions match", () => {
588
+ // Multiple sessions can have the same beadId (e.g., retried agent)
589
+ // checkBeadLock returns the first match
590
+ const sessions = [
591
+ makeBeadSession("builder-1", "overstory-abc"),
592
+ makeBeadSession("builder-2", "overstory-abc"),
593
+ ];
594
+
595
+ expect(checkBeadLock(sessions, "overstory-abc")).toBe("builder-1");
596
+ });
597
+ });
598
+
599
+ describe("checkBeadLock parent bypass", () => {
600
+ test("parent matching lock holder is allowed (returns lock holder name for caller to compare)", () => {
601
+ // checkBeadLock is a pure function — it returns the lock holder name or null.
602
+ // The parent bypass logic is in slingCommand, not checkBeadLock.
603
+ // These tests verify the building blocks work correctly.
604
+ const sessions = [makeBeadSession("lead-alpha", "overstory-abc")];
605
+ // checkBeadLock still returns the holder — the caller (slingCommand) decides
606
+ // whether to allow based on parentAgent match.
607
+ expect(checkBeadLock(sessions, "overstory-abc")).toBe("lead-alpha");
608
+ });
609
+
610
+ test("non-parent lock holder blocks spawn", () => {
611
+ const sessions = [makeBeadSession("other-agent", "overstory-abc")];
612
+ const lockHolder = checkBeadLock(sessions, "overstory-abc");
613
+ const parentAgent = "lead-alpha";
614
+ // lockHolder is 'other-agent', parentAgent is 'lead-alpha' — not equal, should block
615
+ expect(lockHolder).not.toBeNull();
616
+ expect(lockHolder).not.toBe(parentAgent);
617
+ });
618
+
619
+ test("null parent with lock holder blocks spawn", () => {
620
+ const sessions = [makeBeadSession("lead-alpha", "overstory-abc")];
621
+ const lockHolder = checkBeadLock(sessions, "overstory-abc");
622
+ const parentAgent = null;
623
+ // lockHolder is non-null and parentAgent is null — should block
624
+ expect(lockHolder).not.toBeNull();
625
+ expect(lockHolder).not.toBe(parentAgent);
626
+ });
627
+ });
628
+
629
+ /**
630
+ * Tests for checkRunSessionLimit.
631
+ *
632
+ * checkRunSessionLimit prevents spawning when the per-run agent cap is reached.
633
+ * A limit of 0 (or negative) means unlimited. Returns true if the limit
634
+ * is reached (spawn should be blocked), false otherwise.
635
+ */
636
+
637
+ describe("checkRunSessionLimit", () => {
638
+ test("returns false when limit is 0 (unlimited)", () => {
639
+ expect(checkRunSessionLimit(0, 100)).toBe(false);
640
+ });
641
+
642
+ test("returns false when count is below limit", () => {
643
+ expect(checkRunSessionLimit(10, 5)).toBe(false);
644
+ });
645
+
646
+ test("returns true when count equals limit", () => {
647
+ expect(checkRunSessionLimit(10, 10)).toBe(true);
648
+ });
649
+
650
+ test("returns true when count exceeds limit", () => {
651
+ expect(checkRunSessionLimit(10, 15)).toBe(true);
652
+ });
653
+
654
+ test("returns false when limit is negative (treated as unlimited)", () => {
655
+ expect(checkRunSessionLimit(-1, 100)).toBe(false);
656
+ });
657
+ });