@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.
- package/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- 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
|
+
});
|