@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Tests for `overstory 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 { afterEach, beforeEach, describe, expect, test } from "bun:test";
13
+ import { mkdtemp, rm } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
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 .overstory/config.yaml structure
35
+ tempDir = await mkdtemp(join(tmpdir(), "doctor-test-"));
36
+ const overstoryDir = join(tempDir, ".overstory");
37
+ await Bun.write(
38
+ join(overstoryDir, "config.yaml"),
39
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
40
+ );
41
+
42
+ // Change to temp dir so loadConfig() works
43
+ originalCwd = process.cwd();
44
+ process.chdir(tempDir);
45
+ });
46
+
47
+ afterEach(async () => {
48
+ process.stdout.write = originalWrite;
49
+ process.chdir(originalCwd);
50
+ await rm(tempDir, { recursive: true, force: true });
51
+ });
52
+
53
+ function output(): string {
54
+ return chunks.join("");
55
+ }
56
+
57
+ // === Help flag ===
58
+
59
+ describe("help flag", () => {
60
+ test("--help shows help text", async () => {
61
+ await doctorCommand(["--help"], { checkRunners: [] });
62
+ const out = output();
63
+
64
+ expect(out).toContain("overstory doctor");
65
+ expect(out).toContain("Run health checks");
66
+ expect(out).toContain("--json");
67
+ expect(out).toContain("--verbose");
68
+ expect(out).toContain("--category");
69
+ });
70
+
71
+ test("-h shows help text", async () => {
72
+ await doctorCommand(["-h"], { checkRunners: [] });
73
+ const out = output();
74
+
75
+ expect(out).toContain("overstory doctor");
76
+ expect(out).toContain("--help");
77
+ });
78
+ });
79
+
80
+ // === JSON output ===
81
+
82
+ describe("JSON output mode", () => {
83
+ test("outputs valid JSON with checks array and summary", async () => {
84
+ await doctorCommand(["--json"], { checkRunners: [] });
85
+ const out = output();
86
+
87
+ const parsed = JSON.parse(out.trim()) as {
88
+ checks: unknown[];
89
+ summary: { pass: number; warn: number; fail: number };
90
+ };
91
+ expect(parsed).toBeDefined();
92
+ expect(Array.isArray(parsed.checks)).toBe(true);
93
+ expect(parsed.summary).toBeDefined();
94
+ expect(typeof parsed.summary.pass).toBe("number");
95
+ expect(typeof parsed.summary.warn).toBe("number");
96
+ expect(typeof parsed.summary.fail).toBe("number");
97
+ });
98
+
99
+ test("empty stubs produce zero counts in summary", async () => {
100
+ await doctorCommand(["--json"], { checkRunners: [] });
101
+ const out = output();
102
+
103
+ const parsed = JSON.parse(out.trim()) as {
104
+ checks: unknown[];
105
+ summary: { pass: number; warn: number; fail: number };
106
+ };
107
+ expect(parsed.checks).toEqual([]);
108
+ expect(parsed.summary.pass).toBe(0);
109
+ expect(parsed.summary.warn).toBe(0);
110
+ expect(parsed.summary.fail).toBe(0);
111
+ });
112
+ });
113
+
114
+ // === Human-readable output ===
115
+
116
+ describe("human-readable output", () => {
117
+ test("shows header", async () => {
118
+ await doctorCommand([], { checkRunners: [] });
119
+ const out = output();
120
+
121
+ expect(out).toContain("Overstory Doctor");
122
+ expect(out).toContain("================");
123
+ });
124
+
125
+ test("shows summary line with zero counts", async () => {
126
+ await doctorCommand([], { checkRunners: [] });
127
+ const out = output();
128
+
129
+ expect(out).toContain("Summary:");
130
+ expect(out).toContain("0 passed");
131
+ expect(out).toContain("0 warning");
132
+ expect(out).toContain("0 failure");
133
+ });
134
+
135
+ test("summary uses singular form for one failure", async () => {
136
+ // This test can't verify "1 failure" without real checks, but we can test the logic
137
+ // by checking that the scaffold doesn't crash on empty checks
138
+ await doctorCommand([], { checkRunners: [] });
139
+ const out = output();
140
+
141
+ // Should show "0 failures" (plural) when count is 0
142
+ expect(out).toContain("0 failure");
143
+ });
144
+
145
+ test("default mode does not show empty categories", async () => {
146
+ await doctorCommand([], { checkRunners: [] });
147
+ const out = output();
148
+
149
+ // Since all stubs return empty arrays, no category headers should appear
150
+ // in non-verbose mode
151
+ expect(out).not.toContain("[dependencies]");
152
+ expect(out).not.toContain("[structure]");
153
+ expect(out).not.toContain("[config]");
154
+ });
155
+ });
156
+
157
+ // === --verbose flag ===
158
+
159
+ describe("--verbose flag", () => {
160
+ test("shows header and summary even with no check runners", async () => {
161
+ await doctorCommand(["--verbose"], { checkRunners: [] });
162
+ const out = output();
163
+
164
+ // With no check runners, no categories appear (even in verbose mode)
165
+ expect(out).toContain("Overstory Doctor");
166
+ expect(out).toContain("Summary:");
167
+ expect(out).toContain("0 passed");
168
+ // No categories should appear with empty checkRunners
169
+ expect(out).not.toContain("[dependencies]");
170
+ expect(out).not.toContain("[structure]");
171
+ });
172
+
173
+ test("verbose mode works with empty check runners", async () => {
174
+ await doctorCommand(["--verbose"], { checkRunners: [] });
175
+ const out = output();
176
+
177
+ // Should still produce valid output with no categories
178
+ expect(out).toContain("Overstory Doctor");
179
+ expect(out).toContain("Summary:");
180
+ // With no check runners, "No checks" doesn't appear (no categories to show it under)
181
+ expect(out).not.toContain("No checks");
182
+ });
183
+ });
184
+
185
+ // === --category flag ===
186
+
187
+ describe("--category flag", () => {
188
+ test("runs only specified category", async () => {
189
+ await doctorCommand(["--category", "dependencies", "--json"], { checkRunners: [] });
190
+ const out = output();
191
+
192
+ const parsed = JSON.parse(out.trim()) as {
193
+ checks: Array<{ category: string }>;
194
+ };
195
+ // Since dependencies stub returns [], checks should be empty
196
+ expect(parsed.checks).toEqual([]);
197
+ });
198
+
199
+ test("validates category name", async () => {
200
+ await expect(
201
+ doctorCommand(["--category", "invalid-category"], { checkRunners: [] }),
202
+ ).rejects.toThrow(ValidationError);
203
+ });
204
+
205
+ test("invalid category error mentions valid categories", async () => {
206
+ try {
207
+ await doctorCommand(["--category", "bad"], { checkRunners: [] });
208
+ expect.unreachable("should have thrown");
209
+ } catch (err) {
210
+ expect(err).toBeInstanceOf(ValidationError);
211
+ const message = (err as ValidationError).message;
212
+ expect(message).toContain("Invalid category");
213
+ expect(message).toContain("dependencies");
214
+ expect(message).toContain("structure");
215
+ expect(message).toContain("config");
216
+ }
217
+ });
218
+
219
+ test("accepts all valid category names", async () => {
220
+ const categories = [
221
+ "dependencies",
222
+ "structure",
223
+ "config",
224
+ "databases",
225
+ "consistency",
226
+ "agents",
227
+ "merge",
228
+ "logs",
229
+ "version",
230
+ ];
231
+
232
+ for (const category of categories) {
233
+ chunks = []; // Reset output
234
+ await doctorCommand(["--category", category, "--json"], { checkRunners: [] });
235
+ const out = output();
236
+ // Should not throw, and output should be valid JSON
237
+ JSON.parse(out.trim());
238
+ }
239
+ });
240
+ });
241
+
242
+ // === Exit code ===
243
+
244
+ describe("exit code", () => {
245
+ test("returns undefined when all checks pass or warn", async () => {
246
+ const exitCode = await doctorCommand([], { checkRunners: [] });
247
+ expect(exitCode).toBeUndefined();
248
+ });
249
+
250
+ test("returns undefined on success (no failures)", async () => {
251
+ const exitCode = await doctorCommand([], { checkRunners: [] });
252
+ expect(exitCode).toBeUndefined();
253
+ });
254
+ });
255
+
256
+ // === Edge cases ===
257
+
258
+ describe("edge cases", () => {
259
+ test("handles multiple flags together", async () => {
260
+ await doctorCommand(["--json", "--verbose", "--category", "config"], {
261
+ checkRunners: [],
262
+ });
263
+ const out = output();
264
+
265
+ const parsed = JSON.parse(out.trim()) as {
266
+ checks: unknown[];
267
+ summary: unknown;
268
+ };
269
+ expect(parsed.checks).toEqual([]);
270
+ });
271
+
272
+ test("flags can appear in any order", async () => {
273
+ chunks = [];
274
+ await doctorCommand(["--category", "logs", "--json"], { checkRunners: [] });
275
+ const out1 = output();
276
+
277
+ chunks = [];
278
+ await doctorCommand(["--json", "--category", "logs"], { checkRunners: [] });
279
+ const out2 = output();
280
+
281
+ // Both should produce the same JSON
282
+ expect(JSON.parse(out1.trim())).toEqual(JSON.parse(out2.trim()));
283
+ });
284
+
285
+ test("runs without crashing on minimal config", async () => {
286
+ // The beforeEach already sets up minimal config, so this just
287
+ // verifies the command doesn't crash
288
+ await doctorCommand([], { checkRunners: [] });
289
+ const out = output();
290
+
291
+ expect(out).toContain("Overstory Doctor");
292
+ });
293
+ });
294
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * CLI command: overstory doctor [options]
3
+ *
4
+ * Runs health checks on overstory 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}Overstory 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 = `overstory doctor -- Run health checks on overstory subsystems
137
+
138
+ Usage: overstory 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 `overstory 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 overstoryDir = join(config.project.root, ".overstory");
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, overstoryDir);
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
+ }