@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,496 @@
1
+ import { mkdir, mkdtemp, realpath, 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 { DEFAULT_CONFIG, loadConfig, resolveProjectRoot } from "./config.ts";
6
+ import { ValidationError } from "./errors.ts";
7
+ import { cleanupTempDir, createTempGitRepo, runGitInDir } from "./test-helpers.ts";
8
+
9
+ describe("loadConfig", () => {
10
+ let tempDir: string;
11
+
12
+ beforeEach(async () => {
13
+ tempDir = await mkdtemp(join(tmpdir(), "legio-test-"));
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await rm(tempDir, { recursive: true, force: true });
18
+ });
19
+
20
+ async function writeConfig(yaml: string): Promise<void> {
21
+ const legioDir = join(tempDir, ".legio");
22
+ await writeFile(join(legioDir, "config.yaml"), yaml);
23
+ }
24
+
25
+ async function ensureLegioDir(): Promise<void> {
26
+ await mkdir(join(tempDir, ".legio"), { recursive: true });
27
+ }
28
+
29
+ test("returns defaults when no config file exists", async () => {
30
+ const config = await loadConfig(tempDir);
31
+
32
+ expect(config.project.root).toBe(tempDir);
33
+ expect(config.project.canonicalBranch).toBe("main");
34
+ expect(config.agents.maxConcurrent).toBe(25);
35
+ expect(config.agents.maxDepth).toBe(2);
36
+ expect(config.beads.enabled).toBe(true);
37
+ expect(config.mulch.enabled).toBe(true);
38
+ expect(config.mulch.primeFormat).toBe("markdown");
39
+ expect(config.logging.verbose).toBe(false);
40
+ });
41
+
42
+ test("sets project.name from directory name", async () => {
43
+ const config = await loadConfig(tempDir);
44
+ const parts = tempDir.split("/");
45
+ const expectedName = parts[parts.length - 1] ?? "unknown";
46
+ expect(config.project.name).toBe(expectedName);
47
+ });
48
+
49
+ test("merges config file values over defaults", async () => {
50
+ await ensureLegioDir();
51
+ await writeConfig(`
52
+ project:
53
+ canonicalBranch: develop
54
+ agents:
55
+ maxConcurrent: 10
56
+ `);
57
+
58
+ const config = await loadConfig(tempDir);
59
+
60
+ expect(config.project.canonicalBranch).toBe("develop");
61
+ expect(config.agents.maxConcurrent).toBe(10);
62
+ // Non-overridden values keep defaults
63
+ expect(config.agents.maxDepth).toBe(2);
64
+ expect(config.beads.enabled).toBe(true);
65
+ });
66
+
67
+ test("always sets project.root to the actual projectRoot", async () => {
68
+ await ensureLegioDir();
69
+ await writeConfig(`
70
+ project:
71
+ root: /some/wrong/path
72
+ `);
73
+
74
+ const config = await loadConfig(tempDir);
75
+ expect(config.project.root).toBe(tempDir);
76
+ });
77
+
78
+ test("parses boolean values correctly", async () => {
79
+ await ensureLegioDir();
80
+ await writeConfig(`
81
+ beads:
82
+ enabled: false
83
+ mulch:
84
+ enabled: true
85
+ logging:
86
+ verbose: true
87
+ redactSecrets: false
88
+ `);
89
+
90
+ const config = await loadConfig(tempDir);
91
+
92
+ expect(config.beads.enabled).toBe(false);
93
+ expect(config.mulch.enabled).toBe(true);
94
+ expect(config.logging.verbose).toBe(true);
95
+ expect(config.logging.redactSecrets).toBe(false);
96
+ });
97
+
98
+ test("parses empty array literal", async () => {
99
+ await ensureLegioDir();
100
+ await writeConfig(`
101
+ mulch:
102
+ domains: []
103
+ `);
104
+
105
+ const config = await loadConfig(tempDir);
106
+ expect(config.mulch.domains).toEqual([]);
107
+ });
108
+
109
+ test("parses numeric values including underscore-separated", async () => {
110
+ await ensureLegioDir();
111
+ await writeConfig(`
112
+ agents:
113
+ staggerDelayMs: 5000
114
+ watchdog:
115
+ tier0IntervalMs: 60000
116
+ zombieThresholdMs: 300000
117
+ `);
118
+
119
+ const config = await loadConfig(tempDir);
120
+ expect(config.agents.staggerDelayMs).toBe(5000);
121
+ expect(config.watchdog.tier0IntervalMs).toBe(60000);
122
+ });
123
+
124
+ test("handles quoted string values", async () => {
125
+ await ensureLegioDir();
126
+ await writeConfig(`
127
+ project:
128
+ canonicalBranch: "develop"
129
+ `);
130
+
131
+ const config = await loadConfig(tempDir);
132
+ expect(config.project.canonicalBranch).toBe("develop");
133
+ });
134
+
135
+ test("ignores comments and empty lines", async () => {
136
+ await ensureLegioDir();
137
+ await writeConfig(`
138
+ # This is a comment
139
+ project:
140
+ canonicalBranch: develop # inline comment
141
+
142
+ # Another comment
143
+ agents:
144
+ maxConcurrent: 3
145
+ `);
146
+
147
+ const config = await loadConfig(tempDir);
148
+ expect(config.project.canonicalBranch).toBe("develop");
149
+ expect(config.agents.maxConcurrent).toBe(3);
150
+ });
151
+
152
+ test("config.local.yaml overrides values from config.yaml", async () => {
153
+ await ensureLegioDir();
154
+ await writeConfig(`
155
+ project:
156
+ canonicalBranch: develop
157
+ agents:
158
+ maxConcurrent: 10
159
+ `);
160
+ await writeFile(join(tempDir, ".legio", "config.local.yaml"), `agents:\n maxConcurrent: 4\n`);
161
+
162
+ const config = await loadConfig(tempDir);
163
+ // Local override wins
164
+ expect(config.agents.maxConcurrent).toBe(4);
165
+ // Non-overridden value from config.yaml preserved
166
+ expect(config.project.canonicalBranch).toBe("develop");
167
+ });
168
+
169
+ test("config.local.yaml works when config.yaml does not exist", async () => {
170
+ await ensureLegioDir();
171
+ // No config.yaml, only config.local.yaml
172
+ await writeFile(join(tempDir, ".legio", "config.local.yaml"), `agents:\n maxConcurrent: 3\n`);
173
+
174
+ const config = await loadConfig(tempDir);
175
+ expect(config.agents.maxConcurrent).toBe(3);
176
+ // Defaults still applied
177
+ expect(config.project.canonicalBranch).toBe("main");
178
+ });
179
+
180
+ test("values from config.local.yaml are validated", async () => {
181
+ await ensureLegioDir();
182
+ await writeConfig(`
183
+ project:
184
+ canonicalBranch: main
185
+ `);
186
+ await writeFile(join(tempDir, ".legio", "config.local.yaml"), `agents:\n maxConcurrent: -1\n`);
187
+
188
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
189
+ });
190
+
191
+ test("config.local.yaml deep merges nested objects", async () => {
192
+ await ensureLegioDir();
193
+ await writeConfig(`
194
+ watchdog:
195
+ tier0Enabled: false
196
+ zombieThresholdMs: 120000
197
+ `);
198
+ await writeFile(
199
+ join(tempDir, ".legio", "config.local.yaml"),
200
+ `watchdog:\n tier0Enabled: true\n`,
201
+ );
202
+
203
+ const config = await loadConfig(tempDir);
204
+ // Local override
205
+ expect(config.watchdog.tier0Enabled).toBe(true);
206
+ // Non-overridden value from config.yaml preserved
207
+ expect(config.watchdog.zombieThresholdMs).toBe(120000);
208
+ });
209
+
210
+ test("migrates deprecated watchdog tier1/tier2 keys to tier0/tier1", async () => {
211
+ await ensureLegioDir();
212
+ await writeConfig(`
213
+ watchdog:
214
+ tier1Enabled: true
215
+ tier1IntervalMs: 45000
216
+ tier2Enabled: true
217
+ `);
218
+
219
+ const config = await loadConfig(tempDir);
220
+ // Old tier1 (mechanical daemon) → new tier0
221
+ expect(config.watchdog.tier0Enabled).toBe(true);
222
+ expect(config.watchdog.tier0IntervalMs).toBe(45000);
223
+ // Old tier2 (AI triage) → new tier1
224
+ expect(config.watchdog.tier1Enabled).toBe(true);
225
+ });
226
+
227
+ test("new-style tier keys take precedence over deprecated keys", async () => {
228
+ await ensureLegioDir();
229
+ await writeConfig(`
230
+ watchdog:
231
+ tier0Enabled: false
232
+ tier0IntervalMs: 20000
233
+ tier1Enabled: true
234
+ `);
235
+
236
+ const config = await loadConfig(tempDir);
237
+ // New keys used directly — no migration needed
238
+ expect(config.watchdog.tier0Enabled).toBe(false);
239
+ expect(config.watchdog.tier0IntervalMs).toBe(20000);
240
+ expect(config.watchdog.tier1Enabled).toBe(true);
241
+ });
242
+ });
243
+
244
+ describe("validateConfig", () => {
245
+ let tempDir: string;
246
+
247
+ beforeEach(async () => {
248
+ tempDir = await mkdtemp(join(tmpdir(), "legio-test-"));
249
+ await mkdir(join(tempDir, ".legio"), { recursive: true });
250
+ });
251
+
252
+ afterEach(async () => {
253
+ await rm(tempDir, { recursive: true, force: true });
254
+ });
255
+
256
+ async function writeConfig(yaml: string): Promise<void> {
257
+ await writeFile(join(tempDir, ".legio", "config.yaml"), yaml);
258
+ }
259
+
260
+ test("rejects negative maxConcurrent", async () => {
261
+ await writeConfig(`
262
+ agents:
263
+ maxConcurrent: -1
264
+ `);
265
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
266
+ });
267
+
268
+ test("rejects zero maxConcurrent", async () => {
269
+ await writeConfig(`
270
+ agents:
271
+ maxConcurrent: 0
272
+ `);
273
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
274
+ });
275
+
276
+ test("rejects negative maxDepth", async () => {
277
+ await writeConfig(`
278
+ agents:
279
+ maxDepth: -1
280
+ `);
281
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
282
+ });
283
+
284
+ test("rejects negative staggerDelayMs", async () => {
285
+ await writeConfig(`
286
+ agents:
287
+ staggerDelayMs: -100
288
+ `);
289
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
290
+ });
291
+
292
+ test("rejects invalid mulch.primeFormat", async () => {
293
+ await writeConfig(`
294
+ mulch:
295
+ primeFormat: yaml
296
+ `);
297
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
298
+ });
299
+
300
+ test("rejects non-positive tier0IntervalMs when tier0 is enabled", async () => {
301
+ await writeConfig(`
302
+ watchdog:
303
+ tier0Enabled: true
304
+ tier0IntervalMs: 0
305
+ `);
306
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
307
+ });
308
+
309
+ test("accepts empty models section", async () => {
310
+ await writeConfig(`
311
+ models:
312
+ `);
313
+ const config = await loadConfig(tempDir);
314
+ expect(config.models).toBeDefined();
315
+ });
316
+
317
+ test("accepts valid model names in models section", async () => {
318
+ await writeConfig(`
319
+ models:
320
+ coordinator: sonnet
321
+ monitor: haiku
322
+ builder: opus
323
+ `);
324
+ const config = await loadConfig(tempDir);
325
+ expect(config.models.coordinator).toBe("sonnet");
326
+ expect(config.models.monitor).toBe("haiku");
327
+ expect(config.models.builder).toBe("opus");
328
+ });
329
+
330
+ test("rejects invalid model name in models section", async () => {
331
+ await writeConfig(`
332
+ models:
333
+ coordinator: gpt4
334
+ `);
335
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
336
+ });
337
+ });
338
+
339
+ describe("resolveProjectRoot", () => {
340
+ let repoDir: string;
341
+
342
+ afterEach(async () => {
343
+ if (repoDir) {
344
+ // Remove worktrees before cleaning up
345
+ try {
346
+ await runGitInDir(repoDir, ["worktree", "prune"]);
347
+ } catch {
348
+ // Best effort
349
+ }
350
+ await cleanupTempDir(repoDir);
351
+ }
352
+ });
353
+
354
+ test("returns startDir when .legio/config.yaml exists there", async () => {
355
+ repoDir = await createTempGitRepo();
356
+ await mkdir(join(repoDir, ".legio"), { recursive: true });
357
+ await writeFile(join(repoDir, ".legio", "config.yaml"), "project:\n canonicalBranch: main\n");
358
+
359
+ const result = await resolveProjectRoot(repoDir);
360
+ expect(result).toBe(repoDir);
361
+ });
362
+
363
+ test("resolves worktree to main project root", async () => {
364
+ repoDir = await createTempGitRepo();
365
+ // Resolve symlinks (macOS /var -> /private/var) to match git's output
366
+ repoDir = await realpath(repoDir);
367
+ await mkdir(join(repoDir, ".legio"), { recursive: true });
368
+ await writeFile(join(repoDir, ".legio", "config.yaml"), "project:\n canonicalBranch: main\n");
369
+
370
+ // Create a worktree like legio sling does
371
+ const worktreeDir = join(repoDir, ".legio", "worktrees", "test-agent");
372
+ await mkdir(join(repoDir, ".legio", "worktrees"), { recursive: true });
373
+ await runGitInDir(repoDir, ["worktree", "add", "-b", "legio/test-agent/task-1", worktreeDir]);
374
+
375
+ // resolveProjectRoot from the worktree should return the main repo
376
+ const result = await resolveProjectRoot(worktreeDir);
377
+ expect(result).toBe(repoDir);
378
+ });
379
+
380
+ test("resolves worktree to main root even when config.yaml is committed (regression)", async () => {
381
+ repoDir = await createTempGitRepo();
382
+ repoDir = await realpath(repoDir);
383
+
384
+ // Commit .legio/config.yaml so the worktree gets a copy via git
385
+ // (this is what legio init does — the file is tracked)
386
+ await mkdir(join(repoDir, ".legio"), { recursive: true });
387
+ await writeFile(join(repoDir, ".legio", "config.yaml"), "project:\n canonicalBranch: main\n");
388
+ await runGitInDir(repoDir, ["add", ".legio/config.yaml"]);
389
+ await runGitInDir(repoDir, ["commit", "-m", "add legio config"]);
390
+
391
+ // Create a worktree — it will now have .legio/config.yaml from git
392
+ const worktreeDir = join(repoDir, ".legio", "worktrees", "mail-scout");
393
+ await mkdir(join(repoDir, ".legio", "worktrees"), { recursive: true });
394
+ await runGitInDir(repoDir, ["worktree", "add", "-b", "legio/mail-scout/task-1", worktreeDir]);
395
+
396
+ // Must resolve to main repo root, NOT the worktree
397
+ // (even though worktree has its own .legio/config.yaml)
398
+ const result = await resolveProjectRoot(worktreeDir);
399
+ expect(result).toBe(repoDir);
400
+ });
401
+
402
+ test("loadConfig resolves correct root from worktree", async () => {
403
+ repoDir = await createTempGitRepo();
404
+ // Resolve symlinks (macOS /var -> /private/var) to match git's output
405
+ repoDir = await realpath(repoDir);
406
+ await mkdir(join(repoDir, ".legio"), { recursive: true });
407
+ await writeFile(
408
+ join(repoDir, ".legio", "config.yaml"),
409
+ "project:\n canonicalBranch: develop\n",
410
+ );
411
+
412
+ const worktreeDir = join(repoDir, ".legio", "worktrees", "agent-2");
413
+ await mkdir(join(repoDir, ".legio", "worktrees"), { recursive: true });
414
+ await runGitInDir(repoDir, ["worktree", "add", "-b", "legio/agent-2/task-2", worktreeDir]);
415
+
416
+ // loadConfig from the worktree should resolve to the main project root
417
+ const config = await loadConfig(worktreeDir);
418
+ expect(config.project.root).toBe(repoDir);
419
+ expect(config.project.canonicalBranch).toBe("develop");
420
+ });
421
+ });
422
+
423
+ describe("DEFAULT_CONFIG", () => {
424
+ test("has all required top-level keys", () => {
425
+ expect(DEFAULT_CONFIG.project).toBeDefined();
426
+ expect(DEFAULT_CONFIG.agents).toBeDefined();
427
+ expect(DEFAULT_CONFIG.worktrees).toBeDefined();
428
+ expect(DEFAULT_CONFIG.beads).toBeDefined();
429
+ expect(DEFAULT_CONFIG.mulch).toBeDefined();
430
+ expect(DEFAULT_CONFIG.merge).toBeDefined();
431
+ expect(DEFAULT_CONFIG.watchdog).toBeDefined();
432
+ expect(DEFAULT_CONFIG.models).toBeDefined();
433
+ expect(DEFAULT_CONFIG.logging).toBeDefined();
434
+ });
435
+
436
+ test("has sensible default values", () => {
437
+ expect(DEFAULT_CONFIG.project.canonicalBranch).toBe("main");
438
+ expect(DEFAULT_CONFIG.agents.maxConcurrent).toBe(25);
439
+ expect(DEFAULT_CONFIG.agents.maxDepth).toBe(2);
440
+ expect(DEFAULT_CONFIG.agents.staggerDelayMs).toBe(2_000);
441
+ expect(DEFAULT_CONFIG.watchdog.tier0IntervalMs).toBe(30_000);
442
+ expect(DEFAULT_CONFIG.watchdog.zombieThresholdMs).toBe(600_000);
443
+ });
444
+
445
+ test("agents.maxAgentsPerLead defaults to 5", () => {
446
+ expect(DEFAULT_CONFIG.agents.maxAgentsPerLead).toBe(5);
447
+ });
448
+
449
+ test("has qualityGates with npm defaults", () => {
450
+ expect(DEFAULT_CONFIG.qualityGates).toBeDefined();
451
+ expect(DEFAULT_CONFIG.qualityGates?.test).toBe("npm test");
452
+ expect(DEFAULT_CONFIG.qualityGates?.lint).toBe("npm run lint");
453
+ expect(DEFAULT_CONFIG.qualityGates?.typecheck).toBe("npm run typecheck");
454
+ });
455
+ });
456
+
457
+ describe("loadConfig qualityGates", () => {
458
+ let tempDir: string;
459
+
460
+ beforeEach(async () => {
461
+ tempDir = await mkdtemp(join(tmpdir(), "legio-test-"));
462
+ await mkdir(join(tempDir, ".legio"), { recursive: true });
463
+ });
464
+
465
+ afterEach(async () => {
466
+ await rm(tempDir, { recursive: true, force: true });
467
+ });
468
+
469
+ async function writeConfig(yaml: string): Promise<void> {
470
+ await writeFile(join(tempDir, ".legio", "config.yaml"), yaml);
471
+ }
472
+
473
+ test("full custom qualityGates override", async () => {
474
+ await writeConfig(`
475
+ qualityGates:
476
+ test: "bun test"
477
+ lint: "bun run lint"
478
+ typecheck: "bun run typecheck"
479
+ `);
480
+ const config = await loadConfig(tempDir);
481
+ expect(config.qualityGates?.test).toBe("bun test");
482
+ expect(config.qualityGates?.lint).toBe("bun run lint");
483
+ expect(config.qualityGates?.typecheck).toBe("bun run typecheck");
484
+ });
485
+
486
+ test("partial qualityGates override keeps defaults for unspecified fields", async () => {
487
+ await writeConfig(`
488
+ qualityGates:
489
+ test: "bun test"
490
+ `);
491
+ const config = await loadConfig(tempDir);
492
+ expect(config.qualityGates?.test).toBe("bun test");
493
+ expect(config.qualityGates?.lint).toBe("npm run lint");
494
+ expect(config.qualityGates?.typecheck).toBe("npm run typecheck");
495
+ });
496
+ });