@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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- 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
|
+
}
|