@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,730 @@
1
+ /**
2
+ * Tests for the `legio clean` command.
3
+ *
4
+ * Uses real filesystem (temp dirs), real git repos, real SQLite.
5
+ * No mocks. tmux operations are tested indirectly — when no tmux
6
+ * server is running, the command handles it gracefully.
7
+ *
8
+ * Philosophy: "never mock what you can use for real" (mx-252b16).
9
+ */
10
+
11
+ import { existsSync } from "node:fs";
12
+ import { access, mkdir, readdir, writeFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
15
+ import { createEventStore } from "../events/store.ts";
16
+ import { createMailStore } from "../mail/store.ts";
17
+ import { createMergeQueue } from "../merge/queue.ts";
18
+ import { createMetricsStore } from "../metrics/store.ts";
19
+ import { openSessionStore } from "../sessions/compat.ts";
20
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
21
+ import type { AgentSession } from "../types.ts";
22
+ import { cleanCommand } from "./clean.ts";
23
+
24
+ let tempDir: string;
25
+ let legioDir: string;
26
+ let originalCwd: string;
27
+ let stdoutOutput: string;
28
+ let _stderrOutput: string;
29
+ let originalStdoutWrite: typeof process.stdout.write;
30
+ let originalStderrWrite: typeof process.stderr.write;
31
+
32
+ beforeEach(async () => {
33
+ tempDir = await createTempGitRepo();
34
+ legioDir = join(tempDir, ".legio");
35
+ await mkdir(legioDir, { recursive: true });
36
+
37
+ // Write minimal config.yaml so loadConfig succeeds
38
+ await writeFile(
39
+ join(legioDir, "config.yaml"),
40
+ `project:\n name: test-project\n root: ${tempDir}\n canonicalBranch: main\n`,
41
+ );
42
+
43
+ // Create the standard directories
44
+ await mkdir(join(legioDir, "logs"), { recursive: true });
45
+ await mkdir(join(legioDir, "agents"), { recursive: true });
46
+ await mkdir(join(legioDir, "specs"), { recursive: true });
47
+ await mkdir(join(legioDir, "worktrees"), { recursive: true });
48
+
49
+ originalCwd = process.cwd();
50
+ process.chdir(tempDir);
51
+
52
+ // Capture stdout/stderr
53
+ stdoutOutput = "";
54
+ _stderrOutput = "";
55
+ originalStdoutWrite = process.stdout.write;
56
+ originalStderrWrite = process.stderr.write;
57
+ process.stdout.write = ((chunk: string) => {
58
+ stdoutOutput += chunk;
59
+ return true;
60
+ }) as typeof process.stdout.write;
61
+ process.stderr.write = ((chunk: string) => {
62
+ _stderrOutput += chunk;
63
+ return true;
64
+ }) as typeof process.stderr.write;
65
+ });
66
+
67
+ afterEach(async () => {
68
+ process.chdir(originalCwd);
69
+ process.stdout.write = originalStdoutWrite;
70
+ process.stderr.write = originalStderrWrite;
71
+ await cleanupTempDir(tempDir);
72
+ });
73
+
74
+ // === help ===
75
+
76
+ describe("help", () => {
77
+ test("--help shows usage", async () => {
78
+ await cleanCommand(["--help"]);
79
+ expect(stdoutOutput).toContain("legio clean");
80
+ expect(stdoutOutput).toContain("--all");
81
+ });
82
+
83
+ test("-h shows usage", async () => {
84
+ await cleanCommand(["-h"]);
85
+ expect(stdoutOutput).toContain("legio clean");
86
+ });
87
+ });
88
+
89
+ // === validation ===
90
+
91
+ describe("validation", () => {
92
+ test("no flags throws ValidationError", async () => {
93
+ await expect(cleanCommand([])).rejects.toThrow("No cleanup targets specified");
94
+ });
95
+ });
96
+
97
+ // === --all ===
98
+
99
+ describe("--all", () => {
100
+ test("wipes mail.db and WAL files", async () => {
101
+ // Create a mail DB with messages
102
+ const mailDbPath = join(legioDir, "mail.db");
103
+ const store = createMailStore(mailDbPath);
104
+ store.insert({
105
+ id: "msg-1",
106
+ from: "agent-a",
107
+ to: "agent-b",
108
+ subject: "test",
109
+ body: "hello",
110
+ type: "status",
111
+ priority: "normal",
112
+ threadId: null,
113
+ });
114
+ store.close();
115
+
116
+ // Verify DB exists
117
+ const existsBefore = await access(mailDbPath)
118
+ .then(() => true)
119
+ .catch(() => false);
120
+ expect(existsBefore).toBe(true);
121
+
122
+ await cleanCommand(["--all"]);
123
+
124
+ // DB should be gone
125
+ const existsAfter = await access(mailDbPath)
126
+ .then(() => true)
127
+ .catch(() => false);
128
+ expect(existsAfter).toBe(false);
129
+ expect(stdoutOutput).toContain("Wiped mail.db");
130
+ });
131
+
132
+ test("wipes metrics.db", async () => {
133
+ const metricsDbPath = join(legioDir, "metrics.db");
134
+ const store = createMetricsStore(metricsDbPath);
135
+ store.recordSession({
136
+ agentName: "test-agent",
137
+ beadId: "task-1",
138
+ capability: "builder",
139
+ startedAt: new Date().toISOString(),
140
+ completedAt: null,
141
+ durationMs: 0,
142
+ exitCode: null,
143
+ mergeResult: null,
144
+ parentAgent: null,
145
+ inputTokens: 0,
146
+ outputTokens: 0,
147
+ cacheReadTokens: 0,
148
+ cacheCreationTokens: 0,
149
+ estimatedCostUsd: null,
150
+ modelUsed: null,
151
+ });
152
+ store.close();
153
+
154
+ const existsBefore = await access(metricsDbPath)
155
+ .then(() => true)
156
+ .catch(() => false);
157
+ expect(existsBefore).toBe(true);
158
+
159
+ await cleanCommand(["--all"]);
160
+
161
+ const existsAfter = await access(metricsDbPath)
162
+ .then(() => true)
163
+ .catch(() => false);
164
+ expect(existsAfter).toBe(false);
165
+ expect(stdoutOutput).toContain("Wiped metrics.db");
166
+ });
167
+
168
+ test("wipes sessions.db", async () => {
169
+ // Use the SessionStore to create sessions.db with data
170
+ const { store } = openSessionStore(legioDir);
171
+ store.upsert({
172
+ id: "s1",
173
+ agentName: "test-agent",
174
+ capability: "builder",
175
+ worktreePath: "/tmp/wt",
176
+ branchName: "legio/test/task",
177
+ beadId: "task-1",
178
+ tmuxSession: "legio-test-agent",
179
+ state: "completed",
180
+ pid: 12345,
181
+ parentAgent: null,
182
+ depth: 1,
183
+ runId: null,
184
+ startedAt: new Date().toISOString(),
185
+ lastActivity: new Date().toISOString(),
186
+ escalationLevel: 0,
187
+ stalledSince: null,
188
+ });
189
+ store.close();
190
+
191
+ const sessionsDbPath = join(legioDir, "sessions.db");
192
+ const existsBefore = await access(sessionsDbPath)
193
+ .then(() => true)
194
+ .catch(() => false);
195
+ expect(existsBefore).toBe(true);
196
+
197
+ await cleanCommand(["--all"]);
198
+
199
+ const existsAfter = await access(sessionsDbPath)
200
+ .then(() => true)
201
+ .catch(() => false);
202
+ expect(existsAfter).toBe(false);
203
+ expect(stdoutOutput).toContain("Wiped sessions.db");
204
+ });
205
+
206
+ test("wipes merge-queue.db", async () => {
207
+ const queuePath = join(legioDir, "merge-queue.db");
208
+ // Create a queue with an entry so we can verify it gets wiped
209
+ const queue = createMergeQueue(queuePath);
210
+ queue.enqueue({
211
+ branchName: "test-branch",
212
+ beadId: "beads-test",
213
+ agentName: "test",
214
+ filesModified: ["src/test.ts"],
215
+ });
216
+ queue.close();
217
+
218
+ await cleanCommand(["--all"]);
219
+
220
+ const existsAfter = await access(queuePath)
221
+ .then(() => true)
222
+ .catch(() => false);
223
+ expect(existsAfter).toBe(false);
224
+ expect(stdoutOutput).toContain("Wiped merge-queue.db");
225
+ });
226
+
227
+ test("clears logs directory contents", async () => {
228
+ const logsDir = join(legioDir, "logs");
229
+ await mkdir(join(logsDir, "agent-a", "2026-01-01"), { recursive: true });
230
+ await writeFile(join(logsDir, "agent-a", "2026-01-01", "session.log"), "log data");
231
+
232
+ await cleanCommand(["--all"]);
233
+
234
+ const entries = await readdir(logsDir);
235
+ expect(entries).toHaveLength(0);
236
+ expect(stdoutOutput).toContain("Cleared logs/");
237
+ });
238
+
239
+ test("clears agents directory contents", async () => {
240
+ const agentsDir = join(legioDir, "agents");
241
+ await mkdir(join(agentsDir, "test-agent"), { recursive: true });
242
+ await writeFile(join(agentsDir, "test-agent", "identity.yaml"), "name: test-agent");
243
+
244
+ await cleanCommand(["--all"]);
245
+
246
+ const entries = await readdir(agentsDir);
247
+ expect(entries).toHaveLength(0);
248
+ expect(stdoutOutput).toContain("Cleared agents/");
249
+ });
250
+
251
+ test("clears specs directory contents", async () => {
252
+ const specsDir = join(legioDir, "specs");
253
+ await writeFile(join(specsDir, "task-123.md"), "# Spec");
254
+
255
+ await cleanCommand(["--all"]);
256
+
257
+ const entries = await readdir(specsDir);
258
+ expect(entries).toHaveLength(0);
259
+ expect(stdoutOutput).toContain("Cleared specs/");
260
+ });
261
+
262
+ test("deletes nudge-state.json", async () => {
263
+ const nudgePath = join(legioDir, "nudge-state.json");
264
+ await writeFile(nudgePath, "{}");
265
+
266
+ await cleanCommand(["--all"]);
267
+
268
+ const existsAfter = await access(nudgePath)
269
+ .then(() => true)
270
+ .catch(() => false);
271
+ expect(existsAfter).toBe(false);
272
+ expect(stdoutOutput).toContain("Cleared nudge-state.json");
273
+ });
274
+
275
+ test("deletes current-run.txt", async () => {
276
+ const currentRunPath = join(legioDir, "current-run.txt");
277
+ await writeFile(currentRunPath, "run-2026-02-13T10-00-00-000Z");
278
+
279
+ await cleanCommand(["--all"]);
280
+
281
+ const existsAfter = await access(currentRunPath)
282
+ .then(() => true)
283
+ .catch(() => false);
284
+ expect(existsAfter).toBe(false);
285
+ expect(stdoutOutput).toContain("Cleared current-run.txt");
286
+ });
287
+
288
+ test("handles missing current-run.txt gracefully", async () => {
289
+ // current-run.txt does not exist — should not error
290
+ await cleanCommand(["--all"]);
291
+ expect(stdoutOutput).not.toContain("Cleared current-run.txt");
292
+ });
293
+ });
294
+
295
+ // === individual flags ===
296
+
297
+ describe("individual flags", () => {
298
+ test("--mail only wipes mail.db, leaves other state intact", async () => {
299
+ // Create mail and sessions
300
+ const mailDbPath = join(legioDir, "mail.db");
301
+ const mailStore = createMailStore(mailDbPath);
302
+ mailStore.insert({
303
+ id: "msg-1",
304
+ from: "a",
305
+ to: "b",
306
+ subject: "test",
307
+ body: "hi",
308
+ type: "status",
309
+ priority: "normal",
310
+ threadId: null,
311
+ });
312
+ mailStore.close();
313
+
314
+ // Seed sessions.db via SessionStore
315
+ const { store: sessionStore } = openSessionStore(legioDir);
316
+ sessionStore.upsert({
317
+ id: "s1",
318
+ agentName: "test-agent",
319
+ capability: "builder",
320
+ worktreePath: "/tmp/wt",
321
+ branchName: "legio/test/task",
322
+ beadId: "task-1",
323
+ tmuxSession: "legio-test-agent",
324
+ state: "completed",
325
+ pid: 12345,
326
+ parentAgent: null,
327
+ depth: 1,
328
+ runId: null,
329
+ startedAt: new Date().toISOString(),
330
+ lastActivity: new Date().toISOString(),
331
+ escalationLevel: 0,
332
+ stalledSince: null,
333
+ });
334
+ sessionStore.close();
335
+
336
+ await cleanCommand(["--mail"]);
337
+
338
+ // Mail gone
339
+ const mailExists = await access(mailDbPath)
340
+ .then(() => true)
341
+ .catch(() => false);
342
+ expect(mailExists).toBe(false);
343
+ // Sessions untouched — sessions.db should still exist with data
344
+ const sessionsDbPath = join(legioDir, "sessions.db");
345
+ const sessionsDbExists = await access(sessionsDbPath)
346
+ .then(() => true)
347
+ .catch(() => false);
348
+ expect(sessionsDbExists).toBe(true);
349
+ const { store: verifyStore } = openSessionStore(legioDir);
350
+ const sessions = verifyStore.getAll();
351
+ verifyStore.close();
352
+ expect(sessions).toHaveLength(1);
353
+ expect(sessions[0]?.id).toBe("s1");
354
+ });
355
+
356
+ test("--sessions only wipes sessions.db", async () => {
357
+ // Create sessions.db with data
358
+ const sessionsDbPath = join(legioDir, "sessions.db");
359
+ const { store } = openSessionStore(legioDir);
360
+ store.upsert({
361
+ id: "s1",
362
+ agentName: "test-agent",
363
+ capability: "builder",
364
+ worktreePath: "/tmp/wt",
365
+ branchName: "legio/test/task",
366
+ beadId: "task-1",
367
+ tmuxSession: "legio-test-agent",
368
+ state: "completed",
369
+ pid: 12345,
370
+ parentAgent: null,
371
+ depth: 1,
372
+ runId: null,
373
+ startedAt: new Date().toISOString(),
374
+ lastActivity: new Date().toISOString(),
375
+ escalationLevel: 0,
376
+ stalledSince: null,
377
+ });
378
+ store.close();
379
+
380
+ // Create a spec file that should survive
381
+ await writeFile(join(legioDir, "specs", "task.md"), "spec");
382
+
383
+ await cleanCommand(["--sessions"]);
384
+
385
+ // sessions.db should be gone
386
+ const sessionsDbExists = await access(sessionsDbPath)
387
+ .then(() => true)
388
+ .catch(() => false);
389
+ expect(sessionsDbExists).toBe(false);
390
+
391
+ // Specs untouched
392
+ const specEntries = await readdir(join(legioDir, "specs"));
393
+ expect(specEntries).toHaveLength(1);
394
+ });
395
+
396
+ test("--logs clears logs but nothing else", async () => {
397
+ const logsDir = join(legioDir, "logs");
398
+ await mkdir(join(logsDir, "agent-x"), { recursive: true });
399
+ await writeFile(join(logsDir, "agent-x", "session.log"), "data");
400
+
401
+ await writeFile(join(legioDir, "specs", "task.md"), "spec");
402
+
403
+ await cleanCommand(["--logs"]);
404
+
405
+ const logEntries = await readdir(logsDir);
406
+ expect(logEntries).toHaveLength(0);
407
+
408
+ // Specs untouched
409
+ const specEntries = await readdir(join(legioDir, "specs"));
410
+ expect(specEntries).toHaveLength(1);
411
+ });
412
+ });
413
+
414
+ // === idempotent ===
415
+
416
+ describe("idempotent", () => {
417
+ test("running --all when nothing exists does not error", async () => {
418
+ await cleanCommand(["--all"]);
419
+ expect(stdoutOutput).toContain("Nothing to clean");
420
+ });
421
+
422
+ test("running --all twice does not error", async () => {
423
+ // Create some state
424
+ const mailDbPath = join(legioDir, "mail.db");
425
+ const store = createMailStore(mailDbPath);
426
+ store.close();
427
+
428
+ await cleanCommand(["--all"]);
429
+ stdoutOutput = "";
430
+ await cleanCommand(["--all"]);
431
+ expect(stdoutOutput).toContain("Nothing to clean");
432
+ });
433
+ });
434
+
435
+ // === JSON output ===
436
+
437
+ describe("JSON output", () => {
438
+ test("--json flag produces valid JSON", async () => {
439
+ const mailDbPath = join(legioDir, "mail.db");
440
+ const store = createMailStore(mailDbPath);
441
+ store.insert({
442
+ id: "msg-1",
443
+ from: "a",
444
+ to: "b",
445
+ subject: "test",
446
+ body: "hi",
447
+ type: "status",
448
+ priority: "normal",
449
+ threadId: null,
450
+ });
451
+ store.close();
452
+
453
+ await cleanCommand(["--all", "--json"]);
454
+
455
+ const result = JSON.parse(stdoutOutput);
456
+ expect(result).toHaveProperty("tmuxKilled");
457
+ expect(result).toHaveProperty("mailWiped");
458
+ expect(result).toHaveProperty("sessionsCleared");
459
+ expect(result).toHaveProperty("metricsWiped");
460
+ expect(result.mailWiped).toBe(true);
461
+ });
462
+
463
+ test("--json includes sessionEndEventsLogged field", async () => {
464
+ await cleanCommand(["--all", "--json"]);
465
+ const result = JSON.parse(stdoutOutput);
466
+ expect(result).toHaveProperty("sessionEndEventsLogged");
467
+ });
468
+
469
+ test("--json includes currentRunCleared field", async () => {
470
+ const currentRunPath = join(legioDir, "current-run.txt");
471
+ await writeFile(currentRunPath, "run-2026-02-13T10-00-00-000Z");
472
+
473
+ await cleanCommand(["--all", "--json"]);
474
+ const result = JSON.parse(stdoutOutput);
475
+ expect(result).toHaveProperty("currentRunCleared");
476
+ expect(result.currentRunCleared).toBe(true);
477
+ });
478
+ });
479
+
480
+ // === synthetic session-end events ===
481
+
482
+ describe("synthetic session-end events", () => {
483
+ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
484
+ return {
485
+ id: "s1",
486
+ agentName: "test-builder",
487
+ capability: "builder",
488
+ worktreePath: "/tmp/wt",
489
+ branchName: "legio/test-builder/task-1",
490
+ beadId: "task-1",
491
+ tmuxSession: "legio-test-builder",
492
+ state: "working",
493
+ pid: 12345,
494
+ parentAgent: null,
495
+ depth: 1,
496
+ runId: null,
497
+ startedAt: new Date().toISOString(),
498
+ lastActivity: new Date().toISOString(),
499
+ escalationLevel: 0,
500
+ stalledSince: null,
501
+ ...overrides,
502
+ };
503
+ }
504
+
505
+ test("logs session-end events for active agents before killing tmux", async () => {
506
+ // Seed sessions.db with an active agent via SessionStore
507
+ const { store } = openSessionStore(legioDir);
508
+ store.upsert(makeSession({ agentName: "builder-a", state: "working" }));
509
+ store.close();
510
+
511
+ await cleanCommand(["--all"]);
512
+
513
+ // Verify event was written to events.db
514
+ const eventsDbPath = join(legioDir, "events.db");
515
+ const eventStore = createEventStore(eventsDbPath);
516
+ const events = eventStore.getByAgent("builder-a");
517
+ eventStore.close();
518
+
519
+ const sessionEndEvents = events.filter((e) => e.eventType === "session_end");
520
+ expect(sessionEndEvents).toHaveLength(1);
521
+ expect(sessionEndEvents[0]?.agentName).toBe("builder-a");
522
+ expect(sessionEndEvents[0]?.level).toBe("info");
523
+
524
+ const data = JSON.parse(sessionEndEvents[0]?.data ?? "{}");
525
+ expect(data.reason).toBe("clean");
526
+ expect(data.capability).toBe("builder");
527
+
528
+ expect(stdoutOutput).toContain("Logged 1 synthetic session-end event");
529
+ });
530
+
531
+ test("logs events for multiple active agents", async () => {
532
+ const { store } = openSessionStore(legioDir);
533
+ store.upsert(makeSession({ id: "s1", agentName: "builder-a", state: "working" }));
534
+ store.upsert(
535
+ makeSession({ id: "s2", agentName: "scout-b", capability: "scout", state: "booting" }),
536
+ );
537
+ store.upsert(makeSession({ id: "s3", agentName: "builder-c", state: "working" }));
538
+ store.close();
539
+
540
+ await cleanCommand(["--all"]);
541
+
542
+ const eventsDbPath = join(legioDir, "events.db");
543
+ const eventStore = createEventStore(eventsDbPath);
544
+
545
+ for (const name of ["builder-a", "scout-b", "builder-c"]) {
546
+ const events = eventStore.getByAgent(name);
547
+ const sessionEndEvents = events.filter((e) => e.eventType === "session_end");
548
+ expect(sessionEndEvents).toHaveLength(1);
549
+ }
550
+ eventStore.close();
551
+
552
+ expect(stdoutOutput).toContain("Logged 3 synthetic session-end events");
553
+ });
554
+
555
+ test("skips completed and zombie sessions", async () => {
556
+ const { store } = openSessionStore(legioDir);
557
+ store.upsert(makeSession({ id: "s1", agentName: "completed-agent", state: "completed" }));
558
+ store.upsert(makeSession({ id: "s2", agentName: "zombie-agent", state: "zombie" }));
559
+ store.close();
560
+
561
+ await cleanCommand(["--all"]);
562
+
563
+ // events.db may not even be created if there are no events to log
564
+ const eventsDbPath = join(legioDir, "events.db");
565
+ const eventsDbExists = await access(eventsDbPath)
566
+ .then(() => true)
567
+ .catch(() => false);
568
+ if (eventsDbExists) {
569
+ const eventStore = createEventStore(eventsDbPath);
570
+ const events1 = eventStore.getByAgent("completed-agent");
571
+ const events2 = eventStore.getByAgent("zombie-agent");
572
+ eventStore.close();
573
+ expect(events1).toHaveLength(0);
574
+ expect(events2).toHaveLength(0);
575
+ }
576
+ });
577
+
578
+ test("--worktrees also logs session-end events (not just --all)", async () => {
579
+ const { store } = openSessionStore(legioDir);
580
+ store.upsert(makeSession({ agentName: "wt-agent", state: "working" }));
581
+ store.close();
582
+
583
+ await cleanCommand(["--worktrees"]);
584
+
585
+ const eventsDbPath = join(legioDir, "events.db");
586
+ const eventStore = createEventStore(eventsDbPath);
587
+ const events = eventStore.getByAgent("wt-agent");
588
+ eventStore.close();
589
+
590
+ const sessionEndEvents = events.filter((e) => e.eventType === "session_end");
591
+ expect(sessionEndEvents).toHaveLength(1);
592
+ });
593
+
594
+ test("includes runId and sessionId from agent session", async () => {
595
+ const { store } = openSessionStore(legioDir);
596
+ store.upsert(
597
+ makeSession({
598
+ agentName: "tracked-agent",
599
+ id: "session-123",
600
+ runId: "run-456",
601
+ state: "working",
602
+ }),
603
+ );
604
+ store.close();
605
+
606
+ await cleanCommand(["--all"]);
607
+
608
+ const eventsDbPath = join(legioDir, "events.db");
609
+ const eventStore = createEventStore(eventsDbPath);
610
+ const events = eventStore.getByAgent("tracked-agent");
611
+ eventStore.close();
612
+
613
+ const sessionEndEvents = events.filter((e) => e.eventType === "session_end");
614
+ expect(sessionEndEvents).toHaveLength(1);
615
+ expect(sessionEndEvents[0]?.sessionId).toBe("session-123");
616
+ expect(sessionEndEvents[0]?.runId).toBe("run-456");
617
+ });
618
+
619
+ test("handles missing sessions.db gracefully", async () => {
620
+ // No sessions.db file — should not error
621
+ await cleanCommand(["--all"]);
622
+ // Just verify it didn't crash
623
+ expect(stdoutOutput).toBeDefined();
624
+ });
625
+ });
626
+
627
+ // === mulch health checks ===
628
+
629
+ describe("mulch health checks", () => {
630
+ test("runs mulch health checks when --all is passed", async () => {
631
+ // Create a real .mulch directory with some data
632
+ const mulchDir = join(tempDir, ".mulch");
633
+ await mkdir(mulchDir, { recursive: true });
634
+ await mkdir(join(mulchDir, "domains"), { recursive: true });
635
+
636
+ // Create a domain file with some records
637
+ const domainPath = join(mulchDir, "domains", "test-domain.jsonl");
638
+ await writeFile(
639
+ domainPath,
640
+ `{"id":"mx-1","type":"convention","description":"Test record 1","recorded_at":"2026-01-01T00:00:00Z"}\n`,
641
+ );
642
+
643
+ await cleanCommand(["--all"]);
644
+
645
+ // Mulch health checks should have run (might show warnings or might be clean)
646
+ // The output should not error, and if there are no issues, it's fine
647
+ expect(stdoutOutput).toBeDefined();
648
+ });
649
+
650
+ test("handles missing .mulch directory gracefully", async () => {
651
+ // No .mulch directory — should not error
652
+ await cleanCommand(["--all"]);
653
+ expect(stdoutOutput).toBeDefined();
654
+ });
655
+
656
+ test("JSON output includes mulchHealth field when mulch checks run", async () => {
657
+ // Create a .mulch directory
658
+ const mulchDir = join(tempDir, ".mulch");
659
+ await mkdir(mulchDir, { recursive: true });
660
+ await mkdir(join(mulchDir, "domains"), { recursive: true });
661
+
662
+ // Create a domain file
663
+ const domainPath = join(mulchDir, "domains", "test-domain.jsonl");
664
+ await writeFile(
665
+ domainPath,
666
+ `{"id":"mx-1","type":"convention","description":"Test","recorded_at":"2026-01-01T00:00:00Z"}\n`,
667
+ );
668
+
669
+ await cleanCommand(["--all", "--json"]);
670
+
671
+ const result = JSON.parse(stdoutOutput);
672
+ expect(result).toHaveProperty("mulchHealth");
673
+
674
+ // If mulch checks ran, mulchHealth should be an object (not null)
675
+ // If mulch was unavailable, it will be null
676
+ if (result.mulchHealth !== null) {
677
+ expect(result.mulchHealth).toHaveProperty("checked");
678
+ expect(result.mulchHealth).toHaveProperty("domainsNearLimit");
679
+ expect(result.mulchHealth).toHaveProperty("stalePruneCandidates");
680
+ expect(result.mulchHealth).toHaveProperty("doctorIssues");
681
+ expect(result.mulchHealth).toHaveProperty("doctorWarnings");
682
+ }
683
+ });
684
+
685
+ test("does not run mulch checks when only individual flags are used", async () => {
686
+ // Create a .mulch directory
687
+ const mulchDir = join(tempDir, ".mulch");
688
+ await mkdir(mulchDir, { recursive: true });
689
+
690
+ // Run clean with only --mail (not --all)
691
+ const mailDbPath = join(legioDir, "mail.db");
692
+ const store = createMailStore(mailDbPath);
693
+ store.close();
694
+
695
+ await cleanCommand(["--mail", "--json"]);
696
+
697
+ const result = JSON.parse(stdoutOutput);
698
+ // mulchHealth should be null because we didn't use --all
699
+ expect(result.mulchHealth).toBeNull();
700
+ });
701
+
702
+ test("warns about domains approaching governance limits", async () => {
703
+ // Create a .mulch directory with a domain that has many records
704
+ const mulchDir = join(tempDir, ".mulch");
705
+ await mkdir(mulchDir, { recursive: true });
706
+ await mkdir(join(mulchDir, "domains"), { recursive: true });
707
+
708
+ // Create a domain with 410 records (above the 400 warn threshold)
709
+ const domainPath = join(mulchDir, "domains", "large-domain.jsonl");
710
+ const records = [];
711
+ for (let i = 1; i <= 410; i++) {
712
+ records.push(
713
+ `{"id":"mx-${i}","type":"convention","description":"Record ${i}","recorded_at":"2026-01-01T00:00:00Z"}`,
714
+ );
715
+ }
716
+ await writeFile(domainPath, `${records.join("\n")}\n`);
717
+
718
+ // Only run if mulch CLI is actually available
719
+ const mulchAvailable = existsSync(join(mulchDir, "domains", "large-domain.jsonl"));
720
+ if (!mulchAvailable) {
721
+ return; // Skip this test if mulch setup failed
722
+ }
723
+
724
+ await cleanCommand(["--all"]);
725
+
726
+ // Should show warning about domain near limit (if mulch CLI is available in the test environment)
727
+ // The exact output depends on whether mulch CLI is available in the test environment
728
+ expect(stdoutOutput).toBeDefined();
729
+ });
730
+ });