@os-eco/overstory-cli 0.6.1 → 0.6.4

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 (80) hide show
  1. package/README.md +7 -6
  2. package/package.json +12 -4
  3. package/src/agents/hooks-deployer.test.ts +94 -16
  4. package/src/agents/hooks-deployer.ts +18 -0
  5. package/src/agents/manifest.test.ts +86 -0
  6. package/src/commands/agents.test.ts +3 -3
  7. package/src/commands/agents.ts +59 -88
  8. package/src/commands/clean.test.ts +31 -46
  9. package/src/commands/clean.ts +28 -49
  10. package/src/commands/completions.ts +14 -0
  11. package/src/commands/coordinator.test.ts +131 -24
  12. package/src/commands/coordinator.ts +100 -63
  13. package/src/commands/costs.test.ts +2 -2
  14. package/src/commands/costs.ts +96 -75
  15. package/src/commands/dashboard.test.ts +2 -2
  16. package/src/commands/dashboard.ts +73 -93
  17. package/src/commands/doctor.test.ts +2 -2
  18. package/src/commands/doctor.ts +92 -79
  19. package/src/commands/errors.test.ts +2 -2
  20. package/src/commands/errors.ts +56 -50
  21. package/src/commands/feed.test.ts +2 -2
  22. package/src/commands/feed.ts +86 -83
  23. package/src/commands/group.ts +167 -177
  24. package/src/commands/hooks.test.ts +2 -2
  25. package/src/commands/hooks.ts +52 -42
  26. package/src/commands/init.test.ts +19 -19
  27. package/src/commands/init.ts +7 -16
  28. package/src/commands/inspect.test.ts +2 -2
  29. package/src/commands/inspect.ts +54 -57
  30. package/src/commands/log.test.ts +5 -10
  31. package/src/commands/log.ts +90 -84
  32. package/src/commands/logs.test.ts +1 -1
  33. package/src/commands/logs.ts +101 -104
  34. package/src/commands/mail.ts +157 -169
  35. package/src/commands/merge.test.ts +20 -58
  36. package/src/commands/merge.ts +13 -43
  37. package/src/commands/metrics.test.ts +2 -2
  38. package/src/commands/metrics.ts +33 -34
  39. package/src/commands/monitor.test.ts +3 -3
  40. package/src/commands/monitor.ts +56 -61
  41. package/src/commands/nudge.ts +41 -89
  42. package/src/commands/prime.test.ts +15 -47
  43. package/src/commands/prime.ts +7 -44
  44. package/src/commands/replay.test.ts +2 -2
  45. package/src/commands/replay.ts +79 -86
  46. package/src/commands/run.ts +97 -77
  47. package/src/commands/sling.test.ts +196 -0
  48. package/src/commands/sling.ts +24 -54
  49. package/src/commands/spec.test.ts +13 -39
  50. package/src/commands/spec.ts +30 -99
  51. package/src/commands/status.ts +46 -42
  52. package/src/commands/stop.test.ts +21 -39
  53. package/src/commands/stop.ts +18 -33
  54. package/src/commands/supervisor.test.ts +3 -5
  55. package/src/commands/supervisor.ts +136 -157
  56. package/src/commands/trace.test.ts +9 -9
  57. package/src/commands/trace.ts +54 -77
  58. package/src/commands/watch.test.ts +2 -2
  59. package/src/commands/watch.ts +38 -45
  60. package/src/commands/worktree.test.ts +8 -8
  61. package/src/commands/worktree.ts +63 -46
  62. package/src/config.test.ts +96 -0
  63. package/src/doctor/databases.test.ts +22 -2
  64. package/src/doctor/databases.ts +16 -0
  65. package/src/doctor/dependencies.test.ts +55 -1
  66. package/src/doctor/dependencies.ts +113 -18
  67. package/src/e2e/init-sling-lifecycle.test.ts +6 -6
  68. package/src/index.ts +223 -213
  69. package/src/logging/color.test.ts +74 -91
  70. package/src/logging/color.ts +52 -46
  71. package/src/logging/reporter.test.ts +10 -10
  72. package/src/logging/reporter.ts +6 -5
  73. package/src/merge/queue.test.ts +66 -0
  74. package/src/merge/queue.ts +15 -0
  75. package/src/schema-consistency.test.ts +239 -0
  76. package/src/sessions/compat.ts +1 -1
  77. package/src/sessions/store.test.ts +37 -0
  78. package/src/sessions/store.ts +11 -0
  79. package/src/worktree/tmux.test.ts +98 -9
  80. package/src/worktree/tmux.ts +18 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * SQL schema consistency tests.
3
+ *
4
+ * Verifies that SQL CREATE TABLE column names match the TypeScript row interfaces
5
+ * and row-to-object conversion functions across all four SQLite stores.
6
+ * Prevents regressions like the bead_id/task_id column rename that caused runtime failures.
7
+ *
8
+ * Strategy: create each store (which runs CREATE TABLE), then open a second
9
+ * read-only connection to the same temp file and query PRAGMA table_info().
10
+ * bun:sqlite with WAL mode allows concurrent readers.
11
+ */
12
+
13
+ import { Database } from "bun:sqlite";
14
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
+ import { mkdtemp, rm } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { createEventStore } from "./events/store.ts";
19
+ import { createMailStore } from "./mail/store.ts";
20
+ import { createMergeQueue } from "./merge/queue.ts";
21
+ import { createMetricsStore } from "./metrics/store.ts";
22
+ import { createSessionStore } from "./sessions/store.ts";
23
+
24
+ /** Extract sorted column names from a table via PRAGMA table_info(). */
25
+ function getTableColumns(db: Database, tableName: string): string[] {
26
+ const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
27
+ return rows.map((r) => r.name).sort();
28
+ }
29
+
30
+ describe("SQL schema consistency", () => {
31
+ let tmpDir: string;
32
+
33
+ beforeEach(async () => {
34
+ tmpDir = await mkdtemp(join(tmpdir(), "overstory-schema-test-"));
35
+ });
36
+
37
+ afterEach(async () => {
38
+ await rm(tmpDir, { recursive: true, force: true });
39
+ });
40
+
41
+ describe("SessionStore", () => {
42
+ test("sessions table columns match SessionRow interface", () => {
43
+ const dbPath = join(tmpDir, "sessions.db");
44
+ const store = createSessionStore(dbPath);
45
+
46
+ const db = new Database(dbPath, { readonly: true });
47
+ const actual = getTableColumns(db, "sessions");
48
+ db.close();
49
+ store.close();
50
+
51
+ // Columns from SessionRow interface in src/sessions/store.ts
52
+ const expected = [
53
+ "agent_name",
54
+ "branch_name",
55
+ "capability",
56
+ "depth",
57
+ "escalation_level",
58
+ "id",
59
+ "last_activity",
60
+ "parent_agent",
61
+ "pid",
62
+ "run_id",
63
+ "stalled_since",
64
+ "started_at",
65
+ "state",
66
+ "task_id",
67
+ "tmux_session",
68
+ "worktree_path",
69
+ ].sort();
70
+
71
+ expect(actual).toEqual(expected);
72
+ });
73
+
74
+ test("runs table columns match RunRow interface", () => {
75
+ const dbPath = join(tmpDir, "sessions.db");
76
+ const store = createSessionStore(dbPath);
77
+
78
+ const db = new Database(dbPath, { readonly: true });
79
+ const actual = getTableColumns(db, "runs");
80
+ db.close();
81
+ store.close();
82
+
83
+ // Columns from RunRow interface in src/sessions/store.ts
84
+ const expected = [
85
+ "agent_count",
86
+ "completed_at",
87
+ "coordinator_session_id",
88
+ "id",
89
+ "started_at",
90
+ "status",
91
+ ].sort();
92
+
93
+ expect(actual).toEqual(expected);
94
+ });
95
+ });
96
+
97
+ describe("EventStore", () => {
98
+ test("events table columns match EventRow interface", () => {
99
+ const dbPath = join(tmpDir, "events.db");
100
+ const store = createEventStore(dbPath);
101
+
102
+ const db = new Database(dbPath, { readonly: true });
103
+ const actual = getTableColumns(db, "events");
104
+ db.close();
105
+ store.close();
106
+
107
+ // Columns from EventRow interface in src/events/store.ts
108
+ const expected = [
109
+ "agent_name",
110
+ "created_at",
111
+ "data",
112
+ "event_type",
113
+ "id",
114
+ "level",
115
+ "run_id",
116
+ "session_id",
117
+ "tool_args",
118
+ "tool_duration_ms",
119
+ "tool_name",
120
+ ].sort();
121
+
122
+ expect(actual).toEqual(expected);
123
+ });
124
+ });
125
+
126
+ describe("MetricsStore", () => {
127
+ test("sessions table columns match metrics SessionRow interface", () => {
128
+ const dbPath = join(tmpDir, "metrics.db");
129
+ const store = createMetricsStore(dbPath);
130
+
131
+ const db = new Database(dbPath, { readonly: true });
132
+ const actual = getTableColumns(db, "sessions");
133
+ db.close();
134
+ store.close();
135
+
136
+ // Columns from SessionRow interface in src/metrics/store.ts
137
+ const expected = [
138
+ "agent_name",
139
+ "cache_creation_tokens",
140
+ "cache_read_tokens",
141
+ "capability",
142
+ "completed_at",
143
+ "duration_ms",
144
+ "estimated_cost_usd",
145
+ "exit_code",
146
+ "input_tokens",
147
+ "merge_result",
148
+ "model_used",
149
+ "output_tokens",
150
+ "parent_agent",
151
+ "run_id",
152
+ "started_at",
153
+ "task_id",
154
+ ].sort();
155
+
156
+ expect(actual).toEqual(expected);
157
+ });
158
+
159
+ test("token_snapshots table columns match SnapshotRow interface", () => {
160
+ const dbPath = join(tmpDir, "metrics.db");
161
+ const store = createMetricsStore(dbPath);
162
+
163
+ const db = new Database(dbPath, { readonly: true });
164
+ const actual = getTableColumns(db, "token_snapshots");
165
+ db.close();
166
+ store.close();
167
+
168
+ // Columns from SnapshotRow interface in src/metrics/store.ts
169
+ const expected = [
170
+ "agent_name",
171
+ "cache_creation_tokens",
172
+ "cache_read_tokens",
173
+ "created_at",
174
+ "estimated_cost_usd",
175
+ "id",
176
+ "input_tokens",
177
+ "model_used",
178
+ "output_tokens",
179
+ ].sort();
180
+
181
+ expect(actual).toEqual(expected);
182
+ });
183
+ });
184
+
185
+ describe("MailStore", () => {
186
+ test("messages table columns match MessageRow interface", () => {
187
+ const dbPath = join(tmpDir, "mail.db");
188
+ const store = createMailStore(dbPath);
189
+
190
+ const db = new Database(dbPath, { readonly: true });
191
+ const actual = getTableColumns(db, "messages");
192
+ db.close();
193
+ store.close();
194
+
195
+ // Columns from MessageRow interface in src/mail/store.ts
196
+ const expected = [
197
+ "body",
198
+ "created_at",
199
+ "from_agent",
200
+ "id",
201
+ "payload",
202
+ "priority",
203
+ "read",
204
+ "subject",
205
+ "thread_id",
206
+ "to_agent",
207
+ "type",
208
+ ].sort();
209
+
210
+ expect(actual).toEqual(expected);
211
+ });
212
+ });
213
+
214
+ describe("MergeQueue", () => {
215
+ test("merge_queue table columns match MergeQueueRow interface", () => {
216
+ const dbPath = join(tmpDir, "merge-queue.db");
217
+ const queue = createMergeQueue(dbPath);
218
+
219
+ const db = new Database(dbPath, { readonly: true });
220
+ const actual = getTableColumns(db, "merge_queue");
221
+ db.close();
222
+ queue.close();
223
+
224
+ // Columns from MergeQueueRow interface in src/merge/queue.ts
225
+ const expected = [
226
+ "agent_name",
227
+ "branch_name",
228
+ "enqueued_at",
229
+ "files_modified",
230
+ "id",
231
+ "resolved_tier",
232
+ "status",
233
+ "task_id",
234
+ ].sort();
235
+
236
+ expect(actual).toEqual(expected);
237
+ });
238
+ });
239
+ });
@@ -86,7 +86,7 @@ export function openSessionStore(overstoryDir: string): {
86
86
  // If the DB already existed AND has data, it is authoritative -- no migration needed.
87
87
  // If the DB file exists but is empty (e.g., created by init before any sessions were
88
88
  // recorded), fall through to check sessions.json for importable records (overstory-036f).
89
- if (dbExists && store.getAll().length > 0) {
89
+ if (dbExists && store.count() > 0) {
90
90
  return { store, migrated: false };
91
91
  }
92
92
 
@@ -231,6 +231,43 @@ describe("getAll", () => {
231
231
  });
232
232
  });
233
233
 
234
+ // === count ===
235
+
236
+ describe("count", () => {
237
+ test("returns 0 on empty database", () => {
238
+ expect(store.count()).toBe(0);
239
+ });
240
+
241
+ test("returns correct count after inserts", () => {
242
+ store.upsert(makeSession({ agentName: "a1", id: "s-1" }));
243
+ expect(store.count()).toBe(1);
244
+
245
+ store.upsert(makeSession({ agentName: "a2", id: "s-2" }));
246
+ expect(store.count()).toBe(2);
247
+
248
+ store.upsert(makeSession({ agentName: "a3", id: "s-3" }));
249
+ expect(store.count()).toBe(3);
250
+ });
251
+
252
+ test("count reflects removals", () => {
253
+ store.upsert(makeSession({ agentName: "a1", id: "s-1" }));
254
+ store.upsert(makeSession({ agentName: "a2", id: "s-2" }));
255
+
256
+ store.remove("a1");
257
+ expect(store.count()).toBe(1);
258
+
259
+ store.remove("a2");
260
+ expect(store.count()).toBe(0);
261
+ });
262
+
263
+ test("count matches getAll().length", () => {
264
+ for (let i = 0; i < 5; i++) {
265
+ store.upsert(makeSession({ agentName: `agent-${i}`, id: `s-${i}` }));
266
+ }
267
+ expect(store.count()).toBe(store.getAll().length);
268
+ });
269
+ });
270
+
234
271
  // === getByRun ===
235
272
 
236
273
  describe("getByRun", () => {
@@ -18,6 +18,8 @@ export interface SessionStore {
18
18
  getActive(): AgentSession[];
19
19
  /** Get all sessions regardless of state. */
20
20
  getAll(): AgentSession[];
21
+ /** Get the total number of sessions. Lightweight alternative to getAll().length. */
22
+ count(): number;
21
23
  /** Get sessions belonging to a specific run. */
22
24
  getByRun(runId: string): AgentSession[];
23
25
  /** Update only the state of a session. */
@@ -233,6 +235,10 @@ export function createSessionStore(dbPath: string): SessionStore {
233
235
  SELECT * FROM sessions ORDER BY started_at ASC
234
236
  `);
235
237
 
238
+ const countStmt = db.prepare<{ cnt: number }, Record<string, never>>(
239
+ "SELECT COUNT(*) as cnt FROM sessions",
240
+ );
241
+
236
242
  const getByRunStmt = db.prepare<SessionRow, { $run_id: string }>(`
237
243
  SELECT * FROM sessions WHERE run_id = $run_id ORDER BY started_at ASC
238
244
  `);
@@ -299,6 +305,11 @@ export function createSessionStore(dbPath: string): SessionStore {
299
305
  return rows.map(rowToSession);
300
306
  },
301
307
 
308
+ count(): number {
309
+ const row = countStmt.get({});
310
+ return row?.cnt ?? 0;
311
+ },
312
+
302
313
  getByRun(runId: string): AgentSession[] {
303
314
  const rows = getByRunStmt.all({ $run_id: runId });
304
315
  return rows.map(rowToSession);
@@ -3,6 +3,7 @@ import { AgentError } from "../errors.ts";
3
3
  import {
4
4
  capturePaneContent,
5
5
  createSession,
6
+ ensureTmuxAvailable,
6
7
  getDescendantPids,
7
8
  getPanePid,
8
9
  isProcessAlive,
@@ -961,21 +962,26 @@ describe("waitForTuiReady", () => {
961
962
  });
962
963
 
963
964
  test("returns true after content appears on later poll", async () => {
964
- let callCount = 0;
965
- spawnSpy.mockImplementation(() => {
966
- callCount++;
967
- if (callCount <= 3) {
968
- // First 3 polls: empty pane (TUI still loading)
969
- return mockSpawnResult("", "", 0);
965
+ let captureCallCount = 0;
966
+ spawnSpy.mockImplementation((...args: unknown[]) => {
967
+ const cmd = args[0] as string[];
968
+ if (cmd[1] === "capture-pane") {
969
+ captureCallCount++;
970
+ if (captureCallCount <= 3) {
971
+ // First 3 capture-pane polls: empty pane (TUI still loading)
972
+ return mockSpawnResult("", "", 0);
973
+ }
974
+ // 4th poll: content appears
975
+ return mockSpawnResult("Welcome to Claude Code!", "", 0);
970
976
  }
971
- // 4th poll: content appears
972
- return mockSpawnResult("Welcome to Claude Code!", "", 0);
977
+ // has-session: session is alive throughout
978
+ return mockSpawnResult("", "", 0);
973
979
  });
974
980
 
975
981
  const ready = await waitForTuiReady("overstory-agent", 10_000, 500);
976
982
 
977
983
  expect(ready).toBe(true);
978
- // Should have slept 3 times (3 empty polls before content appeared)
984
+ // Should have slept 3 times (3 empty capture-pane polls before content appeared)
979
985
  expect(sleepSpy).toHaveBeenCalledTimes(3);
980
986
  });
981
987
 
@@ -1006,4 +1012,87 @@ describe("waitForTuiReady", () => {
1006
1012
 
1007
1013
  expect(ready).toBe(true);
1008
1014
  });
1015
+
1016
+ test("returns false immediately when session is dead", async () => {
1017
+ // capture-pane fails (session dead), has-session also fails (session dead)
1018
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1019
+ const cmd = args[0] as string[];
1020
+ if (cmd[1] === "capture-pane") {
1021
+ return mockSpawnResult("", "can't find session", 1);
1022
+ }
1023
+ // has-session: session is dead
1024
+ return mockSpawnResult("", "can't find session", 1);
1025
+ });
1026
+
1027
+ const ready = await waitForTuiReady("dead-session", 15_000, 500);
1028
+
1029
+ expect(ready).toBe(false);
1030
+ // Should NOT have polled the full timeout (no sleeps — returned immediately)
1031
+ expect(sleepSpy).not.toHaveBeenCalled();
1032
+ });
1033
+
1034
+ test("continues polling when session is alive but pane is empty", async () => {
1035
+ let captureCallCount = 0;
1036
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1037
+ const cmd = args[0] as string[];
1038
+ if (cmd[1] === "capture-pane") {
1039
+ captureCallCount++;
1040
+ // Pane stays empty for all polls (session alive but TUI not rendered yet)
1041
+ return mockSpawnResult("", "", 0);
1042
+ }
1043
+ // has-session: session is alive
1044
+ return mockSpawnResult("", "", 0);
1045
+ });
1046
+
1047
+ // Use a short timeout so the test doesn't take long
1048
+ const ready = await waitForTuiReady("loading-session", 1_000, 500);
1049
+
1050
+ expect(ready).toBe(false);
1051
+ // Should have polled multiple times (not returned early)
1052
+ expect(captureCallCount).toBeGreaterThan(1);
1053
+ expect(sleepSpy).toHaveBeenCalled();
1054
+ });
1055
+ });
1056
+
1057
+ describe("ensureTmuxAvailable", () => {
1058
+ let spawnSpy: ReturnType<typeof spyOn>;
1059
+
1060
+ beforeEach(() => {
1061
+ spawnSpy = spyOn(Bun, "spawn");
1062
+ });
1063
+
1064
+ afterEach(() => {
1065
+ spawnSpy.mockRestore();
1066
+ });
1067
+
1068
+ test("succeeds when tmux is available", async () => {
1069
+ spawnSpy.mockImplementation(() => mockSpawnResult("tmux 3.3a\n", "", 0));
1070
+
1071
+ // Should not throw
1072
+ await ensureTmuxAvailable();
1073
+
1074
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
1075
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
1076
+ const cmd = callArgs[0] as string[];
1077
+ expect(cmd).toEqual(["tmux", "-V"]);
1078
+ });
1079
+
1080
+ test("throws AgentError when tmux is not installed", async () => {
1081
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "tmux: command not found", 1));
1082
+
1083
+ await expect(ensureTmuxAvailable()).rejects.toThrow(AgentError);
1084
+ });
1085
+
1086
+ test("AgentError message mentions tmux not installed", async () => {
1087
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 127));
1088
+
1089
+ try {
1090
+ await ensureTmuxAvailable();
1091
+ expect(true).toBe(false); // Should have thrown
1092
+ } catch (err: unknown) {
1093
+ expect(err).toBeInstanceOf(AgentError);
1094
+ const agentErr = err as AgentError;
1095
+ expect(agentErr.message).toContain("tmux is not installed");
1096
+ }
1097
+ });
1009
1098
  });
@@ -455,11 +455,29 @@ export async function waitForTuiReady(
455
455
  if (content !== null) {
456
456
  return true;
457
457
  }
458
+ // Check if session died — no point waiting if it's gone
459
+ const alive = await isSessionAlive(name);
460
+ if (!alive) {
461
+ return false;
462
+ }
458
463
  await Bun.sleep(pollIntervalMs);
459
464
  }
460
465
  return false;
461
466
  }
462
467
 
468
+ /**
469
+ * Verify that tmux is installed and executable.
470
+ * Throws AgentError with a clear message if tmux is not available.
471
+ */
472
+ export async function ensureTmuxAvailable(): Promise<void> {
473
+ const { exitCode } = await runCommand(["tmux", "-V"]);
474
+ if (exitCode !== 0) {
475
+ throw new AgentError(
476
+ "tmux is not installed or not on PATH. Install tmux to use overstory agent orchestration.",
477
+ );
478
+ }
479
+ }
480
+
463
481
  /**
464
482
  * Send keys to a tmux session.
465
483
  *