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