@os-eco/overstory-cli 0.6.1 → 0.6.5

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 (110) hide show
  1. package/README.md +8 -7
  2. package/package.json +12 -4
  3. package/src/agents/checkpoint.test.ts +2 -2
  4. package/src/agents/hooks-deployer.test.ts +131 -16
  5. package/src/agents/hooks-deployer.ts +33 -1
  6. package/src/agents/identity.test.ts +27 -27
  7. package/src/agents/identity.ts +10 -10
  8. package/src/agents/lifecycle.test.ts +6 -6
  9. package/src/agents/lifecycle.ts +2 -2
  10. package/src/agents/manifest.test.ts +86 -0
  11. package/src/agents/overlay.test.ts +9 -9
  12. package/src/agents/overlay.ts +4 -4
  13. package/src/commands/agents.test.ts +8 -8
  14. package/src/commands/agents.ts +62 -91
  15. package/src/commands/clean.test.ts +36 -51
  16. package/src/commands/clean.ts +28 -49
  17. package/src/commands/completions.ts +14 -0
  18. package/src/commands/coordinator.test.ts +133 -26
  19. package/src/commands/coordinator.ts +101 -64
  20. package/src/commands/costs.test.ts +47 -47
  21. package/src/commands/costs.ts +96 -75
  22. package/src/commands/dashboard.test.ts +2 -2
  23. package/src/commands/dashboard.ts +75 -95
  24. package/src/commands/doctor.test.ts +2 -2
  25. package/src/commands/doctor.ts +92 -79
  26. package/src/commands/errors.test.ts +2 -2
  27. package/src/commands/errors.ts +56 -50
  28. package/src/commands/feed.test.ts +2 -2
  29. package/src/commands/feed.ts +86 -83
  30. package/src/commands/group.ts +167 -177
  31. package/src/commands/hooks.test.ts +2 -2
  32. package/src/commands/hooks.ts +52 -42
  33. package/src/commands/init.test.ts +19 -19
  34. package/src/commands/init.ts +7 -16
  35. package/src/commands/inspect.test.ts +18 -18
  36. package/src/commands/inspect.ts +55 -58
  37. package/src/commands/log.test.ts +26 -31
  38. package/src/commands/log.ts +97 -91
  39. package/src/commands/logs.test.ts +1 -1
  40. package/src/commands/logs.ts +101 -104
  41. package/src/commands/mail.test.ts +5 -5
  42. package/src/commands/mail.ts +157 -169
  43. package/src/commands/merge.test.ts +28 -66
  44. package/src/commands/merge.ts +21 -51
  45. package/src/commands/metrics.test.ts +8 -8
  46. package/src/commands/metrics.ts +34 -35
  47. package/src/commands/monitor.test.ts +3 -3
  48. package/src/commands/monitor.ts +57 -62
  49. package/src/commands/nudge.test.ts +1 -1
  50. package/src/commands/nudge.ts +41 -89
  51. package/src/commands/prime.test.ts +19 -51
  52. package/src/commands/prime.ts +13 -50
  53. package/src/commands/replay.test.ts +2 -2
  54. package/src/commands/replay.ts +79 -86
  55. package/src/commands/run.test.ts +1 -1
  56. package/src/commands/run.ts +97 -77
  57. package/src/commands/sling.test.ts +201 -5
  58. package/src/commands/sling.ts +37 -64
  59. package/src/commands/spec.test.ts +14 -40
  60. package/src/commands/spec.ts +32 -101
  61. package/src/commands/status.test.ts +97 -1
  62. package/src/commands/status.ts +63 -58
  63. package/src/commands/stop.test.ts +22 -40
  64. package/src/commands/stop.ts +18 -33
  65. package/src/commands/supervisor.test.ts +12 -14
  66. package/src/commands/supervisor.ts +144 -165
  67. package/src/commands/trace.test.ts +15 -15
  68. package/src/commands/trace.ts +59 -82
  69. package/src/commands/watch.test.ts +2 -2
  70. package/src/commands/watch.ts +38 -45
  71. package/src/commands/worktree.test.ts +213 -37
  72. package/src/commands/worktree.ts +110 -55
  73. package/src/config.test.ts +96 -0
  74. package/src/doctor/consistency.test.ts +14 -14
  75. package/src/doctor/databases.test.ts +22 -2
  76. package/src/doctor/databases.ts +16 -0
  77. package/src/doctor/dependencies.test.ts +55 -1
  78. package/src/doctor/dependencies.ts +113 -18
  79. package/src/doctor/merge-queue.test.ts +4 -4
  80. package/src/e2e/init-sling-lifecycle.test.ts +8 -8
  81. package/src/errors.ts +1 -1
  82. package/src/index.ts +223 -213
  83. package/src/logging/color.test.ts +74 -91
  84. package/src/logging/color.ts +52 -46
  85. package/src/logging/reporter.test.ts +10 -10
  86. package/src/logging/reporter.ts +6 -5
  87. package/src/mail/broadcast.test.ts +1 -1
  88. package/src/mail/client.test.ts +6 -6
  89. package/src/mail/store.test.ts +3 -3
  90. package/src/merge/queue.test.ts +73 -7
  91. package/src/merge/queue.ts +17 -2
  92. package/src/merge/resolver.test.ts +159 -7
  93. package/src/merge/resolver.ts +46 -2
  94. package/src/metrics/store.test.ts +44 -44
  95. package/src/metrics/store.ts +2 -2
  96. package/src/metrics/summary.test.ts +35 -35
  97. package/src/mulch/client.test.ts +1 -1
  98. package/src/schema-consistency.test.ts +239 -0
  99. package/src/sessions/compat.test.ts +3 -3
  100. package/src/sessions/compat.ts +2 -2
  101. package/src/sessions/store.test.ts +41 -4
  102. package/src/sessions/store.ts +13 -2
  103. package/src/types.ts +14 -14
  104. package/src/watchdog/daemon.test.ts +10 -10
  105. package/src/watchdog/daemon.ts +1 -1
  106. package/src/watchdog/health.test.ts +1 -1
  107. package/src/worktree/manager.test.ts +20 -20
  108. package/src/worktree/manager.ts +120 -4
  109. package/src/worktree/tmux.test.ts +98 -9
  110. package/src/worktree/tmux.ts +18 -0
@@ -33,7 +33,7 @@ afterEach(async () => {
33
33
  function makeSession(overrides: Partial<SessionMetrics> = {}): SessionMetrics {
34
34
  return {
35
35
  agentName: "test-agent",
36
- beadId: "test-task-123",
36
+ taskId: "test-task-123",
37
37
  capability: "builder",
38
38
  startedAt: new Date("2026-01-01T00:00:00Z").toISOString(),
39
39
  completedAt: new Date("2026-01-01T00:05:00Z").toISOString(),
@@ -66,9 +66,9 @@ describe("generateSummary", () => {
66
66
  });
67
67
 
68
68
  test("counts total and completed sessions correctly", () => {
69
- store.recordSession(makeSession({ beadId: "task-1", completedAt: "2026-01-01T00:05:00Z" }));
70
- store.recordSession(makeSession({ beadId: "task-2", completedAt: null }));
71
- store.recordSession(makeSession({ beadId: "task-3", completedAt: "2026-01-01T00:10:00Z" }));
69
+ store.recordSession(makeSession({ taskId: "task-1", completedAt: "2026-01-01T00:05:00Z" }));
70
+ store.recordSession(makeSession({ taskId: "task-2", completedAt: null }));
71
+ store.recordSession(makeSession({ taskId: "task-3", completedAt: "2026-01-01T00:10:00Z" }));
72
72
 
73
73
  const summary = generateSummary(store);
74
74
 
@@ -79,21 +79,21 @@ describe("generateSummary", () => {
79
79
  test("groups by capability with correct counts and avg durations", () => {
80
80
  store.recordSession(
81
81
  makeSession({
82
- beadId: "task-1",
82
+ taskId: "task-1",
83
83
  capability: "builder",
84
84
  durationMs: 100_000,
85
85
  }),
86
86
  );
87
87
  store.recordSession(
88
88
  makeSession({
89
- beadId: "task-2",
89
+ taskId: "task-2",
90
90
  capability: "builder",
91
91
  durationMs: 200_000,
92
92
  }),
93
93
  );
94
94
  store.recordSession(
95
95
  makeSession({
96
- beadId: "task-3",
96
+ taskId: "task-3",
97
97
  capability: "scout",
98
98
  durationMs: 50_000,
99
99
  }),
@@ -112,10 +112,10 @@ describe("generateSummary", () => {
112
112
  });
113
113
 
114
114
  test("respects the limit parameter for recentSessions", () => {
115
- store.recordSession(makeSession({ beadId: "task-1" }));
116
- store.recordSession(makeSession({ beadId: "task-2" }));
117
- store.recordSession(makeSession({ beadId: "task-3" }));
118
- store.recordSession(makeSession({ beadId: "task-4" }));
115
+ store.recordSession(makeSession({ taskId: "task-1" }));
116
+ store.recordSession(makeSession({ taskId: "task-2" }));
117
+ store.recordSession(makeSession({ taskId: "task-3" }));
118
+ store.recordSession(makeSession({ taskId: "task-4" }));
119
119
 
120
120
  const summary = generateSummary(store, 2);
121
121
 
@@ -124,9 +124,9 @@ describe("generateSummary", () => {
124
124
  });
125
125
 
126
126
  test("sessions without completedAt counted in total but not completed", () => {
127
- store.recordSession(makeSession({ beadId: "task-1", completedAt: null }));
128
- store.recordSession(makeSession({ beadId: "task-2", completedAt: null }));
129
- store.recordSession(makeSession({ beadId: "task-3", completedAt: "2026-01-01T00:05:00Z" }));
127
+ store.recordSession(makeSession({ taskId: "task-1", completedAt: null }));
128
+ store.recordSession(makeSession({ taskId: "task-2", completedAt: null }));
129
+ store.recordSession(makeSession({ taskId: "task-3", completedAt: "2026-01-01T00:05:00Z" }));
130
130
 
131
131
  const summary = generateSummary(store);
132
132
 
@@ -137,7 +137,7 @@ describe("generateSummary", () => {
137
137
  test("aggregates token totals across all sessions", () => {
138
138
  store.recordSession(
139
139
  makeSession({
140
- beadId: "task-1",
140
+ taskId: "task-1",
141
141
  inputTokens: 10_000,
142
142
  outputTokens: 2_000,
143
143
  cacheReadTokens: 50_000,
@@ -147,7 +147,7 @@ describe("generateSummary", () => {
147
147
  );
148
148
  store.recordSession(
149
149
  makeSession({
150
- beadId: "task-2",
150
+ taskId: "task-2",
151
151
  inputTokens: 20_000,
152
152
  outputTokens: 3_000,
153
153
  cacheReadTokens: 80_000,
@@ -166,7 +166,7 @@ describe("generateSummary", () => {
166
166
  });
167
167
 
168
168
  test("token totals are zero when no sessions have token data", () => {
169
- store.recordSession(makeSession({ beadId: "task-1" }));
169
+ store.recordSession(makeSession({ taskId: "task-1" }));
170
170
 
171
171
  const summary = generateSummary(store);
172
172
 
@@ -180,14 +180,14 @@ describe("generateSummary", () => {
180
180
  test("token totals skip null cost entries gracefully", () => {
181
181
  store.recordSession(
182
182
  makeSession({
183
- beadId: "task-1",
183
+ taskId: "task-1",
184
184
  inputTokens: 100,
185
185
  estimatedCostUsd: 0.5,
186
186
  }),
187
187
  );
188
188
  store.recordSession(
189
189
  makeSession({
190
- beadId: "task-2",
190
+ taskId: "task-2",
191
191
  inputTokens: 200,
192
192
  estimatedCostUsd: null, // no cost data
193
193
  }),
@@ -202,7 +202,7 @@ describe("generateSummary", () => {
202
202
  test("capability breakdown excludes incomplete sessions from avgDurationMs", () => {
203
203
  store.recordSession(
204
204
  makeSession({
205
- beadId: "task-1",
205
+ taskId: "task-1",
206
206
  capability: "builder",
207
207
  durationMs: 100_000,
208
208
  completedAt: null,
@@ -210,14 +210,14 @@ describe("generateSummary", () => {
210
210
  );
211
211
  store.recordSession(
212
212
  makeSession({
213
- beadId: "task-2",
213
+ taskId: "task-2",
214
214
  capability: "builder",
215
215
  durationMs: 200_000,
216
216
  }),
217
217
  );
218
218
  store.recordSession(
219
219
  makeSession({
220
- beadId: "task-3",
220
+ taskId: "task-3",
221
221
  capability: "builder",
222
222
  durationMs: 300_000,
223
223
  }),
@@ -242,8 +242,8 @@ describe("formatSummary", () => {
242
242
  });
243
243
 
244
244
  test("shows total/completed/average duration", () => {
245
- store.recordSession(makeSession({ beadId: "task-1", durationMs: 100_000 }));
246
- store.recordSession(makeSession({ beadId: "task-2", durationMs: 200_000 }));
245
+ store.recordSession(makeSession({ taskId: "task-1", durationMs: 100_000 }));
246
+ store.recordSession(makeSession({ taskId: "task-2", durationMs: 200_000 }));
247
247
 
248
248
  const summary = generateSummary(store);
249
249
  const formatted = formatSummary(summary);
@@ -254,8 +254,8 @@ describe("formatSummary", () => {
254
254
  });
255
255
 
256
256
  test("shows capability breakdown", () => {
257
- store.recordSession(makeSession({ beadId: "task-1", capability: "builder" }));
258
- store.recordSession(makeSession({ beadId: "task-2", capability: "scout" }));
257
+ store.recordSession(makeSession({ taskId: "task-1", capability: "builder" }));
258
+ store.recordSession(makeSession({ taskId: "task-2", capability: "scout" }));
259
259
 
260
260
  const summary = generateSummary(store);
261
261
  const formatted = formatSummary(summary);
@@ -268,14 +268,14 @@ describe("formatSummary", () => {
268
268
  test("shows recent sessions with status (done vs running)", () => {
269
269
  store.recordSession(
270
270
  makeSession({
271
- beadId: "task-1",
271
+ taskId: "task-1",
272
272
  agentName: "agent-done",
273
273
  completedAt: "2026-01-01T00:05:00Z",
274
274
  }),
275
275
  );
276
276
  store.recordSession(
277
277
  makeSession({
278
- beadId: "task-2",
278
+ taskId: "task-2",
279
279
  agentName: "agent-running",
280
280
  completedAt: null,
281
281
  }),
@@ -293,7 +293,7 @@ describe("formatSummary", () => {
293
293
  });
294
294
 
295
295
  test("formatDuration: <1000ms shows ms", () => {
296
- store.recordSession(makeSession({ beadId: "task-1", durationMs: 500 }));
296
+ store.recordSession(makeSession({ taskId: "task-1", durationMs: 500 }));
297
297
 
298
298
  const summary = generateSummary(store);
299
299
  const formatted = formatSummary(summary);
@@ -302,7 +302,7 @@ describe("formatSummary", () => {
302
302
  });
303
303
 
304
304
  test("formatDuration: <60000ms shows seconds", () => {
305
- store.recordSession(makeSession({ beadId: "task-1", durationMs: 5_500 }));
305
+ store.recordSession(makeSession({ taskId: "task-1", durationMs: 5_500 }));
306
306
 
307
307
  const summary = generateSummary(store);
308
308
  const formatted = formatSummary(summary);
@@ -311,7 +311,7 @@ describe("formatSummary", () => {
311
311
  });
312
312
 
313
313
  test("formatDuration: >=60000ms shows minutes+seconds", () => {
314
- store.recordSession(makeSession({ beadId: "task-1", durationMs: 125_000 }));
314
+ store.recordSession(makeSession({ taskId: "task-1", durationMs: 125_000 }));
315
315
 
316
316
  const summary = generateSummary(store);
317
317
  const formatted = formatSummary(summary);
@@ -322,7 +322,7 @@ describe("formatSummary", () => {
322
322
  test("shows token usage section when sessions have token data", () => {
323
323
  store.recordSession(
324
324
  makeSession({
325
- beadId: "task-1",
325
+ taskId: "task-1",
326
326
  inputTokens: 15_000,
327
327
  outputTokens: 3_000,
328
328
  cacheReadTokens: 100_000,
@@ -344,7 +344,7 @@ describe("formatSummary", () => {
344
344
  });
345
345
 
346
346
  test("hides token usage section when no token data exists", () => {
347
- store.recordSession(makeSession({ beadId: "task-1" }));
347
+ store.recordSession(makeSession({ taskId: "task-1" }));
348
348
 
349
349
  const summary = generateSummary(store);
350
350
  const formatted = formatSummary(summary);
@@ -355,7 +355,7 @@ describe("formatSummary", () => {
355
355
  test("shows per-session cost in recent sessions", () => {
356
356
  store.recordSession(
357
357
  makeSession({
358
- beadId: "task-1",
358
+ taskId: "task-1",
359
359
  agentName: "agent-costly",
360
360
  inputTokens: 10_000,
361
361
  outputTokens: 2_000,
@@ -373,7 +373,7 @@ describe("formatSummary", () => {
373
373
  test("formats large token counts with M suffix", () => {
374
374
  store.recordSession(
375
375
  makeSession({
376
- beadId: "task-1",
376
+ taskId: "task-1",
377
377
  inputTokens: 2_500_000,
378
378
  outputTokens: 500_000,
379
379
  cacheReadTokens: 0,
@@ -360,7 +360,7 @@ describe("createMulchClient", () => {
360
360
  await addProc.exited;
361
361
 
362
362
  const client = createMulchClient(tempDir);
363
- // The flag is passed correctly, but may fail if the bead ID is invalid
363
+ // The flag is passed correctly, but may fail if the task ID is invalid
364
364
  // or if other required fields are missing. This test documents that the
365
365
  // flag is properly passed to the CLI.
366
366
  try {
@@ -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
+ });
@@ -39,7 +39,7 @@ function makeJsonSession(overrides: Record<string, unknown> = {}): Record<string
39
39
  capability: "builder",
40
40
  worktreePath: "/tmp/worktrees/test-agent",
41
41
  branchName: "overstory/test-agent/task-1",
42
- beadId: "task-1",
42
+ taskId: "task-1",
43
43
  tmuxSession: "overstory-test-agent",
44
44
  state: "working",
45
45
  pid: 12345,
@@ -198,7 +198,7 @@ describe("data integrity", () => {
198
198
  capability: "scout",
199
199
  worktreePath: "/tmp/worktrees/full-agent",
200
200
  branchName: "overstory/full-agent/task-42",
201
- beadId: "task-42",
201
+ taskId: "task-42",
202
202
  tmuxSession: "overstory-full-agent",
203
203
  state: "stalled",
204
204
  pid: 99999,
@@ -221,7 +221,7 @@ describe("data integrity", () => {
221
221
  expect(result?.capability).toBe("scout");
222
222
  expect(result?.worktreePath).toBe("/tmp/worktrees/full-agent");
223
223
  expect(result?.branchName).toBe("overstory/full-agent/task-42");
224
- expect(result?.beadId).toBe("task-42");
224
+ expect(result?.taskId).toBe("task-42");
225
225
  expect(result?.tmuxSession).toBe("overstory-full-agent");
226
226
  expect(result?.state).toBe("stalled");
227
227
  expect(result?.pid).toBe(99999);
@@ -25,7 +25,7 @@ function normalizeSession(raw: Record<string, unknown>): AgentSession {
25
25
  capability: raw.capability as string,
26
26
  worktreePath: raw.worktreePath as string,
27
27
  branchName: raw.branchName as string,
28
- beadId: raw.beadId as string,
28
+ taskId: raw.taskId as string,
29
29
  tmuxSession: raw.tmuxSession as string,
30
30
  state: raw.state as AgentSession["state"],
31
31
  pid: (raw.pid as number | null) ?? null,
@@ -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
 
@@ -35,7 +35,7 @@ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
35
35
  capability: "builder",
36
36
  worktreePath: "/tmp/worktrees/test-agent",
37
37
  branchName: "overstory/test-agent/task-1",
38
- beadId: "task-1",
38
+ taskId: "task-1",
39
39
  tmuxSession: "overstory-test-agent",
40
40
  state: "booting",
41
41
  pid: 12345,
@@ -81,7 +81,7 @@ describe("upsert", () => {
81
81
  capability: "scout",
82
82
  worktreePath: "/tmp/worktrees/roundtrip",
83
83
  branchName: "overstory/roundtrip-agent/task-42",
84
- beadId: "task-42",
84
+ taskId: "task-42",
85
85
  tmuxSession: "overstory-roundtrip-agent",
86
86
  state: "working",
87
87
  pid: 99999,
@@ -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", () => {
@@ -510,11 +547,11 @@ describe("edge cases", () => {
510
547
  });
511
548
 
512
549
  test("empty string fields are stored correctly", () => {
513
- const session = makeSession({ beadId: "", capability: "builder" });
550
+ const session = makeSession({ taskId: "", capability: "builder" });
514
551
  store.upsert(session);
515
552
 
516
553
  const result = store.getByName("test-agent");
517
- expect(result?.beadId).toBe("");
554
+ expect(result?.taskId).toBe("");
518
555
  });
519
556
  });
520
557
 
@@ -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. */
@@ -111,7 +113,7 @@ function rowToSession(row: SessionRow): AgentSession {
111
113
  capability: row.capability,
112
114
  worktreePath: row.worktree_path,
113
115
  branchName: row.branch_name,
114
- beadId: row.task_id,
116
+ taskId: row.task_id,
115
117
  tmuxSession: row.tmux_session,
116
118
  state: row.state as AgentState,
117
119
  pid: row.pid,
@@ -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
  `);
@@ -270,7 +276,7 @@ export function createSessionStore(dbPath: string): SessionStore {
270
276
  $capability: session.capability,
271
277
  $worktree_path: session.worktreePath,
272
278
  $branch_name: session.branchName,
273
- $task_id: session.beadId,
279
+ $task_id: session.taskId,
274
280
  $tmux_session: session.tmuxSession,
275
281
  $state: session.state,
276
282
  $pid: session.pid,
@@ -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);