@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,592 @@
1
+ import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
5
+ import { AgentError } from "../errors.ts";
6
+ import type { OverlayConfig } from "../types.ts";
7
+ import { generateOverlay, isCanonicalRoot, writeOverlay } from "./overlay.ts";
8
+
9
+ const SAMPLE_BASE_DEFINITION = `# Builder Agent
10
+
11
+ You are a **builder agent** in the legio swarm system.
12
+
13
+ ## Role
14
+ Implement changes according to a spec.
15
+
16
+ ## Propulsion Principle
17
+ Read your assignment. Execute immediately.
18
+
19
+ ## Failure Modes
20
+ - FILE_SCOPE_VIOLATION
21
+ - SILENT_FAILURE
22
+ `;
23
+
24
+ /** Build a complete OverlayConfig with sensible defaults, overrideable by partial. */
25
+ function makeConfig(overrides?: Partial<OverlayConfig>): OverlayConfig {
26
+ return {
27
+ agentName: "test-builder",
28
+ beadId: "legio-abc",
29
+ specPath: ".legio/specs/legio-abc.md",
30
+ branchName: "agent/test-builder/legio-abc",
31
+ worktreePath: "/tmp/test-project/.legio/worktrees/test-builder",
32
+ fileScope: ["src/agents/manifest.ts", "src/agents/overlay.ts"],
33
+ mulchDomains: ["typescript", "testing"],
34
+ parentAgent: "lead-alpha",
35
+ depth: 1,
36
+ canSpawn: false,
37
+ capability: "builder",
38
+ baseDefinition: SAMPLE_BASE_DEFINITION,
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ describe("generateOverlay", () => {
44
+ test("output contains agent name", async () => {
45
+ const config = makeConfig({ agentName: "my-scout" });
46
+ const output = await generateOverlay(config);
47
+
48
+ expect(output).toContain("my-scout");
49
+ });
50
+
51
+ test("output contains bead ID", async () => {
52
+ const config = makeConfig({ beadId: "legio-xyz" });
53
+ const output = await generateOverlay(config);
54
+
55
+ expect(output).toContain("legio-xyz");
56
+ });
57
+
58
+ test("output contains branch name", async () => {
59
+ const config = makeConfig({ branchName: "agent/scout/legio-xyz" });
60
+ const output = await generateOverlay(config);
61
+
62
+ expect(output).toContain("agent/scout/legio-xyz");
63
+ });
64
+
65
+ test("output contains parent agent name", async () => {
66
+ const config = makeConfig({ parentAgent: "lead-bravo" });
67
+ const output = await generateOverlay(config);
68
+
69
+ expect(output).toContain("lead-bravo");
70
+ });
71
+
72
+ test("output contains depth", async () => {
73
+ const config = makeConfig({ depth: 2 });
74
+ const output = await generateOverlay(config);
75
+
76
+ expect(output).toContain("2");
77
+ });
78
+
79
+ test("output contains spec path when provided", async () => {
80
+ const config = makeConfig({ specPath: ".legio/specs/my-task.md" });
81
+ const output = await generateOverlay(config);
82
+
83
+ expect(output).toContain(".legio/specs/my-task.md");
84
+ });
85
+
86
+ test("shows fallback text when specPath is null", async () => {
87
+ const config = makeConfig({ specPath: null });
88
+ const output = await generateOverlay(config);
89
+
90
+ expect(output).toContain("No spec file provided");
91
+ expect(output).not.toContain("{{SPEC_PATH}}");
92
+ });
93
+
94
+ test("includes 'Read your task spec' instruction when spec provided", async () => {
95
+ const config = makeConfig({ specPath: ".legio/specs/my-task.md" });
96
+ const output = await generateOverlay(config);
97
+
98
+ expect(output).toContain("Read your task spec at the path above");
99
+ });
100
+
101
+ test("does not include 'Read your task spec' instruction when specPath is null", async () => {
102
+ const config = makeConfig({ specPath: null });
103
+ const output = await generateOverlay(config);
104
+
105
+ expect(output).not.toContain("Read your task spec at the path above");
106
+ expect(output).toContain("No task spec was provided");
107
+ });
108
+
109
+ test("shows 'orchestrator' when parentAgent is null", async () => {
110
+ const config = makeConfig({ parentAgent: null });
111
+ const output = await generateOverlay(config);
112
+
113
+ expect(output).toContain("orchestrator");
114
+ });
115
+
116
+ test("file scope is formatted as markdown bullets", async () => {
117
+ const config = makeConfig({
118
+ fileScope: ["src/foo.ts", "src/bar.ts"],
119
+ });
120
+ const output = await generateOverlay(config);
121
+
122
+ expect(output).toContain("- `src/foo.ts`");
123
+ expect(output).toContain("- `src/bar.ts`");
124
+ });
125
+
126
+ test("empty file scope shows fallback text", async () => {
127
+ const config = makeConfig({ fileScope: [] });
128
+ const output = await generateOverlay(config);
129
+
130
+ expect(output).toContain("No file scope restrictions");
131
+ });
132
+
133
+ test("mulch domains formatted as prime command", async () => {
134
+ const config = makeConfig({ mulchDomains: ["typescript", "testing"] });
135
+ const output = await generateOverlay(config);
136
+
137
+ expect(output).toContain("mulch prime typescript testing");
138
+ });
139
+
140
+ test("empty mulch domains shows fallback text", async () => {
141
+ const config = makeConfig({ mulchDomains: [] });
142
+ const output = await generateOverlay(config);
143
+
144
+ expect(output).toContain("No specific expertise domains configured");
145
+ });
146
+
147
+ test("canSpawn false says 'You may NOT spawn sub-workers'", async () => {
148
+ const config = makeConfig({ canSpawn: false });
149
+ const output = await generateOverlay(config);
150
+
151
+ expect(output).toContain("You may NOT spawn sub-workers");
152
+ });
153
+
154
+ test("canSpawn true includes sling example", async () => {
155
+ const config = makeConfig({
156
+ canSpawn: true,
157
+ agentName: "lead-alpha",
158
+ depth: 1,
159
+ });
160
+ const output = await generateOverlay(config);
161
+
162
+ expect(output).toContain("legio sling");
163
+ expect(output).toContain("--parent lead-alpha");
164
+ expect(output).toContain("--depth 2");
165
+ });
166
+
167
+ test("no unreplaced placeholders remain in output", async () => {
168
+ const config = makeConfig();
169
+ const output = await generateOverlay(config);
170
+
171
+ expect(output).not.toContain("{{");
172
+ expect(output).not.toContain("}}");
173
+ });
174
+
175
+ test("includes pre-loaded expertise when mulchExpertise is provided", async () => {
176
+ const config = makeConfig({
177
+ mulchExpertise: "## architecture\n- Pattern: use singleton for config loader",
178
+ });
179
+ const output = await generateOverlay(config);
180
+
181
+ expect(output).toContain("### Pre-loaded Expertise");
182
+ expect(output).toContain("automatically loaded at spawn time");
183
+ expect(output).toContain("## architecture");
184
+ expect(output).toContain("Pattern: use singleton for config loader");
185
+ });
186
+
187
+ test("omits expertise section when mulchExpertise is undefined", async () => {
188
+ const config = makeConfig({ mulchExpertise: undefined });
189
+ const output = await generateOverlay(config);
190
+
191
+ expect(output).not.toContain("### Pre-loaded Expertise");
192
+ expect(output).not.toContain("automatically loaded at spawn time");
193
+ });
194
+
195
+ test("omits expertise section when mulchExpertise is empty string", async () => {
196
+ const config = makeConfig({ mulchExpertise: "" });
197
+ const output = await generateOverlay(config);
198
+
199
+ expect(output).not.toContain("### Pre-loaded Expertise");
200
+ });
201
+
202
+ test("omits expertise section when mulchExpertise is whitespace only", async () => {
203
+ const config = makeConfig({ mulchExpertise: " \n\t \n " });
204
+ const output = await generateOverlay(config);
205
+
206
+ expect(output).not.toContain("### Pre-loaded Expertise");
207
+ });
208
+
209
+ test("builder capability includes full quality gates section", async () => {
210
+ const config = makeConfig({ capability: "builder" });
211
+ const output = await generateOverlay(config);
212
+
213
+ expect(output).toContain("Quality Gates");
214
+ expect(output).toContain("npm run test:unit");
215
+ expect(output).toContain("npm run lint");
216
+ expect(output).toContain("Commit");
217
+ });
218
+
219
+ test("lead capability includes full quality gates section", async () => {
220
+ const config = makeConfig({ capability: "lead" });
221
+ const output = await generateOverlay(config);
222
+
223
+ expect(output).toContain("Quality Gates");
224
+ expect(output).toContain("npm run test:unit");
225
+ expect(output).toContain("npm run lint");
226
+ });
227
+
228
+ test("merger capability includes full quality gates section", async () => {
229
+ const config = makeConfig({ capability: "merger" });
230
+ const output = await generateOverlay(config);
231
+
232
+ expect(output).toContain("Quality Gates");
233
+ expect(output).toContain("npm run test:unit");
234
+ });
235
+
236
+ test("uses default npm commands when qualityGates not in config", async () => {
237
+ const config = makeConfig({ capability: "builder" });
238
+ const output = await generateOverlay(config);
239
+ expect(output).toContain("npm run test:unit");
240
+ expect(output).toContain("npm run lint");
241
+ expect(output).toContain("npm run typecheck");
242
+ });
243
+
244
+ test("uses custom qualityGates commands when provided in config", async () => {
245
+ const baseConfig = makeConfig({ capability: "builder" });
246
+ const config = {
247
+ ...baseConfig,
248
+ qualityGates: { test: "bun test", lint: "bun run lint", typecheck: "bun run typecheck" },
249
+ };
250
+ // biome-ignore lint/suspicious/noExplicitAny: testing extended config with qualityGates field
251
+ const output = await generateOverlay(config as any);
252
+ expect(output).toContain("bun test");
253
+ expect(output).toContain("bun run lint");
254
+ expect(output).toContain("bun run typecheck");
255
+ expect(output).not.toContain("npm run test:unit");
256
+ });
257
+
258
+ test("scout capability gets read-only completion section instead of quality gates", async () => {
259
+ const config = makeConfig({ capability: "scout", agentName: "my-scout" });
260
+ const output = await generateOverlay(config);
261
+
262
+ expect(output).toContain("Completion");
263
+ expect(output).toContain("read-only agent");
264
+ expect(output).toContain("Do NOT commit");
265
+ expect(output).not.toContain("Quality Gates");
266
+ expect(output).not.toContain("npm run test:unit");
267
+ expect(output).not.toContain("npm run lint");
268
+ });
269
+
270
+ test("reviewer capability gets read-only completion section instead of quality gates", async () => {
271
+ const config = makeConfig({ capability: "reviewer", agentName: "my-reviewer" });
272
+ const output = await generateOverlay(config);
273
+
274
+ expect(output).toContain("Completion");
275
+ expect(output).toContain("read-only agent");
276
+ expect(output).toContain("Do NOT commit");
277
+ expect(output).not.toContain("Quality Gates");
278
+ expect(output).not.toContain("npm run test:unit");
279
+ expect(output).not.toContain("npm run lint");
280
+ });
281
+
282
+ test("scout completion section includes bd close and mail send", async () => {
283
+ const config = makeConfig({
284
+ capability: "scout",
285
+ agentName: "recon-1",
286
+ beadId: "legio-task1",
287
+ parentAgent: "lead-alpha",
288
+ });
289
+ const output = await generateOverlay(config);
290
+
291
+ expect(output).toContain("bd close legio-task1");
292
+ expect(output).toContain("legio mail send --to lead-alpha");
293
+ });
294
+
295
+ test("reviewer completion section uses orchestrator when no parent", async () => {
296
+ const config = makeConfig({
297
+ capability: "reviewer",
298
+ parentAgent: null,
299
+ });
300
+ const output = await generateOverlay(config);
301
+
302
+ expect(output).toContain("--to orchestrator");
303
+ });
304
+
305
+ test("output includes communication section with agent address", async () => {
306
+ const config = makeConfig({ agentName: "worker-42" });
307
+ const output = await generateOverlay(config);
308
+
309
+ expect(output).toContain("legio mail check --agent worker-42");
310
+ expect(output).toContain("legio mail send --to");
311
+ });
312
+
313
+ test("output includes base agent definition content (Layer 1)", async () => {
314
+ const config = makeConfig();
315
+ const output = await generateOverlay(config);
316
+
317
+ expect(output).toContain("# Builder Agent");
318
+ expect(output).toContain("Propulsion Principle");
319
+ expect(output).toContain("FILE_SCOPE_VIOLATION");
320
+ });
321
+
322
+ test("base definition appears before task assignment section", async () => {
323
+ const config = makeConfig();
324
+ const output = await generateOverlay(config);
325
+
326
+ const baseDefIndex = output.indexOf("# Builder Agent");
327
+ const assignmentIndex = output.indexOf("## Your Assignment");
328
+ expect(baseDefIndex).toBeGreaterThan(-1);
329
+ expect(assignmentIndex).toBeGreaterThan(-1);
330
+ expect(baseDefIndex).toBeLessThan(assignmentIndex);
331
+ });
332
+
333
+ test("output contains worktree path in assignment section", async () => {
334
+ const config = makeConfig({
335
+ worktreePath: "/project/.legio/worktrees/my-builder",
336
+ });
337
+ const output = await generateOverlay(config);
338
+
339
+ expect(output).toContain("/project/.legio/worktrees/my-builder");
340
+ expect(output).toContain("**Worktree:**");
341
+ });
342
+
343
+ test("output contains Working Directory section with worktree path", async () => {
344
+ const config = makeConfig({
345
+ worktreePath: "/tmp/worktrees/builder-1",
346
+ });
347
+ const output = await generateOverlay(config);
348
+
349
+ expect(output).toContain("## Working Directory");
350
+ expect(output).toContain("Your worktree root is: `/tmp/worktrees/builder-1`");
351
+ expect(output).toContain("PATH_BOUNDARY_VIOLATION");
352
+ });
353
+
354
+ test("file scope section references worktree root", async () => {
355
+ const config = makeConfig({
356
+ worktreePath: "/tmp/worktrees/builder-scope",
357
+ });
358
+ const output = await generateOverlay(config);
359
+
360
+ expect(output).toContain(
361
+ "These paths are relative to your worktree root: `/tmp/worktrees/builder-scope`",
362
+ );
363
+ });
364
+
365
+ test("builder constraints include worktree isolation", async () => {
366
+ const config = makeConfig({
367
+ capability: "builder",
368
+ worktreePath: "/tmp/worktrees/builder-constraints",
369
+ });
370
+ const output = await generateOverlay(config);
371
+
372
+ expect(output).toContain("WORKTREE ISOLATION");
373
+ expect(output).toContain("/tmp/worktrees/builder-constraints");
374
+ expect(output).toContain("NEVER write to the canonical repo root");
375
+ });
376
+
377
+ test("no unreplaced WORKTREE_PATH placeholders", async () => {
378
+ const config = makeConfig();
379
+ const output = await generateOverlay(config);
380
+
381
+ expect(output).not.toContain("{{WORKTREE_PATH}}");
382
+ });
383
+
384
+ test("replaces CANONICAL_ROOT inside base definition content", async () => {
385
+ const baseDef = "Write output to {{CANONICAL_ROOT}}/.legio/strategy.json";
386
+ const config = makeConfig({
387
+ baseDefinition: baseDef,
388
+ canonicalRoot: "/projects/my-app",
389
+ });
390
+ const output = await generateOverlay(config);
391
+
392
+ expect(output).toContain("/projects/my-app/.legio/strategy.json");
393
+ expect(output).not.toContain("{{CANONICAL_ROOT}}");
394
+ });
395
+
396
+ test("no unreplaced CANONICAL_ROOT placeholder when not used in base definition", async () => {
397
+ const config = makeConfig({ canonicalRoot: "/projects/my-app" });
398
+ const output = await generateOverlay(config);
399
+
400
+ expect(output).not.toContain("{{CANONICAL_ROOT}}");
401
+ });
402
+ });
403
+
404
+ describe("writeOverlay", () => {
405
+ let tempDir: string;
406
+
407
+ beforeEach(async () => {
408
+ tempDir = await mkdtemp(join(tmpdir(), "legio-overlay-test-"));
409
+ });
410
+
411
+ afterEach(async () => {
412
+ await rm(tempDir, { recursive: true, force: true });
413
+ });
414
+
415
+ test("creates .claude/CLAUDE.md in worktree directory", async () => {
416
+ const worktreePath = join(tempDir, "worktree");
417
+ const config = makeConfig();
418
+
419
+ await writeOverlay(worktreePath, config, "/nonexistent-canonical-root");
420
+
421
+ const outputPath = join(worktreePath, ".claude", "CLAUDE.md");
422
+ const exists = await access(outputPath)
423
+ .then(() => true)
424
+ .catch(() => false);
425
+ expect(exists).toBe(true);
426
+ });
427
+
428
+ test("written file contains the overlay content", async () => {
429
+ const worktreePath = join(tempDir, "worktree");
430
+ const config = makeConfig({ agentName: "file-writer-test" });
431
+
432
+ await writeOverlay(worktreePath, config, "/nonexistent-canonical-root");
433
+
434
+ const outputPath = join(worktreePath, ".claude", "CLAUDE.md");
435
+ const content = await readFile(outputPath, "utf-8");
436
+ expect(content).toContain("file-writer-test");
437
+ expect(content).toContain(config.beadId);
438
+ expect(content).toContain(config.branchName);
439
+ });
440
+
441
+ test("creates .claude directory even if worktree already exists", async () => {
442
+ const worktreePath = join(tempDir, "existing-worktree");
443
+ await mkdir(worktreePath, { recursive: true });
444
+
445
+ const config = makeConfig();
446
+ await writeOverlay(worktreePath, config, "/nonexistent-canonical-root");
447
+
448
+ const outputPath = join(worktreePath, ".claude", "CLAUDE.md");
449
+ const exists = await access(outputPath)
450
+ .then(() => true)
451
+ .catch(() => false);
452
+ expect(exists).toBe(true);
453
+ });
454
+
455
+ test("overwrites existing CLAUDE.md if it already exists", async () => {
456
+ const worktreePath = join(tempDir, "worktree");
457
+ const claudeDir = join(worktreePath, ".claude");
458
+ await mkdir(claudeDir, { recursive: true });
459
+ await writeFile(join(claudeDir, "CLAUDE.md"), "old content");
460
+
461
+ const config = makeConfig({ agentName: "new-agent" });
462
+ await writeOverlay(worktreePath, config, "/nonexistent-canonical-root");
463
+
464
+ const content = await readFile(join(claudeDir, "CLAUDE.md"), "utf-8");
465
+ expect(content).toContain("new-agent");
466
+ expect(content).not.toContain("old content");
467
+ });
468
+
469
+ test("writeOverlay content matches generateOverlay output", async () => {
470
+ const worktreePath = join(tempDir, "worktree");
471
+ const config = makeConfig();
472
+
473
+ const generated = await generateOverlay(config);
474
+ await writeOverlay(worktreePath, config, "/nonexistent-canonical-root");
475
+
476
+ const written = await readFile(join(worktreePath, ".claude", "CLAUDE.md"), "utf-8");
477
+ expect(written).toBe(generated);
478
+ });
479
+
480
+ test("throws AgentError when worktreePath is the canonical project root", async () => {
481
+ const fakeProjectRoot = join(tempDir, "project-root");
482
+ await mkdir(fakeProjectRoot, { recursive: true });
483
+
484
+ const config = makeConfig({ agentName: "rogue-agent" });
485
+
486
+ await expect(async () => {
487
+ await writeOverlay(fakeProjectRoot, config, fakeProjectRoot);
488
+ }).rejects.toThrow(AgentError);
489
+ });
490
+
491
+ test("error message mentions canonical project root when guard triggers", async () => {
492
+ const fakeProjectRoot = join(tempDir, "project-root-msg");
493
+ await mkdir(fakeProjectRoot, { recursive: true });
494
+
495
+ const config = makeConfig({ agentName: "rogue-agent" });
496
+
497
+ try {
498
+ await writeOverlay(fakeProjectRoot, config, fakeProjectRoot);
499
+ expect.unreachable("should have thrown");
500
+ } catch (err) {
501
+ expect(err).toBeInstanceOf(AgentError);
502
+ const agentErr = err as AgentError;
503
+ expect(agentErr.message).toContain("canonical project root");
504
+ expect(agentErr.message).toContain(fakeProjectRoot);
505
+ expect(agentErr.agentName).toBe("rogue-agent");
506
+ }
507
+ });
508
+
509
+ test("does NOT throw when worktreePath is a proper worktree subdirectory", async () => {
510
+ const fakeProjectRoot = join(tempDir, "project-with-worktrees");
511
+ await mkdir(join(fakeProjectRoot, ".legio", "worktrees", "my-agent"), { recursive: true });
512
+
513
+ const worktreePath = join(fakeProjectRoot, ".legio", "worktrees", "my-agent");
514
+ const config = makeConfig();
515
+
516
+ // This should succeed — the worktree is not the canonical root
517
+ await writeOverlay(worktreePath, config, fakeProjectRoot);
518
+
519
+ const outputPath = join(worktreePath, ".claude", "CLAUDE.md");
520
+ const exists = await access(outputPath)
521
+ .then(() => true)
522
+ .catch(() => false);
523
+ expect(exists).toBe(true);
524
+ });
525
+
526
+ test("does not write CLAUDE.md when guard rejects the path", async () => {
527
+ const fakeProjectRoot = join(tempDir, "project-no-write");
528
+ await mkdir(fakeProjectRoot, { recursive: true });
529
+
530
+ const config = makeConfig();
531
+
532
+ try {
533
+ await writeOverlay(fakeProjectRoot, config, fakeProjectRoot);
534
+ } catch {
535
+ // Expected
536
+ }
537
+
538
+ // Verify CLAUDE.md was NOT written
539
+ const claudeMdPath = join(fakeProjectRoot, ".claude", "CLAUDE.md");
540
+ const exists = await access(claudeMdPath)
541
+ .then(() => true)
542
+ .catch(() => false);
543
+ expect(exists).toBe(false);
544
+ });
545
+
546
+ test("succeeds for worktree with .legio/config.yaml (dogfooding scenario)", async () => {
547
+ // When dogfooding on legio's own repo, .legio/config.yaml is tracked
548
+ // in git. Every worktree checkout includes it. The old file-existence heuristic
549
+ // would incorrectly reject these worktrees. The path-comparison guard must allow
550
+ // writes because the worktree path differs from the canonical root (legio-p4st).
551
+ const fakeProjectRoot = join(tempDir, "legio-dogfood");
552
+ const worktreePath = join(fakeProjectRoot, ".legio", "worktrees", "dogfood-agent");
553
+ await mkdir(join(worktreePath, ".legio"), { recursive: true });
554
+ // Simulate tracked .legio/config.yaml appearing in the worktree checkout
555
+ await writeFile(join(worktreePath, ".legio", "config.yaml"), "project:\n name: legio\n");
556
+
557
+ const config = makeConfig({ agentName: "dogfood-agent" });
558
+
559
+ // Must succeed — worktreePath !== fakeProjectRoot even though config.yaml exists
560
+ await writeOverlay(worktreePath, config, fakeProjectRoot);
561
+
562
+ const outputPath = join(worktreePath, ".claude", "CLAUDE.md");
563
+ const exists = await access(outputPath)
564
+ .then(() => true)
565
+ .catch(() => false);
566
+ expect(exists).toBe(true);
567
+ });
568
+ });
569
+
570
+ describe("isCanonicalRoot", () => {
571
+ test("returns true when dir matches canonicalRoot", () => {
572
+ expect(isCanonicalRoot("/projects/my-app", "/projects/my-app")).toBe(true);
573
+ });
574
+
575
+ test("returns true when paths resolve to the same location", () => {
576
+ expect(isCanonicalRoot("/projects/my-app/./", "/projects/my-app")).toBe(true);
577
+ });
578
+
579
+ test("returns false when dir differs from canonicalRoot", () => {
580
+ expect(isCanonicalRoot("/projects/my-app/.legio/worktrees/agent-1", "/projects/my-app")).toBe(
581
+ false,
582
+ );
583
+ });
584
+
585
+ test("returns false for worktree even when it contains .legio/config.yaml (dogfooding)", () => {
586
+ // This is the core dogfooding scenario: the worktree has .legio/config.yaml
587
+ // because it's tracked in git, but the path is different from the canonical root.
588
+ const canonicalRoot = "/projects/legio";
589
+ const worktreePath = "/projects/legio/.legio/worktrees/dogfood-agent";
590
+ expect(isCanonicalRoot(worktreePath, canonicalRoot)).toBe(false);
591
+ });
592
+ });