@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,822 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DEFAULT_CONFIG, DEFAULT_QUALITY_GATES, loadConfig, resolveProjectRoot } from "./config.ts";
6
+ import { ValidationError } from "./errors.ts";
7
+ import { cleanupTempDir, createTempGitRepo, runGitInDir } from "./test-helpers.ts";
8
+
9
+ describe("loadConfig", () => {
10
+ let tempDir: string;
11
+
12
+ beforeEach(async () => {
13
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-test-"));
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await rm(tempDir, { recursive: true, force: true });
18
+ });
19
+
20
+ async function writeConfig(yaml: string): Promise<void> {
21
+ const overstoryDir = join(tempDir, ".overstory");
22
+ await Bun.write(join(overstoryDir, "config.yaml"), yaml);
23
+ }
24
+
25
+ async function ensureOverstoryDir(): Promise<void> {
26
+ const { mkdir } = await import("node:fs/promises");
27
+ await mkdir(join(tempDir, ".overstory"), { recursive: true });
28
+ }
29
+
30
+ test("returns defaults when no config file exists", async () => {
31
+ const config = await loadConfig(tempDir);
32
+
33
+ expect(config.project.root).toBe(tempDir);
34
+ expect(config.project.canonicalBranch).toBe("main");
35
+ expect(config.agents.maxConcurrent).toBe(25);
36
+ expect(config.agents.maxDepth).toBe(2);
37
+ expect(config.taskTracker.enabled).toBe(true);
38
+ expect(config.mulch.enabled).toBe(true);
39
+ expect(config.mulch.primeFormat).toBe("markdown");
40
+ expect(config.logging.verbose).toBe(false);
41
+ });
42
+
43
+ test("sets project.name from directory name", async () => {
44
+ const config = await loadConfig(tempDir);
45
+ const parts = tempDir.split("/");
46
+ const expectedName = parts[parts.length - 1] ?? "unknown";
47
+ expect(config.project.name).toBe(expectedName);
48
+ });
49
+
50
+ test("merges config file values over defaults", async () => {
51
+ await ensureOverstoryDir();
52
+ await writeConfig(`
53
+ project:
54
+ canonicalBranch: develop
55
+ agents:
56
+ maxConcurrent: 10
57
+ `);
58
+
59
+ const config = await loadConfig(tempDir);
60
+
61
+ expect(config.project.canonicalBranch).toBe("develop");
62
+ expect(config.agents.maxConcurrent).toBe(10);
63
+ // Non-overridden values keep defaults
64
+ expect(config.agents.maxDepth).toBe(2);
65
+ expect(config.taskTracker.enabled).toBe(true);
66
+ });
67
+
68
+ test("always sets project.root to the actual projectRoot", async () => {
69
+ await ensureOverstoryDir();
70
+ await writeConfig(`
71
+ project:
72
+ root: /some/wrong/path
73
+ `);
74
+
75
+ const config = await loadConfig(tempDir);
76
+ expect(config.project.root).toBe(tempDir);
77
+ });
78
+
79
+ test("parses boolean values correctly", async () => {
80
+ await ensureOverstoryDir();
81
+ await writeConfig(`
82
+ beads:
83
+ enabled: false
84
+ mulch:
85
+ enabled: true
86
+ logging:
87
+ verbose: true
88
+ redactSecrets: false
89
+ `);
90
+
91
+ const config = await loadConfig(tempDir);
92
+
93
+ expect(config.taskTracker.enabled).toBe(false);
94
+ expect(config.mulch.enabled).toBe(true);
95
+ expect(config.logging.verbose).toBe(true);
96
+ expect(config.logging.redactSecrets).toBe(false);
97
+ });
98
+
99
+ test("parses empty array literal", async () => {
100
+ await ensureOverstoryDir();
101
+ await writeConfig(`
102
+ mulch:
103
+ domains: []
104
+ `);
105
+
106
+ const config = await loadConfig(tempDir);
107
+ expect(config.mulch.domains).toEqual([]);
108
+ });
109
+
110
+ test("parses numeric values including underscore-separated", async () => {
111
+ await ensureOverstoryDir();
112
+ await writeConfig(`
113
+ agents:
114
+ staggerDelayMs: 5000
115
+ watchdog:
116
+ tier0IntervalMs: 60000
117
+ staleThresholdMs: 120000
118
+ zombieThresholdMs: 300000
119
+ `);
120
+
121
+ const config = await loadConfig(tempDir);
122
+ expect(config.agents.staggerDelayMs).toBe(5000);
123
+ expect(config.watchdog.tier0IntervalMs).toBe(60000);
124
+ });
125
+
126
+ test("handles quoted string values", async () => {
127
+ await ensureOverstoryDir();
128
+ await writeConfig(`
129
+ project:
130
+ canonicalBranch: "develop"
131
+ `);
132
+
133
+ const config = await loadConfig(tempDir);
134
+ expect(config.project.canonicalBranch).toBe("develop");
135
+ });
136
+
137
+ test("ignores comments and empty lines", async () => {
138
+ await ensureOverstoryDir();
139
+ await writeConfig(`
140
+ # This is a comment
141
+ project:
142
+ canonicalBranch: develop # inline comment
143
+
144
+ # Another comment
145
+ agents:
146
+ maxConcurrent: 3
147
+ `);
148
+
149
+ const config = await loadConfig(tempDir);
150
+ expect(config.project.canonicalBranch).toBe("develop");
151
+ expect(config.agents.maxConcurrent).toBe(3);
152
+ });
153
+
154
+ test("config.local.yaml overrides values from config.yaml", async () => {
155
+ await ensureOverstoryDir();
156
+ await writeConfig(`
157
+ project:
158
+ canonicalBranch: develop
159
+ agents:
160
+ maxConcurrent: 10
161
+ `);
162
+ await Bun.write(
163
+ join(tempDir, ".overstory", "config.local.yaml"),
164
+ `agents:\n maxConcurrent: 4\n`,
165
+ );
166
+
167
+ const config = await loadConfig(tempDir);
168
+ // Local override wins
169
+ expect(config.agents.maxConcurrent).toBe(4);
170
+ // Non-overridden value from config.yaml preserved
171
+ expect(config.project.canonicalBranch).toBe("develop");
172
+ });
173
+
174
+ test("config.local.yaml works when config.yaml does not exist", async () => {
175
+ await ensureOverstoryDir();
176
+ // No config.yaml, only config.local.yaml
177
+ await Bun.write(
178
+ join(tempDir, ".overstory", "config.local.yaml"),
179
+ `agents:\n maxConcurrent: 3\n`,
180
+ );
181
+
182
+ const config = await loadConfig(tempDir);
183
+ expect(config.agents.maxConcurrent).toBe(3);
184
+ // Defaults still applied
185
+ expect(config.project.canonicalBranch).toBe("main");
186
+ });
187
+
188
+ test("values from config.local.yaml are validated", async () => {
189
+ await ensureOverstoryDir();
190
+ await writeConfig(`
191
+ project:
192
+ canonicalBranch: main
193
+ `);
194
+ await Bun.write(
195
+ join(tempDir, ".overstory", "config.local.yaml"),
196
+ `agents:\n maxConcurrent: -1\n`,
197
+ );
198
+
199
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
200
+ });
201
+
202
+ test("config.local.yaml deep merges nested objects", async () => {
203
+ await ensureOverstoryDir();
204
+ await writeConfig(`
205
+ watchdog:
206
+ tier0Enabled: false
207
+ staleThresholdMs: 120000
208
+ `);
209
+ await Bun.write(
210
+ join(tempDir, ".overstory", "config.local.yaml"),
211
+ `watchdog:\n tier0Enabled: true\n`,
212
+ );
213
+
214
+ const config = await loadConfig(tempDir);
215
+ // Local override
216
+ expect(config.watchdog.tier0Enabled).toBe(true);
217
+ // Non-overridden value from config.yaml preserved
218
+ expect(config.watchdog.staleThresholdMs).toBe(120000);
219
+ });
220
+
221
+ test("parses providers section from config.yaml", async () => {
222
+ await ensureOverstoryDir();
223
+ await writeConfig(`
224
+ providers:
225
+ openrouter:
226
+ type: gateway
227
+ baseUrl: https://openrouter.ai/api/v1
228
+ authTokenEnv: OPENROUTER_API_KEY
229
+ `);
230
+ const config = await loadConfig(tempDir);
231
+ expect(config.providers.openrouter).toEqual({
232
+ type: "gateway",
233
+ baseUrl: "https://openrouter.ai/api/v1",
234
+ authTokenEnv: "OPENROUTER_API_KEY",
235
+ });
236
+ // Default anthropic provider preserved via deep merge
237
+ expect(config.providers.anthropic).toEqual({ type: "native" });
238
+ });
239
+
240
+ test("config.local.yaml overrides provider settings", async () => {
241
+ await ensureOverstoryDir();
242
+ await writeConfig(`
243
+ providers:
244
+ anthropic:
245
+ type: native
246
+ `);
247
+ await Bun.write(
248
+ join(tempDir, ".overstory", "config.local.yaml"),
249
+ `providers:\n anthropic:\n type: gateway\n baseUrl: http://localhost:8080\n authTokenEnv: ANTHROPIC_GATEWAY_KEY\n`,
250
+ );
251
+ const config = await loadConfig(tempDir);
252
+ expect(config.providers.anthropic).toEqual({
253
+ type: "gateway",
254
+ baseUrl: "http://localhost:8080",
255
+ authTokenEnv: "ANTHROPIC_GATEWAY_KEY",
256
+ });
257
+ });
258
+
259
+ test("empty providers section preserves defaults", async () => {
260
+ await ensureOverstoryDir();
261
+ await writeConfig(`
262
+ providers:
263
+ `);
264
+ const config = await loadConfig(tempDir);
265
+ expect(config.providers.anthropic).toEqual({ type: "native" });
266
+ });
267
+
268
+ test("migrates deprecated watchdog tier1/tier2 keys to tier0/tier1", async () => {
269
+ await ensureOverstoryDir();
270
+ await writeConfig(`
271
+ watchdog:
272
+ tier1Enabled: true
273
+ tier1IntervalMs: 45000
274
+ tier2Enabled: true
275
+ `);
276
+
277
+ const config = await loadConfig(tempDir);
278
+ // Old tier1 (mechanical daemon) → new tier0
279
+ expect(config.watchdog.tier0Enabled).toBe(true);
280
+ expect(config.watchdog.tier0IntervalMs).toBe(45000);
281
+ // Old tier2 (AI triage) → new tier1
282
+ expect(config.watchdog.tier1Enabled).toBe(true);
283
+ });
284
+
285
+ test("new-style tier keys take precedence over deprecated keys", async () => {
286
+ await ensureOverstoryDir();
287
+ await writeConfig(`
288
+ watchdog:
289
+ tier0Enabled: false
290
+ tier0IntervalMs: 20000
291
+ tier1Enabled: true
292
+ `);
293
+
294
+ const config = await loadConfig(tempDir);
295
+ // New keys used directly — no migration needed
296
+ expect(config.watchdog.tier0Enabled).toBe(false);
297
+ expect(config.watchdog.tier0IntervalMs).toBe(20000);
298
+ expect(config.watchdog.tier1Enabled).toBe(true);
299
+ });
300
+
301
+ test("migrates deprecated beads: key to taskTracker:", async () => {
302
+ await ensureOverstoryDir();
303
+ await writeConfig(`
304
+ beads:
305
+ enabled: false
306
+ `);
307
+
308
+ const config = await loadConfig(tempDir);
309
+ expect(config.taskTracker.backend).toBe("beads");
310
+ expect(config.taskTracker.enabled).toBe(false);
311
+ });
312
+
313
+ test("migrates deprecated seeds: key to taskTracker:", async () => {
314
+ await ensureOverstoryDir();
315
+ await writeConfig(`
316
+ seeds:
317
+ enabled: true
318
+ `);
319
+
320
+ const config = await loadConfig(tempDir);
321
+ expect(config.taskTracker.backend).toBe("seeds");
322
+ expect(config.taskTracker.enabled).toBe(true);
323
+ });
324
+
325
+ test("taskTracker: key takes precedence over legacy keys", async () => {
326
+ await ensureOverstoryDir();
327
+ await writeConfig(`
328
+ taskTracker:
329
+ backend: auto
330
+ enabled: true
331
+ beads:
332
+ enabled: false
333
+ `);
334
+
335
+ const config = await loadConfig(tempDir);
336
+ // taskTracker present — beads key ignored
337
+ expect(config.taskTracker.backend).toBe("auto");
338
+ expect(config.taskTracker.enabled).toBe(true);
339
+ });
340
+
341
+ test("defaults taskTracker.backend to auto", async () => {
342
+ const config = await loadConfig(tempDir);
343
+ expect(config.taskTracker.backend).toBe("auto");
344
+ });
345
+ });
346
+
347
+ describe("validateConfig", () => {
348
+ let tempDir: string;
349
+
350
+ beforeEach(async () => {
351
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-test-"));
352
+ const { mkdir } = await import("node:fs/promises");
353
+ await mkdir(join(tempDir, ".overstory"), { recursive: true });
354
+ });
355
+
356
+ afterEach(async () => {
357
+ await rm(tempDir, { recursive: true, force: true });
358
+ });
359
+
360
+ async function writeConfig(yaml: string): Promise<void> {
361
+ await Bun.write(join(tempDir, ".overstory", "config.yaml"), yaml);
362
+ }
363
+
364
+ test("rejects negative maxConcurrent", async () => {
365
+ await writeConfig(`
366
+ agents:
367
+ maxConcurrent: -1
368
+ `);
369
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
370
+ });
371
+
372
+ test("rejects zero maxConcurrent", async () => {
373
+ await writeConfig(`
374
+ agents:
375
+ maxConcurrent: 0
376
+ `);
377
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
378
+ });
379
+
380
+ test("rejects negative maxDepth", async () => {
381
+ await writeConfig(`
382
+ agents:
383
+ maxDepth: -1
384
+ `);
385
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
386
+ });
387
+
388
+ test("rejects negative staggerDelayMs", async () => {
389
+ await writeConfig(`
390
+ agents:
391
+ staggerDelayMs: -100
392
+ `);
393
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
394
+ });
395
+
396
+ test("accepts maxSessionsPerRun of 0 (unlimited)", async () => {
397
+ await writeConfig(`
398
+ agents:
399
+ maxSessionsPerRun: 0
400
+ `);
401
+ const config = await loadConfig(tempDir);
402
+ expect(config.agents.maxSessionsPerRun).toBe(0);
403
+ });
404
+
405
+ test("accepts positive maxSessionsPerRun", async () => {
406
+ await writeConfig(`
407
+ agents:
408
+ maxSessionsPerRun: 20
409
+ `);
410
+ const config = await loadConfig(tempDir);
411
+ expect(config.agents.maxSessionsPerRun).toBe(20);
412
+ });
413
+
414
+ test("rejects negative maxSessionsPerRun", async () => {
415
+ await writeConfig(`
416
+ agents:
417
+ maxSessionsPerRun: -1
418
+ `);
419
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
420
+ });
421
+
422
+ test("rejects non-integer maxSessionsPerRun", async () => {
423
+ await writeConfig(`
424
+ agents:
425
+ maxSessionsPerRun: 1.5
426
+ `);
427
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
428
+ });
429
+
430
+ test("rejects invalid mulch.primeFormat", async () => {
431
+ await writeConfig(`
432
+ mulch:
433
+ primeFormat: yaml
434
+ `);
435
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
436
+ });
437
+
438
+ test("rejects invalid taskTracker.backend", async () => {
439
+ await writeConfig(`
440
+ taskTracker:
441
+ backend: invalid
442
+ enabled: true
443
+ `);
444
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
445
+ });
446
+
447
+ test("rejects zombieThresholdMs <= staleThresholdMs", async () => {
448
+ await writeConfig(`
449
+ watchdog:
450
+ staleThresholdMs: 300000
451
+ zombieThresholdMs: 300000
452
+ `);
453
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
454
+ });
455
+
456
+ test("rejects non-positive tier0IntervalMs when tier0 is enabled", async () => {
457
+ await writeConfig(`
458
+ watchdog:
459
+ tier0Enabled: true
460
+ tier0IntervalMs: 0
461
+ `);
462
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
463
+ });
464
+
465
+ test("accepts empty models section", async () => {
466
+ await writeConfig(`
467
+ models:
468
+ `);
469
+ const config = await loadConfig(tempDir);
470
+ expect(config.models).toBeDefined();
471
+ });
472
+
473
+ test("accepts valid model names in models section", async () => {
474
+ await writeConfig(`
475
+ models:
476
+ coordinator: sonnet
477
+ monitor: haiku
478
+ builder: opus
479
+ `);
480
+ const config = await loadConfig(tempDir);
481
+ expect(config.models.coordinator).toBe("sonnet");
482
+ expect(config.models.monitor).toBe("haiku");
483
+ expect(config.models.builder).toBe("opus");
484
+ });
485
+
486
+ test("rejects invalid model name in models section", async () => {
487
+ await writeConfig(`
488
+ models:
489
+ coordinator: gpt4
490
+ `);
491
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
492
+ });
493
+
494
+ // Provider validation tests
495
+
496
+ test("rejects provider with invalid type", async () => {
497
+ await writeConfig(`
498
+ providers:
499
+ custom:
500
+ type: custom
501
+ `);
502
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
503
+ });
504
+
505
+ test("rejects gateway provider without baseUrl", async () => {
506
+ await writeConfig(`
507
+ providers:
508
+ mygateway:
509
+ type: gateway
510
+ authTokenEnv: MY_TOKEN
511
+ `);
512
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
513
+ });
514
+
515
+ test("rejects gateway provider without authTokenEnv", async () => {
516
+ await writeConfig(`
517
+ providers:
518
+ mygateway:
519
+ type: gateway
520
+ baseUrl: https://example.com
521
+ `);
522
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
523
+ });
524
+
525
+ test("accepts native provider without baseUrl or authTokenEnv", async () => {
526
+ await writeConfig(`
527
+ providers:
528
+ mylocal:
529
+ type: native
530
+ `);
531
+ const config = await loadConfig(tempDir);
532
+ expect(config.providers.mylocal).toEqual({ type: "native" });
533
+ });
534
+
535
+ // Model validation tests
536
+
537
+ test("accepts provider-prefixed model ref when provider exists", async () => {
538
+ await writeConfig(`
539
+ providers:
540
+ openrouter:
541
+ type: gateway
542
+ baseUrl: https://openrouter.ai/api/v1
543
+ authTokenEnv: OPENROUTER_API_KEY
544
+ models:
545
+ coordinator: openrouter/openai/gpt-5.3
546
+ `);
547
+ const config = await loadConfig(tempDir);
548
+ expect(config.models.coordinator).toBe("openrouter/openai/gpt-5.3");
549
+ });
550
+
551
+ test("rejects provider-prefixed model ref when provider is unknown", async () => {
552
+ await writeConfig(`
553
+ models:
554
+ coordinator: unknown/model
555
+ `);
556
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
557
+ });
558
+
559
+ test("rejects bare invalid model name", async () => {
560
+ await writeConfig(`
561
+ models:
562
+ coordinator: gpt4
563
+ `);
564
+ const err = await loadConfig(tempDir).catch((e: unknown) => e);
565
+ expect(err).toBeInstanceOf(ValidationError);
566
+ expect((err as ValidationError).message).toContain("provider-prefixed ref");
567
+ });
568
+
569
+ test("warns on non-Anthropic model in tool-heavy role", async () => {
570
+ await writeConfig(`
571
+ providers:
572
+ openrouter:
573
+ type: gateway
574
+ baseUrl: https://openrouter.ai/api/v1
575
+ authTokenEnv: OPENROUTER_API_KEY
576
+ models:
577
+ builder: openrouter/openai/gpt-4
578
+ `);
579
+ const origWrite = process.stderr.write;
580
+ let capturedStderr = "";
581
+ process.stderr.write = ((s: string | Uint8Array) => {
582
+ if (typeof s === "string") capturedStderr += s;
583
+ return true;
584
+ }) as typeof process.stderr.write;
585
+ try {
586
+ await loadConfig(tempDir);
587
+ } finally {
588
+ process.stderr.write = origWrite;
589
+ }
590
+ expect(capturedStderr).toContain("WARNING: models.builder uses non-Anthropic model");
591
+ expect(capturedStderr).toContain("openrouter/openai/gpt-4");
592
+ });
593
+
594
+ test("does not warn for non-Anthropic model in non-tool-heavy role", async () => {
595
+ await writeConfig(`
596
+ providers:
597
+ openrouter:
598
+ type: gateway
599
+ baseUrl: https://openrouter.ai/api/v1
600
+ authTokenEnv: OPENROUTER_API_KEY
601
+ models:
602
+ coordinator: openrouter/openai/gpt-4
603
+ `);
604
+ const origWrite = process.stderr.write;
605
+ let capturedStderr = "";
606
+ process.stderr.write = ((s: string | Uint8Array) => {
607
+ if (typeof s === "string") capturedStderr += s;
608
+ return true;
609
+ }) as typeof process.stderr.write;
610
+ try {
611
+ await loadConfig(tempDir);
612
+ } finally {
613
+ process.stderr.write = origWrite;
614
+ }
615
+ expect(capturedStderr).not.toContain("WARNING");
616
+ });
617
+
618
+ test("custom qualityGates from config.yaml are loaded", async () => {
619
+ await writeConfig(`
620
+ project:
621
+ canonicalBranch: main
622
+ qualityGates:
623
+ - name: Test
624
+ command: pytest
625
+ description: all tests pass
626
+ - name: Lint
627
+ command: ruff check .
628
+ description: no lint errors
629
+ `);
630
+ const config = await loadConfig(tempDir);
631
+ expect(config.project.qualityGates?.length).toBe(2);
632
+ expect(config.project.qualityGates?.[0]?.command).toBe("pytest");
633
+ expect(config.project.qualityGates?.[1]?.command).toBe("ruff check .");
634
+ });
635
+
636
+ test("rejects qualityGate with empty name", async () => {
637
+ await writeConfig(`
638
+ project:
639
+ canonicalBranch: main
640
+ qualityGates:
641
+ - name: ""
642
+ command: pytest
643
+ description: all tests pass
644
+ `);
645
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
646
+ });
647
+
648
+ test("rejects qualityGate with empty command", async () => {
649
+ await writeConfig(`
650
+ project:
651
+ canonicalBranch: main
652
+ qualityGates:
653
+ - name: Test
654
+ command: ""
655
+ description: all tests pass
656
+ `);
657
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
658
+ });
659
+
660
+ test("rejects qualityGate with empty description", async () => {
661
+ await writeConfig(`
662
+ project:
663
+ canonicalBranch: main
664
+ qualityGates:
665
+ - name: Test
666
+ command: pytest
667
+ description: ""
668
+ `);
669
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
670
+ });
671
+ });
672
+
673
+ describe("resolveProjectRoot", () => {
674
+ let repoDir: string;
675
+
676
+ afterEach(async () => {
677
+ if (repoDir) {
678
+ // Remove worktrees before cleaning up
679
+ try {
680
+ await runGitInDir(repoDir, ["worktree", "prune"]);
681
+ } catch {
682
+ // Best effort
683
+ }
684
+ await cleanupTempDir(repoDir);
685
+ }
686
+ });
687
+
688
+ test("returns startDir when .overstory/config.yaml exists there", async () => {
689
+ repoDir = await createTempGitRepo();
690
+ await mkdir(join(repoDir, ".overstory"), { recursive: true });
691
+ await Bun.write(
692
+ join(repoDir, ".overstory", "config.yaml"),
693
+ "project:\n canonicalBranch: main\n",
694
+ );
695
+
696
+ const result = await resolveProjectRoot(repoDir);
697
+ expect(result).toBe(repoDir);
698
+ });
699
+
700
+ test("resolves worktree to main project root", async () => {
701
+ repoDir = await createTempGitRepo();
702
+ // Resolve symlinks (macOS /var -> /private/var) to match git's output
703
+ repoDir = await realpath(repoDir);
704
+ await mkdir(join(repoDir, ".overstory"), { recursive: true });
705
+ await Bun.write(
706
+ join(repoDir, ".overstory", "config.yaml"),
707
+ "project:\n canonicalBranch: main\n",
708
+ );
709
+
710
+ // Create a worktree like overstory sling does
711
+ const worktreeDir = join(repoDir, ".overstory", "worktrees", "test-agent");
712
+ await mkdir(join(repoDir, ".overstory", "worktrees"), { recursive: true });
713
+ await runGitInDir(repoDir, [
714
+ "worktree",
715
+ "add",
716
+ "-b",
717
+ "overstory/test-agent/task-1",
718
+ worktreeDir,
719
+ ]);
720
+
721
+ // resolveProjectRoot from the worktree should return the main repo
722
+ const result = await resolveProjectRoot(worktreeDir);
723
+ expect(result).toBe(repoDir);
724
+ });
725
+
726
+ test("resolves worktree to main root even when config.yaml is committed (regression)", async () => {
727
+ repoDir = await createTempGitRepo();
728
+ repoDir = await realpath(repoDir);
729
+
730
+ // Commit .overstory/config.yaml so the worktree gets a copy via git
731
+ // (this is what overstory init does — the file is tracked)
732
+ await mkdir(join(repoDir, ".overstory"), { recursive: true });
733
+ await Bun.write(
734
+ join(repoDir, ".overstory", "config.yaml"),
735
+ "project:\n canonicalBranch: main\n",
736
+ );
737
+ await runGitInDir(repoDir, ["add", ".overstory/config.yaml"]);
738
+ await runGitInDir(repoDir, ["commit", "-m", "add overstory config"]);
739
+
740
+ // Create a worktree — it will now have .overstory/config.yaml from git
741
+ const worktreeDir = join(repoDir, ".overstory", "worktrees", "mail-scout");
742
+ await mkdir(join(repoDir, ".overstory", "worktrees"), { recursive: true });
743
+ await runGitInDir(repoDir, [
744
+ "worktree",
745
+ "add",
746
+ "-b",
747
+ "overstory/mail-scout/task-1",
748
+ worktreeDir,
749
+ ]);
750
+
751
+ // Must resolve to main repo root, NOT the worktree
752
+ // (even though worktree has its own .overstory/config.yaml)
753
+ const result = await resolveProjectRoot(worktreeDir);
754
+ expect(result).toBe(repoDir);
755
+ });
756
+
757
+ test("loadConfig resolves correct root from worktree", async () => {
758
+ repoDir = await createTempGitRepo();
759
+ // Resolve symlinks (macOS /var -> /private/var) to match git's output
760
+ repoDir = await realpath(repoDir);
761
+ await mkdir(join(repoDir, ".overstory"), { recursive: true });
762
+ await Bun.write(
763
+ join(repoDir, ".overstory", "config.yaml"),
764
+ "project:\n canonicalBranch: develop\n",
765
+ );
766
+
767
+ const worktreeDir = join(repoDir, ".overstory", "worktrees", "agent-2");
768
+ await mkdir(join(repoDir, ".overstory", "worktrees"), { recursive: true });
769
+ await runGitInDir(repoDir, ["worktree", "add", "-b", "overstory/agent-2/task-2", worktreeDir]);
770
+
771
+ // loadConfig from the worktree should resolve to the main project root
772
+ const config = await loadConfig(worktreeDir);
773
+ expect(config.project.root).toBe(repoDir);
774
+ expect(config.project.canonicalBranch).toBe("develop");
775
+ });
776
+ });
777
+
778
+ describe("DEFAULT_CONFIG", () => {
779
+ test("has all required top-level keys", () => {
780
+ expect(DEFAULT_CONFIG.project).toBeDefined();
781
+ expect(DEFAULT_CONFIG.agents).toBeDefined();
782
+ expect(DEFAULT_CONFIG.worktrees).toBeDefined();
783
+ expect(DEFAULT_CONFIG.taskTracker).toBeDefined();
784
+ expect(DEFAULT_CONFIG.mulch).toBeDefined();
785
+ expect(DEFAULT_CONFIG.merge).toBeDefined();
786
+ expect(DEFAULT_CONFIG.providers).toBeDefined();
787
+ expect(DEFAULT_CONFIG.watchdog).toBeDefined();
788
+ expect(DEFAULT_CONFIG.models).toBeDefined();
789
+ expect(DEFAULT_CONFIG.logging).toBeDefined();
790
+ });
791
+
792
+ test("has default providers with anthropic native", () => {
793
+ expect(DEFAULT_CONFIG.providers).toBeDefined();
794
+ expect(DEFAULT_CONFIG.providers.anthropic).toEqual({ type: "native" });
795
+ });
796
+
797
+ test("has sensible default values", () => {
798
+ expect(DEFAULT_CONFIG.project.canonicalBranch).toBe("main");
799
+ expect(DEFAULT_CONFIG.agents.maxConcurrent).toBe(25);
800
+ expect(DEFAULT_CONFIG.agents.maxDepth).toBe(2);
801
+ expect(DEFAULT_CONFIG.agents.staggerDelayMs).toBe(2_000);
802
+ expect(DEFAULT_CONFIG.agents.maxSessionsPerRun).toBe(0);
803
+ expect(DEFAULT_CONFIG.watchdog.tier0IntervalMs).toBe(30_000);
804
+ expect(DEFAULT_CONFIG.watchdog.staleThresholdMs).toBe(300_000);
805
+ expect(DEFAULT_CONFIG.watchdog.zombieThresholdMs).toBe(600_000);
806
+ });
807
+
808
+ test("includes default qualityGates", () => {
809
+ expect(DEFAULT_CONFIG.project.qualityGates).toBeDefined();
810
+ expect(DEFAULT_CONFIG.project.qualityGates?.length).toBe(3);
811
+ expect(DEFAULT_CONFIG.project.qualityGates?.[0]?.command).toBe("bun test");
812
+ expect(DEFAULT_CONFIG.project.qualityGates?.[1]?.command).toBe("bun run lint");
813
+ expect(DEFAULT_CONFIG.project.qualityGates?.[2]?.command).toBe("bun run typecheck");
814
+ });
815
+
816
+ test("DEFAULT_QUALITY_GATES matches the project default gates", () => {
817
+ expect(DEFAULT_QUALITY_GATES).toHaveLength(3);
818
+ expect(DEFAULT_QUALITY_GATES[0]?.name).toBe("Tests");
819
+ expect(DEFAULT_QUALITY_GATES[1]?.name).toBe("Lint");
820
+ expect(DEFAULT_QUALITY_GATES[2]?.name).toBe("Typecheck");
821
+ });
822
+ });