@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
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { AgentError } from "../errors.ts";
|
|
6
|
+
import type { AgentManifest, OverstoryConfig } from "../types.ts";
|
|
7
|
+
import { createManifestLoader, resolveModel, resolveProviderEnv } from "./manifest.ts";
|
|
8
|
+
|
|
9
|
+
const VALID_MANIFEST = {
|
|
10
|
+
version: "1.0",
|
|
11
|
+
agents: {
|
|
12
|
+
scout: {
|
|
13
|
+
file: "scout.md",
|
|
14
|
+
model: "sonnet",
|
|
15
|
+
tools: ["Read", "Grep", "Glob"],
|
|
16
|
+
capabilities: ["explore", "review"],
|
|
17
|
+
canSpawn: false,
|
|
18
|
+
constraints: ["read-only"],
|
|
19
|
+
},
|
|
20
|
+
builder: {
|
|
21
|
+
file: "builder.md",
|
|
22
|
+
model: "opus",
|
|
23
|
+
tools: ["Read", "Write", "Edit", "Bash"],
|
|
24
|
+
capabilities: ["implement", "refactor"],
|
|
25
|
+
canSpawn: false,
|
|
26
|
+
constraints: [],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("createManifestLoader", () => {
|
|
32
|
+
let tempDir: string;
|
|
33
|
+
let manifestPath: string;
|
|
34
|
+
let agentBaseDir: string;
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
tempDir = await mkdtemp(join(tmpdir(), "overstory-manifest-test-"));
|
|
38
|
+
manifestPath = join(tempDir, "agent-manifest.json");
|
|
39
|
+
agentBaseDir = join(tempDir, "agents");
|
|
40
|
+
await mkdir(agentBaseDir, { recursive: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Write the manifest JSON and create matching .md files. */
|
|
48
|
+
async function writeManifest(
|
|
49
|
+
data: Record<string, unknown>,
|
|
50
|
+
options?: { skipMdFiles?: boolean; mdFilesToSkip?: string[] },
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
await Bun.write(manifestPath, JSON.stringify(data));
|
|
53
|
+
if (options?.skipMdFiles) return;
|
|
54
|
+
|
|
55
|
+
const agents = data.agents;
|
|
56
|
+
if (agents && typeof agents === "object" && !Array.isArray(agents)) {
|
|
57
|
+
for (const [, def] of Object.entries(agents as Record<string, Record<string, unknown>>)) {
|
|
58
|
+
const file = def.file;
|
|
59
|
+
if (
|
|
60
|
+
typeof file === "string" &&
|
|
61
|
+
file.length > 0 &&
|
|
62
|
+
!options?.mdFilesToSkip?.includes(file)
|
|
63
|
+
) {
|
|
64
|
+
await Bun.write(join(agentBaseDir, file), `# ${file}\n`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("load", () => {
|
|
71
|
+
test("reads manifest, validates structure, verifies .md files, builds capability index", async () => {
|
|
72
|
+
await writeManifest(VALID_MANIFEST);
|
|
73
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
74
|
+
|
|
75
|
+
const manifest = await loader.load();
|
|
76
|
+
|
|
77
|
+
expect(manifest.version).toBe("1.0");
|
|
78
|
+
expect(Object.keys(manifest.agents)).toHaveLength(2);
|
|
79
|
+
expect(manifest.agents.scout).toBeDefined();
|
|
80
|
+
expect(manifest.agents.builder).toBeDefined();
|
|
81
|
+
expect(manifest.capabilityIndex).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("builds capability index mapping capabilities to agent names", async () => {
|
|
85
|
+
await writeManifest(VALID_MANIFEST);
|
|
86
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
87
|
+
|
|
88
|
+
const manifest = await loader.load();
|
|
89
|
+
|
|
90
|
+
expect(manifest.capabilityIndex.explore).toEqual(["scout"]);
|
|
91
|
+
expect(manifest.capabilityIndex.review).toEqual(["scout"]);
|
|
92
|
+
expect(manifest.capabilityIndex.implement).toEqual(["builder"]);
|
|
93
|
+
expect(manifest.capabilityIndex.refactor).toEqual(["builder"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("capability index includes multiple agents for shared capabilities", async () => {
|
|
97
|
+
const data = {
|
|
98
|
+
version: "1.0",
|
|
99
|
+
agents: {
|
|
100
|
+
scout: {
|
|
101
|
+
file: "scout.md",
|
|
102
|
+
model: "sonnet",
|
|
103
|
+
tools: ["Read"],
|
|
104
|
+
capabilities: ["review"],
|
|
105
|
+
canSpawn: false,
|
|
106
|
+
constraints: [],
|
|
107
|
+
},
|
|
108
|
+
reviewer: {
|
|
109
|
+
file: "reviewer.md",
|
|
110
|
+
model: "sonnet",
|
|
111
|
+
tools: ["Read"],
|
|
112
|
+
capabilities: ["review"],
|
|
113
|
+
canSpawn: false,
|
|
114
|
+
constraints: [],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
await writeManifest(data);
|
|
119
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
120
|
+
|
|
121
|
+
const manifest = await loader.load();
|
|
122
|
+
|
|
123
|
+
expect(manifest.capabilityIndex.review).toEqual(["scout", "reviewer"]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("getAgent", () => {
|
|
128
|
+
test("returns undefined before load is called", async () => {
|
|
129
|
+
await writeManifest(VALID_MANIFEST);
|
|
130
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
131
|
+
|
|
132
|
+
expect(loader.getAgent("scout")).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("returns agent definition after load", async () => {
|
|
136
|
+
await writeManifest(VALID_MANIFEST);
|
|
137
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
138
|
+
await loader.load();
|
|
139
|
+
|
|
140
|
+
const scout = loader.getAgent("scout");
|
|
141
|
+
expect(scout).toBeDefined();
|
|
142
|
+
expect(scout?.model).toBe("sonnet");
|
|
143
|
+
expect(scout?.tools).toEqual(["Read", "Grep", "Glob"]);
|
|
144
|
+
expect(scout?.capabilities).toEqual(["explore", "review"]);
|
|
145
|
+
expect(scout?.canSpawn).toBe(false);
|
|
146
|
+
expect(scout?.constraints).toEqual(["read-only"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("returns undefined for non-existent agent after load", async () => {
|
|
150
|
+
await writeManifest(VALID_MANIFEST);
|
|
151
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
152
|
+
await loader.load();
|
|
153
|
+
|
|
154
|
+
expect(loader.getAgent("nonexistent")).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("findByCapability", () => {
|
|
159
|
+
test("returns empty array before load is called", async () => {
|
|
160
|
+
await writeManifest(VALID_MANIFEST);
|
|
161
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
162
|
+
|
|
163
|
+
expect(loader.findByCapability("explore")).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("returns matching agents after load", async () => {
|
|
167
|
+
await writeManifest(VALID_MANIFEST);
|
|
168
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
169
|
+
await loader.load();
|
|
170
|
+
|
|
171
|
+
const explorers = loader.findByCapability("explore");
|
|
172
|
+
expect(explorers).toHaveLength(1);
|
|
173
|
+
expect(explorers[0]?.file).toBe("scout.md");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("returns empty array for non-existent capability", async () => {
|
|
177
|
+
await writeManifest(VALID_MANIFEST);
|
|
178
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
179
|
+
await loader.load();
|
|
180
|
+
|
|
181
|
+
expect(loader.findByCapability("nonexistent")).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("returns multiple agents sharing a capability", async () => {
|
|
185
|
+
const data = {
|
|
186
|
+
version: "1.0",
|
|
187
|
+
agents: {
|
|
188
|
+
scout: {
|
|
189
|
+
file: "scout.md",
|
|
190
|
+
model: "sonnet",
|
|
191
|
+
tools: ["Read"],
|
|
192
|
+
capabilities: ["review"],
|
|
193
|
+
canSpawn: false,
|
|
194
|
+
constraints: [],
|
|
195
|
+
},
|
|
196
|
+
reviewer: {
|
|
197
|
+
file: "reviewer.md",
|
|
198
|
+
model: "sonnet",
|
|
199
|
+
tools: ["Read"],
|
|
200
|
+
capabilities: ["review"],
|
|
201
|
+
canSpawn: false,
|
|
202
|
+
constraints: [],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
await writeManifest(data);
|
|
207
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
208
|
+
await loader.load();
|
|
209
|
+
|
|
210
|
+
const reviewers = loader.findByCapability("review");
|
|
211
|
+
expect(reviewers).toHaveLength(2);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("validate", () => {
|
|
216
|
+
test("returns error message if not loaded", () => {
|
|
217
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
218
|
+
|
|
219
|
+
const errors = loader.validate();
|
|
220
|
+
expect(errors).toHaveLength(1);
|
|
221
|
+
expect(errors[0]).toContain("not loaded");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("returns empty array for valid manifest", async () => {
|
|
225
|
+
await writeManifest(VALID_MANIFEST);
|
|
226
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
227
|
+
await loader.load();
|
|
228
|
+
|
|
229
|
+
const errors = loader.validate();
|
|
230
|
+
expect(errors).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("error handling", () => {
|
|
235
|
+
test("throws AgentError for missing manifest file", async () => {
|
|
236
|
+
const loader = createManifestLoader(join(tempDir, "nonexistent.json"), agentBaseDir);
|
|
237
|
+
|
|
238
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
239
|
+
await expect(loader.load()).rejects.toThrow("not found");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("throws AgentError for invalid JSON", async () => {
|
|
243
|
+
await Bun.write(manifestPath, "not valid json {{{");
|
|
244
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
245
|
+
|
|
246
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
247
|
+
await expect(loader.load()).rejects.toThrow("Failed to parse");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("throws AgentError for missing version field", async () => {
|
|
251
|
+
await Bun.write(manifestPath, JSON.stringify({ agents: {} }));
|
|
252
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
253
|
+
|
|
254
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
255
|
+
await expect(loader.load()).rejects.toThrow("version");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("throws AgentError for empty version string", async () => {
|
|
259
|
+
await Bun.write(manifestPath, JSON.stringify({ version: "", agents: {} }));
|
|
260
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
261
|
+
|
|
262
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
263
|
+
await expect(loader.load()).rejects.toThrow("version");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("throws AgentError when agents field is missing", async () => {
|
|
267
|
+
await Bun.write(manifestPath, JSON.stringify({ version: "1.0" }));
|
|
268
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
269
|
+
|
|
270
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
271
|
+
await expect(loader.load()).rejects.toThrow("agents");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("throws AgentError when agents field is an array", async () => {
|
|
275
|
+
await Bun.write(manifestPath, JSON.stringify({ version: "1.0", agents: [] }));
|
|
276
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
277
|
+
|
|
278
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
279
|
+
await expect(loader.load()).rejects.toThrow("agents");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("throws AgentError for empty model string", async () => {
|
|
283
|
+
const data = {
|
|
284
|
+
version: "1.0",
|
|
285
|
+
agents: {
|
|
286
|
+
bad: {
|
|
287
|
+
file: "bad.md",
|
|
288
|
+
model: "",
|
|
289
|
+
tools: ["Read"],
|
|
290
|
+
capabilities: ["test"],
|
|
291
|
+
canSpawn: false,
|
|
292
|
+
constraints: [],
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
await writeManifest(data);
|
|
297
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
298
|
+
|
|
299
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
300
|
+
await expect(loader.load()).rejects.toThrow("model");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("throws AgentError for missing tools array", async () => {
|
|
304
|
+
const data = {
|
|
305
|
+
version: "1.0",
|
|
306
|
+
agents: {
|
|
307
|
+
bad: {
|
|
308
|
+
file: "bad.md",
|
|
309
|
+
model: "sonnet",
|
|
310
|
+
capabilities: ["test"],
|
|
311
|
+
canSpawn: false,
|
|
312
|
+
constraints: [],
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
await writeManifest(data);
|
|
317
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
318
|
+
|
|
319
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
320
|
+
await expect(loader.load()).rejects.toThrow("tools");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("throws AgentError for missing capabilities array", async () => {
|
|
324
|
+
const data = {
|
|
325
|
+
version: "1.0",
|
|
326
|
+
agents: {
|
|
327
|
+
bad: {
|
|
328
|
+
file: "bad.md",
|
|
329
|
+
model: "sonnet",
|
|
330
|
+
tools: ["Read"],
|
|
331
|
+
canSpawn: false,
|
|
332
|
+
constraints: [],
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
await writeManifest(data);
|
|
337
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
338
|
+
|
|
339
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
340
|
+
await expect(loader.load()).rejects.toThrow("capabilities");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("throws AgentError when canSpawn is not boolean", async () => {
|
|
344
|
+
const data = {
|
|
345
|
+
version: "1.0",
|
|
346
|
+
agents: {
|
|
347
|
+
bad: {
|
|
348
|
+
file: "bad.md",
|
|
349
|
+
model: "sonnet",
|
|
350
|
+
tools: ["Read"],
|
|
351
|
+
capabilities: ["test"],
|
|
352
|
+
canSpawn: "yes",
|
|
353
|
+
constraints: [],
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
await writeManifest(data);
|
|
358
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
359
|
+
|
|
360
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
361
|
+
await expect(loader.load()).rejects.toThrow("canSpawn");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("throws AgentError when constraints is not an array", async () => {
|
|
365
|
+
const data = {
|
|
366
|
+
version: "1.0",
|
|
367
|
+
agents: {
|
|
368
|
+
bad: {
|
|
369
|
+
file: "bad.md",
|
|
370
|
+
model: "sonnet",
|
|
371
|
+
tools: ["Read"],
|
|
372
|
+
capabilities: ["test"],
|
|
373
|
+
canSpawn: false,
|
|
374
|
+
constraints: "read-only",
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
await writeManifest(data);
|
|
379
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
380
|
+
|
|
381
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
382
|
+
await expect(loader.load()).rejects.toThrow("constraints");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("throws AgentError when agent definition is not an object", async () => {
|
|
386
|
+
const data = {
|
|
387
|
+
version: "1.0",
|
|
388
|
+
agents: {
|
|
389
|
+
bad: "not an object",
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
await Bun.write(manifestPath, JSON.stringify(data));
|
|
393
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
394
|
+
|
|
395
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
396
|
+
await expect(loader.load()).rejects.toThrow("definition must be an object");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("throws AgentError when referenced .md file does not exist", async () => {
|
|
400
|
+
await writeManifest(VALID_MANIFEST, { mdFilesToSkip: ["scout.md"] });
|
|
401
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
402
|
+
|
|
403
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
404
|
+
await expect(loader.load()).rejects.toThrow("does not exist");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("throws AgentError when file field is empty string", async () => {
|
|
408
|
+
const data = {
|
|
409
|
+
version: "1.0",
|
|
410
|
+
agents: {
|
|
411
|
+
bad: {
|
|
412
|
+
file: "",
|
|
413
|
+
model: "sonnet",
|
|
414
|
+
tools: ["Read"],
|
|
415
|
+
capabilities: ["test"],
|
|
416
|
+
canSpawn: false,
|
|
417
|
+
constraints: [],
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
await writeManifest(data);
|
|
422
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
423
|
+
|
|
424
|
+
await expect(loader.load()).rejects.toThrow(AgentError);
|
|
425
|
+
await expect(loader.load()).rejects.toThrow("file");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("aggregates multiple validation errors", async () => {
|
|
429
|
+
const data = {
|
|
430
|
+
version: "1.0",
|
|
431
|
+
agents: {
|
|
432
|
+
bad: {
|
|
433
|
+
file: "",
|
|
434
|
+
model: "",
|
|
435
|
+
tools: "not-array",
|
|
436
|
+
capabilities: "not-array",
|
|
437
|
+
canSpawn: "not-bool",
|
|
438
|
+
constraints: "not-array",
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
await Bun.write(manifestPath, JSON.stringify(data));
|
|
443
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
await loader.load();
|
|
447
|
+
expect(true).toBe(false); // Should not reach here
|
|
448
|
+
} catch (err) {
|
|
449
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
450
|
+
const message = (err as AgentError).message;
|
|
451
|
+
expect(message).toContain("file");
|
|
452
|
+
expect(message).toContain("model");
|
|
453
|
+
expect(message).toContain("tools");
|
|
454
|
+
expect(message).toContain("capabilities");
|
|
455
|
+
expect(message).toContain("canSpawn");
|
|
456
|
+
expect(message).toContain("constraints");
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe("agent with all valid models", () => {
|
|
462
|
+
for (const model of ["sonnet", "opus", "haiku"] as const) {
|
|
463
|
+
test(`accepts model "${model}"`, async () => {
|
|
464
|
+
const data = {
|
|
465
|
+
version: "1.0",
|
|
466
|
+
agents: {
|
|
467
|
+
agent: {
|
|
468
|
+
file: "agent.md",
|
|
469
|
+
model,
|
|
470
|
+
tools: ["Read"],
|
|
471
|
+
capabilities: ["test"],
|
|
472
|
+
canSpawn: false,
|
|
473
|
+
constraints: [],
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
await writeManifest(data);
|
|
478
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
479
|
+
|
|
480
|
+
const manifest = await loader.load();
|
|
481
|
+
expect(manifest.agents.agent).toBeDefined();
|
|
482
|
+
expect(manifest.agents.agent?.model).toBe(model);
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
describe("resolveModel", () => {
|
|
489
|
+
const baseManifest: AgentManifest = {
|
|
490
|
+
version: "1.0",
|
|
491
|
+
agents: {
|
|
492
|
+
coordinator: {
|
|
493
|
+
file: "coordinator.md",
|
|
494
|
+
model: "opus",
|
|
495
|
+
tools: ["Read", "Bash"],
|
|
496
|
+
capabilities: ["coordinate"],
|
|
497
|
+
canSpawn: true,
|
|
498
|
+
constraints: [],
|
|
499
|
+
},
|
|
500
|
+
monitor: {
|
|
501
|
+
file: "monitor.md",
|
|
502
|
+
model: "sonnet",
|
|
503
|
+
tools: ["Read", "Bash"],
|
|
504
|
+
capabilities: ["monitor"],
|
|
505
|
+
canSpawn: false,
|
|
506
|
+
constraints: [],
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
capabilityIndex: { coordinate: ["coordinator"], monitor: ["monitor"] },
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
function makeConfig(
|
|
513
|
+
models: OverstoryConfig["models"] = {},
|
|
514
|
+
providers: OverstoryConfig["providers"] = { anthropic: { type: "native" } },
|
|
515
|
+
): OverstoryConfig {
|
|
516
|
+
return {
|
|
517
|
+
project: { name: "test", root: "/tmp/test", canonicalBranch: "main" },
|
|
518
|
+
agents: {
|
|
519
|
+
manifestPath: ".overstory/agent-manifest.json",
|
|
520
|
+
baseDir: ".overstory/agent-defs",
|
|
521
|
+
maxConcurrent: 5,
|
|
522
|
+
staggerDelayMs: 1000,
|
|
523
|
+
maxDepth: 2,
|
|
524
|
+
maxSessionsPerRun: 0,
|
|
525
|
+
},
|
|
526
|
+
worktrees: { baseDir: ".overstory/worktrees" },
|
|
527
|
+
taskTracker: { backend: "auto", enabled: false },
|
|
528
|
+
mulch: { enabled: false, domains: [], primeFormat: "markdown" },
|
|
529
|
+
merge: { aiResolveEnabled: false, reimagineEnabled: false },
|
|
530
|
+
providers,
|
|
531
|
+
watchdog: {
|
|
532
|
+
tier0Enabled: false,
|
|
533
|
+
tier0IntervalMs: 30000,
|
|
534
|
+
tier1Enabled: false,
|
|
535
|
+
tier2Enabled: false,
|
|
536
|
+
staleThresholdMs: 300000,
|
|
537
|
+
zombieThresholdMs: 600000,
|
|
538
|
+
nudgeIntervalMs: 60000,
|
|
539
|
+
},
|
|
540
|
+
models,
|
|
541
|
+
logging: { verbose: false, redactSecrets: true },
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
test("returns manifest model when no config override", () => {
|
|
546
|
+
const config = makeConfig();
|
|
547
|
+
expect(resolveModel(config, baseManifest, "coordinator", "haiku")).toEqual({ model: "opus" });
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("config override takes precedence over manifest", () => {
|
|
551
|
+
const config = makeConfig({ coordinator: "sonnet" });
|
|
552
|
+
expect(resolveModel(config, baseManifest, "coordinator", "haiku")).toEqual({
|
|
553
|
+
model: "sonnet",
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("falls back to default when role is not in manifest or config", () => {
|
|
558
|
+
const config = makeConfig();
|
|
559
|
+
expect(resolveModel(config, baseManifest, "unknown-role", "haiku")).toEqual({
|
|
560
|
+
model: "haiku",
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("config override works for roles not in manifest", () => {
|
|
565
|
+
const config = makeConfig({ supervisor: "opus" });
|
|
566
|
+
expect(resolveModel(config, baseManifest, "supervisor", "sonnet")).toEqual({ model: "opus" });
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("returns gateway env for provider-prefixed model", () => {
|
|
570
|
+
const config = makeConfig(
|
|
571
|
+
{ coordinator: "openrouter/openai/gpt-5.3" },
|
|
572
|
+
{
|
|
573
|
+
openrouter: {
|
|
574
|
+
type: "gateway",
|
|
575
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
576
|
+
authTokenEnv: "OPENROUTER_API_KEY",
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
);
|
|
580
|
+
const result = resolveModel(config, baseManifest, "coordinator", "opus");
|
|
581
|
+
expect(result).toEqual({
|
|
582
|
+
model: "sonnet",
|
|
583
|
+
env: {
|
|
584
|
+
ANTHROPIC_BASE_URL: "https://openrouter.ai/api/v1",
|
|
585
|
+
ANTHROPIC_API_KEY: "",
|
|
586
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: "openai/gpt-5.3",
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("includes auth token in env when env var is set", () => {
|
|
592
|
+
const config = makeConfig(
|
|
593
|
+
{ coordinator: "openrouter/openai/gpt-5.3" },
|
|
594
|
+
{
|
|
595
|
+
openrouter: {
|
|
596
|
+
type: "gateway",
|
|
597
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
598
|
+
authTokenEnv: "OPENROUTER_API_KEY",
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
);
|
|
602
|
+
const savedEnv = process.env.OPENROUTER_API_KEY;
|
|
603
|
+
process.env.OPENROUTER_API_KEY = "test-token-123";
|
|
604
|
+
try {
|
|
605
|
+
const result = resolveModel(config, baseManifest, "coordinator", "opus");
|
|
606
|
+
expect(result).toEqual({
|
|
607
|
+
model: "sonnet",
|
|
608
|
+
env: {
|
|
609
|
+
ANTHROPIC_BASE_URL: "https://openrouter.ai/api/v1",
|
|
610
|
+
ANTHROPIC_API_KEY: "",
|
|
611
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: "openai/gpt-5.3",
|
|
612
|
+
ANTHROPIC_AUTH_TOKEN: "test-token-123",
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
} finally {
|
|
616
|
+
if (savedEnv === undefined) {
|
|
617
|
+
delete process.env.OPENROUTER_API_KEY;
|
|
618
|
+
} else {
|
|
619
|
+
process.env.OPENROUTER_API_KEY = savedEnv;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("unknown provider falls through to model as-is", () => {
|
|
625
|
+
const config = makeConfig({ coordinator: "unknown-provider/some-model" });
|
|
626
|
+
const result = resolveModel(config, baseManifest, "coordinator", "opus");
|
|
627
|
+
expect(result).toEqual({ model: "unknown-provider/some-model" });
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("native provider returns model string without env", () => {
|
|
631
|
+
const config = makeConfig(
|
|
632
|
+
{ coordinator: "native-gw/claude-3-5-sonnet" },
|
|
633
|
+
{ "native-gw": { type: "native" } },
|
|
634
|
+
);
|
|
635
|
+
const result = resolveModel(config, baseManifest, "coordinator", "opus");
|
|
636
|
+
expect(result).toEqual({ model: "native-gw/claude-3-5-sonnet" });
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
describe("resolveProviderEnv", () => {
|
|
641
|
+
test("returns null for unknown provider", () => {
|
|
642
|
+
const result = resolveProviderEnv("unknown", "some/model", {});
|
|
643
|
+
expect(result).toBeNull();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("returns null for native provider type", () => {
|
|
647
|
+
const result = resolveProviderEnv("anthropic", "some/model", {
|
|
648
|
+
anthropic: { type: "native" },
|
|
649
|
+
});
|
|
650
|
+
expect(result).toBeNull();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("returns null for gateway without baseUrl", () => {
|
|
654
|
+
const result = resolveProviderEnv("gw", "some/model", {
|
|
655
|
+
gw: { type: "gateway" },
|
|
656
|
+
});
|
|
657
|
+
expect(result).toBeNull();
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test("returns env dict for gateway with baseUrl", () => {
|
|
661
|
+
const result = resolveProviderEnv("openrouter", "openai/gpt-5.3", {
|
|
662
|
+
openrouter: { type: "gateway", baseUrl: "https://openrouter.ai/api/v1" },
|
|
663
|
+
});
|
|
664
|
+
expect(result).toEqual({
|
|
665
|
+
ANTHROPIC_BASE_URL: "https://openrouter.ai/api/v1",
|
|
666
|
+
ANTHROPIC_API_KEY: "",
|
|
667
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: "openai/gpt-5.3",
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("includes auth token when env var is present", () => {
|
|
672
|
+
const result = resolveProviderEnv(
|
|
673
|
+
"openrouter",
|
|
674
|
+
"openai/gpt-5.3",
|
|
675
|
+
{
|
|
676
|
+
openrouter: {
|
|
677
|
+
type: "gateway",
|
|
678
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
679
|
+
authTokenEnv: "MY_TOKEN",
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
{ MY_TOKEN: "secret-token" },
|
|
683
|
+
);
|
|
684
|
+
expect(result).toEqual({
|
|
685
|
+
ANTHROPIC_BASE_URL: "https://openrouter.ai/api/v1",
|
|
686
|
+
ANTHROPIC_API_KEY: "",
|
|
687
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: "openai/gpt-5.3",
|
|
688
|
+
ANTHROPIC_AUTH_TOKEN: "secret-token",
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("omits auth token when env var is not set", () => {
|
|
693
|
+
const result = resolveProviderEnv(
|
|
694
|
+
"openrouter",
|
|
695
|
+
"openai/gpt-5.3",
|
|
696
|
+
{
|
|
697
|
+
openrouter: {
|
|
698
|
+
type: "gateway",
|
|
699
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
700
|
+
authTokenEnv: "MISSING_TOKEN",
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
{},
|
|
704
|
+
);
|
|
705
|
+
expect(result).not.toHaveProperty("ANTHROPIC_AUTH_TOKEN");
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
describe("manifest validation accepts arbitrary model strings", () => {
|
|
710
|
+
let tempDir: string;
|
|
711
|
+
let manifestPath: string;
|
|
712
|
+
let agentBaseDir: string;
|
|
713
|
+
|
|
714
|
+
beforeEach(async () => {
|
|
715
|
+
tempDir = await mkdtemp(join(tmpdir(), "overstory-model-test-"));
|
|
716
|
+
manifestPath = join(tempDir, "agent-manifest.json");
|
|
717
|
+
agentBaseDir = join(tempDir, "agents");
|
|
718
|
+
await mkdir(agentBaseDir, { recursive: true });
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
afterEach(async () => {
|
|
722
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("accepts provider-prefixed model string", async () => {
|
|
726
|
+
const data = {
|
|
727
|
+
version: "1.0",
|
|
728
|
+
agents: {
|
|
729
|
+
agent: {
|
|
730
|
+
file: "agent.md",
|
|
731
|
+
model: "openrouter/openai/gpt-5.3",
|
|
732
|
+
tools: ["Read"],
|
|
733
|
+
capabilities: ["test"],
|
|
734
|
+
canSpawn: false,
|
|
735
|
+
constraints: [],
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
};
|
|
739
|
+
await Bun.write(manifestPath, JSON.stringify(data));
|
|
740
|
+
await Bun.write(join(agentBaseDir, "agent.md"), "# Agent\n");
|
|
741
|
+
const loader = createManifestLoader(manifestPath, agentBaseDir);
|
|
742
|
+
|
|
743
|
+
const manifest = await loader.load();
|
|
744
|
+
expect(manifest.agents.agent?.model).toBe("openrouter/openai/gpt-5.3");
|
|
745
|
+
});
|
|
746
|
+
});
|