@katyella/legio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,648 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { access, mkdir, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
6
+ import { createSessionStore } from "../sessions/store.ts";
7
+ import { cleanupTempDir, createTempGitRepo, runGitInDir } from "../test-helpers.ts";
8
+ import type { AgentSession } from "../types.ts";
9
+ import { createWorktree } from "../worktree/manager.ts";
10
+ import { worktreeCommand } from "./worktree.ts";
11
+
12
+ /**
13
+ * Tests for `legio worktree` command.
14
+ *
15
+ * Uses real git worktrees in temp repos to test list and clean subcommands.
16
+ * Captures process.stdout.write to verify output formatting.
17
+ */
18
+
19
+ describe("worktreeCommand", () => {
20
+ let chunks: string[];
21
+ let originalWrite: typeof process.stdout.write;
22
+ let tempDir: string;
23
+ let originalCwd: string;
24
+
25
+ beforeEach(async () => {
26
+ // Spy on stdout
27
+ chunks = [];
28
+ originalWrite = process.stdout.write;
29
+ process.stdout.write = ((chunk: string) => {
30
+ chunks.push(chunk);
31
+ return true;
32
+ }) as typeof process.stdout.write;
33
+
34
+ // Create temp git repo with .legio/config.yaml structure
35
+ tempDir = await createTempGitRepo();
36
+ // Normalize tempDir to resolve macOS /var -> /private/var symlink
37
+ tempDir = realpathSync(tempDir);
38
+ const legioDir = join(tempDir, ".legio");
39
+ await mkdir(legioDir, { recursive: true });
40
+ await writeFile(
41
+ join(legioDir, "config.yaml"),
42
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
43
+ );
44
+
45
+ // Change to temp dir so loadConfig() works
46
+ originalCwd = process.cwd();
47
+ process.chdir(tempDir);
48
+ });
49
+
50
+ afterEach(async () => {
51
+ process.stdout.write = originalWrite;
52
+ process.chdir(originalCwd);
53
+ await cleanupTempDir(tempDir);
54
+ });
55
+
56
+ function output(): string {
57
+ return chunks.join("");
58
+ }
59
+
60
+ /**
61
+ * Helper to create an AgentSession with sensible defaults.
62
+ * Uses FAKE tmux session names to avoid real tmux calls during tests.
63
+ */
64
+ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
65
+ return {
66
+ id: "session-test",
67
+ agentName: "test-agent",
68
+ capability: "builder",
69
+ worktreePath: join(tempDir, ".legio", "worktrees", "test-agent"),
70
+ branchName: "legio/test-agent/task-1",
71
+ beadId: "task-1",
72
+ tmuxSession: "legio-test-agent-fake", // FAKE tmux session name
73
+ state: "working",
74
+ pid: 12345,
75
+ parentAgent: null,
76
+ depth: 0,
77
+ runId: null,
78
+ startedAt: new Date().toISOString(),
79
+ lastActivity: new Date().toISOString(),
80
+ escalationLevel: 0,
81
+ stalledSince: null,
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Helper to write sessions to SessionStore (sessions.db) in the temp repo.
88
+ */
89
+ function writeSessionsToStore(sessions: AgentSession[]): void {
90
+ const dbPath = join(tempDir, ".legio", "sessions.db");
91
+ const store = createSessionStore(dbPath);
92
+ for (const session of sessions) {
93
+ store.upsert(session);
94
+ }
95
+ store.close();
96
+ }
97
+
98
+ describe("help flags", () => {
99
+ test("--help shows help text", async () => {
100
+ await worktreeCommand(["--help"]);
101
+ const out = output();
102
+
103
+ expect(out).toContain("legio worktree");
104
+ expect(out).toContain("list");
105
+ expect(out).toContain("clean");
106
+ expect(out).toContain("--json");
107
+ });
108
+
109
+ test("-h shows help text", async () => {
110
+ await worktreeCommand(["-h"]);
111
+ const out = output();
112
+
113
+ expect(out).toContain("legio worktree");
114
+ expect(out).toContain("list");
115
+ });
116
+ });
117
+
118
+ describe("validation", () => {
119
+ test("unknown subcommand throws ValidationError", async () => {
120
+ await expect(worktreeCommand(["unknown"])).rejects.toThrow(
121
+ "Unknown worktree subcommand: unknown",
122
+ );
123
+ });
124
+
125
+ test("missing subcommand throws ValidationError with (none)", async () => {
126
+ await expect(worktreeCommand([])).rejects.toThrow("Unknown worktree subcommand: (none)");
127
+ });
128
+ });
129
+
130
+ describe("worktree list", () => {
131
+ test("no legio worktrees returns empty message", async () => {
132
+ await worktreeCommand(["list"]);
133
+ const out = output();
134
+
135
+ expect(out).toBe("No agent worktrees found.\n");
136
+ });
137
+
138
+ test("with legio worktrees lists them with agent info", async () => {
139
+ // Create a real git worktree with legio/ prefix branch
140
+ const worktreesDir = join(tempDir, ".legio", "worktrees");
141
+ await mkdir(worktreesDir, { recursive: true });
142
+
143
+ const worktreePath = join(worktreesDir, "test-agent");
144
+ await runGitInDir(tempDir, [
145
+ "worktree",
146
+ "add",
147
+ worktreePath,
148
+ "-b",
149
+ "legio/test-agent/task-1",
150
+ ]);
151
+
152
+ // Write sessions.db to associate worktree with agent
153
+ writeSessionsToStore([
154
+ {
155
+ id: "session-1",
156
+ agentName: "test-agent",
157
+ capability: "builder",
158
+ worktreePath,
159
+ branchName: "legio/test-agent/task-1",
160
+ beadId: "task-1",
161
+ tmuxSession: "legio-test-agent",
162
+ state: "working",
163
+ pid: 12345,
164
+ parentAgent: null,
165
+ depth: 0,
166
+ runId: null,
167
+ startedAt: new Date().toISOString(),
168
+ lastActivity: new Date().toISOString(),
169
+ escalationLevel: 0,
170
+ stalledSince: null,
171
+ },
172
+ ]);
173
+
174
+ await worktreeCommand(["list"]);
175
+ const out = output();
176
+
177
+ expect(out).toContain("🌳 Agent worktrees: 1");
178
+ expect(out).toContain("legio/test-agent/task-1");
179
+ expect(out).toContain("Agent: test-agent");
180
+ expect(out).toContain("State: working");
181
+ expect(out).toContain("Task: task-1");
182
+ expect(out).toContain(`Path: ${worktreePath}`);
183
+ });
184
+
185
+ test("--json flag outputs valid JSON array", async () => {
186
+ // Create a real git worktree
187
+ const worktreesDir = join(tempDir, ".legio", "worktrees");
188
+ await mkdir(worktreesDir, { recursive: true });
189
+
190
+ const worktreePath = join(worktreesDir, "test-agent");
191
+ await runGitInDir(tempDir, [
192
+ "worktree",
193
+ "add",
194
+ worktreePath,
195
+ "-b",
196
+ "legio/test-agent/task-1",
197
+ ]);
198
+
199
+ // Write sessions.db
200
+ writeSessionsToStore([
201
+ {
202
+ id: "session-1",
203
+ agentName: "test-agent",
204
+ capability: "builder",
205
+ worktreePath,
206
+ branchName: "legio/test-agent/task-1",
207
+ beadId: "task-1",
208
+ tmuxSession: "legio-test-agent",
209
+ state: "working",
210
+ pid: 12345,
211
+ parentAgent: null,
212
+ depth: 0,
213
+ runId: null,
214
+ startedAt: new Date().toISOString(),
215
+ lastActivity: new Date().toISOString(),
216
+ escalationLevel: 0,
217
+ stalledSince: null,
218
+ },
219
+ ]);
220
+
221
+ await worktreeCommand(["list", "--json"]);
222
+ const out = output();
223
+
224
+ const parsed = JSON.parse(out.trim()) as Array<{
225
+ path: string;
226
+ branch: string;
227
+ head: string;
228
+ agentName: string | null;
229
+ state: string | null;
230
+ beadId: string | null;
231
+ }>;
232
+
233
+ expect(parsed).toHaveLength(1);
234
+ expect(parsed[0]?.path).toBe(worktreePath);
235
+ expect(parsed[0]?.branch).toBe("legio/test-agent/task-1");
236
+ expect(parsed[0]?.agentName).toBe("test-agent");
237
+ expect(parsed[0]?.state).toBe("working");
238
+ expect(parsed[0]?.beadId).toBe("task-1");
239
+ });
240
+
241
+ test("worktrees without sessions show unknown state", async () => {
242
+ // Create a worktree but no sessions.db entry
243
+ const worktreesDir = join(tempDir, ".legio", "worktrees");
244
+ await mkdir(worktreesDir, { recursive: true });
245
+
246
+ const worktreePath = join(worktreesDir, "orphan-agent");
247
+ await runGitInDir(tempDir, [
248
+ "worktree",
249
+ "add",
250
+ worktreePath,
251
+ "-b",
252
+ "legio/orphan-agent/task-2",
253
+ ]);
254
+
255
+ await worktreeCommand(["list"]);
256
+ const out = output();
257
+
258
+ expect(out).toContain("legio/orphan-agent/task-2");
259
+ expect(out).toContain("Agent: ?");
260
+ expect(out).toContain("State: unknown");
261
+ expect(out).toContain("Task: ?");
262
+ });
263
+ });
264
+
265
+ describe("worktree clean", { timeout: 15_000 }, () => {
266
+ test("no legio worktrees returns empty message", async () => {
267
+ await worktreeCommand(["clean"]);
268
+ const out = output();
269
+
270
+ expect(out).toBe("No worktrees to clean.\n");
271
+ });
272
+
273
+ test("with completed agent worktree removes it and reports count", async () => {
274
+ // Create a real git worktree
275
+ const worktreesDir = join(tempDir, ".legio", "worktrees");
276
+ await mkdir(worktreesDir, { recursive: true });
277
+
278
+ const worktreePath = join(worktreesDir, "completed-agent");
279
+ await runGitInDir(tempDir, [
280
+ "worktree",
281
+ "add",
282
+ worktreePath,
283
+ "-b",
284
+ "legio/completed-agent/task-done",
285
+ ]);
286
+
287
+ // Write sessions.db with completed state
288
+ writeSessionsToStore([
289
+ {
290
+ id: "session-1",
291
+ agentName: "completed-agent",
292
+ capability: "builder",
293
+ worktreePath,
294
+ branchName: "legio/completed-agent/task-done",
295
+ beadId: "task-done",
296
+ tmuxSession: "legio-completed-agent",
297
+ state: "completed",
298
+ pid: 12345,
299
+ parentAgent: null,
300
+ depth: 0,
301
+ runId: null,
302
+ startedAt: new Date().toISOString(),
303
+ lastActivity: new Date().toISOString(),
304
+ escalationLevel: 0,
305
+ stalledSince: null,
306
+ },
307
+ ]);
308
+
309
+ await worktreeCommand(["clean"]);
310
+ const out = output();
311
+
312
+ expect(out).toContain("🗑️ Removed: legio/completed-agent/task-done");
313
+ expect(out).toContain("Cleaned 1 worktree");
314
+
315
+ // Verify the worktree directory is gone
316
+ const worktreeExists = await access(worktreePath).then(
317
+ () => true,
318
+ () => false,
319
+ );
320
+ expect(worktreeExists).toBe(false);
321
+
322
+ // Verify the branch is deleted
323
+ const branchListResult = await new Promise<{ stdout: string }>((resolve) => {
324
+ const proc = spawn("git", ["branch", "--list", "legio/completed-agent/*"], {
325
+ cwd: tempDir,
326
+ stdio: ["ignore", "pipe", "pipe"],
327
+ });
328
+ const chunks: Buffer[] = [];
329
+ proc.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk));
330
+ proc.on("close", () => resolve({ stdout: Buffer.concat(chunks).toString("utf-8") }));
331
+ });
332
+ const branchList = branchListResult.stdout;
333
+ expect(branchList.trim()).toBe("");
334
+ });
335
+
336
+ test("--json flag returns JSON with cleaned/failed/pruned arrays", async () => {
337
+ // Create a completed worktree
338
+ const worktreesDir = join(tempDir, ".legio", "worktrees");
339
+ await mkdir(worktreesDir, { recursive: true });
340
+
341
+ const worktreePath = join(worktreesDir, "done-agent");
342
+ await runGitInDir(tempDir, [
343
+ "worktree",
344
+ "add",
345
+ worktreePath,
346
+ "-b",
347
+ "legio/done-agent/task-x",
348
+ ]);
349
+
350
+ writeSessionsToStore([
351
+ {
352
+ id: "session-1",
353
+ agentName: "done-agent",
354
+ capability: "builder",
355
+ worktreePath,
356
+ branchName: "legio/done-agent/task-x",
357
+ beadId: "task-x",
358
+ tmuxSession: "legio-done-agent",
359
+ state: "completed",
360
+ pid: 12345,
361
+ parentAgent: null,
362
+ depth: 0,
363
+ runId: null,
364
+ startedAt: new Date().toISOString(),
365
+ lastActivity: new Date().toISOString(),
366
+ escalationLevel: 0,
367
+ stalledSince: null,
368
+ },
369
+ ]);
370
+
371
+ await worktreeCommand(["clean", "--json"]);
372
+ const out = output();
373
+
374
+ const parsed = JSON.parse(out.trim()) as {
375
+ cleaned: string[];
376
+ failed: string[];
377
+ pruned: number;
378
+ };
379
+
380
+ expect(parsed.cleaned).toEqual(["legio/done-agent/task-x"]);
381
+ expect(parsed.failed).toEqual([]);
382
+ expect(parsed.pruned).toBe(1); // The zombie session was pruned
383
+ });
384
+
385
+ test("zombie sessions whose worktree paths no longer exist get pruned from sessions.db", async () => {
386
+ // Create sessions.db with a zombie entry whose worktree doesn't exist
387
+ const nonExistentPath = join(tempDir, ".legio", "worktrees", "ghost-agent");
388
+ writeSessionsToStore([
389
+ {
390
+ id: "session-ghost",
391
+ agentName: "ghost-agent",
392
+ capability: "builder",
393
+ worktreePath: nonExistentPath,
394
+ branchName: "legio/ghost-agent/task-ghost",
395
+ beadId: "task-ghost",
396
+ tmuxSession: "legio-ghost-agent",
397
+ state: "zombie",
398
+ pid: null,
399
+ parentAgent: null,
400
+ depth: 0,
401
+ runId: null,
402
+ startedAt: new Date().toISOString(),
403
+ lastActivity: new Date().toISOString(),
404
+ escalationLevel: 0,
405
+ stalledSince: null,
406
+ },
407
+ ]);
408
+
409
+ await worktreeCommand(["clean", "--json"]);
410
+ const out = output();
411
+
412
+ const parsed = JSON.parse(out.trim()) as {
413
+ cleaned: string[];
414
+ failed: string[];
415
+ pruned: number;
416
+ };
417
+
418
+ expect(parsed.pruned).toBe(1);
419
+
420
+ // Verify sessions.db no longer contains the zombie
421
+ const dbPath = join(tempDir, ".legio", "sessions.db");
422
+ const store = createSessionStore(dbPath);
423
+ const updatedSessions = store.getAll();
424
+ store.close();
425
+ expect(updatedSessions).toHaveLength(0);
426
+ });
427
+
428
+ test("stalled agents are cleaned like working agents (not by default)", async () => {
429
+ // Create a worktree with stalled state
430
+ const worktreesDir = join(tempDir, ".legio", "worktrees");
431
+ await mkdir(worktreesDir, { recursive: true });
432
+
433
+ const worktreePath = join(worktreesDir, "stalled-agent");
434
+ await runGitInDir(tempDir, [
435
+ "worktree",
436
+ "add",
437
+ worktreePath,
438
+ "-b",
439
+ "legio/stalled-agent/task-stuck",
440
+ ]);
441
+
442
+ writeSessionsToStore([
443
+ {
444
+ id: "session-1",
445
+ agentName: "stalled-agent",
446
+ capability: "builder",
447
+ worktreePath,
448
+ branchName: "legio/stalled-agent/task-stuck",
449
+ beadId: "task-stuck",
450
+ tmuxSession: "legio-stalled-agent",
451
+ state: "working",
452
+ pid: 12345,
453
+ parentAgent: null,
454
+ depth: 0,
455
+ runId: null,
456
+ startedAt: new Date().toISOString(),
457
+ lastActivity: new Date().toISOString(),
458
+ escalationLevel: 0,
459
+ stalledSince: null,
460
+ },
461
+ ]);
462
+
463
+ await worktreeCommand(["clean"]);
464
+ const out = output();
465
+
466
+ // Stalled agents should not be cleaned by default (only completed/zombie are cleaned)
467
+ expect(out).toBe("No worktrees to clean.\n");
468
+ });
469
+
470
+ test("--completed flag only cleans completed agents", async () => {
471
+ // Create two worktrees using createWorktree
472
+ const worktreesDir = join(tempDir, ".legio", "worktrees");
473
+ await mkdir(worktreesDir, { recursive: true });
474
+
475
+ const { path: completedPath } = await createWorktree({
476
+ repoRoot: tempDir,
477
+ baseDir: worktreesDir,
478
+ agentName: "completed-agent",
479
+ baseBranch: "main",
480
+ beadId: "task-done",
481
+ });
482
+
483
+ const { path: workingPath } = await createWorktree({
484
+ repoRoot: tempDir,
485
+ baseDir: worktreesDir,
486
+ agentName: "working-agent",
487
+ baseBranch: "main",
488
+ beadId: "task-wip",
489
+ });
490
+
491
+ // Write sessions.db with both agents
492
+ writeSessionsToStore([
493
+ makeSession({
494
+ id: "session-1",
495
+ agentName: "completed-agent",
496
+ worktreePath: completedPath,
497
+ branchName: "legio/completed-agent/task-done",
498
+ beadId: "task-done",
499
+ tmuxSession: "legio-completed-agent-fake",
500
+ state: "completed",
501
+ }),
502
+ makeSession({
503
+ id: "session-2",
504
+ agentName: "working-agent",
505
+ worktreePath: workingPath,
506
+ branchName: "legio/working-agent/task-wip",
507
+ beadId: "task-wip",
508
+ tmuxSession: "legio-working-agent-fake",
509
+ state: "working",
510
+ pid: 12346,
511
+ }),
512
+ ]);
513
+
514
+ await worktreeCommand(["clean", "--completed"]);
515
+ const out = output();
516
+
517
+ expect(out).toContain("Cleaned 1 worktree");
518
+
519
+ // Verify only the completed worktree is removed
520
+ expect(existsSync(completedPath)).toBe(false);
521
+ expect(existsSync(workingPath)).toBe(true);
522
+ });
523
+
524
+ test("--all flag cleans all worktrees regardless of state", async () => {
525
+ // Create three worktrees with different states
526
+ const worktreesDir = join(tempDir, ".legio", "worktrees");
527
+ await mkdir(worktreesDir, { recursive: true });
528
+
529
+ const { path: completedPath } = await createWorktree({
530
+ repoRoot: tempDir,
531
+ baseDir: worktreesDir,
532
+ agentName: "completed-agent",
533
+ baseBranch: "main",
534
+ beadId: "task-done",
535
+ });
536
+
537
+ const { path: workingPath } = await createWorktree({
538
+ repoRoot: tempDir,
539
+ baseDir: worktreesDir,
540
+ agentName: "working-agent",
541
+ baseBranch: "main",
542
+ beadId: "task-wip",
543
+ });
544
+
545
+ const { path: stalledPath } = await createWorktree({
546
+ repoRoot: tempDir,
547
+ baseDir: worktreesDir,
548
+ agentName: "stalled-agent",
549
+ baseBranch: "main",
550
+ beadId: "task-stuck",
551
+ });
552
+
553
+ // Write sessions with different states
554
+ writeSessionsToStore([
555
+ makeSession({
556
+ id: "session-1",
557
+ agentName: "completed-agent",
558
+ worktreePath: completedPath,
559
+ branchName: "legio/completed-agent/task-done",
560
+ beadId: "task-done",
561
+ state: "completed",
562
+ }),
563
+ makeSession({
564
+ id: "session-2",
565
+ agentName: "working-agent",
566
+ worktreePath: workingPath,
567
+ branchName: "legio/working-agent/task-wip",
568
+ beadId: "task-wip",
569
+ state: "working",
570
+ }),
571
+ makeSession({
572
+ id: "session-3",
573
+ agentName: "stalled-agent",
574
+ worktreePath: stalledPath,
575
+ branchName: "legio/stalled-agent/task-stuck",
576
+ beadId: "task-stuck",
577
+ state: "working",
578
+ }),
579
+ ]);
580
+
581
+ await worktreeCommand(["clean", "--all"]);
582
+ const out = output();
583
+
584
+ expect(out).toContain("Cleaned 3 worktrees");
585
+
586
+ // Verify all worktrees are removed
587
+ expect(existsSync(completedPath)).toBe(false);
588
+ expect(existsSync(workingPath)).toBe(false);
589
+ expect(existsSync(stalledPath)).toBe(false);
590
+ });
591
+
592
+ test("multiple completed worktrees reports correct count", async () => {
593
+ // Create two completed worktrees
594
+ const worktreesDir = join(tempDir, ".legio", "worktrees");
595
+ await mkdir(worktreesDir, { recursive: true });
596
+
597
+ const path1 = join(worktreesDir, "agent-1");
598
+ await runGitInDir(tempDir, ["worktree", "add", path1, "-b", "legio/agent-1/task-1"]);
599
+
600
+ const path2 = join(worktreesDir, "agent-2");
601
+ await runGitInDir(tempDir, ["worktree", "add", path2, "-b", "legio/agent-2/task-2"]);
602
+
603
+ writeSessionsToStore([
604
+ {
605
+ id: "session-1",
606
+ agentName: "agent-1",
607
+ capability: "builder",
608
+ worktreePath: path1,
609
+ branchName: "legio/agent-1/task-1",
610
+ beadId: "task-1",
611
+ tmuxSession: "legio-agent-1",
612
+ state: "completed",
613
+ pid: 12345,
614
+ parentAgent: null,
615
+ depth: 0,
616
+ runId: null,
617
+ startedAt: new Date().toISOString(),
618
+ lastActivity: new Date().toISOString(),
619
+ escalationLevel: 0,
620
+ stalledSince: null,
621
+ },
622
+ {
623
+ id: "session-2",
624
+ agentName: "agent-2",
625
+ capability: "builder",
626
+ worktreePath: path2,
627
+ branchName: "legio/agent-2/task-2",
628
+ beadId: "task-2",
629
+ tmuxSession: "legio-agent-2",
630
+ state: "completed",
631
+ pid: 12346,
632
+ parentAgent: null,
633
+ depth: 0,
634
+ runId: null,
635
+ startedAt: new Date().toISOString(),
636
+ lastActivity: new Date().toISOString(),
637
+ escalationLevel: 0,
638
+ stalledSince: null,
639
+ },
640
+ ]);
641
+
642
+ await worktreeCommand(["clean"]);
643
+ const out = output();
644
+
645
+ expect(out).toContain("Cleaned 2 worktrees");
646
+ });
647
+ });
648
+ });