@os-eco/overstory-cli 0.6.9 → 0.6.10
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/README.md +17 -5
- package/agents/builder.md +6 -15
- package/agents/lead.md +4 -6
- package/agents/merger.md +5 -13
- package/agents/reviewer.md +2 -9
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +52 -0
- package/src/agents/hooks-deployer.ts +22 -7
- package/src/agents/overlay.test.ts +156 -1
- package/src/agents/overlay.ts +67 -7
- package/src/commands/completions.test.ts +8 -20
- package/src/commands/completions.ts +4 -2
- package/src/commands/doctor.ts +97 -48
- package/src/commands/ecosystem.ts +291 -0
- package/src/commands/feed.ts +2 -2
- package/src/commands/sling.ts +1 -1
- package/src/commands/upgrade.test.ts +46 -0
- package/src/commands/upgrade.ts +259 -0
- package/src/doctor/databases.test.ts +38 -0
- package/src/doctor/databases.ts +7 -10
- package/src/doctor/ecosystem.test.ts +307 -0
- package/src/doctor/ecosystem.ts +155 -0
- package/src/doctor/merge-queue.test.ts +98 -0
- package/src/doctor/merge-queue.ts +23 -0
- package/src/doctor/structure.test.ts +130 -1
- package/src/doctor/structure.ts +87 -1
- package/src/doctor/types.ts +5 -2
- package/src/index.ts +23 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ecosystem health checks.
|
|
5
|
+
*
|
|
6
|
+
* Validates that os-eco CLI tools (ml, sd, cn) are on PATH and report valid
|
|
7
|
+
* semver versions. Intentionally does NOT duplicate the availability checks in
|
|
8
|
+
* dependencies.ts — those confirm the binaries exist. These checks focus on
|
|
9
|
+
* whether the reported version string is parseable semver, and whether the
|
|
10
|
+
* tools are mutually compatible.
|
|
11
|
+
*
|
|
12
|
+
* Fix closures reinstall the relevant package via `bun install -g <pkg>`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A single os-eco ecosystem tool. */
|
|
16
|
+
interface EcosystemTool {
|
|
17
|
+
/** Human-readable tool name. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Primary binary to invoke for version check. */
|
|
20
|
+
bin: string;
|
|
21
|
+
/** npm package name for install / reinstall. */
|
|
22
|
+
pkg: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ECOSYSTEM_TOOLS: EcosystemTool[] = [
|
|
26
|
+
{ name: "mulch", bin: "ml", pkg: "@os-eco/mulch-cli" },
|
|
27
|
+
{ name: "seeds", bin: "sd", pkg: "@os-eco/seeds-cli" },
|
|
28
|
+
{ name: "canopy", bin: "cn", pkg: "@os-eco/canopy-cli" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** Spawner abstraction — injected in tests, uses Bun.spawn in production. */
|
|
32
|
+
export type Spawner = (
|
|
33
|
+
args: string[],
|
|
34
|
+
) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
|
35
|
+
|
|
36
|
+
async function defaultSpawner(
|
|
37
|
+
args: string[],
|
|
38
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
39
|
+
const proc = Bun.spawn(args, {
|
|
40
|
+
stdout: "pipe",
|
|
41
|
+
stderr: "pipe",
|
|
42
|
+
});
|
|
43
|
+
const exitCode = await proc.exited;
|
|
44
|
+
const stdout = await new Response(proc.stdout).text();
|
|
45
|
+
const stderr = await new Response(proc.stderr).text();
|
|
46
|
+
return { exitCode, stdout, stderr };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Loose semver extractor.
|
|
51
|
+
* Finds the first x.y.z (optionally x.y.z-pre or x.y.z+build) token in a string.
|
|
52
|
+
* Returns null when no valid semver token is found.
|
|
53
|
+
*/
|
|
54
|
+
export function parseSemver(output: string): string | null {
|
|
55
|
+
const match = /(\d+\.\d+\.\d+(?:[-.+][a-zA-Z0-9._-]*)?)/.exec(output);
|
|
56
|
+
return match?.[1] ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Internal result of probing a binary's version output. */
|
|
60
|
+
interface VersionProbeResult {
|
|
61
|
+
available: boolean;
|
|
62
|
+
version: string | null;
|
|
63
|
+
raw: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function probeVersion(bin: string, spawner: Spawner): Promise<VersionProbeResult> {
|
|
67
|
+
try {
|
|
68
|
+
const { exitCode, stdout, stderr } = await spawner([bin, "--version"]);
|
|
69
|
+
const raw = (stdout + stderr).trim();
|
|
70
|
+
if (exitCode !== 0) {
|
|
71
|
+
return { available: false, version: null, raw };
|
|
72
|
+
}
|
|
73
|
+
const version = parseSemver(raw);
|
|
74
|
+
return { available: true, version, raw };
|
|
75
|
+
} catch {
|
|
76
|
+
return { available: false, version: null, raw: "" };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build a DoctorCheck for a single ecosystem tool. */
|
|
81
|
+
function buildCheck(tool: EcosystemTool, probe: VersionProbeResult): DoctorCheck {
|
|
82
|
+
const { bin, pkg, name } = tool;
|
|
83
|
+
|
|
84
|
+
if (!probe.available) {
|
|
85
|
+
return {
|
|
86
|
+
name: `${name} semver`,
|
|
87
|
+
category: "ecosystem",
|
|
88
|
+
status: "warn",
|
|
89
|
+
message: `${bin} is not available — cannot verify version`,
|
|
90
|
+
details: [`Install: bun install -g ${pkg}`],
|
|
91
|
+
fixable: true,
|
|
92
|
+
fix: async () => {
|
|
93
|
+
const proc = Bun.spawn(["bun", "install", "-g", pkg], {
|
|
94
|
+
stdout: "inherit",
|
|
95
|
+
stderr: "inherit",
|
|
96
|
+
});
|
|
97
|
+
await proc.exited;
|
|
98
|
+
return [`Installed ${pkg}`];
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (probe.version === null) {
|
|
104
|
+
return {
|
|
105
|
+
name: `${name} semver`,
|
|
106
|
+
category: "ecosystem",
|
|
107
|
+
status: "warn",
|
|
108
|
+
message: `${bin} --version output is not parseable semver`,
|
|
109
|
+
details: [
|
|
110
|
+
`Raw output: ${probe.raw || "(empty)"}`,
|
|
111
|
+
"Expected format: x.y.z",
|
|
112
|
+
`Reinstall: bun install -g ${pkg}`,
|
|
113
|
+
],
|
|
114
|
+
fixable: true,
|
|
115
|
+
fix: async () => {
|
|
116
|
+
const proc = Bun.spawn(["bun", "install", "-g", pkg], {
|
|
117
|
+
stdout: "inherit",
|
|
118
|
+
stderr: "inherit",
|
|
119
|
+
});
|
|
120
|
+
await proc.exited;
|
|
121
|
+
return [`Reinstalled ${pkg}`];
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
name: `${name} semver`,
|
|
128
|
+
category: "ecosystem",
|
|
129
|
+
status: "pass",
|
|
130
|
+
message: `${name} v${probe.version} (valid semver)`,
|
|
131
|
+
details: [probe.raw],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Factory that creates a DoctorCheckFn with an injectable spawner.
|
|
137
|
+
* Used for testing without module-level mocks.
|
|
138
|
+
*/
|
|
139
|
+
export function makeCheckEcosystem(spawner: Spawner = defaultSpawner): DoctorCheckFn {
|
|
140
|
+
return async (_config, _overstoryDir): Promise<DoctorCheck[]> => {
|
|
141
|
+
const checks: DoctorCheck[] = [];
|
|
142
|
+
|
|
143
|
+
for (const tool of ECOSYSTEM_TOOLS) {
|
|
144
|
+
const probe = await probeVersion(tool.bin, spawner);
|
|
145
|
+
checks.push(buildCheck(tool, probe));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return checks;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Ecosystem health check — validates semver version output for ml, sd, cn.
|
|
154
|
+
*/
|
|
155
|
+
export const checkEcosystem: DoctorCheckFn = makeCheckEcosystem();
|
|
@@ -213,4 +213,102 @@ describe("checkMergeQueue", () => {
|
|
|
213
213
|
expect(duplicateCheck?.message).toContain("duplicate branch entries");
|
|
214
214
|
expect(duplicateCheck?.details?.[0]).toContain("feature/duplicate");
|
|
215
215
|
});
|
|
216
|
+
|
|
217
|
+
test("fix() deletes stale pending entries", () => {
|
|
218
|
+
const dbPath = join(tempDir, "merge-queue.db");
|
|
219
|
+
const queue = createMergeQueue(dbPath);
|
|
220
|
+
queue.close();
|
|
221
|
+
|
|
222
|
+
const staleDate = new Date();
|
|
223
|
+
staleDate.setDate(staleDate.getDate() - 2); // 2 days ago
|
|
224
|
+
|
|
225
|
+
const db = new Database(dbPath);
|
|
226
|
+
db.prepare(
|
|
227
|
+
"INSERT INTO merge_queue (branch_name, task_id, agent_name, files_modified, status, enqueued_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
228
|
+
).run(
|
|
229
|
+
"feature/stale-1",
|
|
230
|
+
"beads-abc",
|
|
231
|
+
"test-agent",
|
|
232
|
+
JSON.stringify(["src/test.ts"]),
|
|
233
|
+
"pending",
|
|
234
|
+
staleDate.toISOString(),
|
|
235
|
+
);
|
|
236
|
+
db.prepare(
|
|
237
|
+
"INSERT INTO merge_queue (branch_name, task_id, agent_name, files_modified, status, enqueued_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
238
|
+
).run(
|
|
239
|
+
"feature/stale-2",
|
|
240
|
+
"beads-def",
|
|
241
|
+
"test-agent",
|
|
242
|
+
JSON.stringify(["src/other.ts"]),
|
|
243
|
+
"merging",
|
|
244
|
+
staleDate.toISOString(),
|
|
245
|
+
);
|
|
246
|
+
db.close();
|
|
247
|
+
|
|
248
|
+
const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
|
|
249
|
+
|
|
250
|
+
const staleCheck = checks.find((c) => c?.name === "merge-queue.db staleness");
|
|
251
|
+
expect(staleCheck?.fix).toBeDefined();
|
|
252
|
+
|
|
253
|
+
const actions = staleCheck?.fix?.();
|
|
254
|
+
expect(Array.isArray(actions)).toBe(true);
|
|
255
|
+
const actionsArr = actions as string[];
|
|
256
|
+
expect(actionsArr.some((a) => a.includes("Deleted") && a.includes("stale"))).toBe(true);
|
|
257
|
+
|
|
258
|
+
// Verify entries were deleted
|
|
259
|
+
const verifyDb = new Database(dbPath);
|
|
260
|
+
const remaining = verifyDb
|
|
261
|
+
.prepare("SELECT COUNT(*) as count FROM merge_queue WHERE status IN ('pending', 'merging')")
|
|
262
|
+
.get() as { count: number };
|
|
263
|
+
verifyDb.close();
|
|
264
|
+
expect(remaining.count).toBe(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("fix() removes duplicate entries keeping newest", () => {
|
|
268
|
+
const dbPath = join(tempDir, "merge-queue.db");
|
|
269
|
+
const queue = createMergeQueue(dbPath);
|
|
270
|
+
queue.enqueue({
|
|
271
|
+
branchName: "feature/dup",
|
|
272
|
+
taskId: "beads-abc",
|
|
273
|
+
agentName: "agent-1",
|
|
274
|
+
filesModified: ["src/a.ts"],
|
|
275
|
+
});
|
|
276
|
+
queue.enqueue({
|
|
277
|
+
branchName: "feature/dup",
|
|
278
|
+
taskId: "beads-def",
|
|
279
|
+
agentName: "agent-2",
|
|
280
|
+
filesModified: ["src/b.ts"],
|
|
281
|
+
});
|
|
282
|
+
queue.enqueue({
|
|
283
|
+
branchName: "feature/other",
|
|
284
|
+
taskId: "beads-ghi",
|
|
285
|
+
agentName: "agent-3",
|
|
286
|
+
filesModified: ["src/c.ts"],
|
|
287
|
+
});
|
|
288
|
+
queue.close();
|
|
289
|
+
|
|
290
|
+
const checks = checkMergeQueue(mockConfig, tempDir) as DoctorCheck[];
|
|
291
|
+
|
|
292
|
+
const dupCheck = checks.find((c) => c?.name === "merge-queue.db duplicates");
|
|
293
|
+
expect(dupCheck?.fix).toBeDefined();
|
|
294
|
+
|
|
295
|
+
const actions = dupCheck?.fix?.();
|
|
296
|
+
expect(Array.isArray(actions)).toBe(true);
|
|
297
|
+
const actionsArr = actions as string[];
|
|
298
|
+
expect(actionsArr.some((a) => a.includes("Removed") && a.includes("duplicate"))).toBe(true);
|
|
299
|
+
|
|
300
|
+
// Verify only 2 entries remain (1 per branch)
|
|
301
|
+
const verifyDb = new Database(dbPath);
|
|
302
|
+
const remaining = verifyDb.prepare("SELECT COUNT(*) as count FROM merge_queue").get() as {
|
|
303
|
+
count: number;
|
|
304
|
+
};
|
|
305
|
+
// Check the newest entry for feature/dup is kept (highest id)
|
|
306
|
+
const dupEntries = verifyDb
|
|
307
|
+
.prepare("SELECT agent_name FROM merge_queue WHERE branch_name = 'feature/dup'")
|
|
308
|
+
.all() as Array<{ agent_name: string }>;
|
|
309
|
+
verifyDb.close();
|
|
310
|
+
expect(remaining.count).toBe(2);
|
|
311
|
+
expect(dupEntries).toHaveLength(1);
|
|
312
|
+
expect(dupEntries[0]?.agent_name).toBe("agent-2"); // newest entry kept
|
|
313
|
+
});
|
|
216
314
|
});
|
|
@@ -114,6 +114,18 @@ export const checkMergeQueue: DoctorCheckFn = (_config, overstoryDir): DoctorChe
|
|
|
114
114
|
message: `Found ${staleEntries.length} potentially stale queue entries`,
|
|
115
115
|
details: staleEntries,
|
|
116
116
|
fixable: true,
|
|
117
|
+
fix: () => {
|
|
118
|
+
const fixDb = new Database(dbPath);
|
|
119
|
+
fixDb.exec("PRAGMA busy_timeout=5000");
|
|
120
|
+
const staleThreshold = new Date(now.getTime() - staleThresholdMs).toISOString();
|
|
121
|
+
const result = fixDb
|
|
122
|
+
.prepare(
|
|
123
|
+
"DELETE FROM merge_queue WHERE status IN ('pending', 'merging') AND enqueued_at < ?",
|
|
124
|
+
)
|
|
125
|
+
.run(staleThreshold);
|
|
126
|
+
fixDb.close();
|
|
127
|
+
return [`Deleted ${result.changes} stale merge queue entries`];
|
|
128
|
+
},
|
|
117
129
|
});
|
|
118
130
|
}
|
|
119
131
|
|
|
@@ -134,6 +146,17 @@ export const checkMergeQueue: DoctorCheckFn = (_config, overstoryDir): DoctorChe
|
|
|
134
146
|
message: "Found duplicate branch entries in queue",
|
|
135
147
|
details: duplicates,
|
|
136
148
|
fixable: true,
|
|
149
|
+
fix: () => {
|
|
150
|
+
const fixDb = new Database(dbPath);
|
|
151
|
+
fixDb.exec("PRAGMA busy_timeout=5000");
|
|
152
|
+
const result = fixDb
|
|
153
|
+
.prepare(
|
|
154
|
+
"DELETE FROM merge_queue WHERE id NOT IN (SELECT MAX(id) FROM merge_queue GROUP BY branch_name)",
|
|
155
|
+
)
|
|
156
|
+
.run();
|
|
157
|
+
fixDb.close();
|
|
158
|
+
return [`Removed ${result.changes} duplicate merge queue entries`];
|
|
159
|
+
},
|
|
137
160
|
});
|
|
138
161
|
}
|
|
139
162
|
} finally {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
-
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
9
|
+
import { mkdir, mkdtemp, rm, utimes } from "node:fs/promises";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import type { OverstoryConfig } from "../types.ts";
|
|
@@ -288,4 +288,133 @@ describe("checkStructure", () => {
|
|
|
288
288
|
expect(tempFilesCheck).toBeDefined();
|
|
289
289
|
expect(tempFilesCheck?.status).toBe("pass");
|
|
290
290
|
});
|
|
291
|
+
|
|
292
|
+
test("fix() creates missing subdirectories", async () => {
|
|
293
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
294
|
+
|
|
295
|
+
const checks = await checkStructure(mockConfig, overstoryDir);
|
|
296
|
+
|
|
297
|
+
const dirsCheck = checks.find((c) => c.name === "Required subdirectories");
|
|
298
|
+
expect(dirsCheck?.status).toBe("fail");
|
|
299
|
+
expect(dirsCheck?.fix).toBeDefined();
|
|
300
|
+
|
|
301
|
+
const actions = await dirsCheck?.fix?.();
|
|
302
|
+
expect(actions).toBeDefined();
|
|
303
|
+
expect(actions?.length).toBeGreaterThan(0);
|
|
304
|
+
expect(actions?.some((a) => a.includes("agents/"))).toBe(true);
|
|
305
|
+
expect(actions?.some((a) => a.includes("worktrees/"))).toBe(true);
|
|
306
|
+
expect(actions?.some((a) => a.includes("specs/"))).toBe(true);
|
|
307
|
+
expect(actions?.some((a) => a.includes("logs/"))).toBe(true);
|
|
308
|
+
|
|
309
|
+
// Verify directories were actually created
|
|
310
|
+
const { stat: fsStat } = await import("node:fs/promises");
|
|
311
|
+
const agentsStat = await fsStat(join(overstoryDir, "agents"));
|
|
312
|
+
expect(agentsStat.isDirectory()).toBe(true);
|
|
313
|
+
const worktreesStat = await fsStat(join(overstoryDir, "worktrees"));
|
|
314
|
+
expect(worktreesStat.isDirectory()).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("fix() appends missing .gitignore entries", async () => {
|
|
318
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
319
|
+
await Bun.write(join(overstoryDir, ".gitignore"), `*\n!.gitignore\n!config.yaml\n`);
|
|
320
|
+
|
|
321
|
+
const checks = await checkStructure(mockConfig, overstoryDir);
|
|
322
|
+
|
|
323
|
+
const gitignoreCheck = checks.find((c) => c.name === ".gitignore entries");
|
|
324
|
+
expect(gitignoreCheck?.status).toBe("warn");
|
|
325
|
+
expect(gitignoreCheck?.fix).toBeDefined();
|
|
326
|
+
|
|
327
|
+
const actions = await gitignoreCheck?.fix?.();
|
|
328
|
+
expect(actions).toBeDefined();
|
|
329
|
+
expect(actions?.length).toBeGreaterThan(0);
|
|
330
|
+
expect(actions?.some((a) => a.includes("!agent-manifest.json"))).toBe(true);
|
|
331
|
+
|
|
332
|
+
// Verify entries were appended
|
|
333
|
+
const content = await Bun.file(join(overstoryDir, ".gitignore")).text();
|
|
334
|
+
expect(content).toContain("!agent-manifest.json");
|
|
335
|
+
expect(content).toContain("!hooks.json");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("fix() removes leftover temp files", async () => {
|
|
339
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
340
|
+
const tmpFile = join(overstoryDir, "config.yaml.tmp");
|
|
341
|
+
const bakFile = join(overstoryDir, "old.bak");
|
|
342
|
+
await Bun.write(tmpFile, "temp content");
|
|
343
|
+
await Bun.write(bakFile, "backup content");
|
|
344
|
+
|
|
345
|
+
const checks = await checkStructure(mockConfig, overstoryDir);
|
|
346
|
+
|
|
347
|
+
const tempCheck = checks.find((c) => c.name === "Leftover temp files");
|
|
348
|
+
expect(tempCheck?.status).toBe("warn");
|
|
349
|
+
expect(tempCheck?.fix).toBeDefined();
|
|
350
|
+
|
|
351
|
+
const actions = await tempCheck?.fix?.();
|
|
352
|
+
expect(actions).toBeDefined();
|
|
353
|
+
expect(actions?.some((a) => a.includes("config.yaml.tmp"))).toBe(true);
|
|
354
|
+
expect(actions?.some((a) => a.includes("old.bak"))).toBe(true);
|
|
355
|
+
|
|
356
|
+
// Verify files were deleted
|
|
357
|
+
expect(await Bun.file(tmpFile).exists()).toBe(false);
|
|
358
|
+
expect(await Bun.file(bakFile).exists()).toBe(false);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("passes when no stale lock files exist", async () => {
|
|
362
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
363
|
+
|
|
364
|
+
const checks = await checkStructure(mockConfig, overstoryDir);
|
|
365
|
+
|
|
366
|
+
const lockCheck = checks.find((c) => c.name === "Stale lock files");
|
|
367
|
+
expect(lockCheck).toBeDefined();
|
|
368
|
+
expect(lockCheck?.status).toBe("pass");
|
|
369
|
+
expect(lockCheck?.fix).toBeUndefined();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("warns when stale lock files exist", async () => {
|
|
373
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
374
|
+
const lockFile = join(overstoryDir, "mail.lock");
|
|
375
|
+
await Bun.write(lockFile, "locked");
|
|
376
|
+
// Set mtime to 10 minutes ago
|
|
377
|
+
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
|
|
378
|
+
await utimes(lockFile, tenMinutesAgo, tenMinutesAgo);
|
|
379
|
+
|
|
380
|
+
const checks = await checkStructure(mockConfig, overstoryDir);
|
|
381
|
+
|
|
382
|
+
const lockCheck = checks.find((c) => c.name === "Stale lock files");
|
|
383
|
+
expect(lockCheck).toBeDefined();
|
|
384
|
+
expect(lockCheck?.status).toBe("warn");
|
|
385
|
+
expect(lockCheck?.details).toContain("mail.lock");
|
|
386
|
+
expect(lockCheck?.fixable).toBe(true);
|
|
387
|
+
expect(lockCheck?.fix).toBeDefined();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("does not warn about fresh lock files", async () => {
|
|
391
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
392
|
+
// Write a fresh lock file (just created = now)
|
|
393
|
+
await Bun.write(join(overstoryDir, "sessions.lock"), "locked");
|
|
394
|
+
|
|
395
|
+
const checks = await checkStructure(mockConfig, overstoryDir);
|
|
396
|
+
|
|
397
|
+
const lockCheck = checks.find((c) => c.name === "Stale lock files");
|
|
398
|
+
expect(lockCheck?.status).toBe("pass");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("fix() removes stale lock files", async () => {
|
|
402
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
403
|
+
const lockFile = join(overstoryDir, "stale.lock");
|
|
404
|
+
await Bun.write(lockFile, "locked");
|
|
405
|
+
// Set mtime to 10 minutes ago
|
|
406
|
+
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
|
|
407
|
+
await utimes(lockFile, tenMinutesAgo, tenMinutesAgo);
|
|
408
|
+
|
|
409
|
+
const checks = await checkStructure(mockConfig, overstoryDir);
|
|
410
|
+
|
|
411
|
+
const lockCheck = checks.find((c) => c.name === "Stale lock files");
|
|
412
|
+
expect(lockCheck?.fix).toBeDefined();
|
|
413
|
+
|
|
414
|
+
const actions = await lockCheck?.fix?.();
|
|
415
|
+
expect(actions?.some((a) => a.includes("stale.lock"))).toBe(true);
|
|
416
|
+
|
|
417
|
+
// Verify the lock file was removed
|
|
418
|
+
expect(await Bun.file(lockFile).exists()).toBe(false);
|
|
419
|
+
});
|
|
291
420
|
});
|
package/src/doctor/structure.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, constants } from "node:fs/promises";
|
|
1
|
+
import { access, constants, mkdir, rm, stat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { AgentManifest } from "../types.ts";
|
|
4
4
|
import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
|
|
@@ -87,6 +87,18 @@ export const checkStructure: DoctorCheckFn = async (
|
|
|
87
87
|
: `Missing ${missingDirs.length} subdirectory(ies)`,
|
|
88
88
|
details: missingDirs.length > 0 ? missingDirs : undefined,
|
|
89
89
|
fixable: missingDirs.length > 0,
|
|
90
|
+
fix:
|
|
91
|
+
missingDirs.length > 0
|
|
92
|
+
? async () => {
|
|
93
|
+
const actions: string[] = [];
|
|
94
|
+
for (const dir of missingDirs) {
|
|
95
|
+
const dirPath = join(overstoryDir, dir.replace(/\/$/, ""));
|
|
96
|
+
await mkdir(dirPath, { recursive: true });
|
|
97
|
+
actions.push(`Created missing directory: ${dir}`);
|
|
98
|
+
}
|
|
99
|
+
return actions;
|
|
100
|
+
}
|
|
101
|
+
: undefined,
|
|
90
102
|
});
|
|
91
103
|
|
|
92
104
|
// Check 4: .gitignore contents — validate wildcard+whitelist model
|
|
@@ -115,6 +127,19 @@ export const checkStructure: DoctorCheckFn = async (
|
|
|
115
127
|
: `Missing ${missingEntries.length} entry(ies)`,
|
|
116
128
|
details: missingEntries.length > 0 ? missingEntries : undefined,
|
|
117
129
|
fixable: missingEntries.length > 0,
|
|
130
|
+
fix:
|
|
131
|
+
missingEntries.length > 0
|
|
132
|
+
? async () => {
|
|
133
|
+
const actions: string[] = [];
|
|
134
|
+
const content = await Bun.file(gitignorePath).text();
|
|
135
|
+
const suffix = content.endsWith("\n") ? "" : "\n";
|
|
136
|
+
await Bun.write(gitignorePath, `${content + suffix + missingEntries.join("\n")}\n`);
|
|
137
|
+
for (const entry of missingEntries) {
|
|
138
|
+
actions.push(`Added .gitignore entry: ${entry}`);
|
|
139
|
+
}
|
|
140
|
+
return actions;
|
|
141
|
+
}
|
|
142
|
+
: undefined,
|
|
118
143
|
});
|
|
119
144
|
} catch {
|
|
120
145
|
// .gitignore doesn't exist, already reported in required files check
|
|
@@ -189,10 +214,71 @@ export const checkStructure: DoctorCheckFn = async (
|
|
|
189
214
|
tempFiles.length === 0 ? "No temp files found" : `Found ${tempFiles.length} temp file(s)`,
|
|
190
215
|
details: tempFiles.length > 0 ? tempFiles : undefined,
|
|
191
216
|
fixable: tempFiles.length > 0,
|
|
217
|
+
fix:
|
|
218
|
+
tempFiles.length > 0
|
|
219
|
+
? async () => {
|
|
220
|
+
const actions: string[] = [];
|
|
221
|
+
for (const file of tempFiles) {
|
|
222
|
+
await rm(join(overstoryDir, file), { force: true });
|
|
223
|
+
actions.push(`Removed temp file: ${file}`);
|
|
224
|
+
}
|
|
225
|
+
return actions;
|
|
226
|
+
}
|
|
227
|
+
: undefined,
|
|
192
228
|
});
|
|
193
229
|
} catch {
|
|
194
230
|
// Ignore errors scanning for temp files
|
|
195
231
|
}
|
|
196
232
|
|
|
233
|
+
// Check 7: Stale lock files (older than 5 minutes)
|
|
234
|
+
try {
|
|
235
|
+
const lockEntries = await Array.fromAsync(new Bun.Glob("*.lock").scan({ cwd: overstoryDir }));
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
const staleLockThresholdMs = 5 * 60 * 1000;
|
|
238
|
+
const staleLockFiles: string[] = [];
|
|
239
|
+
|
|
240
|
+
for (const lockFile of lockEntries) {
|
|
241
|
+
try {
|
|
242
|
+
const lockPath = join(overstoryDir, lockFile);
|
|
243
|
+
const stats = await stat(lockPath);
|
|
244
|
+
const ageMs = now - stats.mtimeMs;
|
|
245
|
+
if (ageMs > staleLockThresholdMs) {
|
|
246
|
+
staleLockFiles.push(lockFile);
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
// ignore stat errors
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
checks.push({
|
|
254
|
+
name: "Stale lock files",
|
|
255
|
+
category: "structure",
|
|
256
|
+
status: staleLockFiles.length === 0 ? "pass" : "warn",
|
|
257
|
+
message:
|
|
258
|
+
staleLockFiles.length === 0
|
|
259
|
+
? "No stale lock files found"
|
|
260
|
+
: `Found ${staleLockFiles.length} stale lock file(s)`,
|
|
261
|
+
details: staleLockFiles.length > 0 ? staleLockFiles : undefined,
|
|
262
|
+
fixable: staleLockFiles.length > 0,
|
|
263
|
+
fix:
|
|
264
|
+
staleLockFiles.length > 0
|
|
265
|
+
? async () => {
|
|
266
|
+
const actions: string[] = [];
|
|
267
|
+
for (const file of staleLockFiles) {
|
|
268
|
+
try {
|
|
269
|
+
await rm(join(overstoryDir, file), { force: true });
|
|
270
|
+
actions.push(`Removed stale lock file: ${file}`);
|
|
271
|
+
} catch {
|
|
272
|
+
// ignore removal errors
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return actions;
|
|
276
|
+
}
|
|
277
|
+
: undefined,
|
|
278
|
+
});
|
|
279
|
+
} catch {
|
|
280
|
+
// ignore errors scanning for lock files
|
|
281
|
+
}
|
|
282
|
+
|
|
197
283
|
return checks;
|
|
198
284
|
};
|
package/src/doctor/types.ts
CHANGED
|
@@ -12,7 +12,8 @@ export type DoctorCategory =
|
|
|
12
12
|
| "agents"
|
|
13
13
|
| "merge"
|
|
14
14
|
| "logs"
|
|
15
|
-
| "version"
|
|
15
|
+
| "version"
|
|
16
|
+
| "ecosystem";
|
|
16
17
|
|
|
17
18
|
/** Result of a single doctor health check. */
|
|
18
19
|
export interface DoctorCheck {
|
|
@@ -21,8 +22,10 @@ export interface DoctorCheck {
|
|
|
21
22
|
status: "pass" | "warn" | "fail";
|
|
22
23
|
message: string;
|
|
23
24
|
details?: string[];
|
|
24
|
-
/** Whether this check issues can be auto-fixed
|
|
25
|
+
/** Whether this check issues can be auto-fixed via --fix. */
|
|
25
26
|
fixable?: boolean;
|
|
27
|
+
/** Auto-fix closure — called when --fix flag is passed. Captures context at construction time. */
|
|
28
|
+
fix?: () => Promise<string[]> | string[];
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/**
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { createCoordinatorCommand } from "./commands/coordinator.ts";
|
|
|
15
15
|
import { createCostsCommand } from "./commands/costs.ts";
|
|
16
16
|
import { createDashboardCommand } from "./commands/dashboard.ts";
|
|
17
17
|
import { createDoctorCommand } from "./commands/doctor.ts";
|
|
18
|
+
import { createEcosystemCommand } from "./commands/ecosystem.ts";
|
|
18
19
|
import { createErrorsCommand } from "./commands/errors.ts";
|
|
19
20
|
import { createFeedCommand } from "./commands/feed.ts";
|
|
20
21
|
import { createGroupCommand } from "./commands/group.ts";
|
|
@@ -37,13 +38,14 @@ import { createStatusCommand } from "./commands/status.ts";
|
|
|
37
38
|
import { stopCommand } from "./commands/stop.ts";
|
|
38
39
|
import { createSupervisorCommand } from "./commands/supervisor.ts";
|
|
39
40
|
import { traceCommand } from "./commands/trace.ts";
|
|
41
|
+
import { createUpgradeCommand } from "./commands/upgrade.ts";
|
|
40
42
|
import { createWatchCommand } from "./commands/watch.ts";
|
|
41
43
|
import { createWorktreeCommand } from "./commands/worktree.ts";
|
|
42
44
|
import { OverstoryError, WorktreeError } from "./errors.ts";
|
|
43
45
|
import { jsonError } from "./json.ts";
|
|
44
46
|
import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
|
|
45
47
|
|
|
46
|
-
export const VERSION = "0.6.
|
|
48
|
+
export const VERSION = "0.6.10";
|
|
47
49
|
|
|
48
50
|
const rawArgs = process.argv.slice(2);
|
|
49
51
|
|
|
@@ -86,12 +88,14 @@ const COMMANDS = [
|
|
|
86
88
|
"logs",
|
|
87
89
|
"watch",
|
|
88
90
|
"trace",
|
|
91
|
+
"ecosystem",
|
|
89
92
|
"feed",
|
|
90
93
|
"errors",
|
|
91
94
|
"replay",
|
|
92
95
|
"run",
|
|
93
96
|
"costs",
|
|
94
97
|
"metrics",
|
|
98
|
+
"upgrade",
|
|
95
99
|
];
|
|
96
100
|
|
|
97
101
|
function editDistance(a: string, b: string): number {
|
|
@@ -129,6 +133,8 @@ function suggestCommand(input: string): string | undefined {
|
|
|
129
133
|
|
|
130
134
|
const program = new Command();
|
|
131
135
|
|
|
136
|
+
let timingStart: number | undefined;
|
|
137
|
+
|
|
132
138
|
program
|
|
133
139
|
.name("ov")
|
|
134
140
|
.description("Multi-agent orchestration for Claude Code")
|
|
@@ -136,6 +142,7 @@ program
|
|
|
136
142
|
.option("-q, --quiet", "Suppress non-error output")
|
|
137
143
|
.option("--json", "JSON output")
|
|
138
144
|
.option("--verbose", "Verbose output")
|
|
145
|
+
.option("--timing", "Print command execution time to stderr")
|
|
139
146
|
.addHelpCommand(false)
|
|
140
147
|
.configureHelp({
|
|
141
148
|
formatHelp(cmd, helper): string {
|
|
@@ -191,6 +198,17 @@ program.hook("preAction", (thisCmd) => {
|
|
|
191
198
|
if (opts.quiet) {
|
|
192
199
|
setQuiet(true);
|
|
193
200
|
}
|
|
201
|
+
if (opts.timing) {
|
|
202
|
+
timingStart = performance.now();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
program.hook("postAction", () => {
|
|
206
|
+
if (program.opts().timing && timingStart !== undefined) {
|
|
207
|
+
const elapsed = performance.now() - timingStart;
|
|
208
|
+
const formatted =
|
|
209
|
+
elapsed < 1000 ? `${Math.round(elapsed)}ms` : `${(elapsed / 1000).toFixed(2)}s`;
|
|
210
|
+
process.stderr.write(`${muted(`Done in ${formatted}`)}\n`);
|
|
211
|
+
}
|
|
194
212
|
});
|
|
195
213
|
|
|
196
214
|
// Migrated commands — use addCommand() with createXCommand() factories
|
|
@@ -344,6 +362,8 @@ program
|
|
|
344
362
|
|
|
345
363
|
program.addCommand(createFeedCommand());
|
|
346
364
|
|
|
365
|
+
program.addCommand(createEcosystemCommand());
|
|
366
|
+
|
|
347
367
|
program.addCommand(createErrorsCommand());
|
|
348
368
|
|
|
349
369
|
program.addCommand(createReplayCommand());
|
|
@@ -354,6 +374,8 @@ program.addCommand(createCostsCommand());
|
|
|
354
374
|
|
|
355
375
|
program.addCommand(createMetricsCommand());
|
|
356
376
|
|
|
377
|
+
program.addCommand(createUpgradeCommand());
|
|
378
|
+
|
|
357
379
|
// Handle unknown commands with Levenshtein fuzzy-match suggestions
|
|
358
380
|
program.on("command:*", (operands) => {
|
|
359
381
|
const unknown = operands[0] ?? "";
|