@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,653 @@
1
+ /**
2
+ * CLI command: legio clean [--all] [--mail] [--sessions] [--metrics]
3
+ * [--logs] [--worktrees] [--branches] [--agents] [--specs]
4
+ *
5
+ * Nuclear cleanup of legio runtime state.
6
+ * --all does everything. Individual flags allow selective cleanup.
7
+ *
8
+ * Execution order for --all (processes → filesystem → databases):
9
+ * 0. Run mulch health checks (informational, non-destructive):
10
+ * - Check domains approaching governance limits
11
+ * - Run mulch prune --dry-run (report stale record counts)
12
+ * - Run mulch doctor (report health issues)
13
+ * 1. Kill all legio tmux sessions
14
+ * 2. Remove all worktrees
15
+ * 3. Delete orphaned legio/* branches
16
+ * 4. Delete SQLite databases (mail.db, metrics.db)
17
+ * 5. Wipe sessions.db, merge-queue.db
18
+ * 6. Clear directory contents (logs/, agents/, specs/)
19
+ * 7. Delete nudge-state.json
20
+ */
21
+
22
+ import { spawn } from "node:child_process";
23
+ import { existsSync } from "node:fs";
24
+ import { access, readdir, rm, unlink, writeFile } from "node:fs/promises";
25
+ import { join } from "node:path";
26
+ import { loadConfig } from "../config.ts";
27
+ import { ValidationError } from "../errors.ts";
28
+ import { createEventStore } from "../events/store.ts";
29
+ import { createMulchClient } from "../mulch/client.ts";
30
+ import { openSessionStore } from "../sessions/compat.ts";
31
+ import type { AgentSession, MulchDoctorResult, MulchPruneResult, MulchStatus } from "../types.ts";
32
+ import { listWorktrees, removeWorktree } from "../worktree/manager.ts";
33
+ import { killSession, listSessions } from "../worktree/tmux.ts";
34
+
35
+ function hasFlag(args: string[], flag: string): boolean {
36
+ return args.includes(flag);
37
+ }
38
+
39
+ /**
40
+ * Check if a file exists using access().
41
+ */
42
+ async function fileExists(path: string): Promise<boolean> {
43
+ try {
44
+ await access(path);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Run an external command and collect stdout/stderr + exit code.
53
+ */
54
+ async function runCommand(
55
+ cmd: string[],
56
+ opts?: { cwd?: string },
57
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
58
+ const [command, ...args] = cmd;
59
+ if (!command) {
60
+ return Promise.resolve({ stdout: "", stderr: "", exitCode: 1 });
61
+ }
62
+ return new Promise((resolve) => {
63
+ const proc = spawn(command, args, {
64
+ cwd: opts?.cwd,
65
+ stdio: ["ignore", "pipe", "pipe"],
66
+ });
67
+ const stdoutChunks: Buffer[] = [];
68
+ const stderrChunks: Buffer[] = [];
69
+ proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
70
+ proc.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
71
+ proc.on("close", (code) => {
72
+ resolve({
73
+ stdout: Buffer.concat(stdoutChunks).toString(),
74
+ stderr: Buffer.concat(stderrChunks).toString(),
75
+ exitCode: code ?? 1,
76
+ });
77
+ });
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Load active agent sessions from SessionStore for session-end event logging.
83
+ * Returns sessions that are in an active state (booting, working, stalled).
84
+ *
85
+ * Checks for sessions.db or sessions.json existence first to avoid creating
86
+ * an empty database file as a side effect (which would interfere with
87
+ * the "Nothing to clean" detection later in the pipeline).
88
+ */
89
+ function loadActiveSessions(legioDir: string): AgentSession[] {
90
+ try {
91
+ const dbPath = join(legioDir, "sessions.db");
92
+ const jsonPath = join(legioDir, "sessions.json");
93
+ if (!existsSync(dbPath) && !existsSync(jsonPath)) {
94
+ return [];
95
+ }
96
+ const { store } = openSessionStore(legioDir);
97
+ try {
98
+ return store.getActive();
99
+ } finally {
100
+ store.close();
101
+ }
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Log synthetic session-end events for all active agents before killing tmux sessions.
109
+ *
110
+ * When clean --all or --worktrees kills tmux sessions, the Stop hook never fires
111
+ * because the process is killed externally. This function writes session_end events
112
+ * to the EventStore with reason='clean' so observability records are complete.
113
+ */
114
+ async function logSyntheticSessionEndEvents(legioDir: string): Promise<number> {
115
+ let logged = 0;
116
+ try {
117
+ const activeSessions = loadActiveSessions(legioDir);
118
+ if (activeSessions.length === 0) {
119
+ return 0;
120
+ }
121
+
122
+ const eventsDbPath = join(legioDir, "events.db");
123
+ const eventStore = createEventStore(eventsDbPath);
124
+ try {
125
+ for (const session of activeSessions) {
126
+ eventStore.insert({
127
+ runId: session.runId,
128
+ agentName: session.agentName,
129
+ sessionId: session.id,
130
+ eventType: "session_end",
131
+ toolName: null,
132
+ toolArgs: null,
133
+ toolDurationMs: null,
134
+ level: "info",
135
+ data: JSON.stringify({ reason: "clean", capability: session.capability }),
136
+ });
137
+ logged++;
138
+ }
139
+ } finally {
140
+ eventStore.close();
141
+ }
142
+ } catch {
143
+ // Best effort: event logging should not block cleanup
144
+ }
145
+ return logged;
146
+ }
147
+
148
+ interface CleanResult {
149
+ sessionEndEventsLogged: number;
150
+ tmuxKilled: number;
151
+ worktreesCleaned: number;
152
+ branchesDeleted: number;
153
+ mailWiped: boolean;
154
+ sessionsCleared: boolean;
155
+ mergeQueueCleared: boolean;
156
+ metricsWiped: boolean;
157
+ logsCleared: boolean;
158
+ agentsCleared: boolean;
159
+ specsCleared: boolean;
160
+ nudgeStateCleared: boolean;
161
+ currentRunCleared: boolean;
162
+ mulchHealth: {
163
+ checked: boolean;
164
+ domainsNearLimit: Array<{ domain: string; recordCount: number; warnThreshold: number }>;
165
+ stalePruneCandidates: number;
166
+ doctorIssues: number;
167
+ doctorWarnings: number;
168
+ } | null;
169
+ }
170
+
171
+ /**
172
+ * Kill legio tmux sessions registered in THIS project's SessionStore.
173
+ *
174
+ * Project-scoped: only kills tmux sessions whose names appear in the
175
+ * project's sessions.db (or sessions.json). This prevents cross-project
176
+ * kills during dogfooding, where `npm test` might run inside a live swarm.
177
+ *
178
+ * Falls back to killing all "legio-{projectName}-" prefixed tmux sessions
179
+ * only if the SessionStore is unavailable (graceful degradation for broken state).
180
+ */
181
+ async function killAllTmuxSessions(legioDir: string, projectName: string): Promise<number> {
182
+ let killed = 0;
183
+ const projectPrefix = `legio-${projectName}-`;
184
+ try {
185
+ const tmuxSessions = await listSessions();
186
+ const legioSessions = tmuxSessions.filter((s) => s.name.startsWith(projectPrefix));
187
+ if (legioSessions.length === 0) {
188
+ return 0;
189
+ }
190
+
191
+ // Build a set of tmux session names registered in this project's SessionStore.
192
+ const registeredNames = loadRegisteredTmuxNames(legioDir);
193
+
194
+ // If we got registered names, only kill those. Otherwise fall back to all
195
+ // legio-{projectName}-* sessions.
196
+ const toKill =
197
+ registeredNames !== null
198
+ ? legioSessions.filter((s) => registeredNames.has(s.name))
199
+ : legioSessions;
200
+
201
+ for (const session of toKill) {
202
+ try {
203
+ await killSession(session.name);
204
+ killed++;
205
+ } catch {
206
+ // Best effort
207
+ }
208
+ }
209
+ } catch {
210
+ // tmux not available or no server running
211
+ }
212
+ return killed;
213
+ }
214
+
215
+ /**
216
+ * Load the set of tmux session names registered in this project's SessionStore.
217
+ *
218
+ * Returns null if the SessionStore cannot be opened (signals the caller to
219
+ * fall back to the legacy "kill all legio-*" behavior).
220
+ */
221
+ function loadRegisteredTmuxNames(legioDir: string): Set<string> | null {
222
+ try {
223
+ const dbPath = join(legioDir, "sessions.db");
224
+ const jsonPath = join(legioDir, "sessions.json");
225
+ if (!existsSync(dbPath) && !existsSync(jsonPath)) {
226
+ // No session data at all -- return empty set (not null).
227
+ // This is distinct from "store unavailable": it means the project
228
+ // has no registered sessions, so nothing should be killed.
229
+ return new Set();
230
+ }
231
+ const { store } = openSessionStore(legioDir);
232
+ try {
233
+ const allSessions = store.getAll();
234
+ return new Set(allSessions.map((s) => s.tmuxSession));
235
+ } finally {
236
+ store.close();
237
+ }
238
+ } catch {
239
+ // SessionStore is broken -- fall back to legacy behavior
240
+ return null;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Remove all legio worktrees (force remove with branch deletion).
246
+ */
247
+ async function cleanAllWorktrees(root: string): Promise<number> {
248
+ let cleaned = 0;
249
+ try {
250
+ const worktrees = await listWorktrees(root);
251
+ const legioWts = worktrees.filter((wt) => wt.branch.startsWith("legio/"));
252
+ for (const wt of legioWts) {
253
+ try {
254
+ await removeWorktree(root, wt.path, { force: true, forceBranch: true });
255
+ cleaned++;
256
+ } catch {
257
+ // Best effort
258
+ }
259
+ }
260
+ } catch {
261
+ // No worktrees or git error
262
+ }
263
+ return cleaned;
264
+ }
265
+
266
+ /**
267
+ * Delete orphaned legio/* branch refs not tied to a worktree.
268
+ */
269
+ async function deleteOrphanedBranches(root: string): Promise<number> {
270
+ let deleted = 0;
271
+ try {
272
+ const { stdout } = await runCommand(
273
+ ["git", "for-each-ref", "refs/heads/legio/", "--format=%(refname:short)"],
274
+ { cwd: root },
275
+ );
276
+
277
+ const branches = stdout
278
+ .trim()
279
+ .split("\n")
280
+ .filter((b) => b.length > 0);
281
+ for (const branch of branches) {
282
+ try {
283
+ const { exitCode } = await runCommand(["git", "branch", "-D", branch], { cwd: root });
284
+ if (exitCode === 0) deleted++;
285
+ } catch {
286
+ // Best effort
287
+ }
288
+ }
289
+ } catch {
290
+ // Git error
291
+ }
292
+ return deleted;
293
+ }
294
+
295
+ /**
296
+ * Delete a SQLite database file and its WAL/SHM companions.
297
+ */
298
+ async function wipeSqliteDb(dbPath: string): Promise<boolean> {
299
+ const extensions = ["", "-wal", "-shm"];
300
+ let wiped = false;
301
+ for (const ext of extensions) {
302
+ try {
303
+ await unlink(`${dbPath}${ext}`);
304
+ if (ext === "") wiped = true;
305
+ } catch {
306
+ // File may not exist
307
+ }
308
+ }
309
+ return wiped;
310
+ }
311
+
312
+ /**
313
+ * Reset a JSON file to an empty array.
314
+ */
315
+ async function resetJsonFile(path: string): Promise<boolean> {
316
+ if (await fileExists(path)) {
317
+ await writeFile(path, "[]\n");
318
+ return true;
319
+ }
320
+ return false;
321
+ }
322
+
323
+ /**
324
+ * Clear all entries inside a directory but keep the directory itself.
325
+ */
326
+ async function clearDirectory(dirPath: string): Promise<boolean> {
327
+ try {
328
+ const entries = await readdir(dirPath);
329
+ for (const entry of entries) {
330
+ await rm(join(dirPath, entry), { recursive: true, force: true });
331
+ }
332
+ return entries.length > 0;
333
+ } catch {
334
+ // Directory may not exist
335
+ return false;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Delete a single file if it exists.
341
+ */
342
+ async function deleteFile(path: string): Promise<boolean> {
343
+ try {
344
+ await unlink(path);
345
+ return true;
346
+ } catch {
347
+ return false;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Check mulch repository health and return diagnostic information.
353
+ *
354
+ * Governance limits warn threshold (based on mulch defaults):
355
+ * - Max records per domain: 500 (warn at 400 = 80%)
356
+ *
357
+ * This is informational only — no data is modified.
358
+ */
359
+ async function checkMulchHealth(repoRoot: string): Promise<{
360
+ domainsNearLimit: Array<{ domain: string; recordCount: number; warnThreshold: number }>;
361
+ stalePruneCandidates: number;
362
+ doctorIssues: number;
363
+ doctorWarnings: number;
364
+ } | null> {
365
+ try {
366
+ const mulch = createMulchClient(repoRoot);
367
+
368
+ // 1. Check domain sizes against governance limits
369
+ let status: MulchStatus;
370
+ try {
371
+ status = await mulch.status();
372
+ } catch {
373
+ // Mulch not available or no .mulch directory
374
+ return null;
375
+ }
376
+
377
+ const warnThreshold = 400; // 80% of 500 max
378
+ const domainsNearLimit = status.domains
379
+ .filter((d) => d.recordCount >= warnThreshold)
380
+ .map((d) => ({ domain: d.name, recordCount: d.recordCount, warnThreshold }));
381
+
382
+ // 2. Run prune --dry-run to count stale records
383
+ let pruneResult: MulchPruneResult;
384
+ try {
385
+ pruneResult = await mulch.prune({ dryRun: true });
386
+ } catch {
387
+ // Prune failed — skip this check
388
+ pruneResult = { success: false, command: "prune", dryRun: true, totalPruned: 0, results: [] };
389
+ }
390
+
391
+ const stalePruneCandidates = pruneResult.totalPruned;
392
+
393
+ // 3. Run doctor to check repository health
394
+ let doctorResult: MulchDoctorResult;
395
+ try {
396
+ doctorResult = await mulch.doctor({ fix: false });
397
+ } catch {
398
+ // Doctor failed — skip this check
399
+ doctorResult = {
400
+ success: false,
401
+ command: "doctor",
402
+ checks: [],
403
+ summary: { pass: 0, warn: 0, fail: 0 },
404
+ };
405
+ }
406
+
407
+ const doctorIssues = doctorResult.summary.fail;
408
+ const doctorWarnings = doctorResult.summary.warn;
409
+
410
+ return {
411
+ domainsNearLimit,
412
+ stalePruneCandidates,
413
+ doctorIssues,
414
+ doctorWarnings,
415
+ };
416
+ } catch {
417
+ // Mulch not available or other error — skip health checks
418
+ return null;
419
+ }
420
+ }
421
+
422
+ const CLEAN_HELP = `legio clean — Wipe runtime state (nuclear cleanup)
423
+
424
+ Usage: legio clean [flags]
425
+
426
+ Flags:
427
+ --all Wipe everything (nuclear option)
428
+ --mail Delete mail.db (all messages)
429
+ --sessions Wipe sessions.db
430
+ --metrics Delete metrics.db
431
+ --logs Remove all agent logs
432
+ --worktrees Remove all worktrees + kill tmux sessions
433
+ --branches Delete all legio/* branch refs
434
+ --agents Remove agent identity files
435
+ --specs Remove task spec files
436
+
437
+ Options:
438
+ --json Output as JSON
439
+ --help, -h Show this help
440
+
441
+ When --all is passed, ALL of the above are executed in safe order:
442
+ 0. Run mulch health checks (informational, non-destructive):
443
+ - Check domains approaching governance limits (warn threshold: 400 records)
444
+ - Run mulch prune --dry-run (report stale record counts)
445
+ - Run mulch doctor (report health issues)
446
+ 1. Kill all legio tmux sessions (processes first)
447
+ 2. Remove all worktrees
448
+ 3. Delete orphaned branch refs
449
+ 4. Wipe mail.db, metrics.db, sessions.db, merge-queue.db
450
+ 5. Clear logs, agents, specs, nudge state`;
451
+
452
+ export async function cleanCommand(args: string[]): Promise<void> {
453
+ if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
454
+ process.stdout.write(`${CLEAN_HELP}\n`);
455
+ return;
456
+ }
457
+
458
+ const json = hasFlag(args, "--json");
459
+ const all = hasFlag(args, "--all");
460
+
461
+ const doWorktrees = all || hasFlag(args, "--worktrees");
462
+ const doBranches = all || hasFlag(args, "--branches");
463
+ const doMail = all || hasFlag(args, "--mail");
464
+ const doSessions = all || hasFlag(args, "--sessions");
465
+ const doMetrics = all || hasFlag(args, "--metrics");
466
+ const doLogs = all || hasFlag(args, "--logs");
467
+ const doAgents = all || hasFlag(args, "--agents");
468
+ const doSpecs = all || hasFlag(args, "--specs");
469
+
470
+ const anySelected =
471
+ doWorktrees || doBranches || doMail || doSessions || doMetrics || doLogs || doAgents || doSpecs;
472
+
473
+ if (!anySelected) {
474
+ throw new ValidationError(
475
+ "No cleanup targets specified. Use --all for full cleanup, or individual flags (--mail, --sessions, --metrics, --logs, --worktrees, --branches, --agents, --specs).",
476
+ { field: "flags" },
477
+ );
478
+ }
479
+
480
+ const config = await loadConfig(process.cwd());
481
+ const root = config.project.root;
482
+ const legioDir = join(root, ".legio");
483
+
484
+ const result: CleanResult = {
485
+ sessionEndEventsLogged: 0,
486
+ tmuxKilled: 0,
487
+ worktreesCleaned: 0,
488
+ branchesDeleted: 0,
489
+ mailWiped: false,
490
+ sessionsCleared: false,
491
+ mergeQueueCleared: false,
492
+ metricsWiped: false,
493
+ logsCleared: false,
494
+ agentsCleared: false,
495
+ specsCleared: false,
496
+ nudgeStateCleared: false,
497
+ currentRunCleared: false,
498
+ mulchHealth: null,
499
+ };
500
+
501
+ // 0. Run mulch health checks BEFORE cleanup operations (when --all is set).
502
+ // This is informational only — no data is modified.
503
+ if (all) {
504
+ const healthCheck = await checkMulchHealth(root);
505
+ if (healthCheck) {
506
+ result.mulchHealth = {
507
+ checked: true,
508
+ domainsNearLimit: healthCheck.domainsNearLimit,
509
+ stalePruneCandidates: healthCheck.stalePruneCandidates,
510
+ doctorIssues: healthCheck.doctorIssues,
511
+ doctorWarnings: healthCheck.doctorWarnings,
512
+ };
513
+ }
514
+ }
515
+
516
+ // 1. Log synthetic session-end events BEFORE killing tmux sessions.
517
+ // When processes are killed externally, the Stop hook never fires,
518
+ // so session_end events would be lost without this step.
519
+ if (doWorktrees || all) {
520
+ result.sessionEndEventsLogged = await logSyntheticSessionEndEvents(legioDir);
521
+ }
522
+
523
+ // 2. Kill tmux sessions (must happen before worktree removal)
524
+ if (doWorktrees || all) {
525
+ result.tmuxKilled = await killAllTmuxSessions(legioDir, config.project.name);
526
+ }
527
+
528
+ // 3. Remove worktrees
529
+ if (doWorktrees) {
530
+ result.worktreesCleaned = await cleanAllWorktrees(root);
531
+ }
532
+
533
+ // 4. Delete orphaned branches
534
+ if (doBranches) {
535
+ result.branchesDeleted = await deleteOrphanedBranches(root);
536
+ }
537
+
538
+ // 5. Wipe databases
539
+ if (doMail) {
540
+ result.mailWiped = await wipeSqliteDb(join(legioDir, "mail.db"));
541
+ }
542
+ if (doMetrics) {
543
+ result.metricsWiped = await wipeSqliteDb(join(legioDir, "metrics.db"));
544
+ }
545
+
546
+ // 6. Wipe sessions.db + legacy sessions.json
547
+ if (doSessions) {
548
+ result.sessionsCleared = await wipeSqliteDb(join(legioDir, "sessions.db"));
549
+ // Also clean legacy sessions.json if it still exists
550
+ await resetJsonFile(join(legioDir, "sessions.json"));
551
+ }
552
+ if (all) {
553
+ result.mergeQueueCleared = await wipeSqliteDb(join(legioDir, "merge-queue.db"));
554
+ }
555
+
556
+ // 7. Clear directories
557
+ if (doLogs) {
558
+ result.logsCleared = await clearDirectory(join(legioDir, "logs"));
559
+ }
560
+ if (doAgents) {
561
+ result.agentsCleared = await clearDirectory(join(legioDir, "agents"));
562
+ }
563
+ if (doSpecs) {
564
+ result.specsCleared = await clearDirectory(join(legioDir, "specs"));
565
+ }
566
+
567
+ // 8. Delete nudge state + pending nudge markers + current-run.txt
568
+ if (all) {
569
+ result.nudgeStateCleared = await deleteFile(join(legioDir, "nudge-state.json"));
570
+ await clearDirectory(join(legioDir, "pending-nudges"));
571
+ result.currentRunCleared = await deleteFile(join(legioDir, "current-run.txt"));
572
+ }
573
+
574
+ // Output
575
+ if (json) {
576
+ process.stdout.write(`${JSON.stringify(result, null, "\t")}\n`);
577
+ return;
578
+ }
579
+
580
+ const lines: string[] = [];
581
+ if (result.sessionEndEventsLogged > 0) {
582
+ lines.push(
583
+ `Logged ${result.sessionEndEventsLogged} synthetic session-end event${result.sessionEndEventsLogged === 1 ? "" : "s"}`,
584
+ );
585
+ }
586
+ if (result.tmuxKilled > 0) {
587
+ lines.push(`Killed ${result.tmuxKilled} tmux session${result.tmuxKilled === 1 ? "" : "s"}`);
588
+ }
589
+ if (result.worktreesCleaned > 0) {
590
+ lines.push(
591
+ `Removed ${result.worktreesCleaned} worktree${result.worktreesCleaned === 1 ? "" : "s"}`,
592
+ );
593
+ }
594
+ if (result.branchesDeleted > 0) {
595
+ lines.push(
596
+ `Deleted ${result.branchesDeleted} orphaned branch${result.branchesDeleted === 1 ? "" : "es"}`,
597
+ );
598
+ }
599
+ if (result.mailWiped) lines.push("Wiped mail.db");
600
+ if (result.metricsWiped) lines.push("Wiped metrics.db");
601
+ if (result.sessionsCleared) lines.push("Wiped sessions.db");
602
+ if (result.mergeQueueCleared) lines.push("Wiped merge-queue.db");
603
+ if (result.logsCleared) lines.push("Cleared logs/");
604
+ if (result.agentsCleared) lines.push("Cleared agents/");
605
+ if (result.specsCleared) lines.push("Cleared specs/");
606
+ if (result.nudgeStateCleared) lines.push("Cleared nudge-state.json");
607
+ if (result.currentRunCleared) lines.push("Cleared current-run.txt");
608
+
609
+ // Mulch health diagnostics (shown before cleanup results)
610
+ if (result.mulchHealth?.checked) {
611
+ const health = result.mulchHealth;
612
+ const healthLines: string[] = [];
613
+
614
+ if (health.domainsNearLimit.length > 0) {
615
+ healthLines.push("\n⚠️ Mulch domains approaching governance limits:");
616
+ for (const d of health.domainsNearLimit) {
617
+ healthLines.push(
618
+ ` ${d.domain}: ${d.recordCount} records (warn threshold: ${d.warnThreshold})`,
619
+ );
620
+ }
621
+ }
622
+
623
+ if (health.stalePruneCandidates > 0) {
624
+ healthLines.push(
625
+ `\n📦 Stale records found: ${health.stalePruneCandidates} candidate${health.stalePruneCandidates === 1 ? "" : "s"} (run 'mulch prune' to remove)`,
626
+ );
627
+ }
628
+
629
+ if (health.doctorWarnings > 0 || health.doctorIssues > 0) {
630
+ healthLines.push(
631
+ `\n🩺 Mulch health check: ${health.doctorWarnings} warning${health.doctorWarnings === 1 ? "" : "s"}, ${health.doctorIssues} issue${health.doctorIssues === 1 ? "" : "s"} (run 'mulch doctor' for details)`,
632
+ );
633
+ }
634
+
635
+ if (healthLines.length > 0) {
636
+ for (const line of healthLines) {
637
+ process.stdout.write(`${line}\n`);
638
+ }
639
+ }
640
+ }
641
+
642
+ if (lines.length === 0) {
643
+ process.stdout.write("Nothing to clean.\n");
644
+ } else {
645
+ if (result.mulchHealth?.checked) {
646
+ process.stdout.write("\n--- Cleanup Results ---\n");
647
+ }
648
+ for (const line of lines) {
649
+ process.stdout.write(`${line}\n`);
650
+ }
651
+ process.stdout.write("\nClean complete.\n");
652
+ }
653
+ }