@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
package/src/config.ts
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { access, readFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { ConfigError, ValidationError } from "./errors.ts";
|
|
7
|
+
import type { LegioConfig } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default configuration with all fields populated.
|
|
11
|
+
* Used as the base; file-loaded values are merged on top.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_CONFIG: LegioConfig = {
|
|
14
|
+
project: {
|
|
15
|
+
name: "",
|
|
16
|
+
root: "",
|
|
17
|
+
canonicalBranch: "main",
|
|
18
|
+
},
|
|
19
|
+
agents: {
|
|
20
|
+
manifestPath: ".legio/agent-manifest.json",
|
|
21
|
+
baseDir: ".legio/agent-defs",
|
|
22
|
+
maxConcurrent: 25,
|
|
23
|
+
staggerDelayMs: 2_000,
|
|
24
|
+
maxDepth: 2,
|
|
25
|
+
maxAgentsPerLead: 5,
|
|
26
|
+
},
|
|
27
|
+
worktrees: {
|
|
28
|
+
baseDir: ".legio/worktrees",
|
|
29
|
+
},
|
|
30
|
+
beads: {
|
|
31
|
+
enabled: true,
|
|
32
|
+
},
|
|
33
|
+
mulch: {
|
|
34
|
+
enabled: true,
|
|
35
|
+
domains: [],
|
|
36
|
+
primeFormat: "markdown",
|
|
37
|
+
domainMap: {},
|
|
38
|
+
},
|
|
39
|
+
merge: {
|
|
40
|
+
aiResolveEnabled: true,
|
|
41
|
+
reimagineEnabled: false,
|
|
42
|
+
},
|
|
43
|
+
watchdog: {
|
|
44
|
+
tier0Enabled: true, // Tier 0: Mechanical daemon
|
|
45
|
+
tier0IntervalMs: 30_000,
|
|
46
|
+
tier1Enabled: false, // Tier 1: Triage agent (AI analysis)
|
|
47
|
+
tier2Enabled: false, // Tier 2: Monitor agent (not yet implemented)
|
|
48
|
+
zombieThresholdMs: 600_000, // 10 minutes
|
|
49
|
+
nudgeIntervalMs: 60_000, // 1 minute between progressive nudge stages
|
|
50
|
+
},
|
|
51
|
+
models: {},
|
|
52
|
+
logging: {
|
|
53
|
+
verbose: false,
|
|
54
|
+
redactSecrets: true,
|
|
55
|
+
},
|
|
56
|
+
qualityGates: {
|
|
57
|
+
test: "npm test",
|
|
58
|
+
lint: "npm run lint",
|
|
59
|
+
typecheck: "npm run typecheck",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const CONFIG_FILENAME = "config.yaml";
|
|
64
|
+
const CONFIG_LOCAL_FILENAME = "config.local.yaml";
|
|
65
|
+
const LEGIO_DIR = ".legio";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Minimal YAML parser that handles the config structure.
|
|
69
|
+
*
|
|
70
|
+
* Supports:
|
|
71
|
+
* - Nested objects via indentation
|
|
72
|
+
* - String, number, boolean values
|
|
73
|
+
* - Arrays using `- item` syntax
|
|
74
|
+
* - Quoted strings (single and double)
|
|
75
|
+
* - Comments (lines starting with #)
|
|
76
|
+
* - Empty lines
|
|
77
|
+
*
|
|
78
|
+
* Does NOT support:
|
|
79
|
+
* - Flow mappings/sequences ({}, [])
|
|
80
|
+
* - Multi-line strings (|, >)
|
|
81
|
+
* - Anchors/aliases
|
|
82
|
+
* - Tags
|
|
83
|
+
*/
|
|
84
|
+
function parseYaml(text: string): Record<string, unknown> {
|
|
85
|
+
const lines = text.split("\n");
|
|
86
|
+
const root: Record<string, unknown> = {};
|
|
87
|
+
|
|
88
|
+
// Stack tracks the current nesting context.
|
|
89
|
+
// Each entry: [indent level, parent object, current key for arrays]
|
|
90
|
+
const stack: Array<{
|
|
91
|
+
indent: number;
|
|
92
|
+
obj: Record<string, unknown>;
|
|
93
|
+
}> = [{ indent: -1, obj: root }];
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < lines.length; i++) {
|
|
96
|
+
const rawLine = lines[i];
|
|
97
|
+
if (rawLine === undefined) continue;
|
|
98
|
+
|
|
99
|
+
// Strip comments (but not inside quoted strings)
|
|
100
|
+
const commentFree = stripComment(rawLine);
|
|
101
|
+
|
|
102
|
+
// Skip empty lines and comment-only lines
|
|
103
|
+
const trimmed = commentFree.trimEnd();
|
|
104
|
+
if (trimmed.trim() === "") continue;
|
|
105
|
+
|
|
106
|
+
const indent = countIndent(trimmed);
|
|
107
|
+
const content = trimmed.trim();
|
|
108
|
+
|
|
109
|
+
// Pop stack to find the correct parent for this indent level
|
|
110
|
+
while (stack.length > 1) {
|
|
111
|
+
const top = stack[stack.length - 1];
|
|
112
|
+
if (top && top.indent >= indent) {
|
|
113
|
+
stack.pop();
|
|
114
|
+
} else {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const parent = stack[stack.length - 1];
|
|
120
|
+
if (!parent) continue;
|
|
121
|
+
|
|
122
|
+
// Array item: "- value"
|
|
123
|
+
if (content.startsWith("- ")) {
|
|
124
|
+
const value = content.slice(2).trim();
|
|
125
|
+
// Find the key this array belongs to.
|
|
126
|
+
// First check parent.obj directly (for inline arrays or subsequent items).
|
|
127
|
+
const lastKey = findLastKey(parent.obj);
|
|
128
|
+
if (lastKey !== null) {
|
|
129
|
+
const existing = parent.obj[lastKey];
|
|
130
|
+
if (Array.isArray(existing)) {
|
|
131
|
+
existing.push(parseValue(value));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Multiline array case: `key:\n - item` pushes an empty {} onto the
|
|
137
|
+
// stack for the nested object. The `- ` item's parent is that empty {},
|
|
138
|
+
// which has no keys. We need to look one level up in the stack to find
|
|
139
|
+
// the key whose value is the empty {} and convert it to [].
|
|
140
|
+
if (stack.length >= 2) {
|
|
141
|
+
const grandparent = stack[stack.length - 2];
|
|
142
|
+
if (grandparent) {
|
|
143
|
+
const gpKey = findLastKey(grandparent.obj);
|
|
144
|
+
if (gpKey !== null) {
|
|
145
|
+
const gpVal = grandparent.obj[gpKey];
|
|
146
|
+
if (
|
|
147
|
+
gpVal !== null &&
|
|
148
|
+
gpVal !== undefined &&
|
|
149
|
+
typeof gpVal === "object" &&
|
|
150
|
+
!Array.isArray(gpVal) &&
|
|
151
|
+
Object.keys(gpVal as Record<string, unknown>).length === 0
|
|
152
|
+
) {
|
|
153
|
+
// Convert {} to [] and push the first item.
|
|
154
|
+
const arr: unknown[] = [parseValue(value)];
|
|
155
|
+
grandparent.obj[gpKey] = arr;
|
|
156
|
+
// Pop the now-stale nested {} from the stack so subsequent
|
|
157
|
+
// `- ` items find the grandparent and the array directly.
|
|
158
|
+
stack.pop();
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Key: value pair
|
|
168
|
+
const colonIndex = content.indexOf(":");
|
|
169
|
+
if (colonIndex === -1) continue;
|
|
170
|
+
|
|
171
|
+
const key = content.slice(0, colonIndex).trim();
|
|
172
|
+
const rawValue = content.slice(colonIndex + 1).trim();
|
|
173
|
+
|
|
174
|
+
if (rawValue === "" || rawValue === undefined) {
|
|
175
|
+
// Nested object - create it and push onto stack
|
|
176
|
+
const nested: Record<string, unknown> = {};
|
|
177
|
+
parent.obj[key] = nested;
|
|
178
|
+
stack.push({ indent, obj: nested });
|
|
179
|
+
} else if (rawValue === "[]") {
|
|
180
|
+
// Empty array literal
|
|
181
|
+
parent.obj[key] = [];
|
|
182
|
+
} else {
|
|
183
|
+
parent.obj[key] = parseValue(rawValue);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return root;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Count leading spaces (tabs count as 2 spaces for indentation). */
|
|
191
|
+
function countIndent(line: string): number {
|
|
192
|
+
let count = 0;
|
|
193
|
+
for (const ch of line) {
|
|
194
|
+
if (ch === " ") count++;
|
|
195
|
+
else if (ch === "\t") count += 2;
|
|
196
|
+
else break;
|
|
197
|
+
}
|
|
198
|
+
return count;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Strip inline comments that are not inside quoted strings. */
|
|
202
|
+
function stripComment(line: string): string {
|
|
203
|
+
let inSingle = false;
|
|
204
|
+
let inDouble = false;
|
|
205
|
+
for (let i = 0; i < line.length; i++) {
|
|
206
|
+
const ch = line[i];
|
|
207
|
+
if (ch === "'" && !inDouble) inSingle = !inSingle;
|
|
208
|
+
else if (ch === '"' && !inSingle) inDouble = !inDouble;
|
|
209
|
+
else if (ch === "#" && !inSingle && !inDouble) {
|
|
210
|
+
// Ensure it's preceded by whitespace (YAML spec)
|
|
211
|
+
if (i === 0 || line[i - 1] === " " || line[i - 1] === "\t") {
|
|
212
|
+
return line.slice(0, i);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return line;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Parse a scalar YAML value into the appropriate JS type. */
|
|
220
|
+
function parseValue(raw: string): string | number | boolean | null {
|
|
221
|
+
// Quoted strings
|
|
222
|
+
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
|
223
|
+
return raw.slice(1, -1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Booleans
|
|
227
|
+
if (raw === "true" || raw === "True" || raw === "TRUE") return true;
|
|
228
|
+
if (raw === "false" || raw === "False" || raw === "FALSE") return false;
|
|
229
|
+
|
|
230
|
+
// Null
|
|
231
|
+
if (raw === "null" || raw === "~" || raw === "Null" || raw === "NULL") return null;
|
|
232
|
+
|
|
233
|
+
// Numbers
|
|
234
|
+
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
|
|
235
|
+
if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
|
|
236
|
+
// Underscore-separated numbers (e.g., 30_000)
|
|
237
|
+
if (/^-?\d[\d_]*\d$/.test(raw)) return Number.parseInt(raw.replace(/_/g, ""), 10);
|
|
238
|
+
|
|
239
|
+
// Plain string
|
|
240
|
+
return raw;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Find the last key added to an object (insertion order). */
|
|
244
|
+
function findLastKey(obj: Record<string, unknown>): string | null {
|
|
245
|
+
const keys = Object.keys(obj);
|
|
246
|
+
return keys[keys.length - 1] ?? null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Deep merge source into target. Source values override target values.
|
|
251
|
+
* Arrays from source replace (not append) target arrays.
|
|
252
|
+
*/
|
|
253
|
+
function deepMerge(
|
|
254
|
+
target: Record<string, unknown>,
|
|
255
|
+
source: Record<string, unknown>,
|
|
256
|
+
): Record<string, unknown> {
|
|
257
|
+
const result: Record<string, unknown> = { ...target };
|
|
258
|
+
|
|
259
|
+
for (const key of Object.keys(source)) {
|
|
260
|
+
const sourceVal = source[key];
|
|
261
|
+
const targetVal = result[key];
|
|
262
|
+
|
|
263
|
+
if (
|
|
264
|
+
sourceVal !== null &&
|
|
265
|
+
sourceVal !== undefined &&
|
|
266
|
+
typeof sourceVal === "object" &&
|
|
267
|
+
!Array.isArray(sourceVal) &&
|
|
268
|
+
targetVal !== null &&
|
|
269
|
+
targetVal !== undefined &&
|
|
270
|
+
typeof targetVal === "object" &&
|
|
271
|
+
!Array.isArray(targetVal)
|
|
272
|
+
) {
|
|
273
|
+
result[key] = deepMerge(
|
|
274
|
+
targetVal as Record<string, unknown>,
|
|
275
|
+
sourceVal as Record<string, unknown>,
|
|
276
|
+
);
|
|
277
|
+
} else if (sourceVal !== undefined) {
|
|
278
|
+
result[key] = sourceVal;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Migrate deprecated watchdog tier key names in a parsed config object.
|
|
287
|
+
*
|
|
288
|
+
* Phase 4 renamed the watchdog tiers:
|
|
289
|
+
* - Old "tier1" (mechanical daemon) → New "tier0"
|
|
290
|
+
* - Old "tier2" (AI triage) → New "tier1"
|
|
291
|
+
*
|
|
292
|
+
* Detection heuristic: if `tier0Enabled` is absent but `tier1Enabled` is present,
|
|
293
|
+
* this is an old-style config. A new-style config would have `tier0Enabled`.
|
|
294
|
+
*
|
|
295
|
+
* If old key names are present and new key names are absent, this function
|
|
296
|
+
* copies the values to the new keys, removes the old keys (to prevent collision
|
|
297
|
+
* with the renamed tiers), and logs a deprecation warning.
|
|
298
|
+
*
|
|
299
|
+
* Mutates the parsed config object in place.
|
|
300
|
+
*/
|
|
301
|
+
function migrateDeprecatedWatchdogKeys(parsed: Record<string, unknown>): void {
|
|
302
|
+
const watchdog = parsed.watchdog;
|
|
303
|
+
if (watchdog === null || watchdog === undefined || typeof watchdog !== "object") {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const wd = watchdog as Record<string, unknown>;
|
|
308
|
+
|
|
309
|
+
// Detect old-style config: tier1Enabled present but tier0Enabled absent.
|
|
310
|
+
// In old naming, tier1 = mechanical daemon. In new naming, tier0 = mechanical daemon.
|
|
311
|
+
const isOldStyle = "tier1Enabled" in wd && !("tier0Enabled" in wd);
|
|
312
|
+
|
|
313
|
+
if (!isOldStyle) {
|
|
314
|
+
// New-style config or no tier keys at all — nothing to migrate
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Old tier1Enabled → new tier0Enabled (mechanical daemon)
|
|
319
|
+
wd.tier0Enabled = wd.tier1Enabled;
|
|
320
|
+
wd.tier1Enabled = undefined;
|
|
321
|
+
process.stderr.write("[legio] DEPRECATED: watchdog.tier1Enabled → use watchdog.tier0Enabled\n");
|
|
322
|
+
|
|
323
|
+
// Old tier1IntervalMs → new tier0IntervalMs (mechanical daemon)
|
|
324
|
+
if ("tier1IntervalMs" in wd) {
|
|
325
|
+
wd.tier0IntervalMs = wd.tier1IntervalMs;
|
|
326
|
+
wd.tier1IntervalMs = undefined;
|
|
327
|
+
process.stderr.write(
|
|
328
|
+
"[legio] DEPRECATED: watchdog.tier1IntervalMs → use watchdog.tier0IntervalMs\n",
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Old tier2Enabled → new tier1Enabled (AI triage)
|
|
333
|
+
if ("tier2Enabled" in wd) {
|
|
334
|
+
wd.tier1Enabled = wd.tier2Enabled;
|
|
335
|
+
wd.tier2Enabled = undefined;
|
|
336
|
+
process.stderr.write("[legio] DEPRECATED: watchdog.tier2Enabled → use watchdog.tier1Enabled\n");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Validate that a config object has the required structure and sane values.
|
|
342
|
+
* Throws ValidationError on failure.
|
|
343
|
+
*/
|
|
344
|
+
function validateConfig(config: LegioConfig): void {
|
|
345
|
+
// project.root is required and must be a non-empty string
|
|
346
|
+
if (!config.project.root || typeof config.project.root !== "string") {
|
|
347
|
+
throw new ValidationError("project.root is required and must be a non-empty string", {
|
|
348
|
+
field: "project.root",
|
|
349
|
+
value: config.project.root,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// project.canonicalBranch must be a non-empty string
|
|
354
|
+
if (!config.project.canonicalBranch || typeof config.project.canonicalBranch !== "string") {
|
|
355
|
+
throw new ValidationError(
|
|
356
|
+
"project.canonicalBranch is required and must be a non-empty string",
|
|
357
|
+
{
|
|
358
|
+
field: "project.canonicalBranch",
|
|
359
|
+
value: config.project.canonicalBranch,
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// agents.maxConcurrent must be a positive integer
|
|
365
|
+
if (!Number.isInteger(config.agents.maxConcurrent) || config.agents.maxConcurrent < 1) {
|
|
366
|
+
throw new ValidationError("agents.maxConcurrent must be a positive integer", {
|
|
367
|
+
field: "agents.maxConcurrent",
|
|
368
|
+
value: config.agents.maxConcurrent,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// agents.maxDepth must be a non-negative integer
|
|
373
|
+
if (!Number.isInteger(config.agents.maxDepth) || config.agents.maxDepth < 0) {
|
|
374
|
+
throw new ValidationError("agents.maxDepth must be a non-negative integer", {
|
|
375
|
+
field: "agents.maxDepth",
|
|
376
|
+
value: config.agents.maxDepth,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// agents.staggerDelayMs must be non-negative
|
|
381
|
+
if (config.agents.staggerDelayMs < 0) {
|
|
382
|
+
throw new ValidationError("agents.staggerDelayMs must be non-negative", {
|
|
383
|
+
field: "agents.staggerDelayMs",
|
|
384
|
+
value: config.agents.staggerDelayMs,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// watchdog intervals must be positive if enabled
|
|
389
|
+
if (config.watchdog.tier0Enabled && config.watchdog.tier0IntervalMs <= 0) {
|
|
390
|
+
throw new ValidationError("watchdog.tier0IntervalMs must be positive when tier0 is enabled", {
|
|
391
|
+
field: "watchdog.tier0IntervalMs",
|
|
392
|
+
value: config.watchdog.tier0IntervalMs,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (config.watchdog.nudgeIntervalMs <= 0) {
|
|
397
|
+
throw new ValidationError("watchdog.nudgeIntervalMs must be positive", {
|
|
398
|
+
field: "watchdog.nudgeIntervalMs",
|
|
399
|
+
value: config.watchdog.nudgeIntervalMs,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (config.watchdog.zombieThresholdMs <= 0) {
|
|
404
|
+
throw new ValidationError("watchdog.zombieThresholdMs must be positive", {
|
|
405
|
+
field: "watchdog.zombieThresholdMs",
|
|
406
|
+
value: config.watchdog.zombieThresholdMs,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// mulch.primeFormat must be one of the valid options
|
|
411
|
+
const validFormats = ["markdown", "xml", "json"] as const;
|
|
412
|
+
if (!validFormats.includes(config.mulch.primeFormat as (typeof validFormats)[number])) {
|
|
413
|
+
throw new ValidationError(`mulch.primeFormat must be one of: ${validFormats.join(", ")}`, {
|
|
414
|
+
field: "mulch.primeFormat",
|
|
415
|
+
value: config.mulch.primeFormat,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// models: each value must be a valid model name
|
|
420
|
+
const validModels = ["sonnet", "opus", "haiku"];
|
|
421
|
+
for (const [role, model] of Object.entries(config.models)) {
|
|
422
|
+
if (model !== undefined && !validModels.includes(model)) {
|
|
423
|
+
throw new ValidationError(`models.${role} must be one of: ${validModels.join(", ")}`, {
|
|
424
|
+
field: `models.${role}`,
|
|
425
|
+
value: model,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Load and merge config.local.yaml on top of the current config.
|
|
433
|
+
*
|
|
434
|
+
* config.local.yaml is gitignored and provides machine-specific overrides
|
|
435
|
+
* (e.g., maxConcurrent for weaker hardware) without dirtying the worktree.
|
|
436
|
+
*
|
|
437
|
+
* Merge order: DEFAULT_CONFIG <- config.yaml <- config.local.yaml
|
|
438
|
+
*/
|
|
439
|
+
async function mergeLocalConfig(resolvedRoot: string, config: LegioConfig): Promise<LegioConfig> {
|
|
440
|
+
const localPath = join(resolvedRoot, LEGIO_DIR, CONFIG_LOCAL_FILENAME);
|
|
441
|
+
|
|
442
|
+
if (
|
|
443
|
+
!(await access(localPath)
|
|
444
|
+
.then(() => true)
|
|
445
|
+
.catch(() => false))
|
|
446
|
+
) {
|
|
447
|
+
return config;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
let text: string;
|
|
451
|
+
try {
|
|
452
|
+
text = await readFile(localPath, "utf-8");
|
|
453
|
+
} catch (err) {
|
|
454
|
+
throw new ConfigError(`Failed to read local config file: ${localPath}`, {
|
|
455
|
+
configPath: localPath,
|
|
456
|
+
cause: err instanceof Error ? err : undefined,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let parsed: Record<string, unknown>;
|
|
461
|
+
try {
|
|
462
|
+
parsed = parseYaml(text);
|
|
463
|
+
} catch (err) {
|
|
464
|
+
throw new ConfigError(`Failed to parse YAML in local config file: ${localPath}`, {
|
|
465
|
+
configPath: localPath,
|
|
466
|
+
cause: err instanceof Error ? err : undefined,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
migrateDeprecatedWatchdogKeys(parsed);
|
|
471
|
+
|
|
472
|
+
return deepMerge(config as unknown as Record<string, unknown>, parsed) as unknown as LegioConfig;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Resolve the actual project root, handling git worktrees.
|
|
477
|
+
*
|
|
478
|
+
* When running from inside a git worktree (e.g., an agent's worktree at
|
|
479
|
+
* `.legio/worktrees/{name}/`), the passed directory won't contain
|
|
480
|
+
* `.legio/config.yaml`. This function detects worktrees using
|
|
481
|
+
* `git rev-parse --git-common-dir` and resolves to the main repository root.
|
|
482
|
+
*
|
|
483
|
+
* @param startDir - The initial directory (usually process.cwd())
|
|
484
|
+
* @returns The resolved project root containing `.legio/`
|
|
485
|
+
*/
|
|
486
|
+
export async function resolveProjectRoot(startDir: string): Promise<string> {
|
|
487
|
+
const execFileAsync = promisify(execFile);
|
|
488
|
+
|
|
489
|
+
// Check git worktree FIRST. When running from an agent worktree
|
|
490
|
+
// (e.g., .legio/worktrees/{name}/), the worktree may contain
|
|
491
|
+
// tracked copies of .legio/config.yaml. We must resolve to the
|
|
492
|
+
// main repository root so runtime state (mail.db, metrics.db, etc.)
|
|
493
|
+
// is shared across all agents, not siloed per worktree.
|
|
494
|
+
try {
|
|
495
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-common-dir"], {
|
|
496
|
+
cwd: startDir,
|
|
497
|
+
});
|
|
498
|
+
const gitCommonDir = stdout.trim();
|
|
499
|
+
const absGitCommon = resolve(startDir, gitCommonDir);
|
|
500
|
+
// Main repo root is the parent of the .git directory
|
|
501
|
+
const mainRoot = dirname(absGitCommon);
|
|
502
|
+
// If mainRoot differs from startDir, we're in a worktree — resolve to canonical root
|
|
503
|
+
if (mainRoot !== startDir && existsSync(join(mainRoot, LEGIO_DIR, CONFIG_FILENAME))) {
|
|
504
|
+
return mainRoot;
|
|
505
|
+
}
|
|
506
|
+
} catch {
|
|
507
|
+
// git not available, fall through
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Not inside a worktree (or git not available).
|
|
511
|
+
// Check if .legio/config.yaml exists at startDir.
|
|
512
|
+
if (existsSync(join(startDir, LEGIO_DIR, CONFIG_FILENAME))) {
|
|
513
|
+
return startDir;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Fallback to the start directory
|
|
517
|
+
return startDir;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Load the legio configuration for a project.
|
|
522
|
+
*
|
|
523
|
+
* Reads `.legio/config.yaml` from the project root, parses it,
|
|
524
|
+
* merges with defaults, and validates the result.
|
|
525
|
+
*
|
|
526
|
+
* Automatically resolves the project root when running inside a git worktree.
|
|
527
|
+
*
|
|
528
|
+
* @param projectRoot - Absolute path to the target project root (or worktree)
|
|
529
|
+
* @returns Fully populated and validated LegioConfig
|
|
530
|
+
* @throws ConfigError if the file cannot be read or parsed
|
|
531
|
+
* @throws ValidationError if the merged config fails validation
|
|
532
|
+
*/
|
|
533
|
+
export async function loadConfig(projectRoot: string): Promise<LegioConfig> {
|
|
534
|
+
// Resolve the actual project root (handles git worktrees)
|
|
535
|
+
const resolvedRoot = await resolveProjectRoot(projectRoot);
|
|
536
|
+
|
|
537
|
+
const configPath = join(resolvedRoot, LEGIO_DIR, CONFIG_FILENAME);
|
|
538
|
+
|
|
539
|
+
// Start with defaults, setting the project root
|
|
540
|
+
const defaults = structuredClone(DEFAULT_CONFIG);
|
|
541
|
+
defaults.project.root = resolvedRoot;
|
|
542
|
+
defaults.project.name = resolvedRoot.split("/").pop() ?? "unknown";
|
|
543
|
+
|
|
544
|
+
// Try to read the config file
|
|
545
|
+
const exists = await access(configPath)
|
|
546
|
+
.then(() => true)
|
|
547
|
+
.catch(() => false);
|
|
548
|
+
|
|
549
|
+
if (!exists) {
|
|
550
|
+
// No config file — use defaults, but still check for local overrides
|
|
551
|
+
let config = defaults;
|
|
552
|
+
config = await mergeLocalConfig(resolvedRoot, config);
|
|
553
|
+
config.project.root = resolvedRoot;
|
|
554
|
+
validateConfig(config);
|
|
555
|
+
return config;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let text: string;
|
|
559
|
+
try {
|
|
560
|
+
text = await readFile(configPath, "utf-8");
|
|
561
|
+
} catch (err) {
|
|
562
|
+
throw new ConfigError(`Failed to read config file: ${configPath}`, {
|
|
563
|
+
configPath,
|
|
564
|
+
cause: err instanceof Error ? err : undefined,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
let parsed: Record<string, unknown>;
|
|
569
|
+
try {
|
|
570
|
+
parsed = parseYaml(text);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
throw new ConfigError(`Failed to parse YAML in config file: ${configPath}`, {
|
|
573
|
+
configPath,
|
|
574
|
+
cause: err instanceof Error ? err : undefined,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Backward compatibility: migrate deprecated watchdog tier key names.
|
|
579
|
+
// Old naming: tier1 = mechanical daemon, tier2 = AI triage
|
|
580
|
+
// New naming: tier0 = mechanical daemon, tier1 = AI triage, tier2 = monitor agent
|
|
581
|
+
migrateDeprecatedWatchdogKeys(parsed);
|
|
582
|
+
|
|
583
|
+
// Deep merge parsed config over defaults
|
|
584
|
+
let merged = deepMerge(
|
|
585
|
+
defaults as unknown as Record<string, unknown>,
|
|
586
|
+
parsed,
|
|
587
|
+
) as unknown as LegioConfig;
|
|
588
|
+
|
|
589
|
+
// Check for config.local.yaml (local overrides, gitignored)
|
|
590
|
+
merged = await mergeLocalConfig(resolvedRoot, merged);
|
|
591
|
+
|
|
592
|
+
// Ensure project.root is always set to the resolved project root
|
|
593
|
+
merged.project.root = resolvedRoot;
|
|
594
|
+
|
|
595
|
+
validateConfig(merged);
|
|
596
|
+
|
|
597
|
+
return merged;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Collect provider-related environment variables from the current process.
|
|
602
|
+
*
|
|
603
|
+
* Returns a Record<string, string> containing only the env vars that are
|
|
604
|
+
* set (non-empty), suitable for passing to tmux createSession() env overrides.
|
|
605
|
+
*/
|
|
606
|
+
export function collectProviderEnv(): Record<string, string> {
|
|
607
|
+
const env: Record<string, string> = {};
|
|
608
|
+
const vars = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"] as const;
|
|
609
|
+
for (const key of vars) {
|
|
610
|
+
const value = process.env[key];
|
|
611
|
+
if (value) {
|
|
612
|
+
env[key] = value;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return env;
|
|
616
|
+
}
|