@os-eco/overstory-cli 0.6.8 → 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.
Files changed (69) hide show
  1. package/README.md +19 -5
  2. package/agents/builder.md +6 -15
  3. package/agents/lead.md +4 -6
  4. package/agents/merger.md +5 -13
  5. package/agents/reviewer.md +2 -9
  6. package/package.json +1 -1
  7. package/src/agents/hooks-deployer.test.ts +232 -0
  8. package/src/agents/hooks-deployer.ts +54 -8
  9. package/src/agents/overlay.test.ts +156 -1
  10. package/src/agents/overlay.ts +67 -7
  11. package/src/commands/agents.ts +9 -6
  12. package/src/commands/clean.ts +2 -1
  13. package/src/commands/completions.test.ts +8 -20
  14. package/src/commands/completions.ts +7 -6
  15. package/src/commands/coordinator.test.ts +8 -0
  16. package/src/commands/coordinator.ts +11 -8
  17. package/src/commands/costs.test.ts +48 -38
  18. package/src/commands/costs.ts +48 -38
  19. package/src/commands/dashboard.ts +7 -7
  20. package/src/commands/doctor.test.ts +8 -0
  21. package/src/commands/doctor.ts +96 -51
  22. package/src/commands/ecosystem.ts +291 -0
  23. package/src/commands/errors.test.ts +47 -40
  24. package/src/commands/errors.ts +5 -4
  25. package/src/commands/feed.test.ts +40 -33
  26. package/src/commands/feed.ts +5 -4
  27. package/src/commands/group.ts +23 -14
  28. package/src/commands/hooks.ts +2 -1
  29. package/src/commands/init.test.ts +104 -0
  30. package/src/commands/init.ts +11 -7
  31. package/src/commands/inspect.test.ts +2 -0
  32. package/src/commands/inspect.ts +9 -8
  33. package/src/commands/logs.test.ts +5 -6
  34. package/src/commands/logs.ts +2 -1
  35. package/src/commands/mail.test.ts +11 -10
  36. package/src/commands/mail.ts +11 -12
  37. package/src/commands/merge.ts +11 -12
  38. package/src/commands/metrics.test.ts +15 -2
  39. package/src/commands/metrics.ts +3 -2
  40. package/src/commands/monitor.ts +5 -4
  41. package/src/commands/nudge.ts +2 -3
  42. package/src/commands/prime.test.ts +1 -6
  43. package/src/commands/prime.ts +2 -3
  44. package/src/commands/replay.test.ts +62 -55
  45. package/src/commands/replay.ts +3 -2
  46. package/src/commands/run.ts +17 -20
  47. package/src/commands/sling.ts +3 -2
  48. package/src/commands/status.test.ts +2 -1
  49. package/src/commands/status.ts +7 -6
  50. package/src/commands/stop.test.ts +2 -0
  51. package/src/commands/stop.ts +10 -11
  52. package/src/commands/supervisor.ts +7 -6
  53. package/src/commands/trace.test.ts +52 -44
  54. package/src/commands/trace.ts +5 -4
  55. package/src/commands/upgrade.test.ts +46 -0
  56. package/src/commands/upgrade.ts +259 -0
  57. package/src/commands/watch.ts +8 -10
  58. package/src/commands/worktree.test.ts +21 -15
  59. package/src/commands/worktree.ts +10 -4
  60. package/src/doctor/databases.test.ts +38 -0
  61. package/src/doctor/databases.ts +7 -10
  62. package/src/doctor/ecosystem.test.ts +307 -0
  63. package/src/doctor/ecosystem.ts +155 -0
  64. package/src/doctor/merge-queue.test.ts +98 -0
  65. package/src/doctor/merge-queue.ts +23 -0
  66. package/src/doctor/structure.test.ts +130 -1
  67. package/src/doctor/structure.ts +87 -1
  68. package/src/doctor/types.ts +5 -2
  69. package/src/index.ts +25 -1
@@ -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
  });
@@ -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
  };
@@ -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 (future --fix flag). */
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.8";
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
@@ -211,6 +229,8 @@ program
211
229
  .command("init")
212
230
  .description("Initialize .overstory/ in current project")
213
231
  .option("--force", "Reinitialize even if .overstory/ already exists")
232
+ .option("-y, --yes", "Accept all defaults without prompting (non-interactive mode)")
233
+ .option("--name <name>", "Project name (skips auto-detection)")
214
234
  .action(async (opts) => {
215
235
  await initCommand(opts);
216
236
  });
@@ -342,6 +362,8 @@ program
342
362
 
343
363
  program.addCommand(createFeedCommand());
344
364
 
365
+ program.addCommand(createEcosystemCommand());
366
+
345
367
  program.addCommand(createErrorsCommand());
346
368
 
347
369
  program.addCommand(createReplayCommand());
@@ -352,6 +374,8 @@ program.addCommand(createCostsCommand());
352
374
 
353
375
  program.addCommand(createMetricsCommand());
354
376
 
377
+ program.addCommand(createUpgradeCommand());
378
+
355
379
  // Handle unknown commands with Levenshtein fuzzy-match suggestions
356
380
  program.on("command:*", (operands) => {
357
381
  const unknown = operands[0] ?? "";