@katyella/legio 0.1.0

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 (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,810 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { generateOverlay } from "../agents/overlay.ts";
3
+ import { AgentError, HierarchyError } from "../errors.ts";
4
+ import type { OverlayConfig } from "../types.ts";
5
+ import {
6
+ type BeaconOptions,
7
+ buildAutoDispatch,
8
+ buildBeacon,
9
+ calculateStaggerDelay,
10
+ checkDuplicateLead,
11
+ checkParentAgentLimit,
12
+ parentHasScouts,
13
+ slingCommand,
14
+ validateHierarchy,
15
+ } from "./sling.ts";
16
+
17
+ /**
18
+ * Tests for the stagger delay enforcement in the sling command (step 4b).
19
+ *
20
+ * The stagger delay logic prevents rapid-fire agent spawning by requiring
21
+ * a minimum delay between consecutive spawns. If the most recently started
22
+ * active session was spawned less than staggerDelayMs ago, the sling command
23
+ * sleeps for the remaining time.
24
+ *
25
+ * calculateStaggerDelay is a pure function that returns the number of
26
+ * milliseconds to sleep (0 if no delay is needed). The sling command calls
27
+ * Bun.sleep with the returned value if it's greater than 0.
28
+ */
29
+
30
+ // --- Helpers ---
31
+
32
+ function makeSession(startedAt: string): { startedAt: string } {
33
+ return { startedAt };
34
+ }
35
+
36
+ describe("calculateStaggerDelay", () => {
37
+ test("returns remaining delay when a recent session exists", () => {
38
+ const now = Date.now();
39
+ // Session started 500ms ago, stagger delay is 2000ms -> should return ~1500ms
40
+ const sessions = [makeSession(new Date(now - 500).toISOString())];
41
+
42
+ const delay = calculateStaggerDelay(2_000, sessions, now);
43
+
44
+ expect(delay).toBe(1_500);
45
+ });
46
+
47
+ test("returns 0 when staggerDelayMs is 0", () => {
48
+ const now = Date.now();
49
+ // Even with a very recent session, delay of 0 means no stagger
50
+ const sessions = [makeSession(new Date(now - 100).toISOString())];
51
+
52
+ const delay = calculateStaggerDelay(0, sessions, now);
53
+
54
+ expect(delay).toBe(0);
55
+ });
56
+
57
+ test("returns 0 when no active sessions exist", () => {
58
+ const now = Date.now();
59
+
60
+ const delay = calculateStaggerDelay(5_000, [], now);
61
+
62
+ expect(delay).toBe(0);
63
+ });
64
+
65
+ test("returns 0 when enough time has already elapsed", () => {
66
+ const now = Date.now();
67
+ // Session started 10 seconds ago, stagger delay is 2 seconds -> no delay
68
+ const sessions = [makeSession(new Date(now - 10_000).toISOString())];
69
+
70
+ const delay = calculateStaggerDelay(2_000, sessions, now);
71
+
72
+ expect(delay).toBe(0);
73
+ });
74
+
75
+ test("returns 0 when elapsed time exactly equals stagger delay", () => {
76
+ const now = Date.now();
77
+ // Session started exactly 2000ms ago, stagger delay is 2000ms -> remaining = 0
78
+ const sessions = [makeSession(new Date(now - 2_000).toISOString())];
79
+
80
+ const delay = calculateStaggerDelay(2_000, sessions, now);
81
+
82
+ expect(delay).toBe(0);
83
+ });
84
+
85
+ test("uses the most recent session for calculation with multiple sessions", () => {
86
+ const now = Date.now();
87
+ // Two sessions: one old (5s ago), one recent (200ms ago)
88
+ // With staggerDelayMs=2000, delay should be based on the 200ms-old session
89
+ const sessions = [
90
+ makeSession(new Date(now - 5_000).toISOString()),
91
+ makeSession(new Date(now - 200).toISOString()),
92
+ ];
93
+
94
+ const delay = calculateStaggerDelay(2_000, sessions, now);
95
+
96
+ expect(delay).toBe(1_800);
97
+ });
98
+
99
+ test("handles sessions in any order (most recent is not last)", () => {
100
+ const now = Date.now();
101
+ // Most recent session is first in the array
102
+ const sessions = [
103
+ makeSession(new Date(now - 300).toISOString()),
104
+ makeSession(new Date(now - 5_000).toISOString()),
105
+ makeSession(new Date(now - 10_000).toISOString()),
106
+ ];
107
+
108
+ const delay = calculateStaggerDelay(2_000, sessions, now);
109
+
110
+ expect(delay).toBe(1_700);
111
+ });
112
+
113
+ test("returns 0 when staggerDelayMs is negative", () => {
114
+ const now = Date.now();
115
+ const sessions = [makeSession(new Date(now - 100).toISOString())];
116
+
117
+ const delay = calculateStaggerDelay(-1_000, sessions, now);
118
+
119
+ expect(delay).toBe(0);
120
+ });
121
+
122
+ test("returns full delay when session was just started (elapsed ~0)", () => {
123
+ const now = Date.now();
124
+ // Session started at exactly now
125
+ const sessions = [makeSession(new Date(now).toISOString())];
126
+
127
+ const delay = calculateStaggerDelay(3_000, sessions, now);
128
+
129
+ expect(delay).toBe(3_000);
130
+ });
131
+
132
+ test("handles a single session correctly", () => {
133
+ const now = Date.now();
134
+ const sessions = [makeSession(new Date(now - 1_000).toISOString())];
135
+
136
+ const delay = calculateStaggerDelay(5_000, sessions, now);
137
+
138
+ expect(delay).toBe(4_000);
139
+ });
140
+
141
+ test("handles large stagger delay values", () => {
142
+ const now = Date.now();
143
+ const sessions = [makeSession(new Date(now - 1_000).toISOString())];
144
+
145
+ const delay = calculateStaggerDelay(60_000, sessions, now);
146
+
147
+ expect(delay).toBe(59_000);
148
+ });
149
+
150
+ test("all sessions old enough means no delay, regardless of count", () => {
151
+ const now = Date.now();
152
+ // Many sessions, but all started well before the stagger window
153
+ const sessions = [
154
+ makeSession(new Date(now - 30_000).toISOString()),
155
+ makeSession(new Date(now - 25_000).toISOString()),
156
+ makeSession(new Date(now - 20_000).toISOString()),
157
+ makeSession(new Date(now - 15_000).toISOString()),
158
+ ];
159
+
160
+ const delay = calculateStaggerDelay(5_000, sessions, now);
161
+
162
+ expect(delay).toBe(0);
163
+ });
164
+ });
165
+
166
+ /**
167
+ * Tests for parentHasScouts check.
168
+ *
169
+ * parentHasScouts is used during sling to detect when a lead agent spawns a
170
+ * builder without having previously spawned any scouts. This provides structural
171
+ * enforcement of the scout-first workflow (Phase 1: explore, Phase 2: build).
172
+ *
173
+ * The function is non-blocking — it only emits a warning to stderr, but does
174
+ * not prevent the spawn. This allows valid edge cases where scout-skip is
175
+ * justified, while surfacing the pattern so agents and operators can see it.
176
+ */
177
+
178
+ function makeAgentSession(
179
+ parentAgent: string | null,
180
+ capability: string,
181
+ ): { parentAgent: string | null; capability: string } {
182
+ return { parentAgent, capability };
183
+ }
184
+
185
+ describe("parentHasScouts", () => {
186
+ test("returns false when sessions is empty", () => {
187
+ expect(parentHasScouts([], "lead-alpha")).toBe(false);
188
+ });
189
+
190
+ test("returns false when parent has only builder children", () => {
191
+ const sessions = [
192
+ makeAgentSession("lead-alpha", "builder"),
193
+ makeAgentSession("lead-alpha", "builder"),
194
+ ];
195
+
196
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
197
+ });
198
+
199
+ test("returns true when parent has a scout child", () => {
200
+ const sessions = [makeAgentSession("lead-alpha", "scout")];
201
+
202
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
203
+ });
204
+
205
+ test("returns true when parent has scout + builder children", () => {
206
+ const sessions = [
207
+ makeAgentSession("lead-alpha", "scout"),
208
+ makeAgentSession("lead-alpha", "builder"),
209
+ ];
210
+
211
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
212
+ });
213
+
214
+ test("ignores scouts from other parents", () => {
215
+ const sessions = [
216
+ makeAgentSession("lead-beta", "scout"),
217
+ makeAgentSession("lead-gamma", "scout"),
218
+ makeAgentSession("lead-alpha", "builder"),
219
+ ];
220
+
221
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
222
+ });
223
+
224
+ test("returns false when parent has only reviewer children", () => {
225
+ const sessions = [
226
+ makeAgentSession("lead-alpha", "reviewer"),
227
+ makeAgentSession("lead-alpha", "reviewer"),
228
+ ];
229
+
230
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
231
+ });
232
+
233
+ test("returns true when parent has multiple scouts", () => {
234
+ const sessions = [
235
+ makeAgentSession("lead-alpha", "scout"),
236
+ makeAgentSession("lead-alpha", "scout"),
237
+ makeAgentSession("lead-alpha", "scout"),
238
+ ];
239
+
240
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
241
+ });
242
+
243
+ test("returns false when sessions contain null parents only", () => {
244
+ const sessions = [makeAgentSession(null, "scout"), makeAgentSession(null, "builder")];
245
+
246
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
247
+ });
248
+
249
+ test("differentiates between parent names (case-sensitive)", () => {
250
+ const sessions = [
251
+ makeAgentSession("lead-alpha", "scout"),
252
+ makeAgentSession("Lead-Alpha", "scout"),
253
+ ];
254
+
255
+ // Should only find the exact match
256
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(true);
257
+ expect(parentHasScouts(sessions, "Lead-Alpha")).toBe(true);
258
+ expect(parentHasScouts(sessions, "lead-beta")).toBe(false);
259
+ });
260
+
261
+ test("works with mixed capability types", () => {
262
+ const sessions = [
263
+ makeAgentSession("lead-alpha", "builder"),
264
+ makeAgentSession("lead-alpha", "reviewer"),
265
+ makeAgentSession("lead-alpha", "merger"),
266
+ ];
267
+
268
+ expect(parentHasScouts(sessions, "lead-alpha")).toBe(false);
269
+ });
270
+ });
271
+
272
+ /**
273
+ * Tests for hierarchy validation in sling.
274
+ *
275
+ * validateHierarchy enforces that the coordinator (no --parent flag) can only
276
+ * spawn lead and scout agents. All other capabilities must be spawned by a lead
277
+ * or supervisor that passes --parent. This prevents the flat delegation
278
+ * anti-pattern where the coordinator short-circuits the hierarchy.
279
+ */
280
+
281
+ describe("validateHierarchy", () => {
282
+ test("rejects builder when parentAgent is null", () => {
283
+ expect(() => validateHierarchy(null, "builder", "test-builder", 0, false)).toThrow(
284
+ HierarchyError,
285
+ );
286
+ });
287
+
288
+ test("allows scout when parentAgent is null", () => {
289
+ expect(() => validateHierarchy(null, "scout", "test-scout", 0, false)).not.toThrow();
290
+ });
291
+
292
+ test("rejects reviewer when parentAgent is null", () => {
293
+ expect(() => validateHierarchy(null, "reviewer", "test-reviewer", 0, false)).toThrow(
294
+ HierarchyError,
295
+ );
296
+ });
297
+
298
+ test("rejects merger when parentAgent is null", () => {
299
+ expect(() => validateHierarchy(null, "merger", "test-merger", 0, false)).toThrow(
300
+ HierarchyError,
301
+ );
302
+ });
303
+
304
+ test("allows lead when parentAgent is null", () => {
305
+ expect(() => validateHierarchy(null, "lead", "test-lead", 0, false)).not.toThrow();
306
+ });
307
+
308
+ test("allows builder when parentAgent is provided", () => {
309
+ expect(() =>
310
+ validateHierarchy("lead-alpha", "builder", "test-builder", 1, false),
311
+ ).not.toThrow();
312
+ });
313
+
314
+ test("allows scout when parentAgent is provided", () => {
315
+ expect(() => validateHierarchy("lead-alpha", "scout", "test-scout", 1, false)).not.toThrow();
316
+ });
317
+
318
+ test("allows reviewer when parentAgent is provided", () => {
319
+ expect(() =>
320
+ validateHierarchy("lead-alpha", "reviewer", "test-reviewer", 1, false),
321
+ ).not.toThrow();
322
+ });
323
+
324
+ test("--force-hierarchy bypasses the check for builder", () => {
325
+ expect(() => validateHierarchy(null, "builder", "test-builder", 0, true)).not.toThrow();
326
+ });
327
+
328
+ test("--force-hierarchy bypasses the check for scout", () => {
329
+ expect(() => validateHierarchy(null, "scout", "test-scout", 0, true)).not.toThrow();
330
+ });
331
+
332
+ test("error has correct fields and code", () => {
333
+ try {
334
+ validateHierarchy(null, "builder", "my-builder", 0, false);
335
+ expect.unreachable("should have thrown");
336
+ } catch (err) {
337
+ expect(err).toBeInstanceOf(HierarchyError);
338
+ const he = err as HierarchyError;
339
+ expect(he.code).toBe("HIERARCHY_VIOLATION");
340
+ expect(he.agentName).toBe("my-builder");
341
+ expect(he.requestedCapability).toBe("builder");
342
+ expect(he.message).toContain("builder");
343
+ expect(he.message).toContain("lead");
344
+ }
345
+ });
346
+ });
347
+
348
+ /**
349
+ * Tests for the structured startup beacon sent to agents via tmux send-keys.
350
+ *
351
+ * buildBeacon is a pure function that constructs the first user message an
352
+ * agent sees. It includes identity context (name, capability, task ID),
353
+ * hierarchy info (depth, parent), and startup instructions.
354
+ *
355
+ * The beacon is a single-line string (parts joined by " — ") to prevent
356
+ * multiline tmux send-keys issues (legio-y2ob, legio-cczf).
357
+ */
358
+
359
+ function makeBeaconOpts(overrides?: Partial<BeaconOptions>): BeaconOptions {
360
+ return {
361
+ agentName: "test-builder",
362
+ capability: "builder",
363
+ taskId: "legio-abc",
364
+ parentAgent: null,
365
+ depth: 0,
366
+ ...overrides,
367
+ };
368
+ }
369
+
370
+ describe("buildBeacon", () => {
371
+ test("is a single line (no newlines)", () => {
372
+ const beacon = buildBeacon(makeBeaconOpts());
373
+
374
+ expect(beacon).not.toContain("\n");
375
+ });
376
+
377
+ test("includes agent identity and task ID in header", () => {
378
+ const beacon = buildBeacon(makeBeaconOpts());
379
+
380
+ expect(beacon).toContain("[LEGIO] test-builder (builder) ");
381
+ expect(beacon).toContain("task:legio-abc");
382
+ });
383
+
384
+ test("includes ISO timestamp", () => {
385
+ const beacon = buildBeacon(makeBeaconOpts());
386
+
387
+ expect(beacon).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
388
+ });
389
+
390
+ test("includes depth and parent info", () => {
391
+ const beacon = buildBeacon(makeBeaconOpts({ depth: 1, parentAgent: "lead-alpha" }));
392
+
393
+ expect(beacon).toContain("Depth: 1 | Parent: lead-alpha");
394
+ });
395
+
396
+ test("shows 'none' for parent when no parent agent", () => {
397
+ const beacon = buildBeacon(makeBeaconOpts({ parentAgent: null }));
398
+
399
+ expect(beacon).toContain("Depth: 0 | Parent: none");
400
+ });
401
+
402
+ test("includes startup instructions with agent name and task ID", () => {
403
+ const opts = makeBeaconOpts({ agentName: "scout-1", taskId: "legio-xyz" });
404
+ const beacon = buildBeacon(opts);
405
+
406
+ expect(beacon).toContain("read .claude/CLAUDE.md");
407
+ expect(beacon).toContain("mulch prime");
408
+ expect(beacon).toContain("legio mail check --agent scout-1");
409
+ expect(beacon).toContain("begin task legio-xyz");
410
+ });
411
+
412
+ test("uses agent name in mail check command", () => {
413
+ const beacon = buildBeacon(makeBeaconOpts({ agentName: "reviewer-beta" }));
414
+
415
+ expect(beacon).toContain("legio mail check --agent reviewer-beta");
416
+ });
417
+
418
+ test("reflects capability in header", () => {
419
+ const beacon = buildBeacon(makeBeaconOpts({ capability: "scout" }));
420
+
421
+ expect(beacon).toContain("(scout)");
422
+ });
423
+
424
+ test("works with hierarchy depth > 0 and parent", () => {
425
+ const beacon = buildBeacon(
426
+ makeBeaconOpts({
427
+ agentName: "worker-3",
428
+ capability: "builder",
429
+ taskId: "legio-deep",
430
+ parentAgent: "lead-main",
431
+ depth: 2,
432
+ }),
433
+ );
434
+
435
+ expect(beacon).toContain("[LEGIO] worker-3 (builder)");
436
+ expect(beacon).toContain("task:legio-deep");
437
+ expect(beacon).toContain("Depth: 2 | Parent: lead-main");
438
+ });
439
+ });
440
+
441
+ /**
442
+ * Tests for the auto-dispatch mail message builder.
443
+ *
444
+ * buildAutoDispatch is a pure function that produces the dispatch mail written
445
+ * to mail.db before tmux session creation. This guarantees the assignment mail
446
+ * exists when the agent's SessionStart hook fires `legio mail check`.
447
+ */
448
+
449
+ describe("buildAutoDispatch", () => {
450
+ test("defaults from to 'orchestrator' when parentAgent is null", () => {
451
+ const msg = buildAutoDispatch({
452
+ parentAgent: null,
453
+ agentName: "test-builder",
454
+ taskId: "legio-abc",
455
+ specPath: null,
456
+ branchName: "legio/test-builder/legio-abc",
457
+ });
458
+
459
+ expect(msg.from).toBe("orchestrator");
460
+ });
461
+
462
+ test("uses parent name as from when parentAgent is provided", () => {
463
+ const msg = buildAutoDispatch({
464
+ parentAgent: "lead-alpha",
465
+ agentName: "test-builder",
466
+ taskId: "legio-abc",
467
+ specPath: null,
468
+ branchName: "legio/test-builder/legio-abc",
469
+ });
470
+
471
+ expect(msg.from).toBe("lead-alpha");
472
+ });
473
+
474
+ test("to matches agentName", () => {
475
+ const msg = buildAutoDispatch({
476
+ parentAgent: null,
477
+ agentName: "my-special-builder",
478
+ taskId: "legio-xyz",
479
+ specPath: null,
480
+ branchName: "legio/my-special-builder/legio-xyz",
481
+ });
482
+
483
+ expect(msg.to).toBe("my-special-builder");
484
+ });
485
+
486
+ test("type is 'dispatch'", () => {
487
+ const msg = buildAutoDispatch({
488
+ parentAgent: null,
489
+ agentName: "test-builder",
490
+ taskId: "legio-abc",
491
+ specPath: null,
492
+ branchName: "legio/test-builder/legio-abc",
493
+ });
494
+
495
+ expect(msg.type).toBe("dispatch");
496
+ });
497
+
498
+ test("subject includes the task ID", () => {
499
+ const msg = buildAutoDispatch({
500
+ parentAgent: null,
501
+ agentName: "test-builder",
502
+ taskId: "legio-task-99",
503
+ specPath: null,
504
+ branchName: "legio/test-builder/legio-task-99",
505
+ });
506
+
507
+ expect(msg.subject).toContain("legio-task-99");
508
+ });
509
+
510
+ test("body includes spec path when provided", () => {
511
+ const msg = buildAutoDispatch({
512
+ parentAgent: null,
513
+ agentName: "test-builder",
514
+ taskId: "legio-abc",
515
+ specPath: "/some/path/to/spec.md",
516
+ branchName: "legio/test-builder/legio-abc",
517
+ });
518
+
519
+ expect(msg.body).toContain("/some/path/to/spec.md");
520
+ });
521
+
522
+ test("body includes 'none' when spec path is null", () => {
523
+ const msg = buildAutoDispatch({
524
+ parentAgent: null,
525
+ agentName: "test-builder",
526
+ taskId: "legio-abc",
527
+ specPath: null,
528
+ branchName: "legio/test-builder/legio-abc",
529
+ });
530
+
531
+ expect(msg.body).toContain("none");
532
+ });
533
+ });
534
+
535
+ /**
536
+ * Tests for checkParentAgentLimit guard.
537
+ *
538
+ * Enforces per-lead agent budget: a parent may not have more than maxAgentsPerLead
539
+ * active (non-zombie, non-completed) children at once.
540
+ */
541
+
542
+ function makeChildSession(
543
+ parentAgent: string | null,
544
+ state: string,
545
+ ): { parentAgent: string | null; state: string } {
546
+ return { parentAgent, state };
547
+ }
548
+
549
+ describe("checkParentAgentLimit", () => {
550
+ test("allows spawn when parent has no active children", () => {
551
+ expect(() => checkParentAgentLimit([], "lead-alpha", 5, "builder-1")).not.toThrow();
552
+ });
553
+
554
+ test("allows spawn when parent is under the limit", () => {
555
+ const sessions = [
556
+ makeChildSession("lead-alpha", "working"),
557
+ makeChildSession("lead-alpha", "booting"),
558
+ makeChildSession("lead-alpha", "working"),
559
+ ];
560
+ expect(() => checkParentAgentLimit(sessions, "lead-alpha", 5, "builder-4")).not.toThrow();
561
+ });
562
+
563
+ test("throws AgentError when parent has exactly maxAgentsPerLead active children", () => {
564
+ const sessions = [
565
+ makeChildSession("lead-alpha", "working"),
566
+ makeChildSession("lead-alpha", "working"),
567
+ makeChildSession("lead-alpha", "working"),
568
+ makeChildSession("lead-alpha", "working"),
569
+ makeChildSession("lead-alpha", "working"),
570
+ ];
571
+ expect(() => checkParentAgentLimit(sessions, "lead-alpha", 5, "builder-6")).toThrow(AgentError);
572
+ });
573
+
574
+ test("throws AgentError when parent exceeds limit", () => {
575
+ const sessions = [
576
+ makeChildSession("lead-alpha", "working"),
577
+ makeChildSession("lead-alpha", "working"),
578
+ makeChildSession("lead-alpha", "working"),
579
+ ];
580
+ expect(() => checkParentAgentLimit(sessions, "lead-alpha", 2, "builder-4")).toThrow(AgentError);
581
+ });
582
+
583
+ test("ignores zombie children when counting", () => {
584
+ const sessions = [
585
+ makeChildSession("lead-alpha", "zombie"),
586
+ makeChildSession("lead-alpha", "zombie"),
587
+ makeChildSession("lead-alpha", "zombie"),
588
+ makeChildSession("lead-alpha", "zombie"),
589
+ makeChildSession("lead-alpha", "zombie"),
590
+ ];
591
+ // 5 zombies should not count toward the limit
592
+ expect(() => checkParentAgentLimit(sessions, "lead-alpha", 5, "builder-1")).not.toThrow();
593
+ });
594
+
595
+ test("ignores completed children when counting", () => {
596
+ const sessions = [
597
+ makeChildSession("lead-alpha", "completed"),
598
+ makeChildSession("lead-alpha", "completed"),
599
+ makeChildSession("lead-alpha", "completed"),
600
+ makeChildSession("lead-alpha", "completed"),
601
+ makeChildSession("lead-alpha", "completed"),
602
+ ];
603
+ // 5 completed should not count toward the limit
604
+ expect(() => checkParentAgentLimit(sessions, "lead-alpha", 5, "builder-1")).not.toThrow();
605
+ });
606
+
607
+ test("only counts children of the specified parent", () => {
608
+ const sessions = [
609
+ makeChildSession("lead-beta", "working"),
610
+ makeChildSession("lead-beta", "working"),
611
+ makeChildSession("lead-beta", "working"),
612
+ makeChildSession("lead-beta", "working"),
613
+ makeChildSession("lead-beta", "working"),
614
+ ];
615
+ // lead-alpha has 0 children despite lead-beta being at limit
616
+ expect(() => checkParentAgentLimit(sessions, "lead-alpha", 5, "builder-1")).not.toThrow();
617
+ });
618
+
619
+ test("error message includes parent name, counts, and agent name", () => {
620
+ const sessions = [
621
+ makeChildSession("lead-alpha", "working"),
622
+ makeChildSession("lead-alpha", "working"),
623
+ ];
624
+ try {
625
+ checkParentAgentLimit(sessions, "lead-alpha", 2, "my-builder");
626
+ expect.unreachable("should have thrown");
627
+ } catch (err) {
628
+ expect(err).toBeInstanceOf(AgentError);
629
+ const ae = err as AgentError;
630
+ expect(ae.message).toContain("lead-alpha");
631
+ expect(ae.message).toContain("2/2");
632
+ }
633
+ });
634
+
635
+ test("skipped when parentAgent is null (calling pattern)", () => {
636
+ // The calling code only invokes checkParentAgentLimit when parentAgent is not null.
637
+ // This test confirms the guard is never called with null by simulating how
638
+ // slingCommand wraps the call.
639
+ const sessions = [makeChildSession(null, "working")];
640
+ const parentAgent: string | null = null;
641
+ // Should not throw because the guard is not called when parentAgent is null
642
+ const wouldCall = parentAgent !== null;
643
+ expect(wouldCall).toBe(false);
644
+ // Extra: verify the function handles arbitrary states correctly
645
+ expect(() => checkParentAgentLimit(sessions, "lead-alpha", 5, "builder-1")).not.toThrow();
646
+ });
647
+ });
648
+
649
+ /**
650
+ * Tests for checkDuplicateLead guard.
651
+ *
652
+ * Prevents two lead agents from concurrently working the same task ID.
653
+ * Non-lead capabilities are not affected by this guard.
654
+ */
655
+
656
+ function makeLeadSession(
657
+ beadId: string,
658
+ capability: string,
659
+ state: string,
660
+ agentName: string,
661
+ ): { beadId: string; capability: string; state: string; agentName: string } {
662
+ return { beadId, capability, state, agentName };
663
+ }
664
+
665
+ describe("checkDuplicateLead", () => {
666
+ test("allows spawn when no existing lead for task", () => {
667
+ expect(() => checkDuplicateLead([], "legio-abc", "lead", "lead-2")).not.toThrow();
668
+ });
669
+
670
+ test("allows spawn when existing sessions are for different tasks", () => {
671
+ const sessions = [makeLeadSession("legio-xyz", "lead", "working", "lead-1")];
672
+ expect(() => checkDuplicateLead(sessions, "legio-abc", "lead", "lead-2")).not.toThrow();
673
+ });
674
+
675
+ test("throws AgentError when lead already active for same task", () => {
676
+ const sessions = [makeLeadSession("legio-abc", "lead", "working", "lead-1")];
677
+ expect(() => checkDuplicateLead(sessions, "legio-abc", "lead", "lead-2")).toThrow(AgentError);
678
+ });
679
+
680
+ test("throws AgentError when existing lead is in booting state", () => {
681
+ const sessions = [makeLeadSession("legio-abc", "lead", "booting", "lead-1")];
682
+ expect(() => checkDuplicateLead(sessions, "legio-abc", "lead", "lead-2")).toThrow(AgentError);
683
+ });
684
+
685
+ test("allows non-lead (builder) even when lead exists for same task", () => {
686
+ const sessions = [makeLeadSession("legio-abc", "lead", "working", "lead-1")];
687
+ // builder capability should pass through without throwing
688
+ expect(() => checkDuplicateLead(sessions, "legio-abc", "builder", "builder-1")).not.toThrow();
689
+ });
690
+
691
+ test("allows non-lead (scout) even when lead exists for same task", () => {
692
+ const sessions = [makeLeadSession("legio-abc", "lead", "working", "lead-1")];
693
+ expect(() => checkDuplicateLead(sessions, "legio-abc", "scout", "scout-1")).not.toThrow();
694
+ });
695
+
696
+ test("ignores zombie leads when checking duplicates", () => {
697
+ const sessions = [makeLeadSession("legio-abc", "lead", "zombie", "lead-1")];
698
+ expect(() => checkDuplicateLead(sessions, "legio-abc", "lead", "lead-2")).not.toThrow();
699
+ });
700
+
701
+ test("ignores completed leads when checking duplicates", () => {
702
+ const sessions = [makeLeadSession("legio-abc", "lead", "completed", "lead-1")];
703
+ expect(() => checkDuplicateLead(sessions, "legio-abc", "lead", "lead-2")).not.toThrow();
704
+ });
705
+
706
+ test("different task IDs do not conflict", () => {
707
+ const sessions = [
708
+ makeLeadSession("legio-aaa", "lead", "working", "lead-1"),
709
+ makeLeadSession("legio-bbb", "lead", "working", "lead-2"),
710
+ makeLeadSession("legio-ccc", "lead", "working", "lead-3"),
711
+ ];
712
+ expect(() => checkDuplicateLead(sessions, "legio-ddd", "lead", "lead-4")).not.toThrow();
713
+ });
714
+
715
+ test("error message includes existing and new agent names", () => {
716
+ const sessions = [makeLeadSession("legio-abc", "lead", "working", "lead-original")];
717
+ try {
718
+ checkDuplicateLead(sessions, "legio-abc", "lead", "lead-duplicate");
719
+ expect.unreachable("should have thrown");
720
+ } catch (err) {
721
+ expect(err).toBeInstanceOf(AgentError);
722
+ const ae = err as AgentError;
723
+ expect(ae.message).toContain("lead-original");
724
+ expect(ae.message).toContain("lead-duplicate");
725
+ expect(ae.message).toContain("legio-abc");
726
+ }
727
+ });
728
+ });
729
+
730
+ /**
731
+ * Root-user guard: slingCommand must reject execution when running as root.
732
+ */
733
+ describe("slingCommand root guard", () => {
734
+ test("throws ValidationError with uid field when process.getuid returns 0", async () => {
735
+ const original = process.getuid;
736
+ // Simulate root
737
+ process.getuid = () => 0;
738
+ try {
739
+ await expect(slingCommand(["legio-test", "--name", "x"])).rejects.toMatchObject({
740
+ name: "ValidationError",
741
+ field: "uid",
742
+ });
743
+ } finally {
744
+ process.getuid = original;
745
+ }
746
+ });
747
+ });
748
+
749
+ /**
750
+ * Tests for --skip-review flag in sling and overlay generation.
751
+ *
752
+ * --skip-review is parsed in slingCommand and passed through to OverlayConfig.
753
+ * When set, generateOverlay inserts a "## Dispatch Overrides" section before
754
+ * "## Expertise" instructing lead agents to skip reviewer spawning.
755
+ */
756
+
757
+ function makeOverlayConfig(overrides?: Partial<OverlayConfig>): OverlayConfig {
758
+ return {
759
+ agentName: "test-lead",
760
+ beadId: "legio-test",
761
+ specPath: null,
762
+ branchName: "legio/test-lead/legio-test",
763
+ worktreePath: "/tmp/test-worktree",
764
+ fileScope: [],
765
+ mulchDomains: [],
766
+ parentAgent: null,
767
+ depth: 0,
768
+ canSpawn: true,
769
+ capability: "lead",
770
+ baseDefinition: "# Lead Agent\n\nYou are a lead agent.",
771
+ ...overrides,
772
+ };
773
+ }
774
+
775
+ describe("generateOverlay with --skip-review", () => {
776
+ test("includes Dispatch Overrides section when skipReview is true", async () => {
777
+ const config = makeOverlayConfig({ skipReview: true });
778
+ const result = await generateOverlay(config);
779
+
780
+ expect(result).toContain("## Dispatch Overrides");
781
+ expect(result).toContain("Skip Review");
782
+ expect(result).toContain("Do NOT spawn a reviewer agent");
783
+ });
784
+
785
+ test("does NOT include Dispatch Overrides when skipReview is false", async () => {
786
+ const config = makeOverlayConfig({ skipReview: false });
787
+ const result = await generateOverlay(config);
788
+
789
+ expect(result).not.toContain("## Dispatch Overrides");
790
+ });
791
+
792
+ test("does NOT include Dispatch Overrides when skipReview is absent", async () => {
793
+ const config = makeOverlayConfig();
794
+ const result = await generateOverlay(config);
795
+
796
+ expect(result).not.toContain("## Dispatch Overrides");
797
+ });
798
+
799
+ test("Dispatch Overrides section appears before Expertise section", async () => {
800
+ const config = makeOverlayConfig({ skipReview: true });
801
+ const result = await generateOverlay(config);
802
+
803
+ const overridesIdx = result.indexOf("## Dispatch Overrides");
804
+ const expertiseIdx = result.indexOf("## Expertise");
805
+
806
+ expect(overridesIdx).toBeGreaterThan(-1);
807
+ expect(expertiseIdx).toBeGreaterThan(-1);
808
+ expect(overridesIdx).toBeLessThan(expertiseIdx);
809
+ });
810
+ });