@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,396 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { AgentManifest } from "../types.ts";
4
+ import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
5
+
6
+ const VALID_MODELS = new Set(["sonnet", "opus", "haiku"]);
7
+ const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
8
+
9
+ /**
10
+ * Check if a path exists.
11
+ */
12
+ async function pathExists(path: string): Promise<boolean> {
13
+ try {
14
+ await stat(path);
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Parse and validate agent-manifest.json structure.
23
+ */
24
+ async function loadAndValidateManifest(
25
+ overstoryDir: string,
26
+ ): Promise<{ manifest: AgentManifest | null; errors: string[] }> {
27
+ const manifestPath = join(overstoryDir, "agent-manifest.json");
28
+ const errors: string[] = [];
29
+
30
+ try {
31
+ const content = await Bun.file(manifestPath).text();
32
+ const raw = JSON.parse(content) as {
33
+ version?: unknown;
34
+ agents?: unknown;
35
+ capabilityIndex?: unknown;
36
+ };
37
+
38
+ // Validate top-level fields
39
+ if (typeof raw.version !== "string" || raw.version.length === 0) {
40
+ errors.push('Missing or empty "version" field');
41
+ }
42
+
43
+ if (typeof raw.agents !== "object" || raw.agents === null) {
44
+ errors.push('"agents" must be an object');
45
+ return { manifest: null, errors };
46
+ }
47
+
48
+ if (typeof raw.capabilityIndex !== "object" || raw.capabilityIndex === null) {
49
+ errors.push('"capabilityIndex" must be an object');
50
+ }
51
+
52
+ const agents = raw.agents as Record<string, unknown>;
53
+
54
+ // Validate each agent definition
55
+ for (const [name, def] of Object.entries(agents)) {
56
+ if (typeof def !== "object" || def === null) {
57
+ errors.push(`Agent "${name}": definition must be an object`);
58
+ continue;
59
+ }
60
+
61
+ const agentDef = def as Record<string, unknown>;
62
+
63
+ if (typeof agentDef.file !== "string" || agentDef.file.length === 0) {
64
+ errors.push(`Agent "${name}": "file" must be a non-empty string`);
65
+ }
66
+
67
+ if (typeof agentDef.model !== "string" || !VALID_MODELS.has(agentDef.model)) {
68
+ errors.push(`Agent "${name}": "model" must be one of: sonnet, opus, haiku`);
69
+ }
70
+
71
+ if (!Array.isArray(agentDef.tools)) {
72
+ errors.push(`Agent "${name}": "tools" must be an array`);
73
+ }
74
+
75
+ if (!Array.isArray(agentDef.capabilities)) {
76
+ errors.push(`Agent "${name}": "capabilities" must be an array`);
77
+ } else if (agentDef.capabilities.length === 0) {
78
+ errors.push(`Agent "${name}": must have at least one capability`);
79
+ }
80
+
81
+ if (typeof agentDef.canSpawn !== "boolean") {
82
+ errors.push(`Agent "${name}": "canSpawn" must be a boolean`);
83
+ }
84
+
85
+ if (!Array.isArray(agentDef.constraints)) {
86
+ errors.push(`Agent "${name}": "constraints" must be an array`);
87
+ }
88
+ }
89
+
90
+ // Return manifest only if structure is valid
91
+ if (errors.length > 0) {
92
+ return { manifest: null, errors };
93
+ }
94
+
95
+ return { manifest: raw as AgentManifest, errors: [] };
96
+ } catch (error) {
97
+ if (error instanceof SyntaxError) {
98
+ errors.push("Invalid JSON syntax");
99
+ } else {
100
+ errors.push(error instanceof Error ? error.message : "Failed to read manifest");
101
+ }
102
+ return { manifest: null, errors };
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Validate capability index bidirectional consistency.
108
+ */
109
+ function validateCapabilityIndex(manifest: AgentManifest): string[] {
110
+ const errors: string[] = [];
111
+
112
+ // Build expected index from agent definitions
113
+ const expectedIndex: Record<string, string[]> = {};
114
+ for (const [name, def] of Object.entries(manifest.agents)) {
115
+ for (const cap of def.capabilities) {
116
+ const existing = expectedIndex[cap];
117
+ if (existing) {
118
+ existing.push(name);
119
+ } else {
120
+ expectedIndex[cap] = [name];
121
+ }
122
+ }
123
+ }
124
+
125
+ // Check that declared index matches expected
126
+ for (const [cap, agentNames] of Object.entries(manifest.capabilityIndex)) {
127
+ const expected = expectedIndex[cap];
128
+ if (!expected) {
129
+ errors.push(`Capability "${cap}" in index but no agents declare it`);
130
+ continue;
131
+ }
132
+
133
+ const missing = expected.filter((name) => !agentNames.includes(name));
134
+ const extra = agentNames.filter((name) => !expected.includes(name));
135
+
136
+ if (missing.length > 0) {
137
+ errors.push(`Capability "${cap}": missing agents in index: ${missing.join(", ")}`);
138
+ }
139
+
140
+ if (extra.length > 0) {
141
+ errors.push(`Capability "${cap}": extra agents in index: ${extra.join(", ")}`);
142
+ }
143
+ }
144
+
145
+ // Check for missing capabilities in index
146
+ for (const [cap, agentNames] of Object.entries(expectedIndex)) {
147
+ if (!manifest.capabilityIndex[cap]) {
148
+ errors.push(
149
+ `Capability "${cap}" declared by ${agentNames.join(", ")} but missing from index`,
150
+ );
151
+ }
152
+ }
153
+
154
+ // Check for capabilities with zero providers
155
+ for (const [cap, agentNames] of Object.entries(expectedIndex)) {
156
+ if (agentNames.length === 0) {
157
+ errors.push(`Capability "${cap}" has zero providers`);
158
+ }
159
+ }
160
+
161
+ return errors;
162
+ }
163
+
164
+ /**
165
+ * Parse a simple YAML identity file.
166
+ */
167
+ function parseIdentityYaml(text: string): {
168
+ name?: string;
169
+ capability?: string;
170
+ created?: string;
171
+ sessionsCompleted?: number;
172
+ } {
173
+ const lines = text.split("\n");
174
+ const identity: {
175
+ name?: string;
176
+ capability?: string;
177
+ created?: string;
178
+ sessionsCompleted?: number;
179
+ } = {};
180
+
181
+ for (const line of lines) {
182
+ const trimmed = line.trim();
183
+ if (!trimmed || trimmed.startsWith("#")) continue;
184
+
185
+ const colonIndex = trimmed.indexOf(":");
186
+ if (colonIndex === -1) continue;
187
+
188
+ const key = trimmed.slice(0, colonIndex).trim();
189
+ let value = trimmed.slice(colonIndex + 1).trim();
190
+
191
+ // Remove quotes if present
192
+ if (
193
+ (value.startsWith('"') && value.endsWith('"')) ||
194
+ (value.startsWith("'") && value.endsWith("'"))
195
+ ) {
196
+ value = value.slice(1, -1);
197
+ }
198
+
199
+ if (key === "name") {
200
+ identity.name = value;
201
+ } else if (key === "capability") {
202
+ identity.capability = value;
203
+ } else if (key === "created") {
204
+ identity.created = value;
205
+ } else if (key === "sessionsCompleted") {
206
+ identity.sessionsCompleted = Number.parseInt(value, 10);
207
+ }
208
+ }
209
+
210
+ return identity;
211
+ }
212
+
213
+ /**
214
+ * Agent state checks.
215
+ * Validates agent definitions, tmux sessions, and agent identity files.
216
+ */
217
+ export const checkAgents: DoctorCheckFn = async (_config, overstoryDir): Promise<DoctorCheck[]> => {
218
+ const checks: DoctorCheck[] = [];
219
+
220
+ // Check 1: Parse agent-manifest.json
221
+ const { manifest, errors: parseErrors } = await loadAndValidateManifest(overstoryDir);
222
+
223
+ if (parseErrors.length > 0) {
224
+ checks.push({
225
+ name: "Manifest parsing",
226
+ category: "agents",
227
+ status: "fail",
228
+ message: `Found ${parseErrors.length} error(s)`,
229
+ details: parseErrors,
230
+ fixable: false,
231
+ });
232
+ return checks; // Can't proceed without valid manifest
233
+ }
234
+
235
+ checks.push({
236
+ name: "Manifest parsing",
237
+ category: "agents",
238
+ status: "pass",
239
+ message: "JSON parses successfully",
240
+ fixable: false,
241
+ });
242
+
243
+ if (!manifest) {
244
+ return checks;
245
+ }
246
+
247
+ // Check 2: Validate referenced .md files exist
248
+ const agentDefsDir = join(overstoryDir, "agent-defs");
249
+ const missingFiles: string[] = [];
250
+
251
+ for (const [name, def] of Object.entries(manifest.agents)) {
252
+ const filePath = join(agentDefsDir, def.file);
253
+ const exists = await pathExists(filePath);
254
+ if (!exists) {
255
+ missingFiles.push(`${name}: ${def.file}`);
256
+ }
257
+ }
258
+
259
+ checks.push({
260
+ name: "Agent definition files",
261
+ category: "agents",
262
+ status: missingFiles.length === 0 ? "pass" : "fail",
263
+ message:
264
+ missingFiles.length === 0 ? "All .md files found" : `Missing ${missingFiles.length} file(s)`,
265
+ details: missingFiles.length > 0 ? missingFiles : undefined,
266
+ fixable: missingFiles.length > 0,
267
+ });
268
+
269
+ // Check 3: Capability index consistency
270
+ const indexErrors = validateCapabilityIndex(manifest);
271
+
272
+ checks.push({
273
+ name: "Capability index",
274
+ category: "agents",
275
+ status: indexErrors.length === 0 ? "pass" : "warn",
276
+ message:
277
+ indexErrors.length === 0 ? "Index is consistent" : `Found ${indexErrors.length} issue(s)`,
278
+ details: indexErrors.length > 0 ? indexErrors : undefined,
279
+ fixable: indexErrors.length > 0,
280
+ });
281
+
282
+ // Check 4: Validate identity files
283
+ const agentsDir = join(overstoryDir, "agents");
284
+ const agentsDirExists = await pathExists(agentsDir);
285
+
286
+ if (!agentsDirExists) {
287
+ checks.push({
288
+ name: "Agent identities",
289
+ category: "agents",
290
+ status: "pass",
291
+ message: "No agent identities yet (agents/ directory missing)",
292
+ fixable: false,
293
+ });
294
+ return checks;
295
+ }
296
+
297
+ try {
298
+ const identityErrors: string[] = [];
299
+ const staleIdentities: string[] = [];
300
+ const agentDirs = await readdir(agentsDir, { withFileTypes: true });
301
+ let identityFileCount = 0;
302
+
303
+ for (const dir of agentDirs) {
304
+ if (!dir.isDirectory()) continue;
305
+
306
+ const agentName = dir.name;
307
+ const identityPath = join(agentsDir, agentName, "identity.yaml");
308
+ const identityExists = await pathExists(identityPath);
309
+
310
+ if (!identityExists) {
311
+ continue; // Skip if no identity file
312
+ }
313
+
314
+ identityFileCount++;
315
+
316
+ // Check if agent still exists in manifest
317
+ if (!manifest.agents[agentName]) {
318
+ staleIdentities.push(agentName);
319
+ continue;
320
+ }
321
+
322
+ // Parse and validate identity
323
+ try {
324
+ const content = await Bun.file(identityPath).text();
325
+ const identity = parseIdentityYaml(content);
326
+
327
+ if (!identity.name) {
328
+ identityErrors.push(`${agentName}: missing "name" field`);
329
+ }
330
+
331
+ if (!identity.capability) {
332
+ identityErrors.push(`${agentName}: missing "capability" field`);
333
+ }
334
+
335
+ if (!identity.created) {
336
+ identityErrors.push(`${agentName}: missing "created" field`);
337
+ } else {
338
+ // Validate ISO timestamp format
339
+ const timestamp = new Date(identity.created);
340
+ if (Number.isNaN(timestamp.getTime())) {
341
+ identityErrors.push(`${agentName}: invalid "created" timestamp`);
342
+ }
343
+ }
344
+
345
+ if (typeof identity.sessionsCompleted !== "number" || identity.sessionsCompleted < 0) {
346
+ identityErrors.push(`${agentName}: "sessionsCompleted" must be a non-negative integer`);
347
+ }
348
+
349
+ // Validate name is valid identifier
350
+ if (identity.name && !VALID_NAME_REGEX.test(identity.name)) {
351
+ identityErrors.push(
352
+ `${agentName}: name "${identity.name}" contains invalid characters (use alphanumeric, dash, underscore only)`,
353
+ );
354
+ }
355
+ } catch (error) {
356
+ identityErrors.push(
357
+ `${agentName}: ${error instanceof Error ? error.message : "failed to parse YAML"}`,
358
+ );
359
+ }
360
+ }
361
+
362
+ if (identityErrors.length > 0) {
363
+ checks.push({
364
+ name: "Identity validation",
365
+ category: "agents",
366
+ status: "warn",
367
+ message: `Found ${identityErrors.length} issue(s)`,
368
+ details: identityErrors,
369
+ fixable: false,
370
+ });
371
+ } else if (identityFileCount > 0) {
372
+ checks.push({
373
+ name: "Identity validation",
374
+ category: "agents",
375
+ status: "pass",
376
+ message: "All identity files are valid",
377
+ fixable: false,
378
+ });
379
+ }
380
+
381
+ if (staleIdentities.length > 0) {
382
+ checks.push({
383
+ name: "Stale identities",
384
+ category: "agents",
385
+ status: "warn",
386
+ message: `Found ${staleIdentities.length} stale identity file(s)`,
387
+ details: staleIdentities.map((name) => `${name} (agent no longer in manifest)`),
388
+ fixable: true,
389
+ });
390
+ }
391
+ } catch {
392
+ // Ignore errors reading agents directory
393
+ }
394
+
395
+ return checks;
396
+ };
@@ -0,0 +1,190 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { OverstoryConfig } from "../types.ts";
6
+ import { checkConfig } from "./config-check.ts";
7
+
8
+ // Helper to create a temp overstory dir with config.yaml
9
+ function createTempOverstoryDir(configYaml: string): string {
10
+ const tempDir = mkdtempSync(join(tmpdir(), "overstory-test-"));
11
+ const overstoryDir = join(tempDir, ".overstory");
12
+ mkdirSync(overstoryDir, { recursive: true });
13
+ writeFileSync(join(overstoryDir, "config.yaml"), configYaml);
14
+ return overstoryDir;
15
+ }
16
+
17
+ // Valid minimal config
18
+ const validConfigYaml = `
19
+ projectName: test-project
20
+ project:
21
+ root: ${tmpdir()}
22
+ canonicalBranch: main
23
+ maxConcurrent: 5
24
+ maxDepth: 2
25
+ watchdog:
26
+ tier0Enabled: false
27
+ tier1Enabled: false
28
+ tier2Enabled: false
29
+ tier3Enabled: false
30
+ `;
31
+
32
+ const mockConfig: OverstoryConfig = {
33
+ project: {
34
+ name: "test-project",
35
+ root: tmpdir(),
36
+ canonicalBranch: "main",
37
+ },
38
+ agents: {
39
+ manifestPath: `${tmpdir()}/.overstory/agent-manifest.json`,
40
+ baseDir: `${tmpdir()}/.overstory/agents`,
41
+ maxConcurrent: 5,
42
+ staggerDelayMs: 1000,
43
+ maxDepth: 2,
44
+ maxSessionsPerRun: 0,
45
+ },
46
+ worktrees: {
47
+ baseDir: `${tmpdir()}/.overstory/worktrees`,
48
+ },
49
+ taskTracker: {
50
+ backend: "auto",
51
+ enabled: false,
52
+ },
53
+ mulch: {
54
+ enabled: false,
55
+ domains: [],
56
+ primeFormat: "markdown",
57
+ },
58
+ merge: {
59
+ aiResolveEnabled: false,
60
+ reimagineEnabled: false,
61
+ },
62
+ providers: {
63
+ anthropic: { type: "native" },
64
+ },
65
+ watchdog: {
66
+ tier0Enabled: false,
67
+ tier0IntervalMs: 30000,
68
+ tier1Enabled: false,
69
+ tier2Enabled: false,
70
+ staleThresholdMs: 300000,
71
+ zombieThresholdMs: 600000,
72
+ nudgeIntervalMs: 60000,
73
+ },
74
+ models: {},
75
+ logging: {
76
+ verbose: false,
77
+ redactSecrets: true,
78
+ },
79
+ };
80
+
81
+ describe("checkConfig", () => {
82
+ test("returns checks with category config", async () => {
83
+ const overstoryDir = createTempOverstoryDir(validConfigYaml);
84
+ const checks = await checkConfig(mockConfig, overstoryDir);
85
+
86
+ expect(checks).toBeArray();
87
+ expect(checks.length).toBeGreaterThan(0);
88
+
89
+ for (const check of checks) {
90
+ expect(check.category).toBe("config");
91
+ }
92
+ });
93
+
94
+ test("includes all four config checks", async () => {
95
+ const overstoryDir = createTempOverstoryDir(validConfigYaml);
96
+ const checks = await checkConfig(mockConfig, overstoryDir);
97
+
98
+ const checkNames = checks.map((c) => c.name);
99
+ expect(checkNames).toContain("config-parseable");
100
+ expect(checkNames).toContain("config-valid");
101
+ expect(checkNames).toContain("project-root-exists");
102
+ expect(checkNames).toContain("canonical-branch-exists");
103
+ });
104
+
105
+ test("config-parseable passes with valid config", async () => {
106
+ const overstoryDir = createTempOverstoryDir(validConfigYaml);
107
+ const checks = await checkConfig(mockConfig, overstoryDir);
108
+
109
+ const parseableCheck = checks.find((c) => c.name === "config-parseable");
110
+ expect(parseableCheck).toBeDefined();
111
+ expect(parseableCheck?.status).toBe("pass");
112
+ });
113
+
114
+ test("config-valid passes with valid config", async () => {
115
+ const overstoryDir = createTempOverstoryDir(validConfigYaml);
116
+ const checks = await checkConfig(mockConfig, overstoryDir);
117
+
118
+ const validCheck = checks.find((c) => c.name === "config-valid");
119
+ expect(validCheck).toBeDefined();
120
+ expect(validCheck?.status).toBe("pass");
121
+ });
122
+
123
+ test("project-root-exists passes when directory exists", async () => {
124
+ const overstoryDir = createTempOverstoryDir(validConfigYaml);
125
+ const checks = await checkConfig(mockConfig, overstoryDir);
126
+
127
+ const rootCheck = checks.find((c) => c.name === "project-root-exists");
128
+ expect(rootCheck).toBeDefined();
129
+ expect(rootCheck?.status).toBe("pass");
130
+ expect(rootCheck?.details).toBeDefined();
131
+ });
132
+
133
+ test("project-root-exists fails when directory does not exist", async () => {
134
+ const overstoryDir = createTempOverstoryDir(validConfigYaml);
135
+ const configWithBadRoot = {
136
+ ...mockConfig,
137
+ project: {
138
+ ...mockConfig.project,
139
+ root: "/nonexistent/path/that/does/not/exist",
140
+ },
141
+ };
142
+ const checks = await checkConfig(configWithBadRoot, overstoryDir);
143
+
144
+ const rootCheck = checks.find((c) => c.name === "project-root-exists");
145
+ expect(rootCheck).toBeDefined();
146
+ expect(rootCheck?.status).toBe("fail");
147
+ expect(rootCheck?.fixable).toBe(true);
148
+ });
149
+
150
+ test("canonical-branch-exists warns when branch does not exist", async () => {
151
+ const overstoryDir = createTempOverstoryDir(validConfigYaml);
152
+ const configWithBadBranch = {
153
+ ...mockConfig,
154
+ project: {
155
+ ...mockConfig.project,
156
+ canonicalBranch: "nonexistent-branch-xyz",
157
+ },
158
+ };
159
+ const checks = await checkConfig(configWithBadBranch, overstoryDir);
160
+
161
+ const branchCheck = checks.find((c) => c.name === "canonical-branch-exists");
162
+ expect(branchCheck).toBeDefined();
163
+ expect(branchCheck?.status).toBe("warn");
164
+ expect(branchCheck?.message).toContain("nonexistent-branch-xyz");
165
+ });
166
+
167
+ test("all checks have required DoctorCheck fields", async () => {
168
+ const overstoryDir = createTempOverstoryDir(validConfigYaml);
169
+ const checks = await checkConfig(mockConfig, overstoryDir);
170
+
171
+ for (const check of checks) {
172
+ expect(check).toHaveProperty("name");
173
+ expect(check).toHaveProperty("category");
174
+ expect(check).toHaveProperty("status");
175
+ expect(check).toHaveProperty("message");
176
+
177
+ expect(typeof check.name).toBe("string");
178
+ expect(typeof check.message).toBe("string");
179
+ expect(["pass", "warn", "fail"]).toContain(check.status);
180
+
181
+ if (check.details !== undefined) {
182
+ expect(check.details).toBeArray();
183
+ }
184
+
185
+ if (check.fixable !== undefined) {
186
+ expect(typeof check.fixable).toBe("boolean");
187
+ }
188
+ }
189
+ });
190
+ });