@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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- 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
|
+
}
|