@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
+ import { realpathSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { openSessionStore } from "../sessions/compat.ts";
4
+ import type { AgentSession, OverstoryConfig } from "../types.ts";
5
+ import { listWorktrees } from "../worktree/manager.ts";
6
+ import { isProcessAlive, listSessions } from "../worktree/tmux.ts";
7
+ import type { DoctorCheck } from "./types.ts";
8
+
9
+ /**
10
+ * Dependencies for consistency checks.
11
+ * Allows injection for testing without module-level mocks.
12
+ */
13
+ export interface ConsistencyCheckDeps {
14
+ listSessions: () => Promise<Array<{ name: string; pid: number }>>;
15
+ isProcessAlive: (pid: number) => boolean;
16
+ }
17
+
18
+ /**
19
+ * Cross-subsystem consistency checks.
20
+ * Validates SessionStore vs worktrees, tmux sessions vs sessions, etc.
21
+ *
22
+ * @param config - Overstory configuration
23
+ * @param overstoryDir - Absolute path to .overstory/
24
+ * @param deps - Optional dependencies for testing (defaults to real implementations)
25
+ */
26
+ export async function checkConsistency(
27
+ config: OverstoryConfig,
28
+ overstoryDir: string,
29
+ deps?: ConsistencyCheckDeps,
30
+ ): Promise<DoctorCheck[]> {
31
+ // Use injected dependencies or defaults
32
+ const { listSessions: listSessionsFn, isProcessAlive: isProcessAliveFn } = deps || {
33
+ listSessions,
34
+ isProcessAlive,
35
+ };
36
+ const checks: DoctorCheck[] = [];
37
+
38
+ // Gather data from all three sources
39
+ let worktrees: Array<{ path: string; branch: string; head: string }> = [];
40
+ let tmuxSessions: Array<{ name: string; pid: number }> = [];
41
+ let storeSessions: AgentSession[] = [];
42
+
43
+ // 1. List git worktrees
44
+ try {
45
+ worktrees = await listWorktrees(config.project.root);
46
+ } catch (error) {
47
+ checks.push({
48
+ name: "worktree-listing",
49
+ category: "consistency",
50
+ status: "fail",
51
+ message: "Failed to list git worktrees",
52
+ details: [error instanceof Error ? error.message : String(error)],
53
+ });
54
+ // Can't continue consistency checks without worktree data
55
+ return checks;
56
+ }
57
+
58
+ // 2. List tmux sessions
59
+ try {
60
+ tmuxSessions = await listSessionsFn();
61
+ } catch (error) {
62
+ // Tmux not installed or not running is not necessarily a fatal error
63
+ checks.push({
64
+ name: "tmux-listing",
65
+ category: "consistency",
66
+ status: "warn",
67
+ message: "Failed to list tmux sessions (tmux may not be installed)",
68
+ details: [error instanceof Error ? error.message : String(error)],
69
+ });
70
+ // Continue with empty tmux session list
71
+ tmuxSessions = [];
72
+ }
73
+
74
+ // 3. Open SessionStore and get all sessions
75
+ let storeHandle: ReturnType<typeof openSessionStore>["store"] | null = null;
76
+ try {
77
+ const { store } = openSessionStore(overstoryDir);
78
+ storeHandle = store;
79
+ storeSessions = store.getAll();
80
+ } catch (error) {
81
+ checks.push({
82
+ name: "sessionstore-open",
83
+ category: "consistency",
84
+ status: "fail",
85
+ message: "Failed to open SessionStore",
86
+ details: [error instanceof Error ? error.message : String(error)],
87
+ });
88
+ // Can't do consistency checks without SessionStore
89
+ return checks;
90
+ }
91
+
92
+ // Now perform cross-validation checks
93
+
94
+ // 4. Check for orphaned worktrees (worktree exists but no SessionStore entry)
95
+ // Normalize all paths to handle symlinks like /tmp -> /private/tmp on macOS
96
+ const worktreeBasePath = realpathSync(join(overstoryDir, "worktrees"));
97
+ const overstoryWorktrees = worktrees.filter((wt) => wt.path.startsWith(worktreeBasePath));
98
+
99
+ // Normalize SessionStore paths for comparison
100
+ const storeWorktreePaths = new Set(
101
+ storeSessions.map((s) => {
102
+ try {
103
+ return realpathSync(s.worktreePath);
104
+ } catch {
105
+ // Path doesn't exist, use as-is
106
+ return s.worktreePath;
107
+ }
108
+ }),
109
+ );
110
+
111
+ const orphanedWorktrees = overstoryWorktrees.filter((wt) => !storeWorktreePaths.has(wt.path));
112
+
113
+ if (orphanedWorktrees.length > 0) {
114
+ checks.push({
115
+ name: "orphaned-worktrees",
116
+ category: "consistency",
117
+ status: "warn",
118
+ message: `Found ${orphanedWorktrees.length} orphaned worktree(s) with no SessionStore entry`,
119
+ details: orphanedWorktrees.map((wt) => `${wt.path} (branch: ${wt.branch})`),
120
+ fixable: true,
121
+ });
122
+ } else {
123
+ checks.push({
124
+ name: "orphaned-worktrees",
125
+ category: "consistency",
126
+ status: "pass",
127
+ message: "No orphaned worktrees found",
128
+ });
129
+ }
130
+
131
+ // 5. Check for orphaned tmux sessions (tmux session exists but no SessionStore entry)
132
+ const projectName = config.project.name;
133
+ const overstoryTmuxPrefix = `overstory-${projectName}-`;
134
+ const overstoryTmuxSessions = tmuxSessions.filter((s) => s.name.startsWith(overstoryTmuxPrefix));
135
+ const storeTmuxNames = new Set(storeSessions.map((s) => s.tmuxSession));
136
+
137
+ const orphanedTmux = overstoryTmuxSessions.filter((s) => !storeTmuxNames.has(s.name));
138
+
139
+ if (orphanedTmux.length > 0) {
140
+ checks.push({
141
+ name: "orphaned-tmux",
142
+ category: "consistency",
143
+ status: "warn",
144
+ message: `Found ${orphanedTmux.length} orphaned tmux session(s) with no SessionStore entry`,
145
+ details: orphanedTmux.map((s) => `${s.name} (pid: ${s.pid})`),
146
+ fixable: true,
147
+ });
148
+ } else {
149
+ checks.push({
150
+ name: "orphaned-tmux",
151
+ category: "consistency",
152
+ status: "pass",
153
+ message: "No orphaned tmux sessions found",
154
+ });
155
+ }
156
+
157
+ // 6. Check for dead processes in SessionStore
158
+ const deadSessions = storeSessions.filter((s) => s.pid !== null && !isProcessAliveFn(s.pid));
159
+
160
+ if (deadSessions.length > 0) {
161
+ checks.push({
162
+ name: "dead-pids",
163
+ category: "consistency",
164
+ status: "warn",
165
+ message: `Found ${deadSessions.length} session(s) with dead PIDs`,
166
+ details: deadSessions.map((s) => `${s.agentName} (pid: ${s.pid}, state: ${s.state})`),
167
+ fixable: true,
168
+ });
169
+ } else {
170
+ checks.push({
171
+ name: "dead-pids",
172
+ category: "consistency",
173
+ status: "pass",
174
+ message: "All SessionStore PIDs are alive or null",
175
+ });
176
+ }
177
+
178
+ // 7. Check for SessionStore entries with missing worktrees
179
+ const existingWorktreePaths = new Set(worktrees.map((wt) => wt.path));
180
+ const missingWorktrees = storeSessions.filter((s) => {
181
+ // Try to normalize the SessionStore path for comparison
182
+ try {
183
+ const normalizedPath = realpathSync(s.worktreePath);
184
+ return !existingWorktreePaths.has(normalizedPath);
185
+ } catch {
186
+ // Path doesn't exist or can't be resolved, check as-is
187
+ return !existingWorktreePaths.has(s.worktreePath);
188
+ }
189
+ });
190
+
191
+ if (missingWorktrees.length > 0) {
192
+ checks.push({
193
+ name: "missing-worktrees",
194
+ category: "consistency",
195
+ status: "warn",
196
+ message: `Found ${missingWorktrees.length} session(s) with missing worktrees`,
197
+ details: missingWorktrees.map((s) => `${s.agentName}: ${s.worktreePath}`),
198
+ fixable: true,
199
+ });
200
+ } else {
201
+ checks.push({
202
+ name: "missing-worktrees",
203
+ category: "consistency",
204
+ status: "pass",
205
+ message: "All SessionStore worktrees exist",
206
+ });
207
+ }
208
+
209
+ // 8. Check for SessionStore entries with missing tmux sessions
210
+ const existingTmuxNames = new Set(tmuxSessions.map((s) => s.name));
211
+ const missingTmux = storeSessions.filter((s) => !existingTmuxNames.has(s.tmuxSession));
212
+
213
+ if (missingTmux.length > 0) {
214
+ checks.push({
215
+ name: "missing-tmux",
216
+ category: "consistency",
217
+ status: "warn",
218
+ message: `Found ${missingTmux.length} session(s) with missing tmux sessions`,
219
+ details: missingTmux.map((s) => `${s.agentName}: ${s.tmuxSession}`),
220
+ fixable: true,
221
+ });
222
+ } else {
223
+ checks.push({
224
+ name: "missing-tmux",
225
+ category: "consistency",
226
+ status: "pass",
227
+ message: "All SessionStore tmux sessions exist",
228
+ });
229
+ }
230
+
231
+ // 9. Check reviewer-to-builder ratio per lead
232
+ const parentGroups = new Map<string, { builders: number; reviewers: number }>();
233
+ for (const session of storeSessions) {
234
+ if (
235
+ session.parentAgent &&
236
+ (session.capability === "builder" || session.capability === "reviewer")
237
+ ) {
238
+ const group = parentGroups.get(session.parentAgent) ?? { builders: 0, reviewers: 0 };
239
+ if (session.capability === "builder") {
240
+ group.builders++;
241
+ } else {
242
+ group.reviewers++;
243
+ }
244
+ parentGroups.set(session.parentAgent, group);
245
+ }
246
+ }
247
+
248
+ const leadsWithoutReview: string[] = [];
249
+ const leadsWithPartialReview: string[] = [];
250
+ for (const [parent, counts] of parentGroups) {
251
+ if (counts.builders > 0 && counts.reviewers === 0) {
252
+ leadsWithoutReview.push(`${parent}: ${counts.builders} builder(s), 0 reviewers`);
253
+ } else if (counts.builders > 0 && counts.reviewers < counts.builders) {
254
+ leadsWithPartialReview.push(
255
+ `${parent}: ${counts.builders} builder(s), ${counts.reviewers} reviewer(s)`,
256
+ );
257
+ }
258
+ }
259
+
260
+ if (leadsWithoutReview.length > 0) {
261
+ checks.push({
262
+ name: "reviewer-coverage",
263
+ category: "consistency",
264
+ status: "warn",
265
+ message: `${leadsWithoutReview.length} lead(s) spawned builders without any reviewers`,
266
+ details: [...leadsWithoutReview, ...leadsWithPartialReview],
267
+ });
268
+ } else if (leadsWithPartialReview.length > 0) {
269
+ checks.push({
270
+ name: "reviewer-coverage",
271
+ category: "consistency",
272
+ status: "warn",
273
+ message: `${leadsWithPartialReview.length} lead(s) have partial reviewer coverage`,
274
+ details: leadsWithPartialReview,
275
+ });
276
+ } else {
277
+ checks.push({
278
+ name: "reviewer-coverage",
279
+ category: "consistency",
280
+ status: "pass",
281
+ message:
282
+ parentGroups.size > 0
283
+ ? "All leads have reviewer coverage for builders"
284
+ : "No builder sessions found (nothing to check)",
285
+ });
286
+ }
287
+
288
+ // Close the SessionStore
289
+ if (storeHandle) {
290
+ storeHandle.close();
291
+ }
292
+
293
+ return checks;
294
+ }
@@ -0,0 +1,290 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { OverstoryConfig } from "../types.ts";
7
+ import { checkDatabases } from "./databases.ts";
8
+ import type { DoctorCheck } from "./types.ts";
9
+
10
+ describe("checkDatabases", () => {
11
+ let tempDir: string;
12
+ let mockConfig: OverstoryConfig;
13
+
14
+ beforeEach(() => {
15
+ tempDir = mkdtempSync(join(tmpdir(), "overstory-test-"));
16
+ mockConfig = {
17
+ project: { name: "test", root: tempDir, canonicalBranch: "main" },
18
+ agents: {
19
+ manifestPath: "",
20
+ baseDir: "",
21
+ maxConcurrent: 5,
22
+ staggerDelayMs: 100,
23
+ maxDepth: 2,
24
+ maxSessionsPerRun: 0,
25
+ },
26
+ worktrees: { baseDir: "" },
27
+ taskTracker: { backend: "auto", enabled: true },
28
+ mulch: { enabled: true, domains: [], primeFormat: "markdown" },
29
+ merge: { aiResolveEnabled: false, reimagineEnabled: false },
30
+ providers: {
31
+ anthropic: { type: "native" },
32
+ },
33
+ watchdog: {
34
+ tier0Enabled: true,
35
+ tier0IntervalMs: 30000,
36
+ tier1Enabled: false,
37
+ tier2Enabled: false,
38
+ staleThresholdMs: 300000,
39
+ zombieThresholdMs: 600000,
40
+ nudgeIntervalMs: 60000,
41
+ },
42
+ models: {},
43
+ logging: { verbose: false, redactSecrets: true },
44
+ };
45
+ });
46
+
47
+ afterEach(() => {
48
+ rmSync(tempDir, { recursive: true, force: true });
49
+ });
50
+
51
+ test("fails when database files do not exist", () => {
52
+ const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
53
+
54
+ expect(checks).toHaveLength(3);
55
+ expect(checks[0]?.status).toBe("fail");
56
+ expect(checks[0]?.name).toBe("mail.db exists");
57
+ expect(checks[1]?.status).toBe("fail");
58
+ expect(checks[1]?.name).toBe("metrics.db exists");
59
+ expect(checks[2]?.status).toBe("fail");
60
+ expect(checks[2]?.name).toBe("sessions.db exists");
61
+ });
62
+
63
+ test("passes when databases exist with correct schema", () => {
64
+ // Create mail.db
65
+ const mailDb = new Database(join(tempDir, "mail.db"));
66
+ mailDb.exec("PRAGMA journal_mode=WAL");
67
+ mailDb.exec(`
68
+ CREATE TABLE messages (
69
+ id TEXT PRIMARY KEY,
70
+ from_agent TEXT NOT NULL,
71
+ to_agent TEXT NOT NULL,
72
+ subject TEXT NOT NULL,
73
+ body TEXT NOT NULL,
74
+ type TEXT NOT NULL DEFAULT 'status',
75
+ priority TEXT NOT NULL DEFAULT 'normal',
76
+ thread_id TEXT,
77
+ payload TEXT,
78
+ read INTEGER NOT NULL DEFAULT 0,
79
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
80
+ )
81
+ `);
82
+ mailDb.close();
83
+
84
+ // Create metrics.db
85
+ const metricsDb = new Database(join(tempDir, "metrics.db"));
86
+ metricsDb.exec("PRAGMA journal_mode=WAL");
87
+ metricsDb.exec(`
88
+ CREATE TABLE sessions (
89
+ agent_name TEXT NOT NULL,
90
+ task_id TEXT NOT NULL,
91
+ capability TEXT NOT NULL,
92
+ started_at TEXT NOT NULL,
93
+ completed_at TEXT,
94
+ duration_ms INTEGER NOT NULL DEFAULT 0,
95
+ exit_code INTEGER,
96
+ merge_result TEXT,
97
+ parent_agent TEXT,
98
+ input_tokens INTEGER NOT NULL DEFAULT 0,
99
+ output_tokens INTEGER NOT NULL DEFAULT 0,
100
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
101
+ cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
102
+ estimated_cost_usd REAL,
103
+ model_used TEXT,
104
+ PRIMARY KEY (agent_name, task_id)
105
+ )
106
+ `);
107
+ metricsDb.close();
108
+
109
+ // Create sessions.db
110
+ const sessionsDb = new Database(join(tempDir, "sessions.db"));
111
+ sessionsDb.exec("PRAGMA journal_mode=WAL");
112
+ sessionsDb.exec(`
113
+ CREATE TABLE sessions (
114
+ id TEXT PRIMARY KEY,
115
+ agent_name TEXT NOT NULL UNIQUE,
116
+ capability TEXT NOT NULL,
117
+ worktree_path TEXT NOT NULL,
118
+ branch_name TEXT NOT NULL,
119
+ task_id TEXT NOT NULL,
120
+ tmux_session TEXT NOT NULL,
121
+ state TEXT NOT NULL DEFAULT 'booting',
122
+ pid INTEGER,
123
+ parent_agent TEXT,
124
+ depth INTEGER NOT NULL DEFAULT 0,
125
+ run_id TEXT,
126
+ started_at TEXT NOT NULL,
127
+ last_activity TEXT NOT NULL,
128
+ escalation_level INTEGER NOT NULL DEFAULT 0,
129
+ stalled_since TEXT
130
+ )
131
+ `);
132
+ sessionsDb.exec(`
133
+ CREATE TABLE runs (
134
+ id TEXT PRIMARY KEY,
135
+ started_at TEXT NOT NULL,
136
+ completed_at TEXT,
137
+ agent_count INTEGER NOT NULL DEFAULT 0,
138
+ coordinator_session_id TEXT,
139
+ status TEXT NOT NULL DEFAULT 'active'
140
+ )
141
+ `);
142
+ sessionsDb.close();
143
+
144
+ const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
145
+
146
+ expect(checks).toHaveLength(3);
147
+ expect(checks.every((c) => c?.status === "pass")).toBe(true);
148
+ expect(checks[0]?.name).toBe("mail.db health");
149
+ expect(checks[1]?.name).toBe("metrics.db health");
150
+ expect(checks[2]?.name).toBe("sessions.db health");
151
+ });
152
+
153
+ test("fails when table is missing", () => {
154
+ // Create mail.db without messages table
155
+ const mailDb = new Database(join(tempDir, "mail.db"));
156
+ mailDb.exec("PRAGMA journal_mode=WAL");
157
+ mailDb.close();
158
+
159
+ // Create other databases properly to isolate the test
160
+ const metricsDb = new Database(join(tempDir, "metrics.db"));
161
+ metricsDb.exec("PRAGMA journal_mode=WAL");
162
+ metricsDb.exec(`
163
+ CREATE TABLE sessions (
164
+ agent_name TEXT NOT NULL,
165
+ task_id TEXT NOT NULL,
166
+ capability TEXT NOT NULL,
167
+ started_at TEXT NOT NULL,
168
+ completed_at TEXT,
169
+ duration_ms INTEGER NOT NULL DEFAULT 0,
170
+ exit_code INTEGER,
171
+ merge_result TEXT,
172
+ parent_agent TEXT,
173
+ input_tokens INTEGER NOT NULL DEFAULT 0,
174
+ output_tokens INTEGER NOT NULL DEFAULT 0,
175
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
176
+ cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
177
+ estimated_cost_usd REAL,
178
+ model_used TEXT,
179
+ PRIMARY KEY (agent_name, task_id)
180
+ )
181
+ `);
182
+ metricsDb.close();
183
+
184
+ const sessionsDb = new Database(join(tempDir, "sessions.db"));
185
+ sessionsDb.exec("PRAGMA journal_mode=WAL");
186
+ sessionsDb.exec(`
187
+ CREATE TABLE sessions (
188
+ id TEXT PRIMARY KEY,
189
+ agent_name TEXT NOT NULL UNIQUE,
190
+ capability TEXT NOT NULL,
191
+ worktree_path TEXT NOT NULL,
192
+ branch_name TEXT NOT NULL,
193
+ task_id TEXT NOT NULL,
194
+ tmux_session TEXT NOT NULL,
195
+ state TEXT NOT NULL DEFAULT 'booting',
196
+ pid INTEGER,
197
+ parent_agent TEXT,
198
+ depth INTEGER NOT NULL DEFAULT 0,
199
+ run_id TEXT,
200
+ started_at TEXT NOT NULL,
201
+ last_activity TEXT NOT NULL,
202
+ escalation_level INTEGER NOT NULL DEFAULT 0,
203
+ stalled_since TEXT
204
+ )
205
+ `);
206
+ sessionsDb.exec(`
207
+ CREATE TABLE runs (
208
+ id TEXT PRIMARY KEY,
209
+ started_at TEXT NOT NULL,
210
+ completed_at TEXT,
211
+ agent_count INTEGER NOT NULL DEFAULT 0,
212
+ coordinator_session_id TEXT,
213
+ status TEXT NOT NULL DEFAULT 'active'
214
+ )
215
+ `);
216
+ sessionsDb.close();
217
+
218
+ const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
219
+
220
+ const mailCheck = checks.find((c) => c?.name === "mail.db schema");
221
+ expect(mailCheck?.status).toBe("fail");
222
+ expect(mailCheck?.details).toContain("Missing tables: messages");
223
+ });
224
+
225
+ test("fails when column is missing", () => {
226
+ // Create messages table without payload column
227
+ const mailDb = new Database(join(tempDir, "mail.db"));
228
+ mailDb.exec("PRAGMA journal_mode=WAL");
229
+ mailDb.exec(`
230
+ CREATE TABLE messages (
231
+ id TEXT PRIMARY KEY,
232
+ from_agent TEXT NOT NULL,
233
+ to_agent TEXT NOT NULL,
234
+ subject TEXT NOT NULL,
235
+ body TEXT NOT NULL,
236
+ type TEXT NOT NULL DEFAULT 'status',
237
+ priority TEXT NOT NULL DEFAULT 'normal',
238
+ thread_id TEXT,
239
+ read INTEGER NOT NULL DEFAULT 0,
240
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
241
+ )
242
+ `);
243
+ mailDb.close();
244
+
245
+ const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
246
+
247
+ const mailCheck = checks.find((c) => c?.name === "mail.db schema");
248
+ expect(mailCheck?.status).toBe("fail");
249
+ expect(mailCheck?.details?.some((d) => d.includes("missing column: payload"))).toBe(true);
250
+ });
251
+
252
+ test("warns when WAL mode is not enabled", () => {
253
+ // Create database without WAL mode
254
+ const mailDb = new Database(join(tempDir, "mail.db"));
255
+ mailDb.exec(`
256
+ CREATE TABLE messages (
257
+ id TEXT PRIMARY KEY,
258
+ from_agent TEXT NOT NULL,
259
+ to_agent TEXT NOT NULL,
260
+ subject TEXT NOT NULL,
261
+ body TEXT NOT NULL,
262
+ type TEXT NOT NULL DEFAULT 'status',
263
+ priority TEXT NOT NULL DEFAULT 'normal',
264
+ thread_id TEXT,
265
+ payload TEXT,
266
+ read INTEGER NOT NULL DEFAULT 0,
267
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
268
+ )
269
+ `);
270
+ mailDb.close();
271
+
272
+ const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
273
+
274
+ const walCheck = checks.find((c) => c?.name === "mail.db WAL mode");
275
+ expect(walCheck?.status).toBe("warn");
276
+ expect(walCheck?.message).toContain("not using WAL mode");
277
+ });
278
+
279
+ test("fails when database is corrupted", () => {
280
+ // Create a corrupt database file (just write garbage)
281
+ const { writeFileSync } = require("node:fs");
282
+ writeFileSync(join(tempDir, "mail.db"), "not a valid sqlite database");
283
+
284
+ const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
285
+
286
+ const integrityCheck = checks.find((c) => c?.name === "mail.db integrity");
287
+ expect(integrityCheck?.status).toBe("fail");
288
+ expect(integrityCheck?.message).toContain("Failed to open or validate");
289
+ });
290
+ });