@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
@@ -0,0 +1,259 @@
1
+ /**
2
+ * CLI command: ov upgrade [--check] [--all] [--json]
3
+ *
4
+ * Upgrades overstory (and optionally all os-eco tools) to their latest npm versions.
5
+ * --check: Compare current vs latest without installing.
6
+ * --all: Upgrade all 4 ecosystem tools (overstory, mulch, seeds, canopy).
7
+ * --json: Output result as JSON envelope.
8
+ */
9
+
10
+ import { Command } from "commander";
11
+ import { jsonError, jsonOutput } from "../json.ts";
12
+ import { muted, printError, printHint, printSuccess, printWarning } from "../logging/color.ts";
13
+
14
+ const OVERSTORY_PACKAGE = "@os-eco/overstory-cli";
15
+
16
+ const ALL_PACKAGES = [
17
+ "@os-eco/overstory-cli",
18
+ "@os-eco/mulch-cli",
19
+ "@os-eco/seeds-cli",
20
+ "@os-eco/canopy-cli",
21
+ ] as const;
22
+
23
+ export interface UpgradeOptions {
24
+ check?: boolean;
25
+ all?: boolean;
26
+ json?: boolean;
27
+ }
28
+
29
+ async function getCurrentVersion(): Promise<string> {
30
+ const pkgPath = new URL("../../package.json", import.meta.url);
31
+ const pkg = JSON.parse(await Bun.file(pkgPath).text()) as { version: string };
32
+ return pkg.version;
33
+ }
34
+
35
+ async function fetchLatestVersion(packageName: string): Promise<string> {
36
+ const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
37
+ if (!res.ok) {
38
+ throw new Error(
39
+ `Failed to fetch npm registry for ${packageName}: ${res.status} ${res.statusText}`,
40
+ );
41
+ }
42
+ const data = (await res.json()) as { version: string };
43
+ return data.version;
44
+ }
45
+
46
+ async function runInstall(packageName: string): Promise<number> {
47
+ const proc = Bun.spawn(["bun", "install", "-g", `${packageName}@latest`], {
48
+ stdout: "inherit",
49
+ stderr: "inherit",
50
+ });
51
+ return proc.exited;
52
+ }
53
+
54
+ interface PackageResult {
55
+ package: string;
56
+ current: string;
57
+ latest: string;
58
+ upToDate: boolean;
59
+ updated: boolean;
60
+ error?: string;
61
+ }
62
+
63
+ async function upgradePackage(packageName: string): Promise<PackageResult> {
64
+ let latest: string;
65
+ try {
66
+ latest = await fetchLatestVersion(packageName);
67
+ } catch (err) {
68
+ const error = err instanceof Error ? err.message : String(err);
69
+ return {
70
+ package: packageName,
71
+ current: "unknown",
72
+ latest: "unknown",
73
+ upToDate: false,
74
+ updated: false,
75
+ error,
76
+ };
77
+ }
78
+
79
+ const exitCode = await runInstall(packageName);
80
+ if (exitCode !== 0) {
81
+ return {
82
+ package: packageName,
83
+ current: "unknown",
84
+ latest,
85
+ upToDate: false,
86
+ updated: false,
87
+ error: `bun install failed with exit code ${exitCode}`,
88
+ };
89
+ }
90
+ return { package: packageName, current: "unknown", latest, upToDate: false, updated: true };
91
+ }
92
+
93
+ async function executeUpgradeSingle(opts: UpgradeOptions): Promise<void> {
94
+ const json = opts.json ?? false;
95
+ const checkOnly = opts.check ?? false;
96
+
97
+ let current: string;
98
+ let latest: string;
99
+ try {
100
+ [current, latest] = await Promise.all([
101
+ getCurrentVersion(),
102
+ fetchLatestVersion(OVERSTORY_PACKAGE),
103
+ ]);
104
+ } catch (err) {
105
+ const msg = err instanceof Error ? err.message : String(err);
106
+ if (json) {
107
+ jsonError("upgrade", msg);
108
+ } else {
109
+ printError("Failed to check for updates", msg);
110
+ }
111
+ process.exitCode = 1;
112
+ return;
113
+ }
114
+
115
+ const upToDate = current === latest;
116
+
117
+ if (checkOnly) {
118
+ if (json) {
119
+ jsonOutput("upgrade", { current, latest, upToDate });
120
+ } else if (upToDate) {
121
+ printSuccess("Already up to date", current);
122
+ } else {
123
+ printWarning(`Update available: ${current} → ${latest}`);
124
+ printHint("Run 'ov upgrade' to install the latest version");
125
+ process.exitCode = 1;
126
+ }
127
+ return;
128
+ }
129
+
130
+ if (upToDate) {
131
+ if (json) {
132
+ jsonOutput("upgrade", { current, latest, upToDate: true, updated: false });
133
+ } else {
134
+ printSuccess("Already up to date", current);
135
+ }
136
+ return;
137
+ }
138
+
139
+ if (!json) {
140
+ process.stdout.write(
141
+ `${muted(`Upgrading ${OVERSTORY_PACKAGE} from ${current} to ${latest}...`)}\n`,
142
+ );
143
+ }
144
+
145
+ const exitCode = await runInstall(OVERSTORY_PACKAGE);
146
+
147
+ if (exitCode !== 0) {
148
+ if (json) {
149
+ jsonError("upgrade", `bun install failed with exit code ${exitCode}`);
150
+ } else {
151
+ printError("Upgrade failed", `bun install exited with code ${exitCode}`);
152
+ }
153
+ process.exitCode = 1;
154
+ return;
155
+ }
156
+
157
+ if (json) {
158
+ jsonOutput("upgrade", { current, latest, upToDate: false, updated: true });
159
+ } else {
160
+ printSuccess("Upgraded to", latest);
161
+ }
162
+ }
163
+
164
+ async function executeUpgradeAll(opts: UpgradeOptions): Promise<void> {
165
+ const json = opts.json ?? false;
166
+ const checkOnly = opts.check ?? false;
167
+
168
+ // Fetch all latest versions in parallel
169
+ const latestResults = await Promise.allSettled(
170
+ ALL_PACKAGES.map((pkg) => fetchLatestVersion(pkg)),
171
+ );
172
+
173
+ if (checkOnly) {
174
+ // For --check --all, we just report status without installing
175
+ const results: Array<{ package: string; latest: string; error?: string }> = [];
176
+ let anyOutdated = false;
177
+
178
+ for (let i = 0; i < ALL_PACKAGES.length; i++) {
179
+ const pkg = ALL_PACKAGES[i] as string;
180
+ const result = latestResults[i];
181
+ if (result === undefined || result.status === "rejected") {
182
+ const error =
183
+ result?.status === "rejected"
184
+ ? result.reason instanceof Error
185
+ ? result.reason.message
186
+ : String(result.reason)
187
+ : "unknown error";
188
+ results.push({ package: pkg, latest: "unknown", error });
189
+ anyOutdated = true;
190
+ } else {
191
+ results.push({ package: pkg, latest: result.value });
192
+ // We can't easily get current versions for all tools without spawning them
193
+ // so for --all --check we just report latest versions available
194
+ }
195
+ }
196
+
197
+ if (json) {
198
+ jsonOutput("upgrade", { packages: results });
199
+ } else {
200
+ for (const r of results) {
201
+ if (r.error) {
202
+ printError(`${r.package}`, r.error);
203
+ } else {
204
+ process.stdout.write(` ${r.package} → ${r.latest}\n`);
205
+ }
206
+ }
207
+ if (anyOutdated) process.exitCode = 1;
208
+ }
209
+ return;
210
+ }
211
+
212
+ // Install all packages
213
+ if (!json) {
214
+ process.stdout.write(`${muted("Upgrading all os-eco tools to latest...")}\n`);
215
+ }
216
+
217
+ const results: PackageResult[] = await Promise.all(
218
+ ALL_PACKAGES.map((pkg) => upgradePackage(pkg)),
219
+ );
220
+
221
+ const anyError = results.some((r) => r.error !== undefined);
222
+
223
+ if (json) {
224
+ if (anyError) {
225
+ jsonError("upgrade", `One or more packages failed to upgrade`);
226
+ } else {
227
+ jsonOutput("upgrade", { packages: results, updated: true });
228
+ }
229
+ } else {
230
+ for (const r of results) {
231
+ if (r.error) {
232
+ printError(`Failed to upgrade ${r.package}`, r.error);
233
+ } else {
234
+ printSuccess(`Upgraded ${r.package} to`, r.latest);
235
+ }
236
+ }
237
+ }
238
+
239
+ if (anyError) process.exitCode = 1;
240
+ }
241
+
242
+ export async function executeUpgrade(opts: UpgradeOptions): Promise<void> {
243
+ if (opts.all) {
244
+ await executeUpgradeAll(opts);
245
+ } else {
246
+ await executeUpgradeSingle(opts);
247
+ }
248
+ }
249
+
250
+ export function createUpgradeCommand(): Command {
251
+ return new Command("upgrade")
252
+ .description("Upgrade overstory to the latest version from npm")
253
+ .option("--check", "Check for updates without installing")
254
+ .option("--all", "Upgrade all os-eco ecosystem tools")
255
+ .option("--json", "Output as JSON")
256
+ .action(async (opts: UpgradeOptions) => {
257
+ await executeUpgrade(opts);
258
+ });
259
+ }
@@ -10,6 +10,7 @@ import { join } from "node:path";
10
10
  import { Command } from "commander";
11
11
  import { loadConfig } from "../config.ts";
12
12
  import { OverstoryError } from "../errors.ts";
13
+ import { printError, printHint, printSuccess } from "../logging/color.ts";
13
14
  import type { HealthCheck } from "../types.ts";
14
15
  import { startDaemon } from "../watchdog/daemon.ts";
15
16
  import { isProcessRunning } from "../watchdog/health.ts";
@@ -130,9 +131,8 @@ async function runWatch(opts: { interval?: string; background?: boolean }): Prom
130
131
  // Check if a watchdog is already running
131
132
  const existingPid = await readPidFile(pidFilePath);
132
133
  if (existingPid !== null && isProcessRunning(existingPid)) {
133
- process.stderr.write(
134
- `Error: Watchdog already running (PID: ${existingPid}). ` +
135
- `Kill it first or remove ${pidFilePath}\n`,
134
+ printError(
135
+ `Watchdog already running (PID: ${existingPid}). Kill it first or remove ${pidFilePath}`,
136
136
  );
137
137
  process.exitCode = 1;
138
138
  return;
@@ -168,16 +168,14 @@ async function runWatch(opts: { interval?: string; background?: boolean }): Prom
168
168
  // Write PID file for later cleanup
169
169
  await writePidFile(pidFilePath, childPid);
170
170
 
171
- process.stdout.write(
172
- `Watchdog started in background (PID: ${childPid}, interval: ${intervalMs}ms)\n`,
173
- );
174
- process.stdout.write(`PID file: ${pidFilePath}\n`);
171
+ printSuccess("Watchdog started in background", `PID: ${childPid}, interval: ${intervalMs}ms`);
172
+ printHint(`PID file: ${pidFilePath}`);
175
173
  return;
176
174
  }
177
175
 
178
176
  // Foreground mode: show real-time health checks
179
- process.stdout.write(`Watchdog running (interval: ${intervalMs}ms)\n`);
180
- process.stdout.write("Press Ctrl+C to stop.\n\n");
177
+ printSuccess("Watchdog running", `interval: ${intervalMs}ms`);
178
+ printHint("Press Ctrl+C to stop.");
181
179
 
182
180
  // Write PID file so `--background` check and external tools can find us
183
181
  await writePidFile(pidFilePath, process.pid);
@@ -200,7 +198,7 @@ async function runWatch(opts: { interval?: string; background?: boolean }): Prom
200
198
  stop();
201
199
  // Clean up PID file on graceful shutdown
202
200
  removePidFile(pidFilePath).finally(() => {
203
- process.stdout.write("\nWatchdog stopped.\n");
201
+ printSuccess("Watchdog stopped.");
204
202
  process.exit(0);
205
203
  });
206
204
  });
@@ -220,21 +220,27 @@ describe("worktreeCommand", () => {
220
220
  await worktreeCommand(["list", "--json"]);
221
221
  const out = output();
222
222
 
223
- const parsed = JSON.parse(out.trim()) as Array<{
224
- path: string;
225
- branch: string;
226
- head: string;
227
- agentName: string | null;
228
- state: string | null;
229
- taskId: string | null;
230
- }>;
231
-
232
- expect(parsed).toHaveLength(1);
233
- expect(parsed[0]?.path).toBe(worktreePath);
234
- expect(parsed[0]?.branch).toBe("overstory/test-agent/task-1");
235
- expect(parsed[0]?.agentName).toBe("test-agent");
236
- expect(parsed[0]?.state).toBe("working");
237
- expect(parsed[0]?.taskId).toBe("task-1");
223
+ const parsed = JSON.parse(out.trim()) as {
224
+ success: boolean;
225
+ command: string;
226
+ worktrees: Array<{
227
+ path: string;
228
+ branch: string;
229
+ head: string;
230
+ agentName: string | null;
231
+ state: string | null;
232
+ taskId: string | null;
233
+ }>;
234
+ };
235
+
236
+ expect(parsed.success).toBe(true);
237
+ expect(parsed.command).toBe("worktree list");
238
+ expect(parsed.worktrees).toHaveLength(1);
239
+ expect(parsed.worktrees[0]?.path).toBe(worktreePath);
240
+ expect(parsed.worktrees[0]?.branch).toBe("overstory/test-agent/task-1");
241
+ expect(parsed.worktrees[0]?.agentName).toBe("test-agent");
242
+ expect(parsed.worktrees[0]?.state).toBe("working");
243
+ expect(parsed.worktrees[0]?.taskId).toBe("task-1");
238
244
  });
239
245
 
240
246
  test("worktrees without sessions show unknown state", async () => {
@@ -10,6 +10,7 @@ import { join } from "node:path";
10
10
  import { Command } from "commander";
11
11
  import { loadConfig } from "../config.ts";
12
12
  import { ValidationError } from "../errors.ts";
13
+ import { jsonOutput } from "../json.ts";
13
14
  import { printHint, printSuccess, printWarning } from "../logging/color.ts";
14
15
  import { createMailStore } from "../mail/store.ts";
15
16
  import { openSessionStore } from "../sessions/compat.ts";
@@ -50,7 +51,7 @@ async function handleList(root: string, json: boolean): Promise<void> {
50
51
  taskId: session?.taskId ?? null,
51
52
  };
52
53
  });
53
- process.stdout.write(`${JSON.stringify(entries, null, "\t")}\n`);
54
+ jsonOutput("worktree list", { worktrees: entries });
54
55
  return;
55
56
  }
56
57
 
@@ -238,9 +239,14 @@ async function handleClean(
238
239
  }
239
240
 
240
241
  if (json) {
241
- process.stdout.write(
242
- `${JSON.stringify({ cleaned, failed, skipped, pruned: pruneCount, mailPurged, seedsPreserved })}\n`,
243
- );
242
+ jsonOutput("worktree clean", {
243
+ cleaned,
244
+ failed,
245
+ skipped,
246
+ pruned: pruneCount,
247
+ mailPurged,
248
+ seedsPreserved,
249
+ });
244
250
  } else if (
245
251
  cleaned.length === 0 &&
246
252
  pruneCount === 0 &&
@@ -307,4 +307,42 @@ describe("checkDatabases", () => {
307
307
  expect(integrityCheck?.status).toBe("fail");
308
308
  expect(integrityCheck?.message).toContain("Failed to open or validate");
309
309
  });
310
+
311
+ test("fix() enables WAL mode on database", () => {
312
+ // Create mail.db without WAL mode
313
+ const mailDb = new Database(join(tempDir, "mail.db"));
314
+ mailDb.exec(`
315
+ CREATE TABLE messages (
316
+ id TEXT PRIMARY KEY,
317
+ from_agent TEXT NOT NULL,
318
+ to_agent TEXT NOT NULL,
319
+ subject TEXT NOT NULL,
320
+ body TEXT NOT NULL,
321
+ type TEXT NOT NULL DEFAULT 'status',
322
+ priority TEXT NOT NULL DEFAULT 'normal',
323
+ thread_id TEXT,
324
+ payload TEXT,
325
+ read INTEGER NOT NULL DEFAULT 0,
326
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
327
+ )
328
+ `);
329
+ mailDb.close();
330
+
331
+ const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
332
+
333
+ const walCheck = checks.find((c) => c?.name === "mail.db WAL mode");
334
+ expect(walCheck?.status).toBe("warn");
335
+ expect(walCheck?.fix).toBeDefined();
336
+
337
+ const actions = walCheck?.fix?.();
338
+ expect(Array.isArray(actions)).toBe(true);
339
+ expect((actions as string[]).some((a) => a.includes("WAL mode"))).toBe(true);
340
+ expect((actions as string[]).some((a) => a.includes("mail.db"))).toBe(true);
341
+
342
+ // Verify WAL mode is now enabled
343
+ const verifyDb = new Database(join(tempDir, "mail.db"));
344
+ const journalMode = verifyDb.prepare<{ journal_mode: string }, []>("PRAGMA journal_mode").get();
345
+ verifyDb.close();
346
+ expect(journalMode?.journal_mode?.toLowerCase()).toBe("wal");
347
+ });
310
348
  });
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { existsSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
4
5
 
@@ -187,6 +188,12 @@ export const checkDatabases: DoctorCheckFn = (_config, overstoryDir): DoctorChec
187
188
  message: `Database ${dbSpec.name} is not using WAL mode`,
188
189
  details: ["WAL mode improves concurrent access performance"],
189
190
  fixable: true,
191
+ fix: () => {
192
+ const fixDb = new Database(dbPath);
193
+ fixDb.exec("PRAGMA journal_mode=WAL");
194
+ fixDb.close();
195
+ return [`Enabled WAL mode on ${dbSpec.name}`];
196
+ },
190
197
  });
191
198
  } else {
192
199
  checks.push({
@@ -222,13 +229,3 @@ export const checkDatabases: DoctorCheckFn = (_config, overstoryDir): DoctorChec
222
229
 
223
230
  return checks;
224
231
  };
225
-
226
- /** Helper to check if file exists (synchronous). */
227
- function existsSync(path: string): boolean {
228
- try {
229
- const { existsSync } = require("node:fs");
230
- return existsSync(path);
231
- } catch {
232
- return false;
233
- }
234
- }