@katyella/legio 0.1.2 → 0.2.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 (111) hide show
  1. package/CHANGELOG.md +47 -3
  2. package/README.md +15 -8
  3. package/agents/builder.md +11 -10
  4. package/agents/coordinator.md +36 -27
  5. package/agents/cto.md +9 -8
  6. package/agents/gateway.md +28 -12
  7. package/agents/lead.md +45 -30
  8. package/agents/merger.md +4 -4
  9. package/agents/monitor.md +10 -9
  10. package/agents/reviewer.md +8 -8
  11. package/agents/scout.md +10 -10
  12. package/agents/supervisor.md +60 -45
  13. package/bin/legio.mjs +13 -2
  14. package/package.json +3 -3
  15. package/src/agents/hooks-deployer.test.ts +46 -41
  16. package/src/agents/hooks-deployer.ts +10 -9
  17. package/src/agents/manifest.test.ts +6 -2
  18. package/src/agents/overlay.test.ts +9 -7
  19. package/src/agents/overlay.ts +29 -7
  20. package/src/commands/agents.test.ts +1 -5
  21. package/src/commands/clean.test.ts +2 -5
  22. package/src/commands/clean.ts +25 -1
  23. package/src/commands/completions.test.ts +1 -1
  24. package/src/commands/completions.ts +26 -7
  25. package/src/commands/coordinator.test.ts +78 -78
  26. package/src/commands/coordinator.ts +92 -47
  27. package/src/commands/costs.test.ts +2 -6
  28. package/src/commands/dashboard.test.ts +2 -5
  29. package/src/commands/doctor.test.ts +2 -6
  30. package/src/commands/down.ts +3 -3
  31. package/src/commands/errors.test.ts +2 -6
  32. package/src/commands/feed.test.ts +2 -6
  33. package/src/commands/gateway.test.ts +39 -13
  34. package/src/commands/gateway.ts +95 -7
  35. package/src/commands/hooks.test.ts +2 -5
  36. package/src/commands/init.test.ts +4 -13
  37. package/src/commands/inspect.test.ts +2 -6
  38. package/src/commands/log.test.ts +2 -6
  39. package/src/commands/logs.test.ts +2 -9
  40. package/src/commands/mail.test.ts +76 -215
  41. package/src/commands/mail.ts +43 -187
  42. package/src/commands/metrics.test.ts +3 -10
  43. package/src/commands/nudge.ts +15 -0
  44. package/src/commands/prime.test.ts +4 -11
  45. package/src/commands/replay.test.ts +2 -6
  46. package/src/commands/server.test.ts +1 -5
  47. package/src/commands/server.ts +1 -1
  48. package/src/commands/sling.ts +40 -16
  49. package/src/commands/spec.test.ts +2 -5
  50. package/src/commands/status.test.ts +2 -4
  51. package/src/commands/stop.test.ts +2 -5
  52. package/src/commands/supervisor.ts +6 -6
  53. package/src/commands/trace.test.ts +2 -6
  54. package/src/commands/up.test.ts +43 -9
  55. package/src/commands/up.ts +15 -11
  56. package/src/commands/watchman.ts +327 -0
  57. package/src/commands/worktree.test.ts +2 -6
  58. package/src/config.test.ts +34 -104
  59. package/src/config.ts +120 -32
  60. package/src/doctor/agents.test.ts +7 -2
  61. package/src/doctor/config-check.test.ts +7 -2
  62. package/src/doctor/consistency.test.ts +7 -2
  63. package/src/doctor/databases.test.ts +6 -2
  64. package/src/doctor/dependencies.test.ts +35 -10
  65. package/src/doctor/dependencies.ts +16 -92
  66. package/src/doctor/logs.test.ts +7 -2
  67. package/src/doctor/merge-queue.test.ts +6 -2
  68. package/src/doctor/structure.test.ts +7 -2
  69. package/src/doctor/version.test.ts +7 -2
  70. package/src/e2e/init-sling-lifecycle.test.ts +2 -5
  71. package/src/index.ts +7 -7
  72. package/src/mail/pending.ts +120 -0
  73. package/src/mail/store.test.ts +89 -0
  74. package/src/mail/store.ts +11 -0
  75. package/src/merge/resolver.test.ts +518 -489
  76. package/src/server/index.ts +33 -2
  77. package/src/server/public/app.js +3 -3
  78. package/src/server/public/components/message-bubble.js +11 -1
  79. package/src/server/public/components/terminal-panel.js +66 -74
  80. package/src/server/public/views/chat.js +18 -2
  81. package/src/server/public/views/costs.js +5 -5
  82. package/src/server/public/views/dashboard.js +80 -51
  83. package/src/server/public/views/gateway-chat.js +37 -131
  84. package/src/server/public/views/inspect.js +16 -4
  85. package/src/server/public/views/issues.js +16 -12
  86. package/src/server/routes.test.ts +55 -39
  87. package/src/server/routes.ts +38 -26
  88. package/src/test-helpers.ts +6 -3
  89. package/src/tracker/beads.ts +159 -0
  90. package/src/tracker/exec.ts +44 -0
  91. package/src/tracker/factory.test.ts +283 -0
  92. package/src/tracker/factory.ts +59 -0
  93. package/src/tracker/seeds.ts +156 -0
  94. package/src/tracker/types.ts +46 -0
  95. package/src/types.ts +11 -2
  96. package/src/{watchdog → watchman}/daemon.test.ts +421 -515
  97. package/src/watchman/daemon.ts +940 -0
  98. package/src/worktree/tmux.test.ts +2 -1
  99. package/src/worktree/tmux.ts +4 -4
  100. package/templates/hooks.json.tmpl +17 -17
  101. package/src/beads/client.test.ts +0 -210
  102. package/src/commands/merge.test.ts +0 -676
  103. package/src/commands/watch.test.ts +0 -152
  104. package/src/commands/watch.ts +0 -238
  105. package/src/test-helpers.test.ts +0 -97
  106. package/src/watchdog/daemon.ts +0 -533
  107. package/src/watchdog/health.test.ts +0 -371
  108. package/src/watchdog/triage.test.ts +0 -162
  109. package/src/worktree/manager.test.ts +0 -444
  110. /package/src/{watchdog → watchman}/health.ts +0 -0
  111. /package/src/{watchdog → watchman}/triage.ts +0 -0
@@ -5,7 +5,7 @@
5
5
  * 1. Check git repo
6
6
  * 2. Initialize .legio/ if needed (legio init)
7
7
  * 3. Start the server in daemon mode (legio server start --daemon)
8
- * The server auto-starts the coordinator with watchdog.
8
+ * The server auto-starts the coordinator with watchman.
9
9
  * 4. Start the gateway (legio gateway start --no-attach)
10
10
  * 5. Open the browser (unless --no-open)
11
11
  *
@@ -16,7 +16,7 @@ import { spawn } from "node:child_process";
16
16
  import { access, readFile } from "node:fs/promises";
17
17
  import { join } from "node:path";
18
18
  import { ServerError, ValidationError } from "../errors.ts";
19
- import { isProcessRunning } from "../watchdog/health.ts";
19
+ import { isProcessRunning } from "../watchman/health.ts";
20
20
 
21
21
  function getFlag(args: string[], flag: string): string | undefined {
22
22
  const idx = args.indexOf(flag);
@@ -118,7 +118,7 @@ Options:
118
118
 
119
119
  legio up initializes .legio/ if needed, starts the server in daemon mode,
120
120
  starts the gateway, and opens the browser. The server auto-starts the
121
- coordinator with watchdog. Running legio up when already running is a no-op.`;
121
+ coordinator with watchman. Running legio up when already running is a no-op.`;
122
122
 
123
123
  /**
124
124
  * Entry point for \`legio up [options]\`.
@@ -238,14 +238,18 @@ export async function upCommand(args: string[], deps: UpDeps = {}): Promise<void
238
238
  // ignore status check errors, proceed to try starting
239
239
  }
240
240
  if (!gatewayRunning) {
241
- const gatewayStart = await run(["legio", "gateway", "start", "--no-attach"], {
242
- cwd: projectRoot,
243
- });
244
- if (gatewayStart.exitCode === 0) {
245
- gatewayStarted = true;
246
- if (!json && gatewayStart.stdout) process.stdout.write(gatewayStart.stdout);
247
- } else {
248
- process.stderr.write(`Warning: gateway start failed: ${gatewayStart.stderr.trim()}\n`);
241
+ try {
242
+ const gatewayResult = await run(["legio", "gateway", "start", "--no-attach", "--json"], {
243
+ cwd: projectRoot,
244
+ });
245
+ if (gatewayResult.exitCode === 0) {
246
+ gatewayStarted = true;
247
+ if (!json) process.stdout.write("Gateway started\n");
248
+ } else if (!json) {
249
+ process.stderr.write(`Warning: gateway start failed: ${gatewayResult.stderr.trim()}\n`);
250
+ }
251
+ } catch {
252
+ if (!json) process.stderr.write("Warning: gateway start failed\n");
249
253
  }
250
254
  }
251
255
 
@@ -0,0 +1,327 @@
1
+ /**
2
+ * CLI command: legio watchman start/stop/status
3
+ *
4
+ * Unified daemon combining:
5
+ * - Health monitoring (watchdog): session health checks, zombie detection, recovery
6
+ * - Mail delivery (mailman): poll for unread mail, nudge agents
7
+ * - Beacon safety net: detect stuck beacons and send follow-up Enter
8
+ *
9
+ * PID file: .legio/watchman.pid
10
+ */
11
+
12
+ import { spawn } from "node:child_process";
13
+ import { readFile, unlink, writeFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { loadConfig } from "../config.ts";
16
+ import { LegioError } from "../errors.ts";
17
+ import type { HealthCheck } from "../types.ts";
18
+ import { startDaemon } from "../watchman/daemon.ts";
19
+ import { isProcessRunning } from "../watchman/health.ts";
20
+
21
+ function getFlag(args: string[], flag: string): string | undefined {
22
+ const idx = args.indexOf(flag);
23
+ if (idx === -1 || idx + 1 >= args.length) return undefined;
24
+ return args[idx + 1];
25
+ }
26
+
27
+ function hasFlag(args: string[], flag: string): boolean {
28
+ return args.includes(flag);
29
+ }
30
+
31
+ /**
32
+ * Format a health check for display.
33
+ */
34
+ function formatCheck(check: HealthCheck): string {
35
+ const actionIcon =
36
+ check.action === "terminate"
37
+ ? "💀"
38
+ : check.action === "escalate"
39
+ ? "⚠️"
40
+ : check.action === "investigate"
41
+ ? "🔍"
42
+ : "✅";
43
+ const pidLabel = check.pidAlive === null ? "n/a" : check.pidAlive ? "up" : "down";
44
+ let line = `${actionIcon} ${check.agentName}: ${check.state} (tmux=${check.tmuxAlive ? "up" : "down"}, pid=${pidLabel})`;
45
+ if (check.reconciliationNote) {
46
+ line += ` [${check.reconciliationNote}]`;
47
+ }
48
+ return line;
49
+ }
50
+
51
+ /**
52
+ * Read the PID from the watchman PID file.
53
+ */
54
+ async function readPidFile(pidFilePath: string): Promise<number | null> {
55
+ try {
56
+ const text = await readFile(pidFilePath, "utf-8");
57
+ const pid = Number.parseInt(text.trim(), 10);
58
+ if (Number.isNaN(pid) || pid <= 0) return null;
59
+ return pid;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Write a PID to the watchman PID file.
67
+ */
68
+ async function writePidFile(pidFilePath: string, pid: number): Promise<void> {
69
+ await writeFile(pidFilePath, `${pid}\n`);
70
+ }
71
+
72
+ /**
73
+ * Remove the watchman PID file.
74
+ */
75
+ async function removePidFile(pidFilePath: string): Promise<void> {
76
+ try {
77
+ await unlink(pidFilePath);
78
+ } catch {
79
+ // File may already be gone
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Resolve the path to the legio binary for re-launching.
85
+ */
86
+ async function resolveLegioBin(): Promise<string> {
87
+ try {
88
+ const result = await new Promise<{ exitCode: number; stdout: string }>((resolve) => {
89
+ const proc = spawn("which", ["legio"], { stdio: ["ignore", "pipe", "pipe"] });
90
+ const stdoutChunks: Buffer[] = [];
91
+ proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
92
+ proc.on("close", (code: number | null) => {
93
+ resolve({ exitCode: code ?? 1, stdout: Buffer.concat(stdoutChunks).toString("utf-8") });
94
+ });
95
+ });
96
+ if (result.exitCode === 0) {
97
+ const binPath = result.stdout.trim();
98
+ if (binPath.length > 0) return binPath;
99
+ }
100
+ } catch {
101
+ // which not available
102
+ }
103
+ const scriptPath = process.argv[1];
104
+ if (scriptPath) return scriptPath;
105
+ throw new LegioError("Cannot resolve legio binary path for background launch", "WATCHMAN_ERROR");
106
+ }
107
+
108
+ /** Handle `legio watchman start` */
109
+ async function handleStart(args: string[]): Promise<void> {
110
+ const background = hasFlag(args, "--background");
111
+ const intervalStr = getFlag(args, "--interval");
112
+
113
+ const cwd = process.cwd();
114
+ const config = await loadConfig(cwd);
115
+ const root = config.project.root;
116
+ const intervalMs = intervalStr
117
+ ? Number.parseInt(intervalStr, 10)
118
+ : config.watchman.tier0IntervalMs;
119
+ const pidFilePath = join(root, ".legio", "watchman.pid");
120
+
121
+ if (background) {
122
+ // Check if already running
123
+ const existingPid = await readPidFile(pidFilePath);
124
+ if (existingPid !== null && isProcessRunning(existingPid)) {
125
+ process.stderr.write(
126
+ `Error: Watchman already running (PID: ${existingPid}). ` +
127
+ `Kill it first or remove ${pidFilePath}\n`,
128
+ );
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+
133
+ if (existingPid !== null) {
134
+ await removePidFile(pidFilePath);
135
+ }
136
+
137
+ const childArgs: string[] = ["watchman", "start"];
138
+ if (intervalStr) {
139
+ childArgs.push("--interval", intervalStr);
140
+ }
141
+
142
+ const legioBin = await resolveLegioBin();
143
+ const child = spawn(process.execPath, ["--import", "tsx", legioBin, ...childArgs], {
144
+ cwd,
145
+ stdio: "ignore",
146
+ detached: true,
147
+ });
148
+ child.unref();
149
+
150
+ const childPid = child.pid ?? 0;
151
+ await writePidFile(pidFilePath, childPid);
152
+
153
+ process.stdout.write(
154
+ `Watchman started in background (PID: ${childPid}, interval: ${intervalMs}ms)\n`,
155
+ );
156
+ process.stdout.write(`PID file: ${pidFilePath}\n`);
157
+ return;
158
+ }
159
+
160
+ // Foreground mode
161
+ process.stdout.write(
162
+ `Watchman running (health: ${intervalMs}ms, mail: ${config.watchman.mailIntervalMs}ms)\n`,
163
+ );
164
+ process.stdout.write("Press Ctrl+C to stop.\n\n");
165
+
166
+ await writePidFile(pidFilePath, process.pid);
167
+
168
+ const { stop } = startDaemon({
169
+ root,
170
+ intervalMs,
171
+ zombieThresholdMs: config.watchman.zombieThresholdMs,
172
+ mailIntervalMs: config.watchman.mailIntervalMs,
173
+ reNudgeIntervalMs: config.watchman.reNudgeIntervalMs,
174
+ warnAfterMs: config.watchman.warnAfterMs,
175
+ beaconNudgeMs: config.watchman.beaconNudgeMs,
176
+ onHealthCheck(check) {
177
+ const timestamp = new Date().toISOString().slice(11, 19);
178
+ process.stdout.write(`[${timestamp}] ${formatCheck(check)}\n`);
179
+ },
180
+ onNudge(agentName, nudgeCount) {
181
+ const timestamp = new Date().toISOString().slice(11, 19);
182
+ process.stdout.write(`[${timestamp}] 📬 Nudged ${agentName} (attempt ${nudgeCount})\n`);
183
+ },
184
+ onWarn(agentName, unreadSinceMs) {
185
+ const timestamp = new Date().toISOString().slice(11, 19);
186
+ const seconds = Math.round(unreadSinceMs / 1000);
187
+ process.stdout.write(`[${timestamp}] ⚠️ ${agentName} has had unread mail for ${seconds}s\n`);
188
+ },
189
+ });
190
+
191
+ process.on("SIGINT", () => {
192
+ stop();
193
+ removePidFile(pidFilePath).finally(() => {
194
+ process.stdout.write("\nWatchman stopped.\n");
195
+ process.exit(0);
196
+ });
197
+ });
198
+
199
+ // Block forever
200
+ await new Promise(() => {});
201
+ }
202
+
203
+ /** Handle `legio watchman stop` */
204
+ async function handleStop(args: string[]): Promise<void> {
205
+ const json = hasFlag(args, "--json");
206
+ const cwd = process.cwd();
207
+ const config = await loadConfig(cwd);
208
+ const pidFilePath = join(config.project.root, ".legio", "watchman.pid");
209
+
210
+ const pid = await readPidFile(pidFilePath);
211
+
212
+ if (pid === null) {
213
+ if (json) {
214
+ process.stdout.write(`${JSON.stringify({ stopped: false, reason: "not running" })}\n`);
215
+ } else {
216
+ process.stdout.write("Watchman is not running (no PID file)\n");
217
+ }
218
+ return;
219
+ }
220
+
221
+ if (!isProcessRunning(pid)) {
222
+ await removePidFile(pidFilePath);
223
+ if (json) {
224
+ process.stdout.write(
225
+ `${JSON.stringify({ stopped: false, reason: "stale PID file cleaned" })}\n`,
226
+ );
227
+ } else {
228
+ process.stdout.write("Watchman is not running (stale PID file cleaned)\n");
229
+ }
230
+ return;
231
+ }
232
+
233
+ try {
234
+ process.kill(pid, "SIGTERM");
235
+ } catch {
236
+ // Process may have just exited
237
+ }
238
+
239
+ await removePidFile(pidFilePath);
240
+
241
+ if (json) {
242
+ process.stdout.write(`${JSON.stringify({ stopped: true, pid })}\n`);
243
+ } else {
244
+ process.stdout.write(`Watchman stopped (PID: ${pid})\n`);
245
+ }
246
+ }
247
+
248
+ /** Handle `legio watchman status` */
249
+ async function handleStatus(args: string[]): Promise<void> {
250
+ const json = hasFlag(args, "--json");
251
+ const cwd = process.cwd();
252
+ const config = await loadConfig(cwd);
253
+ const pidFilePath = join(config.project.root, ".legio", "watchman.pid");
254
+
255
+ const pid = await readPidFile(pidFilePath);
256
+
257
+ if (pid === null) {
258
+ if (json) {
259
+ process.stdout.write(`${JSON.stringify({ running: false })}\n`);
260
+ } else {
261
+ process.stdout.write("Watchman: not running\n");
262
+ }
263
+ return;
264
+ }
265
+
266
+ const running = isProcessRunning(pid);
267
+
268
+ if (!running) {
269
+ await removePidFile(pidFilePath);
270
+ }
271
+
272
+ if (json) {
273
+ process.stdout.write(`${JSON.stringify({ running, pid: running ? pid : null })}\n`);
274
+ } else if (running) {
275
+ process.stdout.write(`Watchman: running (PID: ${pid})\n`);
276
+ } else {
277
+ process.stdout.write("Watchman: not running (stale PID file cleaned)\n");
278
+ }
279
+ }
280
+
281
+ const WATCHMAN_HELP = `legio watchman — Unified daemon (health + mail + beacon)
282
+
283
+ Usage: legio watchman <subcommand> [options]
284
+
285
+ Subcommands:
286
+ start Start the watchman daemon
287
+ --background Daemonize (run in background)
288
+ --interval <ms> Health check interval in milliseconds (default: from config)
289
+ stop Stop the watchman daemon
290
+ --json JSON output
291
+ status Show watchman status
292
+ --json JSON output
293
+
294
+ Options:
295
+ --help, -h Show this help
296
+
297
+ The watchman daemon combines three capabilities:
298
+ 1. Health monitoring: session health checks, zombie detection, auto-recovery
299
+ 2. Mail delivery: polls for unread mail and nudges agents until they read it
300
+ 3. Beacon safety net: detects stuck beacons and sends follow-up Enter`;
301
+
302
+ export async function watchmanCommand(args: string[]): Promise<void> {
303
+ if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
304
+ process.stdout.write(`${WATCHMAN_HELP}\n`);
305
+ return;
306
+ }
307
+
308
+ const subcommand = args[0];
309
+ const subArgs = args.slice(1);
310
+
311
+ switch (subcommand) {
312
+ case "start":
313
+ await handleStart(subArgs);
314
+ break;
315
+ case "stop":
316
+ await handleStop(subArgs);
317
+ break;
318
+ case "status":
319
+ await handleStatus(subArgs);
320
+ break;
321
+ default:
322
+ process.stderr.write(
323
+ `Unknown watchman subcommand: ${subcommand ?? "(none)"}. Use: start, stop, status\n`,
324
+ );
325
+ process.exitCode = 1;
326
+ }
327
+ }
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync, realpathSync } from "node:fs";
3
3
  import { access, mkdir, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
- import { afterEach, beforeEach, describe, expect, test } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
6
6
  import { createSessionStore } from "../sessions/store.ts";
7
7
  import { cleanupTempDir, createTempGitRepo, runGitInDir } from "../test-helpers.ts";
8
8
  import type { AgentSession } from "../types.ts";
@@ -20,7 +20,6 @@ describe("worktreeCommand", () => {
20
20
  let chunks: string[];
21
21
  let originalWrite: typeof process.stdout.write;
22
22
  let tempDir: string;
23
- let originalCwd: string;
24
23
 
25
24
  beforeEach(async () => {
26
25
  // Spy on stdout
@@ -42,14 +41,11 @@ describe("worktreeCommand", () => {
42
41
  `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
43
42
  );
44
43
 
45
- // Change to temp dir so loadConfig() works
46
- originalCwd = process.cwd();
47
- process.chdir(tempDir);
44
+ vi.spyOn(process, "cwd").mockReturnValue(tempDir);
48
45
  });
49
46
 
50
47
  afterEach(async () => {
51
48
  process.stdout.write = originalWrite;
52
- process.chdir(originalCwd);
53
49
  await cleanupTempDir(tempDir);
54
50
  });
55
51
 
@@ -1,10 +1,9 @@
1
- import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
5
- import { DEFAULT_CONFIG, loadConfig, resolveProjectRoot } from "./config.ts";
5
+ import { DEFAULT_CONFIG, loadConfig } from "./config.ts";
6
6
  import { ValidationError } from "./errors.ts";
7
- import { cleanupTempDir, createTempGitRepo, runGitInDir } from "./test-helpers.ts";
8
7
 
9
8
  describe("loadConfig", () => {
10
9
  let tempDir: string;
@@ -33,7 +32,8 @@ describe("loadConfig", () => {
33
32
  expect(config.project.canonicalBranch).toBe("main");
34
33
  expect(config.agents.maxConcurrent).toBe(25);
35
34
  expect(config.agents.maxDepth).toBe(2);
36
- expect(config.beads.enabled).toBe(true);
35
+ expect(config.taskTracker.enabled).toBe(true);
36
+ expect(config.taskTracker.backend).toBe("auto");
37
37
  expect(config.mulch.enabled).toBe(true);
38
38
  expect(config.mulch.primeFormat).toBe("markdown");
39
39
  expect(config.logging.verbose).toBe(false);
@@ -61,7 +61,7 @@ agents:
61
61
  expect(config.agents.maxConcurrent).toBe(10);
62
62
  // Non-overridden values keep defaults
63
63
  expect(config.agents.maxDepth).toBe(2);
64
- expect(config.beads.enabled).toBe(true);
64
+ expect(config.taskTracker.enabled).toBe(true);
65
65
  });
66
66
 
67
67
  test("always sets project.root to the actual projectRoot", async () => {
@@ -78,8 +78,9 @@ project:
78
78
  test("parses boolean values correctly", async () => {
79
79
  await ensureLegioDir();
80
80
  await writeConfig(`
81
- beads:
81
+ taskTracker:
82
82
  enabled: false
83
+ backend: beads
83
84
  mulch:
84
85
  enabled: true
85
86
  logging:
@@ -89,7 +90,8 @@ logging:
89
90
 
90
91
  const config = await loadConfig(tempDir);
91
92
 
92
- expect(config.beads.enabled).toBe(false);
93
+ expect(config.taskTracker.enabled).toBe(false);
94
+ expect(config.taskTracker.backend).toBe("beads");
93
95
  expect(config.mulch.enabled).toBe(true);
94
96
  expect(config.logging.verbose).toBe(true);
95
97
  expect(config.logging.redactSecrets).toBe(false);
@@ -118,7 +120,7 @@ watchdog:
118
120
 
119
121
  const config = await loadConfig(tempDir);
120
122
  expect(config.agents.staggerDelayMs).toBe(5000);
121
- expect(config.watchdog.tier0IntervalMs).toBe(60000);
123
+ expect(config.watchman.tier0IntervalMs).toBe(60000);
122
124
  });
123
125
 
124
126
  test("handles quoted string values", async () => {
@@ -202,9 +204,9 @@ watchdog:
202
204
 
203
205
  const config = await loadConfig(tempDir);
204
206
  // Local override
205
- expect(config.watchdog.tier0Enabled).toBe(true);
207
+ expect(config.watchman.tier0Enabled).toBe(true);
206
208
  // Non-overridden value from config.yaml preserved
207
- expect(config.watchdog.zombieThresholdMs).toBe(120000);
209
+ expect(config.watchman.zombieThresholdMs).toBe(120000);
208
210
  });
209
211
 
210
212
  test("migrates deprecated watchdog tier1/tier2 keys to tier0/tier1", async () => {
@@ -218,10 +220,22 @@ watchdog:
218
220
 
219
221
  const config = await loadConfig(tempDir);
220
222
  // Old tier1 (mechanical daemon) → new tier0
221
- expect(config.watchdog.tier0Enabled).toBe(true);
222
- expect(config.watchdog.tier0IntervalMs).toBe(45000);
223
+ expect(config.watchman.tier0Enabled).toBe(true);
224
+ expect(config.watchman.tier0IntervalMs).toBe(45000);
223
225
  // Old tier2 (AI triage) → new tier1
224
- expect(config.watchdog.tier1Enabled).toBe(true);
226
+ expect(config.watchman.tier1Enabled).toBe(true);
227
+ });
228
+
229
+ test("migrates deprecated 'beads' key to taskTracker", async () => {
230
+ await ensureLegioDir();
231
+ await writeConfig(`
232
+ beads:
233
+ enabled: false
234
+ `);
235
+
236
+ const config = await loadConfig(tempDir);
237
+ expect(config.taskTracker.enabled).toBe(false);
238
+ expect(config.taskTracker.backend).toBe("auto");
225
239
  });
226
240
 
227
241
  test("new-style tier keys take precedence over deprecated keys", async () => {
@@ -235,9 +249,9 @@ watchdog:
235
249
 
236
250
  const config = await loadConfig(tempDir);
237
251
  // 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);
252
+ expect(config.watchman.tier0Enabled).toBe(false);
253
+ expect(config.watchman.tier0IntervalMs).toBe(20000);
254
+ expect(config.watchman.tier1Enabled).toBe(true);
241
255
  });
242
256
  });
243
257
 
@@ -336,99 +350,15 @@ models:
336
350
  });
337
351
  });
338
352
 
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
353
  describe("DEFAULT_CONFIG", () => {
424
354
  test("has all required top-level keys", () => {
425
355
  expect(DEFAULT_CONFIG.project).toBeDefined();
426
356
  expect(DEFAULT_CONFIG.agents).toBeDefined();
427
357
  expect(DEFAULT_CONFIG.worktrees).toBeDefined();
428
- expect(DEFAULT_CONFIG.beads).toBeDefined();
358
+ expect(DEFAULT_CONFIG.taskTracker).toBeDefined();
429
359
  expect(DEFAULT_CONFIG.mulch).toBeDefined();
430
360
  expect(DEFAULT_CONFIG.merge).toBeDefined();
431
- expect(DEFAULT_CONFIG.watchdog).toBeDefined();
361
+ expect(DEFAULT_CONFIG.watchman).toBeDefined();
432
362
  expect(DEFAULT_CONFIG.models).toBeDefined();
433
363
  expect(DEFAULT_CONFIG.logging).toBeDefined();
434
364
  });
@@ -438,8 +368,8 @@ describe("DEFAULT_CONFIG", () => {
438
368
  expect(DEFAULT_CONFIG.agents.maxConcurrent).toBe(25);
439
369
  expect(DEFAULT_CONFIG.agents.maxDepth).toBe(2);
440
370
  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);
371
+ expect(DEFAULT_CONFIG.watchman.tier0IntervalMs).toBe(30_000);
372
+ expect(DEFAULT_CONFIG.watchman.zombieThresholdMs).toBe(600_000);
443
373
  });
444
374
 
445
375
  test("agents.maxAgentsPerLead defaults to 5", () => {