@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,251 @@
1
+ /**
2
+ * Tests for logs doctor checks.
3
+ *
4
+ * Uses temp directories with real filesystem operations.
5
+ * No mocks needed -- all operations are cheap and local.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import type { OverstoryConfig } from "../types.ts";
13
+ import { checkLogs } from "./logs.ts";
14
+
15
+ describe("checkLogs", () => {
16
+ let tempDir: string;
17
+ let overstoryDir: string;
18
+ let logsDir: string;
19
+ let mockConfig: OverstoryConfig;
20
+
21
+ beforeEach(async () => {
22
+ tempDir = await mkdtemp(join(tmpdir(), "logs-test-"));
23
+ overstoryDir = join(tempDir, ".overstory");
24
+ logsDir = join(overstoryDir, "logs");
25
+
26
+ mockConfig = {
27
+ project: {
28
+ name: "test-project",
29
+ root: tempDir,
30
+ canonicalBranch: "main",
31
+ },
32
+ agents: {
33
+ manifestPath: ".overstory/agent-manifest.json",
34
+ baseDir: ".overstory/agent-defs",
35
+ maxConcurrent: 5,
36
+ staggerDelayMs: 1000,
37
+ maxDepth: 2,
38
+ maxSessionsPerRun: 0,
39
+ },
40
+ worktrees: {
41
+ baseDir: ".overstory/worktrees",
42
+ },
43
+ taskTracker: {
44
+ backend: "auto",
45
+ enabled: true,
46
+ },
47
+ mulch: {
48
+ enabled: true,
49
+ domains: [],
50
+ primeFormat: "markdown",
51
+ },
52
+ merge: {
53
+ aiResolveEnabled: false,
54
+ reimagineEnabled: false,
55
+ },
56
+ providers: {
57
+ anthropic: { type: "native" },
58
+ },
59
+ watchdog: {
60
+ tier0Enabled: true,
61
+ tier0IntervalMs: 30000,
62
+ tier1Enabled: false,
63
+ tier2Enabled: false,
64
+ staleThresholdMs: 300000,
65
+ zombieThresholdMs: 600000,
66
+ nudgeIntervalMs: 60000,
67
+ },
68
+ models: {},
69
+ logging: {
70
+ verbose: false,
71
+ redactSecrets: true,
72
+ },
73
+ };
74
+
75
+ await mkdir(overstoryDir, { recursive: true });
76
+ });
77
+
78
+ afterEach(async () => {
79
+ await rm(tempDir, { recursive: true, force: true });
80
+ });
81
+
82
+ test("warns when logs/ directory does not exist", async () => {
83
+ const checks = await checkLogs(mockConfig, overstoryDir);
84
+
85
+ const dirCheck = checks.find((c) => c.name === "logs/ directory");
86
+ expect(dirCheck).toBeDefined();
87
+ expect(dirCheck?.status).toBe("warn");
88
+ expect(dirCheck?.message).toContain("missing");
89
+ });
90
+
91
+ test("passes when logs/ directory exists but is empty", async () => {
92
+ await mkdir(logsDir, { recursive: true });
93
+
94
+ const checks = await checkLogs(mockConfig, overstoryDir);
95
+
96
+ const dirCheck = checks.find((c) => c.name === "logs/ directory");
97
+ expect(dirCheck?.status).toBe("pass");
98
+
99
+ const usageCheck = checks.find((c) => c.name === "Total disk usage");
100
+ expect(usageCheck?.status).toBe("pass");
101
+ expect(usageCheck?.message).toContain("0B");
102
+ });
103
+
104
+ test("calculates total disk usage correctly", async () => {
105
+ await mkdir(logsDir, { recursive: true });
106
+ const agentDir = join(logsDir, "test-agent", "session-1");
107
+ await mkdir(agentDir, { recursive: true });
108
+
109
+ // Create log files with known sizes
110
+ await writeFile(join(agentDir, "session.log"), "a".repeat(1024)); // 1KB
111
+ await writeFile(join(agentDir, "events.ndjson"), "b".repeat(2048)); // 2KB
112
+ await writeFile(join(agentDir, "tools.ndjson"), "c".repeat(512)); // 512B
113
+
114
+ const checks = await checkLogs(mockConfig, overstoryDir);
115
+
116
+ const usageCheck = checks.find((c) => c.name === "Total disk usage");
117
+ expect(usageCheck).toBeDefined();
118
+ expect(usageCheck?.status).toBe("pass");
119
+ expect(usageCheck?.message).toContain("3.5KB"); // 1024 + 2048 + 512 = 3584 bytes
120
+ });
121
+
122
+ test("warns when disk usage exceeds threshold", async () => {
123
+ await mkdir(logsDir, { recursive: true });
124
+ const agentDir = join(logsDir, "test-agent", "session-1");
125
+ await mkdir(agentDir, { recursive: true });
126
+
127
+ // Create a sparse file that reports as 600MB without allocating real disk space
128
+ const filePath = join(agentDir, "session.log");
129
+ const { promises: fsp } = await import("node:fs");
130
+ const fd = await fsp.open(filePath, "w");
131
+ await fd.truncate(600 * 1024 * 1024); // 600MB sparse file
132
+ await fd.close();
133
+
134
+ const checks = await checkLogs(mockConfig, overstoryDir);
135
+
136
+ const usageCheck = checks.find((c) => c.name === "Total disk usage");
137
+ expect(usageCheck).toBeDefined();
138
+ expect(usageCheck?.status).toBe("warn");
139
+ expect(usageCheck?.details).toBeDefined();
140
+ expect(usageCheck?.details?.some((d) => d.includes("threshold"))).toBe(true);
141
+ });
142
+
143
+ test("reports per-agent log sizes", async () => {
144
+ await mkdir(logsDir, { recursive: true });
145
+
146
+ // Create logs for multiple agents
147
+ const agent1Dir = join(logsDir, "agent-1", "session-1");
148
+ await mkdir(agent1Dir, { recursive: true });
149
+ await writeFile(join(agent1Dir, "session.log"), "a".repeat(10000));
150
+
151
+ const agent2Dir = join(logsDir, "agent-2", "session-1");
152
+ await mkdir(agent2Dir, { recursive: true });
153
+ await writeFile(join(agent2Dir, "session.log"), "b".repeat(5000));
154
+
155
+ const checks = await checkLogs(mockConfig, overstoryDir);
156
+
157
+ const sizesCheck = checks.find((c) => c.name === "Per-agent log sizes");
158
+ expect(sizesCheck).toBeDefined();
159
+ expect(sizesCheck?.status).toBe("pass");
160
+ expect(sizesCheck?.message).toContain("2 agent(s)");
161
+ expect(sizesCheck?.details).toBeDefined();
162
+ expect(sizesCheck?.details?.some((d) => d.includes("agent-1"))).toBe(true);
163
+ expect(sizesCheck?.details?.some((d) => d.includes("agent-2"))).toBe(true);
164
+ });
165
+
166
+ test("detects malformed NDJSON in events.ndjson", async () => {
167
+ await mkdir(logsDir, { recursive: true });
168
+ const agentDir = join(logsDir, "test-agent", "session-1");
169
+ await mkdir(agentDir, { recursive: true });
170
+
171
+ // Valid and invalid JSON lines
172
+ const content = `{"event":"start","timestamp":"2024-01-01T00:00:00Z"}
173
+ invalid json line here
174
+ {"event":"end","timestamp":"2024-01-01T00:01:00Z"}`;
175
+
176
+ await writeFile(join(agentDir, "events.ndjson"), content);
177
+
178
+ const checks = await checkLogs(mockConfig, overstoryDir);
179
+
180
+ const integrityCheck = checks.find((c) => c.name === "NDJSON integrity");
181
+ expect(integrityCheck).toBeDefined();
182
+ expect(integrityCheck?.status).toBe("warn");
183
+ expect(integrityCheck?.message).toContain("malformed JSON");
184
+ expect(integrityCheck?.details?.some((d) => d.includes("events.ndjson"))).toBe(true);
185
+ });
186
+
187
+ test("passes when all NDJSON files are valid", async () => {
188
+ await mkdir(logsDir, { recursive: true });
189
+ const agentDir = join(logsDir, "test-agent", "session-1");
190
+ await mkdir(agentDir, { recursive: true });
191
+
192
+ const validContent = `{"event":"start","timestamp":"2024-01-01T00:00:00Z"}
193
+ {"event":"end","timestamp":"2024-01-01T00:01:00Z"}`;
194
+
195
+ await writeFile(join(agentDir, "events.ndjson"), validContent);
196
+ await writeFile(join(agentDir, "tools.ndjson"), validContent);
197
+
198
+ const checks = await checkLogs(mockConfig, overstoryDir);
199
+
200
+ const integrityCheck = checks.find((c) => c.name === "NDJSON integrity");
201
+ expect(integrityCheck).toBeDefined();
202
+ expect(integrityCheck?.status).toBe("pass");
203
+ });
204
+
205
+ test("detects orphaned toolStart events", async () => {
206
+ await mkdir(logsDir, { recursive: true });
207
+ const agentDir = join(logsDir, "test-agent", "session-1");
208
+ await mkdir(agentDir, { recursive: true });
209
+
210
+ // toolStart without matching toolEnd
211
+ const content = `{"event":"toolStart","tool":"Read","timestamp":"2024-01-01T00:00:00Z"}
212
+ {"event":"toolStart","tool":"Write","timestamp":"2024-01-01T00:01:00Z"}
213
+ {"event":"toolEnd","tool":"Write","timestamp":"2024-01-01T00:02:00Z"}`;
214
+
215
+ await writeFile(join(agentDir, "tools.ndjson"), content);
216
+
217
+ const checks = await checkLogs(mockConfig, overstoryDir);
218
+
219
+ const orphanCheck = checks.find((c) => c.name === "Orphaned tool events");
220
+ expect(orphanCheck).toBeDefined();
221
+ expect(orphanCheck?.status).toBe("warn");
222
+ expect(orphanCheck?.message).toContain("incomplete");
223
+ expect(orphanCheck?.details?.some((d) => d.includes("Read"))).toBe(true);
224
+ });
225
+
226
+ test("handles missing tools.ndjson gracefully", async () => {
227
+ await mkdir(logsDir, { recursive: true });
228
+ const agentDir = join(logsDir, "test-agent", "session-1");
229
+ await mkdir(agentDir, { recursive: true });
230
+
231
+ // Only create events.ndjson, no tools.ndjson
232
+ await writeFile(join(agentDir, "events.ndjson"), '{"event":"start"}');
233
+
234
+ const checks = await checkLogs(mockConfig, overstoryDir);
235
+
236
+ // Should not crash, should pass NDJSON integrity
237
+ const integrityCheck = checks.find((c) => c.name === "NDJSON integrity");
238
+ expect(integrityCheck).toBeDefined();
239
+ expect(integrityCheck?.status).toBe("pass");
240
+ });
241
+
242
+ test("handles empty logs directory gracefully", async () => {
243
+ await mkdir(logsDir, { recursive: true });
244
+
245
+ const checks = await checkLogs(mockConfig, overstoryDir);
246
+
247
+ expect(checks.length).toBeGreaterThan(0);
248
+ const dirCheck = checks.find((c) => c.name === "logs/ directory");
249
+ expect(dirCheck?.status).toBe("pass");
250
+ });
251
+ });
@@ -0,0 +1,295 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
4
+
5
+ const DISK_USAGE_WARN_THRESHOLD = 500 * 1024 * 1024; // 500MB in bytes
6
+
7
+ /**
8
+ * Check if a path exists.
9
+ */
10
+ async function pathExists(path: string): Promise<boolean> {
11
+ try {
12
+ await stat(path);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Calculate total disk usage for a directory recursively.
21
+ */
22
+ async function calculateDiskUsage(dirPath: string): Promise<number> {
23
+ let totalBytes = 0;
24
+
25
+ try {
26
+ const entries = await readdir(dirPath, { withFileTypes: true });
27
+
28
+ for (const entry of entries) {
29
+ const fullPath = join(dirPath, entry.name);
30
+
31
+ if (entry.isDirectory()) {
32
+ totalBytes += await calculateDiskUsage(fullPath);
33
+ } else if (entry.isFile()) {
34
+ const stats = await stat(fullPath);
35
+ totalBytes += stats.size;
36
+ }
37
+ }
38
+ } catch {
39
+ // Ignore errors (permission denied, etc.)
40
+ }
41
+
42
+ return totalBytes;
43
+ }
44
+
45
+ /**
46
+ * Format bytes as human-readable size.
47
+ */
48
+ function formatBytes(bytes: number): string {
49
+ if (bytes < 1024) return `${bytes}B`;
50
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
51
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
52
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
53
+ }
54
+
55
+ /**
56
+ * Check NDJSON file for malformed lines.
57
+ */
58
+ async function checkNDJSON(filePath: string): Promise<string[]> {
59
+ const errors: string[] = [];
60
+
61
+ try {
62
+ const content = await Bun.file(filePath).text();
63
+ const lines = content.split("\n").filter((line) => line.trim() !== "");
64
+
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const line = lines[i];
67
+ if (!line) continue;
68
+
69
+ try {
70
+ JSON.parse(line);
71
+ } catch {
72
+ errors.push(`Line ${i + 1}: malformed JSON`);
73
+ }
74
+ }
75
+ } catch {
76
+ // File doesn't exist or can't be read
77
+ }
78
+
79
+ return errors;
80
+ }
81
+
82
+ /**
83
+ * Check tools.ndjson for orphaned toolStart events.
84
+ */
85
+ async function checkOrphanedToolEvents(filePath: string): Promise<string[]> {
86
+ const orphans: string[] = [];
87
+
88
+ try {
89
+ const content = await Bun.file(filePath).text();
90
+ const lines = content.split("\n").filter((line) => line.trim() !== "");
91
+
92
+ const startEvents = new Set<string>();
93
+
94
+ for (const line of lines) {
95
+ if (!line) continue;
96
+
97
+ try {
98
+ const event = JSON.parse(line) as { event?: string; tool?: string; data?: unknown };
99
+
100
+ if (event.event === "toolStart" && typeof event.tool === "string") {
101
+ startEvents.add(event.tool);
102
+ } else if (event.event === "toolEnd" && typeof event.tool === "string") {
103
+ startEvents.delete(event.tool);
104
+ }
105
+ } catch {
106
+ // Ignore malformed lines
107
+ }
108
+ }
109
+
110
+ for (const tool of startEvents) {
111
+ orphans.push(tool);
112
+ }
113
+ } catch {
114
+ // File doesn't exist or can't be read
115
+ }
116
+
117
+ return orphans;
118
+ }
119
+
120
+ /**
121
+ * Get per-agent log sizes.
122
+ */
123
+ async function getPerAgentSizes(logsDir: string): Promise<Map<string, number>> {
124
+ const sizes = new Map<string, number>();
125
+
126
+ try {
127
+ const agentDirs = await readdir(logsDir, { withFileTypes: true });
128
+
129
+ for (const agentDir of agentDirs) {
130
+ if (!agentDir.isDirectory()) continue;
131
+
132
+ const agentPath = join(logsDir, agentDir.name);
133
+ const size = await calculateDiskUsage(agentPath);
134
+ sizes.set(agentDir.name, size);
135
+ }
136
+ } catch {
137
+ // Logs directory doesn't exist or can't be read
138
+ }
139
+
140
+ return sizes;
141
+ }
142
+
143
+ /**
144
+ * Log directory health checks.
145
+ * Validates log directory structure and detects excessive log accumulation.
146
+ */
147
+ export const checkLogs: DoctorCheckFn = async (_config, overstoryDir): Promise<DoctorCheck[]> => {
148
+ const checks: DoctorCheck[] = [];
149
+ const logsDir = join(overstoryDir, "logs");
150
+
151
+ // Check 1: logs/ directory exists
152
+ const logsDirExists = await pathExists(logsDir);
153
+ checks.push({
154
+ name: "logs/ directory",
155
+ category: "logs",
156
+ status: logsDirExists ? "pass" : "warn",
157
+ message: logsDirExists
158
+ ? "Directory exists"
159
+ : "Directory missing (will be created on first log)",
160
+ details: logsDirExists ? undefined : ["Not an error - created automatically on agent spawn"],
161
+ fixable: false,
162
+ });
163
+
164
+ // If logs/ doesn't exist, no further checks
165
+ if (!logsDirExists) {
166
+ return checks;
167
+ }
168
+
169
+ // Check 2: Total disk usage
170
+ const totalBytes = await calculateDiskUsage(logsDir);
171
+ checks.push({
172
+ name: "Total disk usage",
173
+ category: "logs",
174
+ status: totalBytes > DISK_USAGE_WARN_THRESHOLD ? "warn" : "pass",
175
+ message: `Using ${formatBytes(totalBytes)}`,
176
+ details:
177
+ totalBytes > DISK_USAGE_WARN_THRESHOLD
178
+ ? [
179
+ `Exceeds ${formatBytes(DISK_USAGE_WARN_THRESHOLD)} threshold`,
180
+ "Consider running 'overstory worktree clean --completed' to remove old logs",
181
+ ]
182
+ : undefined,
183
+ fixable: totalBytes > DISK_USAGE_WARN_THRESHOLD,
184
+ });
185
+
186
+ // Check 3: Per-agent log sizes
187
+ const perAgentSizes = await getPerAgentSizes(logsDir);
188
+ if (perAgentSizes.size > 0) {
189
+ const details = Array.from(perAgentSizes.entries())
190
+ .sort((a, b) => b[1] - a[1]) // Sort by size descending
191
+ .map(([agent, size]) => `${agent}: ${formatBytes(size)}`);
192
+
193
+ checks.push({
194
+ name: "Per-agent log sizes",
195
+ category: "logs",
196
+ status: "pass",
197
+ message: `${perAgentSizes.size} agent(s) with logs`,
198
+ details,
199
+ fixable: false,
200
+ });
201
+ }
202
+
203
+ // Check 4: Sample NDJSON files for malformed JSON
204
+ // We'll check a few random session directories
205
+ try {
206
+ const agentDirs = await readdir(logsDir, { withFileTypes: true });
207
+ const ndjsonErrors: string[] = [];
208
+
209
+ for (const agentDir of agentDirs) {
210
+ if (!agentDir.isDirectory()) continue;
211
+
212
+ const agentPath = join(logsDir, agentDir.name);
213
+ const sessionDirs = await readdir(agentPath, { withFileTypes: true });
214
+
215
+ for (const sessionDir of sessionDirs) {
216
+ if (!sessionDir.isDirectory()) continue;
217
+
218
+ const sessionPath = join(agentPath, sessionDir.name);
219
+
220
+ // Check events.ndjson
221
+ const eventsErrors = await checkNDJSON(join(sessionPath, "events.ndjson"));
222
+ if (eventsErrors.length > 0) {
223
+ ndjsonErrors.push(
224
+ `${agentDir.name}/${sessionDir.name}/events.ndjson: ${eventsErrors.length} error(s)`,
225
+ );
226
+ }
227
+
228
+ // Check tools.ndjson
229
+ const toolsErrors = await checkNDJSON(join(sessionPath, "tools.ndjson"));
230
+ if (toolsErrors.length > 0) {
231
+ ndjsonErrors.push(
232
+ `${agentDir.name}/${sessionDir.name}/tools.ndjson: ${toolsErrors.length} error(s)`,
233
+ );
234
+ }
235
+ }
236
+ }
237
+
238
+ if (ndjsonErrors.length > 0) {
239
+ checks.push({
240
+ name: "NDJSON integrity",
241
+ category: "logs",
242
+ status: "warn",
243
+ message: `Found ${ndjsonErrors.length} file(s) with malformed JSON`,
244
+ details: ndjsonErrors.slice(0, 10), // Limit to first 10
245
+ fixable: false,
246
+ });
247
+ } else {
248
+ checks.push({
249
+ name: "NDJSON integrity",
250
+ category: "logs",
251
+ status: "pass",
252
+ message: "All sampled NDJSON files are valid",
253
+ fixable: false,
254
+ });
255
+ }
256
+
257
+ // Check 5: Orphaned toolStart events
258
+ const orphanedTools: string[] = [];
259
+
260
+ for (const agentDir of agentDirs) {
261
+ if (!agentDir.isDirectory()) continue;
262
+
263
+ const agentPath = join(logsDir, agentDir.name);
264
+ const sessionDirs = await readdir(agentPath, { withFileTypes: true });
265
+
266
+ for (const sessionDir of sessionDirs) {
267
+ if (!sessionDir.isDirectory()) continue;
268
+
269
+ const sessionPath = join(agentPath, sessionDir.name);
270
+ const orphans = await checkOrphanedToolEvents(join(sessionPath, "tools.ndjson"));
271
+
272
+ if (orphans.length > 0) {
273
+ orphanedTools.push(
274
+ `${agentDir.name}/${sessionDir.name}: ${orphans.join(", ")} (incomplete session)`,
275
+ );
276
+ }
277
+ }
278
+ }
279
+
280
+ if (orphanedTools.length > 0) {
281
+ checks.push({
282
+ name: "Orphaned tool events",
283
+ category: "logs",
284
+ status: "warn",
285
+ message: `Found ${orphanedTools.length} session(s) with incomplete tool logs`,
286
+ details: orphanedTools.slice(0, 10), // Limit to first 10
287
+ fixable: false,
288
+ });
289
+ }
290
+ } catch {
291
+ // Ignore errors reading log directories
292
+ }
293
+
294
+ return checks;
295
+ };