@katyella/legio 0.1.0

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 (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,444 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
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 `legio metrics` command.
11
+ *
12
+ * Uses real better-sqlite3 (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 .legio/config.yaml structure
32
+ tempDir = await mkdtemp(join(tmpdir(), "metrics-test-"));
33
+ const legioDir = join(tempDir, ".legio");
34
+ await mkdir(legioDir, { recursive: true });
35
+ await writeFile(
36
+ join(legioDir, "config.yaml"),
37
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
38
+ );
39
+
40
+ // Change to temp dir so loadConfig() works
41
+ originalCwd = process.cwd();
42
+ process.chdir(tempDir);
43
+ });
44
+
45
+ afterEach(async () => {
46
+ process.stdout.write = originalWrite;
47
+ process.chdir(originalCwd);
48
+ await rm(tempDir, { recursive: true, force: true });
49
+ });
50
+
51
+ function output(): string {
52
+ return chunks.join("");
53
+ }
54
+
55
+ function makeSession(overrides: Partial<SessionMetrics> = {}): SessionMetrics {
56
+ return {
57
+ agentName: "test-agent",
58
+ beadId: "bead-001",
59
+ capability: "builder",
60
+ startedAt: new Date(Date.now() - 120_000).toISOString(),
61
+ completedAt: new Date().toISOString(),
62
+ durationMs: 120_000,
63
+ exitCode: 0,
64
+ mergeResult: "clean-merge",
65
+ parentAgent: null,
66
+ inputTokens: 0,
67
+ outputTokens: 0,
68
+ cacheReadTokens: 0,
69
+ cacheCreationTokens: 0,
70
+ estimatedCostUsd: null,
71
+ modelUsed: 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("legio 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("legio 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, ".legio", "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, ".legio", "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, ".legio", "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, ".legio", "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, ".legio", "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, ".legio", "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, ".legio", "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 legioDir = join(tempDir, ".legio");
344
+ await mkdir(legioDir, { recursive: true });
345
+ await writeFile(
346
+ join(legioDir, "config.yaml"),
347
+ `project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
348
+ );
349
+
350
+ originalCwd = process.cwd();
351
+ process.chdir(tempDir);
352
+ });
353
+
354
+ afterEach(async () => {
355
+ process.stdout.write = originalWrite;
356
+ process.chdir(originalCwd);
357
+ await rm(tempDir, { recursive: true, force: true });
358
+ });
359
+
360
+ function output(): string {
361
+ return chunks.join("");
362
+ }
363
+
364
+ function makeSession(durationMs: number): SessionMetrics {
365
+ return {
366
+ agentName: "test-agent",
367
+ beadId: "bead-001",
368
+ capability: "builder",
369
+ startedAt: new Date(Date.now() - durationMs).toISOString(),
370
+ completedAt: new Date().toISOString(),
371
+ durationMs,
372
+ exitCode: 0,
373
+ mergeResult: "clean-merge",
374
+ parentAgent: null,
375
+ inputTokens: 0,
376
+ outputTokens: 0,
377
+ cacheReadTokens: 0,
378
+ cacheCreationTokens: 0,
379
+ estimatedCostUsd: null,
380
+ modelUsed: null,
381
+ };
382
+ }
383
+
384
+ test("0ms formats as 0s", async () => {
385
+ const dbPath = join(tempDir, ".legio", "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, ".legio", "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, ".legio", "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, ".legio", "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, ".legio", "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,150 @@
1
+ /**
2
+ * CLI command: legio 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 { access } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { loadConfig } from "../config.ts";
11
+ import { createMetricsStore } from "../metrics/store.ts";
12
+
13
+ /**
14
+ * Parse a named flag value from args.
15
+ */
16
+ function getFlag(args: string[], flag: string): string | undefined {
17
+ const idx = args.indexOf(flag);
18
+ if (idx === -1 || idx + 1 >= args.length) {
19
+ return undefined;
20
+ }
21
+ return args[idx + 1];
22
+ }
23
+
24
+ function hasFlag(args: string[], flag: string): boolean {
25
+ return args.includes(flag);
26
+ }
27
+
28
+ /**
29
+ * Format milliseconds as human-readable duration.
30
+ */
31
+ function formatDuration(ms: number): string {
32
+ if (ms === 0) return "0s";
33
+ const seconds = Math.floor(ms / 1000);
34
+ if (seconds < 60) return `${seconds}s`;
35
+ const minutes = Math.floor(seconds / 60);
36
+ const remainSec = seconds % 60;
37
+ if (minutes < 60) return `${minutes}m ${remainSec}s`;
38
+ const hours = Math.floor(minutes / 60);
39
+ const remainMin = minutes % 60;
40
+ return `${hours}h ${remainMin}m`;
41
+ }
42
+
43
+ /**
44
+ * Entry point for `legio metrics [--last <n>] [--json]`.
45
+ */
46
+ const METRICS_HELP = `legio metrics — Show session metrics
47
+
48
+ Usage: legio metrics [--last <n>] [--json]
49
+
50
+ Options:
51
+ --last <n> Number of recent sessions to show (default: 20)
52
+ --json Output as JSON
53
+ --help, -h Show this help`;
54
+
55
+ export async function metricsCommand(args: string[]): Promise<void> {
56
+ if (args.includes("--help") || args.includes("-h")) {
57
+ process.stdout.write(`${METRICS_HELP}\n`);
58
+ return;
59
+ }
60
+
61
+ const lastStr = getFlag(args, "--last");
62
+ const limit = lastStr ? Number.parseInt(lastStr, 10) : 20;
63
+ const json = hasFlag(args, "--json");
64
+
65
+ const cwd = process.cwd();
66
+ const config = await loadConfig(cwd);
67
+ const dbPath = join(config.project.root, ".legio", "metrics.db");
68
+
69
+ let dbFileExists = false;
70
+ try {
71
+ await access(dbPath);
72
+ dbFileExists = true;
73
+ } catch {
74
+ /* not found */
75
+ }
76
+ if (!dbFileExists) {
77
+ if (json) {
78
+ process.stdout.write('{"sessions":[]}\n');
79
+ } else {
80
+ process.stdout.write("No metrics data yet.\n");
81
+ }
82
+ return;
83
+ }
84
+
85
+ const store = createMetricsStore(dbPath);
86
+
87
+ try {
88
+ const sessions = store.getRecentSessions(limit);
89
+
90
+ if (json) {
91
+ process.stdout.write(`${JSON.stringify({ sessions })}\n`);
92
+ return;
93
+ }
94
+
95
+ if (sessions.length === 0) {
96
+ process.stdout.write("No sessions recorded yet.\n");
97
+ return;
98
+ }
99
+
100
+ process.stdout.write("📈 Session Metrics\n");
101
+ process.stdout.write(`${"═".repeat(60)}\n\n`);
102
+
103
+ // Summary stats
104
+ const completed = sessions.filter((s) => s.completedAt !== null);
105
+ const avgDuration = store.getAverageDuration();
106
+
107
+ process.stdout.write(`Total sessions: ${sessions.length}\n`);
108
+ process.stdout.write(`Completed: ${completed.length}\n`);
109
+ process.stdout.write(`Avg duration: ${formatDuration(avgDuration)}\n\n`);
110
+
111
+ // Merge tier distribution
112
+ const tierCounts: Record<string, number> = {};
113
+ for (const s of completed) {
114
+ if (s.mergeResult) {
115
+ tierCounts[s.mergeResult] = (tierCounts[s.mergeResult] ?? 0) + 1;
116
+ }
117
+ }
118
+ if (Object.keys(tierCounts).length > 0) {
119
+ process.stdout.write("Merge tiers:\n");
120
+ for (const [tier, count] of Object.entries(tierCounts)) {
121
+ process.stdout.write(` ${tier}: ${count}\n`);
122
+ }
123
+ process.stdout.write("\n");
124
+ }
125
+
126
+ // Capability breakdown
127
+ const capCounts: Record<string, number> = {};
128
+ for (const s of sessions) {
129
+ capCounts[s.capability] = (capCounts[s.capability] ?? 0) + 1;
130
+ }
131
+ process.stdout.write("By capability:\n");
132
+ for (const [cap, count] of Object.entries(capCounts)) {
133
+ const capAvg = store.getAverageDuration(cap);
134
+ process.stdout.write(` ${cap}: ${count} sessions (avg ${formatDuration(capAvg)})\n`);
135
+ }
136
+ process.stdout.write("\n");
137
+
138
+ // Recent sessions table
139
+ process.stdout.write("Recent sessions:\n");
140
+ for (const s of sessions) {
141
+ const status = s.completedAt ? "done" : "running";
142
+ const duration = formatDuration(s.durationMs);
143
+ process.stdout.write(
144
+ ` ${s.agentName} [${s.capability}] ${s.beadId} | ${status} | ${duration}\n`,
145
+ );
146
+ }
147
+ } finally {
148
+ store.close();
149
+ }
150
+ }