@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,444 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createMetricsStore } from "../metrics/store.ts";
6
+ import type { SessionMetrics } from "../types.ts";
7
+ import { metricsCommand } from "./metrics.ts";
8
+
9
+ /**
10
+ * Tests for `overstory metrics` command.
11
+ *
12
+ * Uses real bun:sqlite (temp files) to test the metrics command end-to-end.
13
+ * Captures process.stdout.write to verify output formatting.
14
+ */
15
+
16
+ describe("metricsCommand", () => {
17
+ let chunks: string[];
18
+ let originalWrite: typeof process.stdout.write;
19
+ let tempDir: string;
20
+ let originalCwd: string;
21
+
22
+ beforeEach(async () => {
23
+ // Spy on stdout
24
+ chunks = [];
25
+ originalWrite = process.stdout.write;
26
+ process.stdout.write = ((chunk: string) => {
27
+ chunks.push(chunk);
28
+ return true;
29
+ }) as typeof process.stdout.write;
30
+
31
+ // Create temp dir with .overstory/config.yaml structure
32
+ tempDir = await mkdtemp(join(tmpdir(), "metrics-test-"));
33
+ const overstoryDir = join(tempDir, ".overstory");
34
+ await Bun.write(
35
+ join(overstoryDir, "config.yaml"),
36
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
37
+ );
38
+
39
+ // Change to temp dir so loadConfig() works
40
+ originalCwd = process.cwd();
41
+ process.chdir(tempDir);
42
+ });
43
+
44
+ afterEach(async () => {
45
+ process.stdout.write = originalWrite;
46
+ process.chdir(originalCwd);
47
+ await rm(tempDir, { recursive: true, force: true });
48
+ });
49
+
50
+ function output(): string {
51
+ return chunks.join("");
52
+ }
53
+
54
+ function makeSession(overrides: Partial<SessionMetrics> = {}): SessionMetrics {
55
+ return {
56
+ agentName: "test-agent",
57
+ beadId: "bead-001",
58
+ capability: "builder",
59
+ startedAt: new Date(Date.now() - 120_000).toISOString(),
60
+ completedAt: new Date().toISOString(),
61
+ durationMs: 120_000,
62
+ exitCode: 0,
63
+ mergeResult: "clean-merge",
64
+ parentAgent: null,
65
+ inputTokens: 0,
66
+ outputTokens: 0,
67
+ cacheReadTokens: 0,
68
+ cacheCreationTokens: 0,
69
+ estimatedCostUsd: null,
70
+ modelUsed: null,
71
+ runId: null,
72
+ ...overrides,
73
+ };
74
+ }
75
+
76
+ test("--help flag shows help text", async () => {
77
+ await metricsCommand(["--help"]);
78
+ const out = output();
79
+
80
+ expect(out).toContain("overstory metrics");
81
+ expect(out).toContain("--last <n>");
82
+ expect(out).toContain("--json");
83
+ expect(out).toContain("--help");
84
+ });
85
+
86
+ test("-h flag shows help text", async () => {
87
+ await metricsCommand(["-h"]);
88
+ const out = output();
89
+
90
+ expect(out).toContain("overstory metrics");
91
+ expect(out).toContain("--last <n>");
92
+ });
93
+
94
+ test("no metrics DB returns empty message (text)", async () => {
95
+ await metricsCommand([]);
96
+ const out = output();
97
+
98
+ expect(out).toBe("No metrics data yet.\n");
99
+ });
100
+
101
+ test("no metrics DB returns empty JSON (--json)", async () => {
102
+ await metricsCommand(["--json"]);
103
+ const out = output();
104
+
105
+ expect(out).toBe('{"sessions":[]}\n');
106
+ });
107
+
108
+ test("empty DB with no sessions", async () => {
109
+ // Create the DB but don't insert any sessions
110
+ const dbPath = join(tempDir, ".overstory", "metrics.db");
111
+ const store = createMetricsStore(dbPath);
112
+ store.close();
113
+
114
+ await metricsCommand([]);
115
+ const out = output();
116
+
117
+ expect(out).toBe("No sessions recorded yet.\n");
118
+ });
119
+
120
+ test("basic output with sample sessions", async () => {
121
+ const dbPath = join(tempDir, ".overstory", "metrics.db");
122
+ const store = createMetricsStore(dbPath);
123
+
124
+ // Insert sample sessions
125
+ store.recordSession(
126
+ makeSession({
127
+ agentName: "builder-1",
128
+ capability: "builder",
129
+ durationMs: 45_000,
130
+ }),
131
+ );
132
+ store.recordSession(
133
+ makeSession({
134
+ agentName: "scout-1",
135
+ capability: "scout",
136
+ durationMs: 90_000,
137
+ }),
138
+ );
139
+ store.recordSession(
140
+ makeSession({
141
+ agentName: "builder-2",
142
+ capability: "builder",
143
+ durationMs: 30_000,
144
+ completedAt: null, // Still running
145
+ }),
146
+ );
147
+
148
+ store.close();
149
+
150
+ await metricsCommand([]);
151
+ const out = output();
152
+
153
+ // Check summary stats
154
+ expect(out).toContain("📈 Session Metrics");
155
+ expect(out).toContain("Total sessions: 3");
156
+ expect(out).toContain("Completed: 2");
157
+ expect(out).toContain("Avg duration:");
158
+
159
+ // Check capability breakdown
160
+ expect(out).toContain("By capability:");
161
+ expect(out).toContain("builder:");
162
+ expect(out).toContain("scout:");
163
+
164
+ // Check recent sessions table
165
+ expect(out).toContain("Recent sessions:");
166
+ expect(out).toContain("builder-1");
167
+ expect(out).toContain("scout-1");
168
+ expect(out).toContain("builder-2");
169
+ expect(out).toContain("done");
170
+ expect(out).toContain("running");
171
+ });
172
+
173
+ test("--json flag returns structured JSON", async () => {
174
+ const dbPath = join(tempDir, ".overstory", "metrics.db");
175
+ const store = createMetricsStore(dbPath);
176
+
177
+ store.recordSession(
178
+ makeSession({
179
+ agentName: "test-builder",
180
+ beadId: "bead-123",
181
+ capability: "builder",
182
+ }),
183
+ );
184
+
185
+ store.close();
186
+
187
+ await metricsCommand(["--json"]);
188
+ const out = output();
189
+
190
+ const parsed = JSON.parse(out.trim()) as { sessions: SessionMetrics[] };
191
+ expect(parsed.sessions).toHaveLength(1);
192
+ expect(parsed.sessions[0]?.agentName).toBe("test-builder");
193
+ expect(parsed.sessions[0]?.beadId).toBe("bead-123");
194
+ expect(parsed.sessions[0]?.capability).toBe("builder");
195
+ });
196
+
197
+ test("--last flag limits number of sessions", async () => {
198
+ const dbPath = join(tempDir, ".overstory", "metrics.db");
199
+ const store = createMetricsStore(dbPath);
200
+
201
+ // Insert 5 sessions
202
+ for (let i = 0; i < 5; i++) {
203
+ store.recordSession(
204
+ makeSession({
205
+ agentName: `agent-${i}`,
206
+ beadId: `bead-${i}`,
207
+ startedAt: new Date(Date.now() - (5 - i) * 1000).toISOString(),
208
+ }),
209
+ );
210
+ }
211
+
212
+ store.close();
213
+
214
+ await metricsCommand(["--last", "2"]);
215
+ const out = output();
216
+
217
+ // Should only show 2 sessions
218
+ expect(out).toContain("Total sessions: 2");
219
+ });
220
+
221
+ test("--last flag with --json limits sessions", async () => {
222
+ const dbPath = join(tempDir, ".overstory", "metrics.db");
223
+ const store = createMetricsStore(dbPath);
224
+
225
+ // Insert 5 sessions
226
+ for (let i = 0; i < 5; i++) {
227
+ store.recordSession(
228
+ makeSession({
229
+ agentName: `agent-${i}`,
230
+ beadId: `bead-${i}`,
231
+ }),
232
+ );
233
+ }
234
+
235
+ store.close();
236
+
237
+ await metricsCommand(["--last", "3", "--json"]);
238
+ const out = output();
239
+
240
+ const parsed = JSON.parse(out.trim()) as { sessions: SessionMetrics[] };
241
+ expect(parsed.sessions).toHaveLength(3);
242
+ });
243
+
244
+ test("merge tier distribution shows in output", async () => {
245
+ const dbPath = join(tempDir, ".overstory", "metrics.db");
246
+ const store = createMetricsStore(dbPath);
247
+
248
+ // Insert sessions with different merge tiers
249
+ store.recordSession(
250
+ makeSession({
251
+ agentName: "agent-1",
252
+ mergeResult: "clean-merge",
253
+ }),
254
+ );
255
+ store.recordSession(
256
+ makeSession({
257
+ agentName: "agent-2",
258
+ mergeResult: "clean-merge",
259
+ }),
260
+ );
261
+ store.recordSession(
262
+ makeSession({
263
+ agentName: "agent-3",
264
+ mergeResult: "auto-resolve",
265
+ }),
266
+ );
267
+ store.recordSession(
268
+ makeSession({
269
+ agentName: "agent-4",
270
+ mergeResult: "ai-resolve",
271
+ }),
272
+ );
273
+
274
+ store.close();
275
+
276
+ await metricsCommand([]);
277
+ const out = output();
278
+
279
+ // Check merge tier counts
280
+ expect(out).toContain("Merge tiers:");
281
+ expect(out).toContain("clean-merge: 2");
282
+ expect(out).toContain("auto-resolve: 1");
283
+ expect(out).toContain("ai-resolve: 1");
284
+ });
285
+
286
+ test("sessions without merge results don't show in tier distribution", async () => {
287
+ const dbPath = join(tempDir, ".overstory", "metrics.db");
288
+ const store = createMetricsStore(dbPath);
289
+
290
+ // Insert sessions: one with merge result, two without
291
+ store.recordSession(
292
+ makeSession({
293
+ agentName: "agent-1",
294
+ mergeResult: "clean-merge",
295
+ }),
296
+ );
297
+ store.recordSession(
298
+ makeSession({
299
+ agentName: "agent-2",
300
+ mergeResult: null,
301
+ }),
302
+ );
303
+ store.recordSession(
304
+ makeSession({
305
+ agentName: "agent-3",
306
+ mergeResult: null,
307
+ completedAt: null,
308
+ }),
309
+ );
310
+
311
+ store.close();
312
+
313
+ await metricsCommand([]);
314
+ const out = output();
315
+
316
+ expect(out).toContain("Merge tiers:");
317
+ expect(out).toContain("clean-merge: 1");
318
+ // Should not include sessions without merge results
319
+ expect(out).toContain("Total sessions: 3");
320
+ expect(out).toContain("Completed: 2");
321
+ });
322
+ });
323
+
324
+ describe("formatDuration helper", () => {
325
+ // We need to test the formatDuration helper directly, but it's not exported.
326
+ // We can infer its behavior from the output format.
327
+ // Alternatively, we can test it indirectly through the command output.
328
+
329
+ let chunks: string[];
330
+ let originalWrite: typeof process.stdout.write;
331
+ let tempDir: string;
332
+ let originalCwd: string;
333
+
334
+ beforeEach(async () => {
335
+ chunks = [];
336
+ originalWrite = process.stdout.write;
337
+ process.stdout.write = ((chunk: string) => {
338
+ chunks.push(chunk);
339
+ return true;
340
+ }) as typeof process.stdout.write;
341
+
342
+ tempDir = await mkdtemp(join(tmpdir(), "metrics-test-"));
343
+ const overstoryDir = join(tempDir, ".overstory");
344
+ await Bun.write(
345
+ join(overstoryDir, "config.yaml"),
346
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
347
+ );
348
+
349
+ originalCwd = process.cwd();
350
+ process.chdir(tempDir);
351
+ });
352
+
353
+ afterEach(async () => {
354
+ process.stdout.write = originalWrite;
355
+ process.chdir(originalCwd);
356
+ await rm(tempDir, { recursive: true, force: true });
357
+ });
358
+
359
+ function output(): string {
360
+ return chunks.join("");
361
+ }
362
+
363
+ function makeSession(durationMs: number): SessionMetrics {
364
+ return {
365
+ agentName: "test-agent",
366
+ beadId: "bead-001",
367
+ capability: "builder",
368
+ startedAt: new Date(Date.now() - durationMs).toISOString(),
369
+ completedAt: new Date().toISOString(),
370
+ durationMs,
371
+ exitCode: 0,
372
+ mergeResult: "clean-merge",
373
+ parentAgent: null,
374
+ inputTokens: 0,
375
+ outputTokens: 0,
376
+ cacheReadTokens: 0,
377
+ cacheCreationTokens: 0,
378
+ estimatedCostUsd: null,
379
+ modelUsed: null,
380
+ runId: null,
381
+ };
382
+ }
383
+
384
+ test("0ms formats as 0s", async () => {
385
+ const dbPath = join(tempDir, ".overstory", "metrics.db");
386
+ const store = createMetricsStore(dbPath);
387
+ store.recordSession(makeSession(0));
388
+ store.close();
389
+
390
+ await metricsCommand([]);
391
+ const out = output();
392
+
393
+ // Should contain "0s" somewhere in the output
394
+ expect(out).toContain("0s");
395
+ });
396
+
397
+ test("45000ms formats as 45s", async () => {
398
+ const dbPath = join(tempDir, ".overstory", "metrics.db");
399
+ const store = createMetricsStore(dbPath);
400
+ store.recordSession(makeSession(45_000));
401
+ store.close();
402
+
403
+ await metricsCommand([]);
404
+ const out = output();
405
+
406
+ expect(out).toContain("45s");
407
+ });
408
+
409
+ test("90000ms formats as 1m 30s", async () => {
410
+ const dbPath = join(tempDir, ".overstory", "metrics.db");
411
+ const store = createMetricsStore(dbPath);
412
+ store.recordSession(makeSession(90_000));
413
+ store.close();
414
+
415
+ await metricsCommand([]);
416
+ const out = output();
417
+
418
+ expect(out).toContain("1m 30s");
419
+ });
420
+
421
+ test("3720000ms formats as 1h 2m", async () => {
422
+ const dbPath = join(tempDir, ".overstory", "metrics.db");
423
+ const store = createMetricsStore(dbPath);
424
+ store.recordSession(makeSession(3_720_000));
425
+ store.close();
426
+
427
+ await metricsCommand([]);
428
+ const out = output();
429
+
430
+ expect(out).toContain("1h 2m");
431
+ });
432
+
433
+ test("3600000ms formats as 1h 0m", async () => {
434
+ const dbPath = join(tempDir, ".overstory", "metrics.db");
435
+ const store = createMetricsStore(dbPath);
436
+ store.recordSession(makeSession(3_600_000));
437
+ store.close();
438
+
439
+ await metricsCommand([]);
440
+ const out = output();
441
+
442
+ expect(out).toContain("1h 0m");
443
+ });
444
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * CLI command: overstory metrics [--last <n>] [--json]
3
+ *
4
+ * Shows metrics summary from SQLite store: session durations, success rates,
5
+ * merge tier distribution, agent utilization.
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { loadConfig } from "../config.ts";
10
+ import { createMetricsStore } from "../metrics/store.ts";
11
+
12
+ /**
13
+ * Parse a named flag value from args.
14
+ */
15
+ function getFlag(args: string[], flag: string): string | undefined {
16
+ const idx = args.indexOf(flag);
17
+ if (idx === -1 || idx + 1 >= args.length) {
18
+ return undefined;
19
+ }
20
+ return args[idx + 1];
21
+ }
22
+
23
+ function hasFlag(args: string[], flag: string): boolean {
24
+ return args.includes(flag);
25
+ }
26
+
27
+ /**
28
+ * Format milliseconds as human-readable duration.
29
+ */
30
+ function formatDuration(ms: number): string {
31
+ if (ms === 0) return "0s";
32
+ const seconds = Math.floor(ms / 1000);
33
+ if (seconds < 60) return `${seconds}s`;
34
+ const minutes = Math.floor(seconds / 60);
35
+ const remainSec = seconds % 60;
36
+ if (minutes < 60) return `${minutes}m ${remainSec}s`;
37
+ const hours = Math.floor(minutes / 60);
38
+ const remainMin = minutes % 60;
39
+ return `${hours}h ${remainMin}m`;
40
+ }
41
+
42
+ /**
43
+ * Entry point for `overstory metrics [--last <n>] [--json]`.
44
+ */
45
+ const METRICS_HELP = `overstory metrics — Show session metrics
46
+
47
+ Usage: overstory metrics [--last <n>] [--json]
48
+
49
+ Options:
50
+ --last <n> Number of recent sessions to show (default: 20)
51
+ --json Output as JSON
52
+ --help, -h Show this help`;
53
+
54
+ export async function metricsCommand(args: string[]): Promise<void> {
55
+ if (args.includes("--help") || args.includes("-h")) {
56
+ process.stdout.write(`${METRICS_HELP}\n`);
57
+ return;
58
+ }
59
+
60
+ const lastStr = getFlag(args, "--last");
61
+ const limit = lastStr ? Number.parseInt(lastStr, 10) : 20;
62
+ const json = hasFlag(args, "--json");
63
+
64
+ const cwd = process.cwd();
65
+ const config = await loadConfig(cwd);
66
+ const dbPath = join(config.project.root, ".overstory", "metrics.db");
67
+
68
+ const dbFile = Bun.file(dbPath);
69
+ if (!(await dbFile.exists())) {
70
+ if (json) {
71
+ process.stdout.write('{"sessions":[]}\n');
72
+ } else {
73
+ process.stdout.write("No metrics data yet.\n");
74
+ }
75
+ return;
76
+ }
77
+
78
+ const store = createMetricsStore(dbPath);
79
+
80
+ try {
81
+ const sessions = store.getRecentSessions(limit);
82
+
83
+ if (json) {
84
+ process.stdout.write(`${JSON.stringify({ sessions })}\n`);
85
+ return;
86
+ }
87
+
88
+ if (sessions.length === 0) {
89
+ process.stdout.write("No sessions recorded yet.\n");
90
+ return;
91
+ }
92
+
93
+ process.stdout.write("📈 Session Metrics\n");
94
+ process.stdout.write(`${"═".repeat(60)}\n\n`);
95
+
96
+ // Summary stats
97
+ const completed = sessions.filter((s) => s.completedAt !== null);
98
+ const avgDuration = store.getAverageDuration();
99
+
100
+ process.stdout.write(`Total sessions: ${sessions.length}\n`);
101
+ process.stdout.write(`Completed: ${completed.length}\n`);
102
+ process.stdout.write(`Avg duration: ${formatDuration(avgDuration)}\n\n`);
103
+
104
+ // Merge tier distribution
105
+ const tierCounts: Record<string, number> = {};
106
+ for (const s of completed) {
107
+ if (s.mergeResult) {
108
+ tierCounts[s.mergeResult] = (tierCounts[s.mergeResult] ?? 0) + 1;
109
+ }
110
+ }
111
+ if (Object.keys(tierCounts).length > 0) {
112
+ process.stdout.write("Merge tiers:\n");
113
+ for (const [tier, count] of Object.entries(tierCounts)) {
114
+ process.stdout.write(` ${tier}: ${count}\n`);
115
+ }
116
+ process.stdout.write("\n");
117
+ }
118
+
119
+ // Capability breakdown
120
+ const capCounts: Record<string, number> = {};
121
+ for (const s of sessions) {
122
+ capCounts[s.capability] = (capCounts[s.capability] ?? 0) + 1;
123
+ }
124
+ process.stdout.write("By capability:\n");
125
+ for (const [cap, count] of Object.entries(capCounts)) {
126
+ const capAvg = store.getAverageDuration(cap);
127
+ process.stdout.write(` ${cap}: ${count} sessions (avg ${formatDuration(capAvg)})\n`);
128
+ }
129
+ process.stdout.write("\n");
130
+
131
+ // Recent sessions table
132
+ process.stdout.write("Recent sessions:\n");
133
+ for (const s of sessions) {
134
+ const status = s.completedAt ? "done" : "running";
135
+ const duration = formatDuration(s.durationMs);
136
+ process.stdout.write(
137
+ ` ${s.agentName} [${s.capability}] ${s.beadId} | ${status} | ${duration}\n`,
138
+ );
139
+ }
140
+ } finally {
141
+ store.close();
142
+ }
143
+ }