@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
package/src/config.ts ADDED
@@ -0,0 +1,829 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { ConfigError, ValidationError } from "./errors.ts";
3
+ import type { OverstoryConfig, QualityGate, TaskTrackerBackend } from "./types.ts";
4
+
5
+ /**
6
+ * Default configuration with all fields populated.
7
+ * Used as the base; file-loaded values are merged on top.
8
+ */
9
+ /** Default quality gates used when no qualityGates are configured in config.yaml. */
10
+ export const DEFAULT_QUALITY_GATES: QualityGate[] = [
11
+ { name: "Tests", command: "bun test", description: "all tests must pass" },
12
+ { name: "Lint", command: "bun run lint", description: "zero errors" },
13
+ { name: "Typecheck", command: "bun run typecheck", description: "no TypeScript errors" },
14
+ ];
15
+
16
+ export const DEFAULT_CONFIG: OverstoryConfig = {
17
+ project: {
18
+ name: "",
19
+ root: "",
20
+ canonicalBranch: "main",
21
+ qualityGates: DEFAULT_QUALITY_GATES,
22
+ },
23
+ agents: {
24
+ manifestPath: ".overstory/agent-manifest.json",
25
+ baseDir: ".overstory/agent-defs",
26
+ maxConcurrent: 25,
27
+ staggerDelayMs: 2_000,
28
+ maxDepth: 2,
29
+ maxSessionsPerRun: 0,
30
+ },
31
+ worktrees: {
32
+ baseDir: ".overstory/worktrees",
33
+ },
34
+ taskTracker: {
35
+ backend: "auto" as TaskTrackerBackend,
36
+ enabled: true,
37
+ },
38
+ mulch: {
39
+ enabled: true,
40
+ domains: [],
41
+ primeFormat: "markdown",
42
+ },
43
+ merge: {
44
+ aiResolveEnabled: true,
45
+ reimagineEnabled: false,
46
+ },
47
+ providers: {
48
+ anthropic: { type: "native" },
49
+ },
50
+ watchdog: {
51
+ tier0Enabled: true, // Tier 0: Mechanical daemon
52
+ tier0IntervalMs: 30_000,
53
+ tier1Enabled: false, // Tier 1: Triage agent (AI analysis)
54
+ tier2Enabled: false, // Tier 2: Monitor agent (continuous patrol)
55
+ staleThresholdMs: 300_000, // 5 minutes
56
+ zombieThresholdMs: 600_000, // 10 minutes
57
+ nudgeIntervalMs: 60_000, // 1 minute between progressive nudge stages
58
+ },
59
+ models: {},
60
+ logging: {
61
+ verbose: false,
62
+ redactSecrets: true,
63
+ },
64
+ };
65
+
66
+ const CONFIG_FILENAME = "config.yaml";
67
+ const CONFIG_LOCAL_FILENAME = "config.local.yaml";
68
+ const OVERSTORY_DIR = ".overstory";
69
+
70
+ /**
71
+ * Minimal YAML parser that handles the config structure.
72
+ *
73
+ * Supports:
74
+ * - Nested objects via indentation
75
+ * - String, number, boolean values
76
+ * - Arrays using `- item` syntax
77
+ * - Quoted strings (single and double)
78
+ * - Comments (lines starting with #)
79
+ * - Empty lines
80
+ *
81
+ * Does NOT support:
82
+ * - Flow mappings/sequences ({}, [])
83
+ * - Multi-line strings (|, >)
84
+ * - Anchors/aliases
85
+ * - Tags
86
+ */
87
+ function parseYaml(text: string): Record<string, unknown> {
88
+ const lines = text.split("\n");
89
+ const root: Record<string, unknown> = {};
90
+
91
+ // Stack tracks the current nesting context.
92
+ // Each entry: [indent level, parent object, current key for arrays]
93
+ const stack: Array<{
94
+ indent: number;
95
+ obj: Record<string, unknown>;
96
+ }> = [{ indent: -1, obj: root }];
97
+
98
+ for (let i = 0; i < lines.length; i++) {
99
+ const rawLine = lines[i];
100
+ if (rawLine === undefined) continue;
101
+
102
+ // Strip comments (but not inside quoted strings)
103
+ const commentFree = stripComment(rawLine);
104
+
105
+ // Skip empty lines and comment-only lines
106
+ const trimmed = commentFree.trimEnd();
107
+ if (trimmed.trim() === "") continue;
108
+
109
+ const indent = countIndent(trimmed);
110
+ const content = trimmed.trim();
111
+
112
+ // Pop stack to find the correct parent for this indent level
113
+ while (stack.length > 1) {
114
+ const top = stack[stack.length - 1];
115
+ if (top && top.indent >= indent) {
116
+ stack.pop();
117
+ } else {
118
+ break;
119
+ }
120
+ }
121
+
122
+ const parent = stack[stack.length - 1];
123
+ if (!parent) continue;
124
+
125
+ // Array item: "- value"
126
+ if (content.startsWith("- ")) {
127
+ const value = content.slice(2).trim();
128
+
129
+ // Detect object array item: "- key: val" where key is a plain identifier.
130
+ // Quoted scalars (starting with " or ') are not object items.
131
+ const objColonIdx = value.indexOf(":");
132
+ const isObjectItem =
133
+ objColonIdx > 0 &&
134
+ !value.startsWith('"') &&
135
+ !value.startsWith("'") &&
136
+ /^[\w-]+$/.test(value.slice(0, objColonIdx).trim());
137
+
138
+ if (isObjectItem) {
139
+ // Parse the first key:value pair of the new object item.
140
+ const itemKey = value.slice(0, objColonIdx).trim();
141
+ const itemVal = value.slice(objColonIdx + 1).trim();
142
+ const newItem: Record<string, unknown> = {};
143
+ if (itemVal !== "") {
144
+ newItem[itemKey] = parseValue(itemVal);
145
+ } else {
146
+ newItem[itemKey] = {};
147
+ }
148
+
149
+ // Find the array this item belongs to and push the new item.
150
+ // Case A: parent.obj already has an array as last value.
151
+ const lastKey = findLastKey(parent.obj);
152
+ if (lastKey !== null) {
153
+ const existing = parent.obj[lastKey];
154
+ if (Array.isArray(existing)) {
155
+ existing.push(newItem);
156
+ stack.push({ indent, obj: newItem });
157
+ continue;
158
+ }
159
+ }
160
+
161
+ // Case B: grandparent has an empty {} for this array's key — convert it.
162
+ if (stack.length >= 2) {
163
+ const grandparent = stack[stack.length - 2];
164
+ if (grandparent) {
165
+ const gpKey = findLastKey(grandparent.obj);
166
+ if (gpKey !== null) {
167
+ const gpVal = grandparent.obj[gpKey];
168
+ if (
169
+ gpVal !== null &&
170
+ gpVal !== undefined &&
171
+ typeof gpVal === "object" &&
172
+ !Array.isArray(gpVal) &&
173
+ Object.keys(gpVal as Record<string, unknown>).length === 0
174
+ ) {
175
+ const arr: unknown[] = [newItem];
176
+ grandparent.obj[gpKey] = arr;
177
+ // Pop the now-stale nested {} so the grandparent becomes parent.
178
+ stack.pop();
179
+ stack.push({ indent, obj: newItem });
180
+ continue;
181
+ }
182
+ }
183
+ }
184
+ }
185
+ continue;
186
+ }
187
+
188
+ // Scalar array item.
189
+ // Find the key this array belongs to.
190
+ // First check parent.obj directly (for inline arrays or subsequent items).
191
+ const lastKey = findLastKey(parent.obj);
192
+ if (lastKey !== null) {
193
+ const existing = parent.obj[lastKey];
194
+ if (Array.isArray(existing)) {
195
+ existing.push(parseValue(value));
196
+ continue;
197
+ }
198
+ }
199
+
200
+ // Multiline array case: `key:\n - item` pushes an empty {} onto the
201
+ // stack for the nested object. The `- ` item's parent is that empty {},
202
+ // which has no keys. We need to look one level up in the stack to find
203
+ // the key whose value is the empty {} and convert it to [].
204
+ if (stack.length >= 2) {
205
+ const grandparent = stack[stack.length - 2];
206
+ if (grandparent) {
207
+ const gpKey = findLastKey(grandparent.obj);
208
+ if (gpKey !== null) {
209
+ const gpVal = grandparent.obj[gpKey];
210
+ if (
211
+ gpVal !== null &&
212
+ gpVal !== undefined &&
213
+ typeof gpVal === "object" &&
214
+ !Array.isArray(gpVal) &&
215
+ Object.keys(gpVal as Record<string, unknown>).length === 0
216
+ ) {
217
+ // Convert {} to [] and push the first item.
218
+ const arr: unknown[] = [parseValue(value)];
219
+ grandparent.obj[gpKey] = arr;
220
+ // Pop the now-stale nested {} from the stack so subsequent
221
+ // `- ` items find the grandparent and the array directly.
222
+ stack.pop();
223
+ continue;
224
+ }
225
+ }
226
+ }
227
+ }
228
+ continue;
229
+ }
230
+
231
+ // Key: value pair
232
+ const colonIndex = content.indexOf(":");
233
+ if (colonIndex === -1) continue;
234
+
235
+ const key = content.slice(0, colonIndex).trim();
236
+ const rawValue = content.slice(colonIndex + 1).trim();
237
+
238
+ if (rawValue === "" || rawValue === undefined) {
239
+ // Nested object - create it and push onto stack
240
+ const nested: Record<string, unknown> = {};
241
+ parent.obj[key] = nested;
242
+ stack.push({ indent, obj: nested });
243
+ } else if (rawValue === "[]") {
244
+ // Empty array literal
245
+ parent.obj[key] = [];
246
+ } else {
247
+ parent.obj[key] = parseValue(rawValue);
248
+ }
249
+ }
250
+
251
+ return root;
252
+ }
253
+
254
+ /** Count leading spaces (tabs count as 2 spaces for indentation). */
255
+ function countIndent(line: string): number {
256
+ let count = 0;
257
+ for (const ch of line) {
258
+ if (ch === " ") count++;
259
+ else if (ch === "\t") count += 2;
260
+ else break;
261
+ }
262
+ return count;
263
+ }
264
+
265
+ /** Strip inline comments that are not inside quoted strings. */
266
+ function stripComment(line: string): string {
267
+ let inSingle = false;
268
+ let inDouble = false;
269
+ for (let i = 0; i < line.length; i++) {
270
+ const ch = line[i];
271
+ if (ch === "'" && !inDouble) inSingle = !inSingle;
272
+ else if (ch === '"' && !inSingle) inDouble = !inDouble;
273
+ else if (ch === "#" && !inSingle && !inDouble) {
274
+ // Ensure it's preceded by whitespace (YAML spec)
275
+ if (i === 0 || line[i - 1] === " " || line[i - 1] === "\t") {
276
+ return line.slice(0, i);
277
+ }
278
+ }
279
+ }
280
+ return line;
281
+ }
282
+
283
+ /** Parse a scalar YAML value into the appropriate JS type. */
284
+ function parseValue(raw: string): string | number | boolean | null {
285
+ // Quoted strings
286
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
287
+ return raw.slice(1, -1);
288
+ }
289
+
290
+ // Booleans
291
+ if (raw === "true" || raw === "True" || raw === "TRUE") return true;
292
+ if (raw === "false" || raw === "False" || raw === "FALSE") return false;
293
+
294
+ // Null
295
+ if (raw === "null" || raw === "~" || raw === "Null" || raw === "NULL") return null;
296
+
297
+ // Numbers
298
+ if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
299
+ if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
300
+ // Underscore-separated numbers (e.g., 30_000)
301
+ if (/^-?\d[\d_]*\d$/.test(raw)) return Number.parseInt(raw.replace(/_/g, ""), 10);
302
+
303
+ // Plain string
304
+ return raw;
305
+ }
306
+
307
+ /** Find the last key added to an object (insertion order). */
308
+ function findLastKey(obj: Record<string, unknown>): string | null {
309
+ const keys = Object.keys(obj);
310
+ return keys[keys.length - 1] ?? null;
311
+ }
312
+
313
+ /**
314
+ * Deep merge source into target. Source values override target values.
315
+ * Arrays from source replace (not append) target arrays.
316
+ */
317
+ function deepMerge(
318
+ target: Record<string, unknown>,
319
+ source: Record<string, unknown>,
320
+ ): Record<string, unknown> {
321
+ const result: Record<string, unknown> = { ...target };
322
+
323
+ for (const key of Object.keys(source)) {
324
+ const sourceVal = source[key];
325
+ const targetVal = result[key];
326
+
327
+ if (
328
+ sourceVal !== null &&
329
+ sourceVal !== undefined &&
330
+ typeof sourceVal === "object" &&
331
+ !Array.isArray(sourceVal) &&
332
+ targetVal !== null &&
333
+ targetVal !== undefined &&
334
+ typeof targetVal === "object" &&
335
+ !Array.isArray(targetVal)
336
+ ) {
337
+ result[key] = deepMerge(
338
+ targetVal as Record<string, unknown>,
339
+ sourceVal as Record<string, unknown>,
340
+ );
341
+ } else if (sourceVal !== undefined) {
342
+ result[key] = sourceVal;
343
+ }
344
+ }
345
+
346
+ return result;
347
+ }
348
+
349
+ /**
350
+ * Migrate deprecated watchdog tier key names in a parsed config object.
351
+ *
352
+ * Phase 4 renamed the watchdog tiers:
353
+ * - Old "tier1" (mechanical daemon) → New "tier0"
354
+ * - Old "tier2" (AI triage) → New "tier1"
355
+ *
356
+ * Detection heuristic: if `tier0Enabled` is absent but `tier1Enabled` is present,
357
+ * this is an old-style config. A new-style config would have `tier0Enabled`.
358
+ *
359
+ * If old key names are present and new key names are absent, this function
360
+ * copies the values to the new keys, removes the old keys (to prevent collision
361
+ * with the renamed tiers), and logs a deprecation warning.
362
+ *
363
+ * Mutates the parsed config object in place.
364
+ */
365
+ function migrateDeprecatedWatchdogKeys(parsed: Record<string, unknown>): void {
366
+ const watchdog = parsed.watchdog;
367
+ if (watchdog === null || watchdog === undefined || typeof watchdog !== "object") {
368
+ return;
369
+ }
370
+
371
+ const wd = watchdog as Record<string, unknown>;
372
+
373
+ // Detect old-style config: tier1Enabled present but tier0Enabled absent.
374
+ // In old naming, tier1 = mechanical daemon. In new naming, tier0 = mechanical daemon.
375
+ const isOldStyle = "tier1Enabled" in wd && !("tier0Enabled" in wd);
376
+
377
+ if (!isOldStyle) {
378
+ // New-style config or no tier keys at all — nothing to migrate
379
+ return;
380
+ }
381
+
382
+ // Old tier1Enabled → new tier0Enabled (mechanical daemon)
383
+ wd.tier0Enabled = wd.tier1Enabled;
384
+ wd.tier1Enabled = undefined;
385
+ process.stderr.write(
386
+ "[overstory] DEPRECATED: watchdog.tier1Enabled → use watchdog.tier0Enabled\n",
387
+ );
388
+
389
+ // Old tier1IntervalMs → new tier0IntervalMs (mechanical daemon)
390
+ if ("tier1IntervalMs" in wd) {
391
+ wd.tier0IntervalMs = wd.tier1IntervalMs;
392
+ wd.tier1IntervalMs = undefined;
393
+ process.stderr.write(
394
+ "[overstory] DEPRECATED: watchdog.tier1IntervalMs → use watchdog.tier0IntervalMs\n",
395
+ );
396
+ }
397
+
398
+ // Old tier2Enabled → new tier1Enabled (AI triage)
399
+ if ("tier2Enabled" in wd) {
400
+ wd.tier1Enabled = wd.tier2Enabled;
401
+ wd.tier2Enabled = undefined;
402
+ process.stderr.write(
403
+ "[overstory] DEPRECATED: watchdog.tier2Enabled → use watchdog.tier1Enabled\n",
404
+ );
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Migrate deprecated task tracker key names in a parsed config object.
410
+ *
411
+ * Handles legacy `beads:` and `seeds:` top-level keys, converting them to
412
+ * the unified `taskTracker:` section. If `taskTracker:` already exists, no
413
+ * migration is performed.
414
+ *
415
+ * Mutates the parsed config object in place.
416
+ */
417
+ function migrateDeprecatedTaskTrackerKeys(parsed: Record<string, unknown>): void {
418
+ if (parsed.taskTracker !== undefined) return; // Already migrated
419
+
420
+ if (parsed.beads !== undefined) {
421
+ const beadsConfig = parsed.beads as Record<string, unknown>;
422
+ parsed.taskTracker = {
423
+ backend: "beads",
424
+ enabled: beadsConfig.enabled ?? true,
425
+ };
426
+ delete parsed.beads;
427
+ process.stderr.write(
428
+ "[overstory] DEPRECATED: beads: -> use taskTracker: { backend: beads, enabled: true }\n",
429
+ );
430
+ } else if (parsed.seeds !== undefined) {
431
+ const seedsConfig = parsed.seeds as Record<string, unknown>;
432
+ parsed.taskTracker = {
433
+ backend: "seeds",
434
+ enabled: seedsConfig.enabled ?? true,
435
+ };
436
+ delete parsed.seeds;
437
+ process.stderr.write(
438
+ "[overstory] DEPRECATED: seeds: -> use taskTracker: { backend: seeds, enabled: true }\n",
439
+ );
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Validate that a config object has the required structure and sane values.
445
+ * Throws ValidationError on failure.
446
+ */
447
+ function validateConfig(config: OverstoryConfig): void {
448
+ // project.root is required and must be a non-empty string
449
+ if (!config.project.root || typeof config.project.root !== "string") {
450
+ throw new ValidationError("project.root is required and must be a non-empty string", {
451
+ field: "project.root",
452
+ value: config.project.root,
453
+ });
454
+ }
455
+
456
+ // project.canonicalBranch must be a non-empty string
457
+ if (!config.project.canonicalBranch || typeof config.project.canonicalBranch !== "string") {
458
+ throw new ValidationError(
459
+ "project.canonicalBranch is required and must be a non-empty string",
460
+ {
461
+ field: "project.canonicalBranch",
462
+ value: config.project.canonicalBranch,
463
+ },
464
+ );
465
+ }
466
+
467
+ // agents.maxConcurrent must be a positive integer
468
+ if (!Number.isInteger(config.agents.maxConcurrent) || config.agents.maxConcurrent < 1) {
469
+ throw new ValidationError("agents.maxConcurrent must be a positive integer", {
470
+ field: "agents.maxConcurrent",
471
+ value: config.agents.maxConcurrent,
472
+ });
473
+ }
474
+
475
+ // agents.maxDepth must be a non-negative integer
476
+ if (!Number.isInteger(config.agents.maxDepth) || config.agents.maxDepth < 0) {
477
+ throw new ValidationError("agents.maxDepth must be a non-negative integer", {
478
+ field: "agents.maxDepth",
479
+ value: config.agents.maxDepth,
480
+ });
481
+ }
482
+
483
+ // agents.staggerDelayMs must be non-negative
484
+ if (config.agents.staggerDelayMs < 0) {
485
+ throw new ValidationError("agents.staggerDelayMs must be non-negative", {
486
+ field: "agents.staggerDelayMs",
487
+ value: config.agents.staggerDelayMs,
488
+ });
489
+ }
490
+
491
+ // agents.maxSessionsPerRun must be a non-negative integer (0 = unlimited)
492
+ if (!Number.isInteger(config.agents.maxSessionsPerRun) || config.agents.maxSessionsPerRun < 0) {
493
+ throw new ValidationError(
494
+ "agents.maxSessionsPerRun must be a non-negative integer (0 = unlimited)",
495
+ {
496
+ field: "agents.maxSessionsPerRun",
497
+ value: config.agents.maxSessionsPerRun,
498
+ },
499
+ );
500
+ }
501
+
502
+ // watchdog intervals must be positive if enabled
503
+ if (config.watchdog.tier0Enabled && config.watchdog.tier0IntervalMs <= 0) {
504
+ throw new ValidationError("watchdog.tier0IntervalMs must be positive when tier0 is enabled", {
505
+ field: "watchdog.tier0IntervalMs",
506
+ value: config.watchdog.tier0IntervalMs,
507
+ });
508
+ }
509
+
510
+ if (config.watchdog.nudgeIntervalMs <= 0) {
511
+ throw new ValidationError("watchdog.nudgeIntervalMs must be positive", {
512
+ field: "watchdog.nudgeIntervalMs",
513
+ value: config.watchdog.nudgeIntervalMs,
514
+ });
515
+ }
516
+
517
+ if (config.watchdog.staleThresholdMs <= 0) {
518
+ throw new ValidationError("watchdog.staleThresholdMs must be positive", {
519
+ field: "watchdog.staleThresholdMs",
520
+ value: config.watchdog.staleThresholdMs,
521
+ });
522
+ }
523
+
524
+ if (config.watchdog.zombieThresholdMs <= config.watchdog.staleThresholdMs) {
525
+ throw new ValidationError("watchdog.zombieThresholdMs must be greater than staleThresholdMs", {
526
+ field: "watchdog.zombieThresholdMs",
527
+ value: config.watchdog.zombieThresholdMs,
528
+ });
529
+ }
530
+
531
+ // mulch.primeFormat must be one of the valid options
532
+ const validFormats = ["markdown", "xml", "json"] as const;
533
+ if (!validFormats.includes(config.mulch.primeFormat as (typeof validFormats)[number])) {
534
+ throw new ValidationError(`mulch.primeFormat must be one of: ${validFormats.join(", ")}`, {
535
+ field: "mulch.primeFormat",
536
+ value: config.mulch.primeFormat,
537
+ });
538
+ }
539
+
540
+ // taskTracker.backend must be one of the valid options
541
+ const validBackends = ["auto", "seeds", "beads"] as const;
542
+ if (!validBackends.includes(config.taskTracker.backend as (typeof validBackends)[number])) {
543
+ throw new ValidationError(`taskTracker.backend must be one of: ${validBackends.join(", ")}`, {
544
+ field: "taskTracker.backend",
545
+ value: config.taskTracker.backend,
546
+ });
547
+ }
548
+
549
+ // providers: validate each entry
550
+ const validProviderTypes = ["native", "gateway"];
551
+ for (const [name, provider] of Object.entries(config.providers)) {
552
+ const p = provider as unknown;
553
+ if (p === null || typeof p !== "object") {
554
+ throw new ValidationError(`providers.${name} must be an object`, {
555
+ field: `providers.${name}`,
556
+ value: p,
557
+ });
558
+ }
559
+ if (!validProviderTypes.includes(provider.type)) {
560
+ throw new ValidationError(
561
+ `providers.${name}.type must be one of: ${validProviderTypes.join(", ")}`,
562
+ {
563
+ field: `providers.${name}.type`,
564
+ value: provider.type,
565
+ },
566
+ );
567
+ }
568
+ if (provider.type === "gateway") {
569
+ if (!provider.baseUrl || typeof provider.baseUrl !== "string") {
570
+ throw new ValidationError(`providers.${name}.baseUrl is required for gateway providers`, {
571
+ field: `providers.${name}.baseUrl`,
572
+ value: provider.baseUrl,
573
+ });
574
+ }
575
+ if (!provider.authTokenEnv || typeof provider.authTokenEnv !== "string") {
576
+ throw new ValidationError(
577
+ `providers.${name}.authTokenEnv is required for gateway providers`,
578
+ {
579
+ field: `providers.${name}.authTokenEnv`,
580
+ value: provider.authTokenEnv,
581
+ },
582
+ );
583
+ }
584
+ }
585
+ }
586
+
587
+ // qualityGates: if present, validate each entry
588
+ if (config.project.qualityGates) {
589
+ for (let i = 0; i < config.project.qualityGates.length; i++) {
590
+ const gate = config.project.qualityGates[i];
591
+ if (!gate) continue;
592
+ if (!gate.name || typeof gate.name !== "string") {
593
+ throw new ValidationError(`project.qualityGates[${i}].name must be a non-empty string`, {
594
+ field: `project.qualityGates[${i}].name`,
595
+ value: gate.name,
596
+ });
597
+ }
598
+ if (!gate.command || typeof gate.command !== "string") {
599
+ throw new ValidationError(`project.qualityGates[${i}].command must be a non-empty string`, {
600
+ field: `project.qualityGates[${i}].command`,
601
+ value: gate.command,
602
+ });
603
+ }
604
+ if (!gate.description || typeof gate.description !== "string") {
605
+ throw new ValidationError(
606
+ `project.qualityGates[${i}].description must be a non-empty string`,
607
+ {
608
+ field: `project.qualityGates[${i}].description`,
609
+ value: gate.description,
610
+ },
611
+ );
612
+ }
613
+ }
614
+ }
615
+
616
+ // models: validate each value — accepts aliases and provider-prefixed refs
617
+ const validAliases = ["sonnet", "opus", "haiku"];
618
+ const toolHeavyRoles = ["builder", "scout"];
619
+ for (const [role, model] of Object.entries(config.models)) {
620
+ if (model === undefined) continue;
621
+ if (model.includes("/")) {
622
+ // Provider-prefixed ref: validate the provider name exists
623
+ const providerName = model.split("/")[0] ?? "";
624
+ if (!providerName || !(providerName in config.providers)) {
625
+ throw new ValidationError(
626
+ `models.${role} references unknown provider '${providerName}'. Add it to the providers section first.`,
627
+ {
628
+ field: `models.${role}`,
629
+ value: model,
630
+ },
631
+ );
632
+ }
633
+ if (toolHeavyRoles.includes(role)) {
634
+ process.stderr.write(
635
+ `[overstory] WARNING: models.${role} uses non-Anthropic model '${model}'. Tool-use compatibility cannot be verified at config time.\n`,
636
+ );
637
+ }
638
+ } else {
639
+ // Must be a valid alias
640
+ if (!validAliases.includes(model)) {
641
+ throw new ValidationError(
642
+ `models.${role} must be a valid alias (${validAliases.join(", ")}) or a provider-prefixed ref (e.g., openrouter/openai/gpt-4)`,
643
+ {
644
+ field: `models.${role}`,
645
+ value: model,
646
+ },
647
+ );
648
+ }
649
+ }
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Load and merge config.local.yaml on top of the current config.
655
+ *
656
+ * config.local.yaml is gitignored and provides machine-specific overrides
657
+ * (e.g., maxConcurrent for weaker hardware) without dirtying the worktree.
658
+ *
659
+ * Merge order: DEFAULT_CONFIG <- config.yaml <- config.local.yaml
660
+ */
661
+ async function mergeLocalConfig(
662
+ resolvedRoot: string,
663
+ config: OverstoryConfig,
664
+ ): Promise<OverstoryConfig> {
665
+ const localPath = join(resolvedRoot, OVERSTORY_DIR, CONFIG_LOCAL_FILENAME);
666
+ const localFile = Bun.file(localPath);
667
+
668
+ if (!(await localFile.exists())) {
669
+ return config;
670
+ }
671
+
672
+ let text: string;
673
+ try {
674
+ text = await localFile.text();
675
+ } catch (err) {
676
+ throw new ConfigError(`Failed to read local config file: ${localPath}`, {
677
+ configPath: localPath,
678
+ cause: err instanceof Error ? err : undefined,
679
+ });
680
+ }
681
+
682
+ let parsed: Record<string, unknown>;
683
+ try {
684
+ parsed = parseYaml(text);
685
+ } catch (err) {
686
+ throw new ConfigError(`Failed to parse YAML in local config file: ${localPath}`, {
687
+ configPath: localPath,
688
+ cause: err instanceof Error ? err : undefined,
689
+ });
690
+ }
691
+
692
+ migrateDeprecatedWatchdogKeys(parsed);
693
+ migrateDeprecatedTaskTrackerKeys(parsed);
694
+
695
+ return deepMerge(
696
+ config as unknown as Record<string, unknown>,
697
+ parsed,
698
+ ) as unknown as OverstoryConfig;
699
+ }
700
+
701
+ /**
702
+ * Resolve the actual project root, handling git worktrees.
703
+ *
704
+ * When running from inside a git worktree (e.g., an agent's worktree at
705
+ * `.overstory/worktrees/{name}/`), the passed directory won't contain
706
+ * `.overstory/config.yaml`. This function detects worktrees using
707
+ * `git rev-parse --git-common-dir` and resolves to the main repository root.
708
+ *
709
+ * @param startDir - The initial directory (usually process.cwd())
710
+ * @returns The resolved project root containing `.overstory/`
711
+ */
712
+ export async function resolveProjectRoot(startDir: string): Promise<string> {
713
+ const { existsSync } = require("node:fs") as typeof import("node:fs");
714
+
715
+ // Check git worktree FIRST. When running from an agent worktree
716
+ // (e.g., .overstory/worktrees/{name}/), the worktree may contain
717
+ // tracked copies of .overstory/config.yaml. We must resolve to the
718
+ // main repository root so runtime state (mail.db, metrics.db, etc.)
719
+ // is shared across all agents, not siloed per worktree.
720
+ try {
721
+ const proc = Bun.spawn(["git", "rev-parse", "--git-common-dir"], {
722
+ cwd: startDir,
723
+ stdout: "pipe",
724
+ stderr: "pipe",
725
+ });
726
+ const exitCode = await proc.exited;
727
+ if (exitCode === 0) {
728
+ const gitCommonDir = (await new Response(proc.stdout).text()).trim();
729
+ const absGitCommon = resolve(startDir, gitCommonDir);
730
+ // Main repo root is the parent of the .git directory
731
+ const mainRoot = dirname(absGitCommon);
732
+ // If mainRoot differs from startDir, we're in a worktree — resolve to canonical root
733
+ if (mainRoot !== startDir && existsSync(join(mainRoot, OVERSTORY_DIR, CONFIG_FILENAME))) {
734
+ return mainRoot;
735
+ }
736
+ }
737
+ } catch {
738
+ // git not available, fall through
739
+ }
740
+
741
+ // Not inside a worktree (or git not available).
742
+ // Check if .overstory/config.yaml exists at startDir.
743
+ if (existsSync(join(startDir, OVERSTORY_DIR, CONFIG_FILENAME))) {
744
+ return startDir;
745
+ }
746
+
747
+ // Fallback to the start directory
748
+ return startDir;
749
+ }
750
+
751
+ /**
752
+ * Load the overstory configuration for a project.
753
+ *
754
+ * Reads `.overstory/config.yaml` from the project root, parses it,
755
+ * merges with defaults, and validates the result.
756
+ *
757
+ * Automatically resolves the project root when running inside a git worktree.
758
+ *
759
+ * @param projectRoot - Absolute path to the target project root (or worktree)
760
+ * @returns Fully populated and validated OverstoryConfig
761
+ * @throws ConfigError if the file cannot be read or parsed
762
+ * @throws ValidationError if the merged config fails validation
763
+ */
764
+ export async function loadConfig(projectRoot: string): Promise<OverstoryConfig> {
765
+ // Resolve the actual project root (handles git worktrees)
766
+ const resolvedRoot = await resolveProjectRoot(projectRoot);
767
+
768
+ const configPath = join(resolvedRoot, OVERSTORY_DIR, CONFIG_FILENAME);
769
+
770
+ // Start with defaults, setting the project root
771
+ const defaults = structuredClone(DEFAULT_CONFIG);
772
+ defaults.project.root = resolvedRoot;
773
+ defaults.project.name = resolvedRoot.split("/").pop() ?? "unknown";
774
+
775
+ // Try to read the config file
776
+ const file = Bun.file(configPath);
777
+ const exists = await file.exists();
778
+
779
+ if (!exists) {
780
+ // No config file — use defaults, but still check for local overrides
781
+ let config = defaults;
782
+ config = await mergeLocalConfig(resolvedRoot, config);
783
+ config.project.root = resolvedRoot;
784
+ validateConfig(config);
785
+ return config;
786
+ }
787
+
788
+ let text: string;
789
+ try {
790
+ text = await file.text();
791
+ } catch (err) {
792
+ throw new ConfigError(`Failed to read config file: ${configPath}`, {
793
+ configPath,
794
+ cause: err instanceof Error ? err : undefined,
795
+ });
796
+ }
797
+
798
+ let parsed: Record<string, unknown>;
799
+ try {
800
+ parsed = parseYaml(text);
801
+ } catch (err) {
802
+ throw new ConfigError(`Failed to parse YAML in config file: ${configPath}`, {
803
+ configPath,
804
+ cause: err instanceof Error ? err : undefined,
805
+ });
806
+ }
807
+
808
+ // Backward compatibility: migrate deprecated watchdog tier key names.
809
+ // Old naming: tier1 = mechanical daemon, tier2 = AI triage
810
+ // New naming: tier0 = mechanical daemon, tier1 = AI triage, tier2 = monitor agent
811
+ migrateDeprecatedWatchdogKeys(parsed);
812
+ migrateDeprecatedTaskTrackerKeys(parsed);
813
+
814
+ // Deep merge parsed config over defaults
815
+ let merged = deepMerge(
816
+ defaults as unknown as Record<string, unknown>,
817
+ parsed,
818
+ ) as unknown as OverstoryConfig;
819
+
820
+ // Check for config.local.yaml (local overrides, gitignored)
821
+ merged = await mergeLocalConfig(resolvedRoot, merged);
822
+
823
+ // Ensure project.root is always set to the resolved project root
824
+ merged.project.root = resolvedRoot;
825
+
826
+ validateConfig(merged);
827
+
828
+ return merged;
829
+ }