@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,295 @@
1
+ /**
2
+ * Tests for `legio doctor` command.
3
+ *
4
+ * Uses temp directories with real config.yaml to test the doctor scaffold.
5
+ * All check modules return empty arrays (stubs), so tests verify the scaffold
6
+ * structure, not individual check implementations.
7
+ *
8
+ * Real implementations used for: filesystem (temp dirs), config loading.
9
+ * No mocks needed -- all dependencies are cheap and local.
10
+ */
11
+
12
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
16
+ import { ValidationError } from "../errors.ts";
17
+ import { doctorCommand } from "./doctor.ts";
18
+
19
+ describe("doctorCommand", () => {
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 dir with .legio/config.yaml structure
35
+ tempDir = await mkdtemp(join(tmpdir(), "doctor-test-"));
36
+ const legioDir = join(tempDir, ".legio");
37
+ await mkdir(legioDir, { recursive: true });
38
+ await writeFile(
39
+ join(legioDir, "config.yaml"),
40
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
41
+ );
42
+
43
+ // Change to temp dir so loadConfig() works
44
+ originalCwd = process.cwd();
45
+ process.chdir(tempDir);
46
+ });
47
+
48
+ afterEach(async () => {
49
+ process.stdout.write = originalWrite;
50
+ process.chdir(originalCwd);
51
+ await rm(tempDir, { recursive: true, force: true });
52
+ });
53
+
54
+ function output(): string {
55
+ return chunks.join("");
56
+ }
57
+
58
+ // === Help flag ===
59
+
60
+ describe("help flag", () => {
61
+ test("--help shows help text", async () => {
62
+ await doctorCommand(["--help"], { checkRunners: [] });
63
+ const out = output();
64
+
65
+ expect(out).toContain("legio doctor");
66
+ expect(out).toContain("Run health checks");
67
+ expect(out).toContain("--json");
68
+ expect(out).toContain("--verbose");
69
+ expect(out).toContain("--category");
70
+ });
71
+
72
+ test("-h shows help text", async () => {
73
+ await doctorCommand(["-h"], { checkRunners: [] });
74
+ const out = output();
75
+
76
+ expect(out).toContain("legio doctor");
77
+ expect(out).toContain("--help");
78
+ });
79
+ });
80
+
81
+ // === JSON output ===
82
+
83
+ describe("JSON output mode", () => {
84
+ test("outputs valid JSON with checks array and summary", async () => {
85
+ await doctorCommand(["--json"], { checkRunners: [] });
86
+ const out = output();
87
+
88
+ const parsed = JSON.parse(out.trim()) as {
89
+ checks: unknown[];
90
+ summary: { pass: number; warn: number; fail: number };
91
+ };
92
+ expect(parsed).toBeDefined();
93
+ expect(Array.isArray(parsed.checks)).toBe(true);
94
+ expect(parsed.summary).toBeDefined();
95
+ expect(typeof parsed.summary.pass).toBe("number");
96
+ expect(typeof parsed.summary.warn).toBe("number");
97
+ expect(typeof parsed.summary.fail).toBe("number");
98
+ });
99
+
100
+ test("empty stubs produce zero counts in summary", async () => {
101
+ await doctorCommand(["--json"], { checkRunners: [] });
102
+ const out = output();
103
+
104
+ const parsed = JSON.parse(out.trim()) as {
105
+ checks: unknown[];
106
+ summary: { pass: number; warn: number; fail: number };
107
+ };
108
+ expect(parsed.checks).toEqual([]);
109
+ expect(parsed.summary.pass).toBe(0);
110
+ expect(parsed.summary.warn).toBe(0);
111
+ expect(parsed.summary.fail).toBe(0);
112
+ });
113
+ });
114
+
115
+ // === Human-readable output ===
116
+
117
+ describe("human-readable output", () => {
118
+ test("shows header", async () => {
119
+ await doctorCommand([], { checkRunners: [] });
120
+ const out = output();
121
+
122
+ expect(out).toContain("Legio Doctor");
123
+ expect(out).toContain("================");
124
+ });
125
+
126
+ test("shows summary line with zero counts", async () => {
127
+ await doctorCommand([], { checkRunners: [] });
128
+ const out = output();
129
+
130
+ expect(out).toContain("Summary:");
131
+ expect(out).toContain("0 passed");
132
+ expect(out).toContain("0 warning");
133
+ expect(out).toContain("0 failure");
134
+ });
135
+
136
+ test("summary uses singular form for one failure", async () => {
137
+ // This test can't verify "1 failure" without real checks, but we can test the logic
138
+ // by checking that the scaffold doesn't crash on empty checks
139
+ await doctorCommand([], { checkRunners: [] });
140
+ const out = output();
141
+
142
+ // Should show "0 failures" (plural) when count is 0
143
+ expect(out).toContain("0 failure");
144
+ });
145
+
146
+ test("default mode does not show empty categories", async () => {
147
+ await doctorCommand([], { checkRunners: [] });
148
+ const out = output();
149
+
150
+ // Since all stubs return empty arrays, no category headers should appear
151
+ // in non-verbose mode
152
+ expect(out).not.toContain("[dependencies]");
153
+ expect(out).not.toContain("[structure]");
154
+ expect(out).not.toContain("[config]");
155
+ });
156
+ });
157
+
158
+ // === --verbose flag ===
159
+
160
+ describe("--verbose flag", () => {
161
+ test("shows header and summary even with no check runners", async () => {
162
+ await doctorCommand(["--verbose"], { checkRunners: [] });
163
+ const out = output();
164
+
165
+ // With no check runners, no categories appear (even in verbose mode)
166
+ expect(out).toContain("Legio Doctor");
167
+ expect(out).toContain("Summary:");
168
+ expect(out).toContain("0 passed");
169
+ // No categories should appear with empty checkRunners
170
+ expect(out).not.toContain("[dependencies]");
171
+ expect(out).not.toContain("[structure]");
172
+ });
173
+
174
+ test("verbose mode works with empty check runners", async () => {
175
+ await doctorCommand(["--verbose"], { checkRunners: [] });
176
+ const out = output();
177
+
178
+ // Should still produce valid output with no categories
179
+ expect(out).toContain("Legio Doctor");
180
+ expect(out).toContain("Summary:");
181
+ // With no check runners, "No checks" doesn't appear (no categories to show it under)
182
+ expect(out).not.toContain("No checks");
183
+ });
184
+ });
185
+
186
+ // === --category flag ===
187
+
188
+ describe("--category flag", () => {
189
+ test("runs only specified category", async () => {
190
+ await doctorCommand(["--category", "dependencies", "--json"], { checkRunners: [] });
191
+ const out = output();
192
+
193
+ const parsed = JSON.parse(out.trim()) as {
194
+ checks: Array<{ category: string }>;
195
+ };
196
+ // Since dependencies stub returns [], checks should be empty
197
+ expect(parsed.checks).toEqual([]);
198
+ });
199
+
200
+ test("validates category name", async () => {
201
+ await expect(
202
+ doctorCommand(["--category", "invalid-category"], { checkRunners: [] }),
203
+ ).rejects.toThrow(ValidationError);
204
+ });
205
+
206
+ test("invalid category error mentions valid categories", async () => {
207
+ try {
208
+ await doctorCommand(["--category", "bad"], { checkRunners: [] });
209
+ expect.unreachable("should have thrown");
210
+ } catch (err) {
211
+ expect(err).toBeInstanceOf(ValidationError);
212
+ const message = (err as ValidationError).message;
213
+ expect(message).toContain("Invalid category");
214
+ expect(message).toContain("dependencies");
215
+ expect(message).toContain("structure");
216
+ expect(message).toContain("config");
217
+ }
218
+ });
219
+
220
+ test("accepts all valid category names", async () => {
221
+ const categories = [
222
+ "dependencies",
223
+ "structure",
224
+ "config",
225
+ "databases",
226
+ "consistency",
227
+ "agents",
228
+ "merge",
229
+ "logs",
230
+ "version",
231
+ ];
232
+
233
+ for (const category of categories) {
234
+ chunks = []; // Reset output
235
+ await doctorCommand(["--category", category, "--json"], { checkRunners: [] });
236
+ const out = output();
237
+ // Should not throw, and output should be valid JSON
238
+ JSON.parse(out.trim());
239
+ }
240
+ });
241
+ });
242
+
243
+ // === Exit code ===
244
+
245
+ describe("exit code", () => {
246
+ test("returns undefined when all checks pass or warn", async () => {
247
+ const exitCode = await doctorCommand([], { checkRunners: [] });
248
+ expect(exitCode).toBeUndefined();
249
+ });
250
+
251
+ test("returns undefined on success (no failures)", async () => {
252
+ const exitCode = await doctorCommand([], { checkRunners: [] });
253
+ expect(exitCode).toBeUndefined();
254
+ });
255
+ });
256
+
257
+ // === Edge cases ===
258
+
259
+ describe("edge cases", () => {
260
+ test("handles multiple flags together", async () => {
261
+ await doctorCommand(["--json", "--verbose", "--category", "config"], {
262
+ checkRunners: [],
263
+ });
264
+ const out = output();
265
+
266
+ const parsed = JSON.parse(out.trim()) as {
267
+ checks: unknown[];
268
+ summary: unknown;
269
+ };
270
+ expect(parsed.checks).toEqual([]);
271
+ });
272
+
273
+ test("flags can appear in any order", async () => {
274
+ chunks = [];
275
+ await doctorCommand(["--category", "logs", "--json"], { checkRunners: [] });
276
+ const out1 = output();
277
+
278
+ chunks = [];
279
+ await doctorCommand(["--json", "--category", "logs"], { checkRunners: [] });
280
+ const out2 = output();
281
+
282
+ // Both should produce the same JSON
283
+ expect(JSON.parse(out1.trim())).toEqual(JSON.parse(out2.trim()));
284
+ });
285
+
286
+ test("runs without crashing on minimal config", async () => {
287
+ // The beforeEach already sets up minimal config, so this just
288
+ // verifies the command doesn't crash
289
+ await doctorCommand([], { checkRunners: [] });
290
+ const out = output();
291
+
292
+ expect(out).toContain("Legio Doctor");
293
+ });
294
+ });
295
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * CLI command: legio doctor [options]
3
+ *
4
+ * Runs health checks on legio subsystems and reports problems.
5
+ */
6
+
7
+ import { join } from "node:path";
8
+ import { loadConfig } from "../config.ts";
9
+ import { checkAgents } from "../doctor/agents.ts";
10
+ import { checkConfig } from "../doctor/config-check.ts";
11
+ import { checkConsistency } from "../doctor/consistency.ts";
12
+ import { checkDatabases } from "../doctor/databases.ts";
13
+ import { checkDependencies } from "../doctor/dependencies.ts";
14
+ import { checkLogs } from "../doctor/logs.ts";
15
+ import { checkMergeQueue } from "../doctor/merge-queue.ts";
16
+ import { checkStructure } from "../doctor/structure.ts";
17
+ import type { DoctorCategory, DoctorCheck, DoctorCheckFn } from "../doctor/types.ts";
18
+ import { checkVersion } from "../doctor/version.ts";
19
+ import { ValidationError } from "../errors.ts";
20
+ import { color } from "../logging/color.ts";
21
+
22
+ /** Registry of all check modules in execution order. */
23
+ const ALL_CHECKS: Array<{ category: DoctorCategory; fn: DoctorCheckFn }> = [
24
+ { category: "dependencies", fn: checkDependencies },
25
+ { category: "config", fn: checkConfig },
26
+ { category: "structure", fn: checkStructure },
27
+ { category: "databases", fn: checkDatabases },
28
+ { category: "consistency", fn: checkConsistency },
29
+ { category: "agents", fn: checkAgents },
30
+ { category: "merge", fn: checkMergeQueue },
31
+ { category: "logs", fn: checkLogs },
32
+ { category: "version", fn: checkVersion },
33
+ ];
34
+
35
+ function hasFlag(args: string[], flag: string): boolean {
36
+ return args.includes(flag);
37
+ }
38
+
39
+ function getFlag(args: string[], flag: string): string | undefined {
40
+ const idx = args.indexOf(flag);
41
+ if (idx === -1 || idx + 1 >= args.length) {
42
+ return undefined;
43
+ }
44
+ return args[idx + 1];
45
+ }
46
+
47
+ /**
48
+ * Format human-readable output for doctor checks.
49
+ */
50
+ function printHumanReadable(
51
+ checks: DoctorCheck[],
52
+ verbose: boolean,
53
+ checkRegistry: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>,
54
+ ): void {
55
+ const w = process.stdout.write.bind(process.stdout);
56
+
57
+ w(`${color.bold}Legio Doctor${color.reset}\n`);
58
+ w("================\n\n");
59
+
60
+ // Group checks by category
61
+ const byCategory = new Map<DoctorCategory, DoctorCheck[]>();
62
+ for (const check of checks) {
63
+ const existing = byCategory.get(check.category);
64
+ if (existing) {
65
+ existing.push(check);
66
+ } else {
67
+ byCategory.set(check.category, [check]);
68
+ }
69
+ }
70
+
71
+ // Print each category
72
+ for (const { category } of checkRegistry) {
73
+ const categoryChecks = byCategory.get(category) ?? [];
74
+ if (categoryChecks.length === 0 && !verbose) {
75
+ continue; // Skip empty categories unless verbose
76
+ }
77
+
78
+ w(`${color.bold}[${category}]${color.reset}\n`);
79
+
80
+ if (categoryChecks.length === 0) {
81
+ w(` ${color.dim}No checks${color.reset}\n`);
82
+ } else {
83
+ for (const check of categoryChecks) {
84
+ // Skip passing checks unless verbose
85
+ if (check.status === "pass" && !verbose) {
86
+ continue;
87
+ }
88
+
89
+ const icon =
90
+ check.status === "pass"
91
+ ? `${color.green}✔${color.reset}`
92
+ : check.status === "warn"
93
+ ? `${color.yellow}⚠${color.reset}`
94
+ : `${color.red}✘${color.reset}`;
95
+
96
+ w(` ${icon} ${check.message}\n`);
97
+
98
+ // Print details if present
99
+ if (check.details && check.details.length > 0) {
100
+ for (const detail of check.details) {
101
+ w(` ${color.dim}→ ${detail}${color.reset}\n`);
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ w("\n");
108
+ }
109
+
110
+ // Summary
111
+ const pass = checks.filter((c) => c.status === "pass").length;
112
+ const warn = checks.filter((c) => c.status === "warn").length;
113
+ const fail = checks.filter((c) => c.status === "fail").length;
114
+
115
+ w(
116
+ `${color.bold}Summary:${color.reset} ${color.green}${pass} passed${color.reset}, ${color.yellow}${warn} warning${warn === 1 ? "" : "s"}${color.reset}, ${color.red}${fail} failure${fail === 1 ? "" : "s"}${color.reset}\n`,
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Format JSON output for doctor checks.
122
+ */
123
+ function printJSON(checks: DoctorCheck[]): void {
124
+ const pass = checks.filter((c) => c.status === "pass").length;
125
+ const warn = checks.filter((c) => c.status === "warn").length;
126
+ const fail = checks.filter((c) => c.status === "fail").length;
127
+
128
+ const output = {
129
+ checks,
130
+ summary: { pass, warn, fail },
131
+ };
132
+
133
+ process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
134
+ }
135
+
136
+ const DOCTOR_HELP = `legio doctor -- Run health checks on legio subsystems
137
+
138
+ Usage: legio doctor [options]
139
+
140
+ Options:
141
+ --json Output as JSON
142
+ --verbose Show passing checks (default: only problems)
143
+ --category <name> Run only one category
144
+ --help, -h Show this help
145
+
146
+ Categories: dependencies, structure, config, databases, consistency, agents, merge, logs, version`;
147
+
148
+ /** Options for dependency injection in doctorCommand. */
149
+ export interface DoctorCommandOptions {
150
+ /** Override the check runners (defaults to ALL_CHECKS). Pass [] to skip all checks. */
151
+ checkRunners?: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>;
152
+ }
153
+
154
+ /**
155
+ * Entry point for `legio doctor [--json] [--verbose] [--category <name>]`.
156
+ *
157
+ * @returns Exit code (1 if any check failed, undefined otherwise)
158
+ */
159
+ export async function doctorCommand(
160
+ args: string[],
161
+ options?: DoctorCommandOptions,
162
+ ): Promise<number | undefined> {
163
+ if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
164
+ process.stdout.write(`${DOCTOR_HELP}\n`);
165
+ return;
166
+ }
167
+
168
+ const json = hasFlag(args, "--json");
169
+ const verbose = hasFlag(args, "--verbose");
170
+ const categoryFilter = getFlag(args, "--category");
171
+
172
+ // Validate category filter if provided
173
+ if (categoryFilter !== undefined) {
174
+ const validCategories = ALL_CHECKS.map((c) => c.category);
175
+ if (!validCategories.includes(categoryFilter as DoctorCategory)) {
176
+ throw new ValidationError(
177
+ `Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
178
+ {
179
+ field: "category",
180
+ value: categoryFilter,
181
+ },
182
+ );
183
+ }
184
+ }
185
+
186
+ const cwd = process.cwd();
187
+ const config = await loadConfig(cwd);
188
+ const legioDir = join(config.project.root, ".legio");
189
+
190
+ // Filter checks by category if specified
191
+ const allChecks = options?.checkRunners ?? ALL_CHECKS;
192
+ const checksToRun = categoryFilter
193
+ ? allChecks.filter((c) => c.category === categoryFilter)
194
+ : allChecks;
195
+
196
+ // Run all checks sequentially
197
+ const results: DoctorCheck[] = [];
198
+ for (const { fn } of checksToRun) {
199
+ const checkResults = await fn(config, legioDir);
200
+ results.push(...checkResults);
201
+ }
202
+
203
+ // Output results
204
+ if (json) {
205
+ printJSON(results);
206
+ } else {
207
+ printHumanReadable(results, verbose, allChecks);
208
+ }
209
+
210
+ // Return exit code if any check failed
211
+ const hasFailures = results.some((c) => c.status === "fail");
212
+ return hasFailures ? 1 : undefined;
213
+ }