@katyella/legio 0.1.3 → 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 (110) hide show
  1. package/CHANGELOG.md +40 -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/package.json +2 -2
  14. package/src/agents/hooks-deployer.test.ts +46 -41
  15. package/src/agents/hooks-deployer.ts +10 -9
  16. package/src/agents/manifest.test.ts +6 -2
  17. package/src/agents/overlay.test.ts +9 -7
  18. package/src/agents/overlay.ts +29 -7
  19. package/src/commands/agents.test.ts +1 -5
  20. package/src/commands/clean.test.ts +2 -5
  21. package/src/commands/clean.ts +25 -1
  22. package/src/commands/completions.test.ts +1 -1
  23. package/src/commands/completions.ts +26 -7
  24. package/src/commands/coordinator.test.ts +78 -78
  25. package/src/commands/coordinator.ts +92 -47
  26. package/src/commands/costs.test.ts +2 -6
  27. package/src/commands/dashboard.test.ts +2 -5
  28. package/src/commands/doctor.test.ts +2 -6
  29. package/src/commands/down.ts +3 -3
  30. package/src/commands/errors.test.ts +2 -6
  31. package/src/commands/feed.test.ts +2 -6
  32. package/src/commands/gateway.test.ts +39 -13
  33. package/src/commands/gateway.ts +95 -7
  34. package/src/commands/hooks.test.ts +2 -5
  35. package/src/commands/init.test.ts +4 -13
  36. package/src/commands/inspect.test.ts +2 -6
  37. package/src/commands/log.test.ts +2 -6
  38. package/src/commands/logs.test.ts +2 -9
  39. package/src/commands/mail.test.ts +76 -215
  40. package/src/commands/mail.ts +43 -187
  41. package/src/commands/metrics.test.ts +3 -10
  42. package/src/commands/nudge.ts +15 -0
  43. package/src/commands/prime.test.ts +4 -11
  44. package/src/commands/replay.test.ts +2 -6
  45. package/src/commands/server.test.ts +1 -5
  46. package/src/commands/server.ts +1 -1
  47. package/src/commands/sling.ts +40 -16
  48. package/src/commands/spec.test.ts +2 -5
  49. package/src/commands/status.test.ts +2 -4
  50. package/src/commands/stop.test.ts +2 -5
  51. package/src/commands/supervisor.ts +6 -6
  52. package/src/commands/trace.test.ts +2 -6
  53. package/src/commands/up.test.ts +43 -9
  54. package/src/commands/up.ts +15 -11
  55. package/src/commands/watchman.ts +327 -0
  56. package/src/commands/worktree.test.ts +2 -6
  57. package/src/config.test.ts +34 -104
  58. package/src/config.ts +120 -32
  59. package/src/doctor/agents.test.ts +7 -2
  60. package/src/doctor/config-check.test.ts +7 -2
  61. package/src/doctor/consistency.test.ts +7 -2
  62. package/src/doctor/databases.test.ts +6 -2
  63. package/src/doctor/dependencies.test.ts +35 -10
  64. package/src/doctor/dependencies.ts +16 -92
  65. package/src/doctor/logs.test.ts +7 -2
  66. package/src/doctor/merge-queue.test.ts +6 -2
  67. package/src/doctor/structure.test.ts +7 -2
  68. package/src/doctor/version.test.ts +7 -2
  69. package/src/e2e/init-sling-lifecycle.test.ts +2 -5
  70. package/src/index.ts +7 -7
  71. package/src/mail/pending.ts +120 -0
  72. package/src/mail/store.test.ts +89 -0
  73. package/src/mail/store.ts +11 -0
  74. package/src/merge/resolver.test.ts +518 -489
  75. package/src/server/index.ts +33 -2
  76. package/src/server/public/app.js +3 -3
  77. package/src/server/public/components/message-bubble.js +11 -1
  78. package/src/server/public/components/terminal-panel.js +66 -74
  79. package/src/server/public/views/chat.js +18 -2
  80. package/src/server/public/views/costs.js +5 -5
  81. package/src/server/public/views/dashboard.js +80 -51
  82. package/src/server/public/views/gateway-chat.js +37 -131
  83. package/src/server/public/views/inspect.js +16 -4
  84. package/src/server/public/views/issues.js +16 -12
  85. package/src/server/routes.test.ts +55 -39
  86. package/src/server/routes.ts +38 -26
  87. package/src/test-helpers.ts +6 -3
  88. package/src/tracker/beads.ts +159 -0
  89. package/src/tracker/exec.ts +44 -0
  90. package/src/tracker/factory.test.ts +283 -0
  91. package/src/tracker/factory.ts +59 -0
  92. package/src/tracker/seeds.ts +156 -0
  93. package/src/tracker/types.ts +46 -0
  94. package/src/types.ts +11 -2
  95. package/src/{watchdog → watchman}/daemon.test.ts +421 -515
  96. package/src/watchman/daemon.ts +940 -0
  97. package/src/worktree/tmux.test.ts +2 -1
  98. package/src/worktree/tmux.ts +4 -4
  99. package/templates/hooks.json.tmpl +17 -17
  100. package/src/beads/client.test.ts +0 -210
  101. package/src/commands/merge.test.ts +0 -676
  102. package/src/commands/watch.test.ts +0 -152
  103. package/src/commands/watch.ts +0 -238
  104. package/src/test-helpers.test.ts +0 -97
  105. package/src/watchdog/daemon.ts +0 -533
  106. package/src/watchdog/health.test.ts +0 -371
  107. package/src/watchdog/triage.test.ts +0 -162
  108. package/src/worktree/manager.test.ts +0 -444
  109. /package/src/{watchdog → watchman}/health.ts +0 -0
  110. /package/src/{watchdog → watchman}/triage.ts +0 -0
@@ -1,152 +0,0 @@
1
- import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, test } from "vitest";
5
- import { watchCommand } from "./watch.ts";
6
-
7
- /**
8
- * Tests for `legio watch` command.
9
- *
10
- * IMPORTANT: We CANNOT test the actual daemon loop (it would hang the test).
11
- * Focus on:
12
- * - Help output (safe, returns immediately)
13
- * - Background mode: already-running detection
14
- * - Background mode: stale PID cleanup
15
- *
16
- * We do NOT test:
17
- * - Foreground mode (blocks forever with await new Promise(() => {}))
18
- * - Actual health check loop behavior
19
- */
20
-
21
- describe("watchCommand", () => {
22
- let chunks: string[];
23
- let stderrChunks: string[];
24
- let originalWrite: typeof process.stdout.write;
25
- let originalStderrWrite: typeof process.stderr.write;
26
- let tempDir: string;
27
- let originalCwd: string;
28
- let originalExitCode: string | number | null | undefined;
29
-
30
- beforeEach(async () => {
31
- // Spy on stdout
32
- chunks = [];
33
- originalWrite = process.stdout.write;
34
- process.stdout.write = ((chunk: string) => {
35
- chunks.push(chunk);
36
- return true;
37
- }) as typeof process.stdout.write;
38
-
39
- // Spy on stderr
40
- stderrChunks = [];
41
- originalStderrWrite = process.stderr.write;
42
- process.stderr.write = ((chunk: string) => {
43
- stderrChunks.push(chunk);
44
- return true;
45
- }) as typeof process.stderr.write;
46
-
47
- // Save original exitCode
48
- originalExitCode = process.exitCode;
49
- process.exitCode = 0;
50
-
51
- // Create temp dir with .legio/config.yaml structure
52
- tempDir = await mkdtemp(join(tmpdir(), "watch-test-"));
53
- const legioDir = join(tempDir, ".legio");
54
- await mkdir(legioDir, { recursive: true });
55
- await writeFile(
56
- join(legioDir, "config.yaml"),
57
- `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
58
- );
59
-
60
- // Change to temp dir so loadConfig() works
61
- originalCwd = process.cwd();
62
- process.chdir(tempDir);
63
- });
64
-
65
- afterEach(async () => {
66
- process.stdout.write = originalWrite;
67
- process.stderr.write = originalStderrWrite;
68
- process.exitCode = originalExitCode;
69
- process.chdir(originalCwd);
70
- await rm(tempDir, { recursive: true, force: true });
71
- });
72
-
73
- function output(): string {
74
- return chunks.join("");
75
- }
76
-
77
- function stderr(): string {
78
- return stderrChunks.join("");
79
- }
80
-
81
- test("--help flag shows help text with key info", async () => {
82
- await watchCommand(["--help"]);
83
- const out = output();
84
-
85
- expect(out).toContain("legio watch");
86
- expect(out).toContain("--interval");
87
- expect(out).toContain("--background");
88
- expect(out).toContain("Tier 0");
89
- });
90
-
91
- test("-h flag shows help text", async () => {
92
- await watchCommand(["-h"]);
93
- const out = output();
94
-
95
- expect(out).toContain("legio watch");
96
- expect(out).toContain("Tier 0");
97
- });
98
-
99
- test("background mode: already running detection", async () => {
100
- // Write a PID file with a running process (use our own PID)
101
- const pidFilePath = join(tempDir, ".legio", "watchdog.pid");
102
- await writeFile(pidFilePath, `${process.pid}\n`);
103
-
104
- // Try to start in background mode — should fail with "already running"
105
- await watchCommand(["--background"]);
106
-
107
- const err = stderr();
108
- expect(err).toContain("already running");
109
- expect(err).toContain(`${process.pid}`);
110
- expect(process.exitCode).toBe(1);
111
- });
112
-
113
- test("background mode: stale PID cleanup", async () => {
114
- // Write a PID file with a non-running process (999999 is very unlikely to exist)
115
- const pidFilePath = join(tempDir, ".legio", "watchdog.pid");
116
- await writeFile(pidFilePath, "999999\n");
117
-
118
- // Verify the stale PID file exists before the test
119
- const fileBeforeExists = await access(pidFilePath).then(
120
- () => true,
121
- () => false,
122
- );
123
- expect(fileBeforeExists).toBe(true);
124
-
125
- // Try to start in background mode
126
- // This will clean up the stale PID file, then attempt to spawn.
127
- // The spawn will fail because there's no real legio binary in test env,
128
- // but the important part is that the stale PID file gets removed.
129
- try {
130
- await watchCommand(["--background"]);
131
- } catch {
132
- // Expected to fail when trying to spawn — that's OK
133
- }
134
-
135
- // The stale PID file should have been removed during the check
136
- // (Even if the spawn itself failed, the cleanup happens before spawn)
137
- // Actually, looking at the code: if existingPid is not null but not running,
138
- // it removes the PID file. Then it tries to spawn. So the file should be gone
139
- // OR replaced with a new PID.
140
-
141
- // Let's check: the file should either not exist, OR contain a different PID
142
- const fileAfterExists = await access(pidFilePath).then(
143
- () => true,
144
- () => false,
145
- );
146
- if (fileAfterExists) {
147
- const content = await readFile(pidFilePath, "utf-8");
148
- expect(content.trim()).not.toBe("999999");
149
- }
150
- // If it doesn't exist, that's also valid (spawn failed before writing new PID)
151
- });
152
- });
@@ -1,238 +0,0 @@
1
- /**
2
- * CLI command: legio watch [--interval <ms>] [--background]
3
- *
4
- * Starts the Tier 0 mechanical watchdog daemon. Foreground mode shows real-time status.
5
- * Background mode spawns a detached process via node:child_process and writes a PID file.
6
- * Interval configurable, default 30000ms.
7
- */
8
-
9
- import { spawn } from "node:child_process";
10
- import { readFile, writeFile } from "node:fs/promises";
11
- import { join } from "node:path";
12
- import { loadConfig } from "../config.ts";
13
- import { LegioError } from "../errors.ts";
14
- import type { HealthCheck } from "../types.ts";
15
- import { startDaemon } from "../watchdog/daemon.ts";
16
- import { isProcessRunning } from "../watchdog/health.ts";
17
-
18
- /**
19
- * Parse a named flag value from args.
20
- */
21
- function getFlag(args: string[], flag: string): string | undefined {
22
- const idx = args.indexOf(flag);
23
- if (idx === -1 || idx + 1 >= args.length) {
24
- return undefined;
25
- }
26
- return args[idx + 1];
27
- }
28
-
29
- function hasFlag(args: string[], flag: string): boolean {
30
- return args.includes(flag);
31
- }
32
-
33
- /**
34
- * Format a health check for display.
35
- */
36
- function formatCheck(check: HealthCheck): string {
37
- const actionIcon =
38
- check.action === "terminate"
39
- ? "💀"
40
- : check.action === "escalate"
41
- ? "⚠️"
42
- : check.action === "investigate"
43
- ? "🔍"
44
- : "✅";
45
- const pidLabel = check.pidAlive === null ? "n/a" : check.pidAlive ? "up" : "down";
46
- let line = `${actionIcon} ${check.agentName}: ${check.state} (tmux=${check.tmuxAlive ? "up" : "down"}, pid=${pidLabel})`;
47
- if (check.reconciliationNote) {
48
- line += ` [${check.reconciliationNote}]`;
49
- }
50
- return line;
51
- }
52
-
53
- // isProcessRunning is imported from ../watchdog/health.ts (ZFC shared utility)
54
-
55
- /**
56
- * Read the PID from the watchdog PID file.
57
- * Returns null if the file doesn't exist or can't be parsed.
58
- */
59
- async function readPidFile(pidFilePath: string): Promise<number | null> {
60
- try {
61
- const text = await readFile(pidFilePath, "utf-8");
62
- const pid = Number.parseInt(text.trim(), 10);
63
- if (Number.isNaN(pid) || pid <= 0) {
64
- return null;
65
- }
66
- return pid;
67
- } catch {
68
- return null;
69
- }
70
- }
71
-
72
- /**
73
- * Write a PID to the watchdog PID file.
74
- */
75
- async function writePidFile(pidFilePath: string, pid: number): Promise<void> {
76
- await writeFile(pidFilePath, `${pid}\n`);
77
- }
78
-
79
- /**
80
- * Remove the watchdog PID file.
81
- */
82
- async function removePidFile(pidFilePath: string): Promise<void> {
83
- const { unlink } = await import("node:fs/promises");
84
- try {
85
- await unlink(pidFilePath);
86
- } catch {
87
- // File may already be gone — not an error
88
- }
89
- }
90
-
91
- /**
92
- * Resolve the path to the legio binary for re-launching.
93
- * Uses `which legio` first, then falls back to process.argv.
94
- */
95
- async function resolveLegioBin(): Promise<string> {
96
- try {
97
- const result = await new Promise<{ exitCode: number; stdout: string }>((resolve) => {
98
- const proc = spawn("which", ["legio"], { stdio: ["ignore", "pipe", "pipe"] });
99
- const stdoutChunks: Buffer[] = [];
100
- proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
101
- proc.on("close", (code: number | null) => {
102
- resolve({ exitCode: code ?? 1, stdout: Buffer.concat(stdoutChunks).toString("utf-8") });
103
- });
104
- });
105
- if (result.exitCode === 0) {
106
- const binPath = result.stdout.trim();
107
- if (binPath.length > 0) {
108
- return binPath;
109
- }
110
- }
111
- } catch {
112
- // which not available or legio not on PATH
113
- }
114
-
115
- // Fallback: use the script that's currently running (process.argv[1])
116
- const scriptPath = process.argv[1];
117
- if (scriptPath) {
118
- return scriptPath;
119
- }
120
-
121
- throw new LegioError("Cannot resolve legio binary path for background launch", "WATCH_ERROR");
122
- }
123
-
124
- /**
125
- * Entry point for `legio watch [--interval <ms>] [--background]`.
126
- */
127
- const WATCH_HELP = `legio watch — Start Tier 0 mechanical watchdog daemon
128
-
129
- Usage: legio watch [--interval <ms>] [--background]
130
-
131
- Tier numbering:
132
- Tier 0 Mechanical daemon (heartbeat, tmux/pid liveness) — this command
133
- Tier 1 Triage agent (ephemeral AI analysis of stalled agents)
134
- Tier 2 Monitor agent (continuous patrol — not yet implemented)
135
- Tier 3 Supervisor monitors (per-project)
136
-
137
- Options:
138
- --interval <ms> Health check interval in milliseconds (default: from config)
139
- --background Daemonize (run in background)
140
- --help, -h Show this help`;
141
-
142
- export async function watchCommand(args: string[]): Promise<void> {
143
- if (args.includes("--help") || args.includes("-h")) {
144
- process.stdout.write(`${WATCH_HELP}\n`);
145
- return;
146
- }
147
-
148
- const intervalStr = getFlag(args, "--interval");
149
- const background = hasFlag(args, "--background");
150
-
151
- const cwd = process.cwd();
152
- const config = await loadConfig(cwd);
153
-
154
- const intervalMs = intervalStr
155
- ? Number.parseInt(intervalStr, 10)
156
- : config.watchdog.tier0IntervalMs;
157
-
158
- const zombieThresholdMs = config.watchdog.zombieThresholdMs;
159
- const pidFilePath = join(config.project.root, ".legio", "watchdog.pid");
160
-
161
- if (background) {
162
- // Check if a watchdog is already running
163
- const existingPid = await readPidFile(pidFilePath);
164
- if (existingPid !== null && isProcessRunning(existingPid)) {
165
- process.stderr.write(
166
- `Error: Watchdog already running (PID: ${existingPid}). ` +
167
- `Kill it first or remove ${pidFilePath}\n`,
168
- );
169
- process.exitCode = 1;
170
- return;
171
- }
172
-
173
- // Clean up stale PID file if process is no longer running
174
- if (existingPid !== null) {
175
- await removePidFile(pidFilePath);
176
- }
177
-
178
- // Build the args for the child process, forwarding --interval but not --background
179
- const childArgs: string[] = ["watch"];
180
- if (intervalStr) {
181
- childArgs.push("--interval", intervalStr);
182
- }
183
-
184
- // Resolve the legio binary path
185
- const legioBin = await resolveLegioBin();
186
-
187
- // Spawn a detached background process running `legio watch` (without --background)
188
- const child = spawn(process.execPath, ["--import", "tsx", legioBin, ...childArgs], {
189
- cwd,
190
- stdio: "ignore",
191
- detached: true,
192
- });
193
-
194
- // Unref the child so the parent can exit without waiting for it
195
- child.unref();
196
-
197
- const childPid = child.pid ?? 0;
198
-
199
- // Write PID file for later cleanup
200
- await writePidFile(pidFilePath, childPid);
201
-
202
- process.stdout.write(
203
- `Watchdog started in background (PID: ${childPid}, interval: ${intervalMs}ms)\n`,
204
- );
205
- process.stdout.write(`PID file: ${pidFilePath}\n`);
206
- return;
207
- }
208
-
209
- // Foreground mode: show real-time health checks
210
- process.stdout.write(`Watchdog running (interval: ${intervalMs}ms)\n`);
211
- process.stdout.write("Press Ctrl+C to stop.\n\n");
212
-
213
- // Write PID file so `--background` check and external tools can find us
214
- await writePidFile(pidFilePath, process.pid);
215
-
216
- const { stop } = startDaemon({
217
- root: config.project.root,
218
- intervalMs,
219
- zombieThresholdMs,
220
- onHealthCheck(check) {
221
- const timestamp = new Date().toISOString().slice(11, 19);
222
- process.stdout.write(`[${timestamp}] ${formatCheck(check)}\n`);
223
- },
224
- });
225
-
226
- // Keep running until interrupted
227
- process.on("SIGINT", () => {
228
- stop();
229
- // Clean up PID file on graceful shutdown
230
- removePidFile(pidFilePath).finally(() => {
231
- process.stdout.write("\nWatchdog stopped.\n");
232
- process.exit(0);
233
- });
234
- });
235
-
236
- // Block forever
237
- await new Promise(() => {});
238
- }
@@ -1,97 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
- import { afterEach, describe, expect, test } from "vitest";
5
- import { cleanupTempDir, commitFile, createTempGitRepo, runGitInDir } from "./test-helpers.ts";
6
-
7
- describe("createTempGitRepo", () => {
8
- let repoDir: string | undefined;
9
-
10
- afterEach(async () => {
11
- if (repoDir) {
12
- await cleanupTempDir(repoDir);
13
- repoDir = undefined;
14
- }
15
- });
16
-
17
- test("creates a directory with an initialized git repo", async () => {
18
- repoDir = await createTempGitRepo();
19
-
20
- expect(existsSync(join(repoDir, ".git"))).toBe(true);
21
- });
22
-
23
- test("repo has at least one commit (HEAD exists)", async () => {
24
- repoDir = await createTempGitRepo();
25
-
26
- // runGitInDir throws on non-zero exit, resolving means exit code was 0
27
- await runGitInDir(repoDir, ["rev-parse", "HEAD"]);
28
- expect(true).toBe(true);
29
- });
30
-
31
- test("repo is on a branch (not detached HEAD)", async () => {
32
- repoDir = await createTempGitRepo();
33
-
34
- const stdout = await runGitInDir(repoDir, ["symbolic-ref", "HEAD"]);
35
- expect(stdout.trim()).toMatch(/^refs\/heads\//);
36
- });
37
- });
38
-
39
- describe("commitFile", () => {
40
- let repoDir: string | undefined;
41
-
42
- afterEach(async () => {
43
- if (repoDir) {
44
- await cleanupTempDir(repoDir);
45
- repoDir = undefined;
46
- }
47
- });
48
-
49
- test("creates file and commits it", async () => {
50
- repoDir = await createTempGitRepo();
51
-
52
- await commitFile(repoDir, "hello.txt", "world");
53
-
54
- // File exists with correct content
55
- const content = await readFile(join(repoDir, "hello.txt"), "utf-8");
56
- expect(content).toBe("world");
57
-
58
- // Git log shows the commit
59
- const stdout = await runGitInDir(repoDir, ["log", "--oneline"]);
60
- expect(stdout).toContain("add hello.txt");
61
- });
62
-
63
- test("creates nested directories as needed", async () => {
64
- repoDir = await createTempGitRepo();
65
-
66
- await commitFile(repoDir, "src/deep/nested/file.ts", "export const x = 1;");
67
-
68
- expect(existsSync(join(repoDir, "src/deep/nested/file.ts"))).toBe(true);
69
- });
70
-
71
- test("uses custom commit message when provided", async () => {
72
- repoDir = await createTempGitRepo();
73
-
74
- await commitFile(repoDir, "readme.md", "# Hi", "docs: add readme");
75
-
76
- const stdout = await runGitInDir(repoDir, ["log", "--oneline", "-1"]);
77
- expect(stdout).toContain("docs: add readme");
78
- });
79
- });
80
-
81
- describe("cleanupTempDir", () => {
82
- test("removes directory and all contents", async () => {
83
- const repoDir = await createTempGitRepo();
84
- await commitFile(repoDir, "file.txt", "data");
85
-
86
- expect(existsSync(repoDir)).toBe(true);
87
-
88
- await cleanupTempDir(repoDir);
89
-
90
- expect(existsSync(repoDir)).toBe(false);
91
- });
92
-
93
- test("does not throw when directory does not exist", async () => {
94
- await cleanupTempDir("/tmp/legio-nonexistent-test-dir-12345");
95
- // No error thrown = pass
96
- });
97
- });