@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,1119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `overstory costs` command.
|
|
3
|
+
*
|
|
4
|
+
* Uses real bun:sqlite (temp files) to test the costs command end-to-end.
|
|
5
|
+
* Captures process.stdout.write to verify output formatting.
|
|
6
|
+
*
|
|
7
|
+
* Real implementations used for: filesystem (temp dirs), SQLite (MetricsStore,
|
|
8
|
+
* SessionStore). No mocks needed -- all dependencies are cheap and local.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { ValidationError } from "../errors.ts";
|
|
16
|
+
import { createMetricsStore } from "../metrics/store.ts";
|
|
17
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
18
|
+
import type { SessionMetrics } from "../types.ts";
|
|
19
|
+
import { costsCommand } from "./costs.ts";
|
|
20
|
+
|
|
21
|
+
/** Helper to create a SessionMetrics with sensible defaults. */
|
|
22
|
+
function makeMetrics(overrides: Partial<SessionMetrics> = {}): SessionMetrics {
|
|
23
|
+
return {
|
|
24
|
+
agentName: "builder-1",
|
|
25
|
+
beadId: "task-001",
|
|
26
|
+
capability: "builder",
|
|
27
|
+
startedAt: new Date().toISOString(),
|
|
28
|
+
completedAt: new Date().toISOString(),
|
|
29
|
+
durationMs: 60000,
|
|
30
|
+
exitCode: 0,
|
|
31
|
+
mergeResult: null,
|
|
32
|
+
parentAgent: null,
|
|
33
|
+
inputTokens: 12345,
|
|
34
|
+
outputTokens: 5678,
|
|
35
|
+
cacheReadTokens: 8000,
|
|
36
|
+
cacheCreationTokens: 901,
|
|
37
|
+
estimatedCostUsd: 0.42,
|
|
38
|
+
modelUsed: "claude-sonnet-4-20250514",
|
|
39
|
+
runId: null,
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("costsCommand", () => {
|
|
45
|
+
let chunks: string[];
|
|
46
|
+
let originalWrite: typeof process.stdout.write;
|
|
47
|
+
let tempDir: string;
|
|
48
|
+
let originalCwd: string;
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
// Spy on stdout
|
|
52
|
+
chunks = [];
|
|
53
|
+
originalWrite = process.stdout.write;
|
|
54
|
+
process.stdout.write = ((chunk: string) => {
|
|
55
|
+
chunks.push(chunk);
|
|
56
|
+
return true;
|
|
57
|
+
}) as typeof process.stdout.write;
|
|
58
|
+
|
|
59
|
+
// Create temp dir with .overstory/config.yaml structure
|
|
60
|
+
tempDir = await mkdtemp(join(tmpdir(), "costs-test-"));
|
|
61
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
62
|
+
await Bun.write(
|
|
63
|
+
join(overstoryDir, "config.yaml"),
|
|
64
|
+
`project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Change to temp dir so loadConfig() works
|
|
68
|
+
originalCwd = process.cwd();
|
|
69
|
+
process.chdir(tempDir);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(async () => {
|
|
73
|
+
process.stdout.write = originalWrite;
|
|
74
|
+
process.chdir(originalCwd);
|
|
75
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function output(): string {
|
|
79
|
+
return chunks.join("");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// === Help flag ===
|
|
83
|
+
|
|
84
|
+
describe("help flag", () => {
|
|
85
|
+
test("--help shows help text", async () => {
|
|
86
|
+
await costsCommand(["--help"]);
|
|
87
|
+
const out = output();
|
|
88
|
+
|
|
89
|
+
expect(out).toContain("overstory costs");
|
|
90
|
+
expect(out).toContain("--agent");
|
|
91
|
+
expect(out).toContain("--run");
|
|
92
|
+
expect(out).toContain("--by-capability");
|
|
93
|
+
expect(out).toContain("--last");
|
|
94
|
+
expect(out).toContain("--json");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("-h shows help text", async () => {
|
|
98
|
+
await costsCommand(["-h"]);
|
|
99
|
+
const out = output();
|
|
100
|
+
|
|
101
|
+
expect(out).toContain("overstory costs");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// === Missing metrics.db (graceful handling) ===
|
|
106
|
+
|
|
107
|
+
describe("missing metrics.db", () => {
|
|
108
|
+
test("text mode outputs friendly message when no metrics.db exists", async () => {
|
|
109
|
+
await costsCommand([]);
|
|
110
|
+
const out = output();
|
|
111
|
+
|
|
112
|
+
expect(out).toBe("No metrics data yet.\n");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("JSON mode outputs empty array when no metrics.db exists", async () => {
|
|
116
|
+
await costsCommand(["--json"]);
|
|
117
|
+
const out = output();
|
|
118
|
+
|
|
119
|
+
expect(out).toBe("[]\n");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// === Argument validation ===
|
|
124
|
+
|
|
125
|
+
describe("argument validation", () => {
|
|
126
|
+
test("--last with non-numeric value throws ValidationError", async () => {
|
|
127
|
+
await expect(costsCommand(["--last", "abc"])).rejects.toThrow(ValidationError);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("--last with zero throws ValidationError", async () => {
|
|
131
|
+
await expect(costsCommand(["--last", "0"])).rejects.toThrow(ValidationError);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("--last with negative value throws ValidationError", async () => {
|
|
135
|
+
await expect(costsCommand(["--last", "-5"])).rejects.toThrow(ValidationError);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// === JSON output mode ===
|
|
140
|
+
|
|
141
|
+
describe("JSON output mode", () => {
|
|
142
|
+
test("outputs valid JSON array with sessions", async () => {
|
|
143
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
144
|
+
const store = createMetricsStore(dbPath);
|
|
145
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
146
|
+
store.recordSession(makeMetrics({ agentName: "scout-1", beadId: "t2", capability: "scout" }));
|
|
147
|
+
store.close();
|
|
148
|
+
|
|
149
|
+
await costsCommand(["--json"]);
|
|
150
|
+
const out = output();
|
|
151
|
+
|
|
152
|
+
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
153
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
154
|
+
expect(parsed).toHaveLength(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("JSON output includes expected token fields", async () => {
|
|
158
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
159
|
+
const store = createMetricsStore(dbPath);
|
|
160
|
+
store.recordSession(
|
|
161
|
+
makeMetrics({
|
|
162
|
+
agentName: "builder-1",
|
|
163
|
+
beadId: "t1",
|
|
164
|
+
inputTokens: 100,
|
|
165
|
+
outputTokens: 50,
|
|
166
|
+
cacheReadTokens: 30,
|
|
167
|
+
cacheCreationTokens: 10,
|
|
168
|
+
estimatedCostUsd: 0.15,
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
store.close();
|
|
172
|
+
|
|
173
|
+
await costsCommand(["--json"]);
|
|
174
|
+
const out = output();
|
|
175
|
+
|
|
176
|
+
const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
|
|
177
|
+
expect(parsed).toHaveLength(1);
|
|
178
|
+
const session = parsed[0];
|
|
179
|
+
expect(session).toBeDefined();
|
|
180
|
+
expect(session?.inputTokens).toBe(100);
|
|
181
|
+
expect(session?.outputTokens).toBe(50);
|
|
182
|
+
expect(session?.cacheReadTokens).toBe(30);
|
|
183
|
+
expect(session?.cacheCreationTokens).toBe(10);
|
|
184
|
+
expect(session?.estimatedCostUsd).toBe(0.15);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("JSON output returns empty array when no sessions match", async () => {
|
|
188
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
189
|
+
const store = createMetricsStore(dbPath);
|
|
190
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
191
|
+
store.close();
|
|
192
|
+
|
|
193
|
+
await costsCommand(["--json", "--agent", "nonexistent"]);
|
|
194
|
+
const out = output();
|
|
195
|
+
|
|
196
|
+
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
197
|
+
expect(parsed).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("JSON --by-capability outputs grouped object", async () => {
|
|
201
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
202
|
+
const store = createMetricsStore(dbPath);
|
|
203
|
+
store.recordSession(
|
|
204
|
+
makeMetrics({
|
|
205
|
+
agentName: "builder-1",
|
|
206
|
+
beadId: "t1",
|
|
207
|
+
capability: "builder",
|
|
208
|
+
inputTokens: 100,
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
store.recordSession(
|
|
212
|
+
makeMetrics({
|
|
213
|
+
agentName: "scout-1",
|
|
214
|
+
beadId: "t2",
|
|
215
|
+
capability: "scout",
|
|
216
|
+
inputTokens: 50,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
store.close();
|
|
220
|
+
|
|
221
|
+
await costsCommand(["--json", "--by-capability"]);
|
|
222
|
+
const out = output();
|
|
223
|
+
|
|
224
|
+
const parsed = JSON.parse(out.trim()) as Record<string, unknown>;
|
|
225
|
+
expect(parsed).toBeDefined();
|
|
226
|
+
expect(parsed.builder).toBeDefined();
|
|
227
|
+
expect(parsed.scout).toBeDefined();
|
|
228
|
+
|
|
229
|
+
const builderGroup = parsed.builder as Record<string, unknown>;
|
|
230
|
+
expect(builderGroup.sessions).toBeDefined();
|
|
231
|
+
expect(builderGroup.totals).toBeDefined();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// === Human output format ===
|
|
236
|
+
|
|
237
|
+
describe("human output format", () => {
|
|
238
|
+
test("shows Cost Summary header", async () => {
|
|
239
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
240
|
+
const store = createMetricsStore(dbPath);
|
|
241
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
242
|
+
store.close();
|
|
243
|
+
|
|
244
|
+
await costsCommand([]);
|
|
245
|
+
const out = output();
|
|
246
|
+
|
|
247
|
+
expect(out).toContain("Cost Summary");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("shows column headers", async () => {
|
|
251
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
252
|
+
const store = createMetricsStore(dbPath);
|
|
253
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
254
|
+
store.close();
|
|
255
|
+
|
|
256
|
+
await costsCommand([]);
|
|
257
|
+
const out = output();
|
|
258
|
+
|
|
259
|
+
expect(out).toContain("Agent");
|
|
260
|
+
expect(out).toContain("Capability");
|
|
261
|
+
expect(out).toContain("Input");
|
|
262
|
+
expect(out).toContain("Output");
|
|
263
|
+
expect(out).toContain("Cache");
|
|
264
|
+
expect(out).toContain("Cost");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("shows agent name and capability in output", async () => {
|
|
268
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
269
|
+
const store = createMetricsStore(dbPath);
|
|
270
|
+
store.recordSession(
|
|
271
|
+
makeMetrics({
|
|
272
|
+
agentName: "builder-1",
|
|
273
|
+
beadId: "t1",
|
|
274
|
+
capability: "builder",
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
store.close();
|
|
278
|
+
|
|
279
|
+
await costsCommand([]);
|
|
280
|
+
const out = output();
|
|
281
|
+
|
|
282
|
+
expect(out).toContain("builder-1");
|
|
283
|
+
expect(out).toContain("builder");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("shows separator line", async () => {
|
|
287
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
288
|
+
const store = createMetricsStore(dbPath);
|
|
289
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
290
|
+
store.close();
|
|
291
|
+
|
|
292
|
+
await costsCommand([]);
|
|
293
|
+
const out = output();
|
|
294
|
+
|
|
295
|
+
expect(out).toContain("=".repeat(70));
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("shows Total row", async () => {
|
|
299
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
300
|
+
const store = createMetricsStore(dbPath);
|
|
301
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
302
|
+
store.close();
|
|
303
|
+
|
|
304
|
+
await costsCommand([]);
|
|
305
|
+
const out = output();
|
|
306
|
+
|
|
307
|
+
expect(out).toContain("Total");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("no sessions shows 'No session data found' message", async () => {
|
|
311
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
312
|
+
const store = createMetricsStore(dbPath);
|
|
313
|
+
// Create DB but don't insert anything
|
|
314
|
+
store.close();
|
|
315
|
+
|
|
316
|
+
await costsCommand([]);
|
|
317
|
+
const out = output();
|
|
318
|
+
|
|
319
|
+
expect(out).toContain("No session data found");
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// === Number formatting ===
|
|
324
|
+
|
|
325
|
+
describe("number formatting", () => {
|
|
326
|
+
test("formats numbers with thousands separators", async () => {
|
|
327
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
328
|
+
const store = createMetricsStore(dbPath);
|
|
329
|
+
store.recordSession(
|
|
330
|
+
makeMetrics({
|
|
331
|
+
agentName: "builder-1",
|
|
332
|
+
beadId: "t1",
|
|
333
|
+
inputTokens: 12345,
|
|
334
|
+
outputTokens: 5678,
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
store.close();
|
|
338
|
+
|
|
339
|
+
await costsCommand([]);
|
|
340
|
+
const out = output();
|
|
341
|
+
|
|
342
|
+
expect(out).toContain("12,345");
|
|
343
|
+
expect(out).toContain("5,678");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("formats cost with dollar sign and 2 decimal places", async () => {
|
|
347
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
348
|
+
const store = createMetricsStore(dbPath);
|
|
349
|
+
store.recordSession(
|
|
350
|
+
makeMetrics({
|
|
351
|
+
agentName: "builder-1",
|
|
352
|
+
beadId: "t1",
|
|
353
|
+
estimatedCostUsd: 0.42,
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
store.close();
|
|
357
|
+
|
|
358
|
+
await costsCommand([]);
|
|
359
|
+
const out = output();
|
|
360
|
+
|
|
361
|
+
expect(out).toContain("$0.42");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("handles zero tokens correctly", async () => {
|
|
365
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
366
|
+
const store = createMetricsStore(dbPath);
|
|
367
|
+
store.recordSession(
|
|
368
|
+
makeMetrics({
|
|
369
|
+
agentName: "builder-1",
|
|
370
|
+
beadId: "t1",
|
|
371
|
+
inputTokens: 0,
|
|
372
|
+
outputTokens: 0,
|
|
373
|
+
cacheReadTokens: 0,
|
|
374
|
+
cacheCreationTokens: 0,
|
|
375
|
+
estimatedCostUsd: 0,
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
store.close();
|
|
379
|
+
|
|
380
|
+
await costsCommand([]);
|
|
381
|
+
const out = output();
|
|
382
|
+
|
|
383
|
+
expect(out).toContain("$0.00");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("handles null cost correctly", async () => {
|
|
387
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
388
|
+
const store = createMetricsStore(dbPath);
|
|
389
|
+
store.recordSession(
|
|
390
|
+
makeMetrics({
|
|
391
|
+
agentName: "builder-1",
|
|
392
|
+
beadId: "t1",
|
|
393
|
+
estimatedCostUsd: null,
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
store.close();
|
|
397
|
+
|
|
398
|
+
await costsCommand([]);
|
|
399
|
+
const out = output();
|
|
400
|
+
|
|
401
|
+
expect(out).toContain("$0.00");
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// === --agent filter ===
|
|
406
|
+
|
|
407
|
+
describe("--agent filter", () => {
|
|
408
|
+
test("filters sessions by agent name", async () => {
|
|
409
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
410
|
+
const store = createMetricsStore(dbPath);
|
|
411
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1", inputTokens: 100 }));
|
|
412
|
+
store.recordSession(makeMetrics({ agentName: "scout-1", beadId: "t2", inputTokens: 200 }));
|
|
413
|
+
store.close();
|
|
414
|
+
|
|
415
|
+
await costsCommand(["--json", "--agent", "builder-1"]);
|
|
416
|
+
const out = output();
|
|
417
|
+
|
|
418
|
+
const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
|
|
419
|
+
expect(parsed).toHaveLength(1);
|
|
420
|
+
expect(parsed[0]?.agentName).toBe("builder-1");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("returns empty for non-existent agent", async () => {
|
|
424
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
425
|
+
const store = createMetricsStore(dbPath);
|
|
426
|
+
store.recordSession(makeMetrics({ agentName: "builder-1", beadId: "t1" }));
|
|
427
|
+
store.close();
|
|
428
|
+
|
|
429
|
+
await costsCommand(["--json", "--agent", "nonexistent"]);
|
|
430
|
+
const out = output();
|
|
431
|
+
|
|
432
|
+
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
433
|
+
expect(parsed).toEqual([]);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// === --run filter ===
|
|
438
|
+
|
|
439
|
+
describe("--run filter", () => {
|
|
440
|
+
test("filters sessions by run ID directly from metrics.db", async () => {
|
|
441
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
442
|
+
const store = createMetricsStore(dbPath);
|
|
443
|
+
store.recordSession(
|
|
444
|
+
makeMetrics({ agentName: "builder-1", beadId: "task-001", runId: "run-2026-01-01" }),
|
|
445
|
+
);
|
|
446
|
+
store.recordSession(
|
|
447
|
+
makeMetrics({
|
|
448
|
+
agentName: "scout-1",
|
|
449
|
+
beadId: "task-002",
|
|
450
|
+
capability: "scout",
|
|
451
|
+
runId: "run-other",
|
|
452
|
+
}),
|
|
453
|
+
);
|
|
454
|
+
store.close();
|
|
455
|
+
|
|
456
|
+
await costsCommand(["--json", "--run", "run-2026-01-01"]);
|
|
457
|
+
const out = output();
|
|
458
|
+
|
|
459
|
+
const parsed = JSON.parse(out.trim()) as Record<string, unknown>[];
|
|
460
|
+
expect(parsed).toHaveLength(1);
|
|
461
|
+
expect(parsed[0]?.agentName).toBe("builder-1");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("returns empty when no sessions match run ID", async () => {
|
|
465
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
466
|
+
const store = createMetricsStore(dbPath);
|
|
467
|
+
store.recordSession(
|
|
468
|
+
makeMetrics({ agentName: "builder-1", beadId: "t1", runId: "run-2026-01-01" }),
|
|
469
|
+
);
|
|
470
|
+
store.close();
|
|
471
|
+
|
|
472
|
+
await costsCommand(["--json", "--run", "run-nonexistent"]);
|
|
473
|
+
const out = output();
|
|
474
|
+
|
|
475
|
+
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
476
|
+
expect(parsed).toEqual([]);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// === --by-capability grouping ===
|
|
481
|
+
|
|
482
|
+
describe("--by-capability grouping", () => {
|
|
483
|
+
test("shows capability header", async () => {
|
|
484
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
485
|
+
const store = createMetricsStore(dbPath);
|
|
486
|
+
store.recordSession(
|
|
487
|
+
makeMetrics({ agentName: "builder-1", beadId: "t1", capability: "builder" }),
|
|
488
|
+
);
|
|
489
|
+
store.close();
|
|
490
|
+
|
|
491
|
+
await costsCommand(["--by-capability"]);
|
|
492
|
+
const out = output();
|
|
493
|
+
|
|
494
|
+
expect(out).toContain("Cost by Capability");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("shows Sessions column header", async () => {
|
|
498
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
499
|
+
const store = createMetricsStore(dbPath);
|
|
500
|
+
store.recordSession(
|
|
501
|
+
makeMetrics({ agentName: "builder-1", beadId: "t1", capability: "builder" }),
|
|
502
|
+
);
|
|
503
|
+
store.close();
|
|
504
|
+
|
|
505
|
+
await costsCommand(["--by-capability"]);
|
|
506
|
+
const out = output();
|
|
507
|
+
|
|
508
|
+
expect(out).toContain("Sessions");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("groups multiple sessions by capability", async () => {
|
|
512
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
513
|
+
const store = createMetricsStore(dbPath);
|
|
514
|
+
store.recordSession(
|
|
515
|
+
makeMetrics({
|
|
516
|
+
agentName: "builder-1",
|
|
517
|
+
beadId: "t1",
|
|
518
|
+
capability: "builder",
|
|
519
|
+
inputTokens: 1000,
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
store.recordSession(
|
|
523
|
+
makeMetrics({
|
|
524
|
+
agentName: "builder-2",
|
|
525
|
+
beadId: "t2",
|
|
526
|
+
capability: "builder",
|
|
527
|
+
inputTokens: 2000,
|
|
528
|
+
}),
|
|
529
|
+
);
|
|
530
|
+
store.recordSession(
|
|
531
|
+
makeMetrics({
|
|
532
|
+
agentName: "scout-1",
|
|
533
|
+
beadId: "t3",
|
|
534
|
+
capability: "scout",
|
|
535
|
+
inputTokens: 500,
|
|
536
|
+
}),
|
|
537
|
+
);
|
|
538
|
+
store.close();
|
|
539
|
+
|
|
540
|
+
await costsCommand(["--by-capability"]);
|
|
541
|
+
const out = output();
|
|
542
|
+
|
|
543
|
+
expect(out).toContain("builder");
|
|
544
|
+
expect(out).toContain("scout");
|
|
545
|
+
expect(out).toContain("Total");
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("shows correct session count per capability", async () => {
|
|
549
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
550
|
+
const store = createMetricsStore(dbPath);
|
|
551
|
+
store.recordSession(makeMetrics({ agentName: "b1", beadId: "t1", capability: "builder" }));
|
|
552
|
+
store.recordSession(makeMetrics({ agentName: "b2", beadId: "t2", capability: "builder" }));
|
|
553
|
+
store.recordSession(makeMetrics({ agentName: "b3", beadId: "t3", capability: "builder" }));
|
|
554
|
+
store.recordSession(makeMetrics({ agentName: "s1", beadId: "t4", capability: "scout" }));
|
|
555
|
+
store.close();
|
|
556
|
+
|
|
557
|
+
await costsCommand(["--json", "--by-capability"]);
|
|
558
|
+
const out = output();
|
|
559
|
+
|
|
560
|
+
const parsed = JSON.parse(out.trim()) as Record<
|
|
561
|
+
string,
|
|
562
|
+
{ sessions: unknown[]; totals: Record<string, unknown> }
|
|
563
|
+
>;
|
|
564
|
+
expect(parsed.builder?.sessions).toHaveLength(3);
|
|
565
|
+
expect(parsed.scout?.sessions).toHaveLength(1);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("empty data shows no session data message", async () => {
|
|
569
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
570
|
+
const store = createMetricsStore(dbPath);
|
|
571
|
+
store.close();
|
|
572
|
+
|
|
573
|
+
await costsCommand(["--by-capability"]);
|
|
574
|
+
const out = output();
|
|
575
|
+
|
|
576
|
+
expect(out).toContain("No session data found");
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// === --last flag ===
|
|
581
|
+
|
|
582
|
+
describe("--last flag", () => {
|
|
583
|
+
test("limits the number of sessions returned", async () => {
|
|
584
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
585
|
+
const store = createMetricsStore(dbPath);
|
|
586
|
+
for (let i = 0; i < 10; i++) {
|
|
587
|
+
store.recordSession(makeMetrics({ agentName: `agent-${i}`, beadId: `t-${i}` }));
|
|
588
|
+
}
|
|
589
|
+
store.close();
|
|
590
|
+
|
|
591
|
+
await costsCommand(["--json", "--last", "3"]);
|
|
592
|
+
const out = output();
|
|
593
|
+
|
|
594
|
+
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
595
|
+
expect(parsed).toHaveLength(3);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("default limit is 20", async () => {
|
|
599
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
600
|
+
const store = createMetricsStore(dbPath);
|
|
601
|
+
for (let i = 0; i < 25; i++) {
|
|
602
|
+
store.recordSession(makeMetrics({ agentName: `agent-${i}`, beadId: `t-${i}` }));
|
|
603
|
+
}
|
|
604
|
+
store.close();
|
|
605
|
+
|
|
606
|
+
await costsCommand(["--json"]);
|
|
607
|
+
const out = output();
|
|
608
|
+
|
|
609
|
+
const parsed = JSON.parse(out.trim()) as unknown[];
|
|
610
|
+
expect(parsed).toHaveLength(20);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// === Edge cases ===
|
|
615
|
+
|
|
616
|
+
describe("edge cases", () => {
|
|
617
|
+
test("handles session with all zero tokens", async () => {
|
|
618
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
619
|
+
const store = createMetricsStore(dbPath);
|
|
620
|
+
store.recordSession(
|
|
621
|
+
makeMetrics({
|
|
622
|
+
agentName: "builder-1",
|
|
623
|
+
beadId: "t1",
|
|
624
|
+
inputTokens: 0,
|
|
625
|
+
outputTokens: 0,
|
|
626
|
+
cacheReadTokens: 0,
|
|
627
|
+
cacheCreationTokens: 0,
|
|
628
|
+
estimatedCostUsd: 0,
|
|
629
|
+
}),
|
|
630
|
+
);
|
|
631
|
+
store.close();
|
|
632
|
+
|
|
633
|
+
// Should not throw
|
|
634
|
+
await costsCommand([]);
|
|
635
|
+
const out = output();
|
|
636
|
+
|
|
637
|
+
expect(out).toContain("Cost Summary");
|
|
638
|
+
expect(out).toContain("builder-1");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("handles session with null cost", async () => {
|
|
642
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
643
|
+
const store = createMetricsStore(dbPath);
|
|
644
|
+
store.recordSession(
|
|
645
|
+
makeMetrics({
|
|
646
|
+
agentName: "builder-1",
|
|
647
|
+
beadId: "t1",
|
|
648
|
+
estimatedCostUsd: null,
|
|
649
|
+
}),
|
|
650
|
+
);
|
|
651
|
+
store.close();
|
|
652
|
+
|
|
653
|
+
// Should not throw
|
|
654
|
+
await costsCommand([]);
|
|
655
|
+
const out = output();
|
|
656
|
+
|
|
657
|
+
expect(out).toContain("Cost Summary");
|
|
658
|
+
expect(out).toContain("$0.00");
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("cache column sums cacheRead + cacheCreation tokens", async () => {
|
|
662
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
663
|
+
const store = createMetricsStore(dbPath);
|
|
664
|
+
store.recordSession(
|
|
665
|
+
makeMetrics({
|
|
666
|
+
agentName: "builder-1",
|
|
667
|
+
beadId: "t1",
|
|
668
|
+
cacheReadTokens: 8000,
|
|
669
|
+
cacheCreationTokens: 901,
|
|
670
|
+
}),
|
|
671
|
+
);
|
|
672
|
+
store.close();
|
|
673
|
+
|
|
674
|
+
await costsCommand([]);
|
|
675
|
+
const out = output();
|
|
676
|
+
|
|
677
|
+
// 8000 + 901 = 8,901
|
|
678
|
+
expect(out).toContain("8,901");
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("total row sums across all sessions", async () => {
|
|
682
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
683
|
+
const store = createMetricsStore(dbPath);
|
|
684
|
+
store.recordSession(
|
|
685
|
+
makeMetrics({
|
|
686
|
+
agentName: "builder-1",
|
|
687
|
+
beadId: "t1",
|
|
688
|
+
inputTokens: 100,
|
|
689
|
+
outputTokens: 50,
|
|
690
|
+
estimatedCostUsd: 0.1,
|
|
691
|
+
}),
|
|
692
|
+
);
|
|
693
|
+
store.recordSession(
|
|
694
|
+
makeMetrics({
|
|
695
|
+
agentName: "scout-1",
|
|
696
|
+
beadId: "t2",
|
|
697
|
+
capability: "scout",
|
|
698
|
+
inputTokens: 200,
|
|
699
|
+
outputTokens: 100,
|
|
700
|
+
estimatedCostUsd: 0.2,
|
|
701
|
+
}),
|
|
702
|
+
);
|
|
703
|
+
store.close();
|
|
704
|
+
|
|
705
|
+
await costsCommand(["--json"]);
|
|
706
|
+
const out = output();
|
|
707
|
+
|
|
708
|
+
const parsed = JSON.parse(out.trim()) as SessionMetrics[];
|
|
709
|
+
const totalInput = parsed.reduce((sum, s) => sum + s.inputTokens, 0);
|
|
710
|
+
const totalOutput = parsed.reduce((sum, s) => sum + s.outputTokens, 0);
|
|
711
|
+
expect(totalInput).toBe(300);
|
|
712
|
+
expect(totalOutput).toBe(150);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("multiple flags work together", async () => {
|
|
716
|
+
const dbPath = join(tempDir, ".overstory", "metrics.db");
|
|
717
|
+
const store = createMetricsStore(dbPath);
|
|
718
|
+
store.recordSession(
|
|
719
|
+
makeMetrics({ agentName: "builder-1", beadId: "t1", capability: "builder" }),
|
|
720
|
+
);
|
|
721
|
+
store.recordSession(
|
|
722
|
+
makeMetrics({ agentName: "builder-2", beadId: "t2", capability: "builder" }),
|
|
723
|
+
);
|
|
724
|
+
store.close();
|
|
725
|
+
|
|
726
|
+
await costsCommand(["--by-capability", "--last", "10"]);
|
|
727
|
+
const out = output();
|
|
728
|
+
|
|
729
|
+
expect(out).toContain("Cost by Capability");
|
|
730
|
+
expect(out).toContain("builder");
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// === --live flag ===
|
|
735
|
+
|
|
736
|
+
describe("--live flag", () => {
|
|
737
|
+
test("shows 'No live data available' when no snapshots exist", async () => {
|
|
738
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
739
|
+
const metricsDbPath = join(overstoryDir, "metrics.db");
|
|
740
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
741
|
+
metricsStore.close();
|
|
742
|
+
|
|
743
|
+
await costsCommand(["--live"]);
|
|
744
|
+
const out = output();
|
|
745
|
+
|
|
746
|
+
expect(out).toContain("No live data available");
|
|
747
|
+
expect(out).toContain("Token snapshots begin after first tool call");
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("shows live table when snapshots exist with active sessions", async () => {
|
|
751
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
752
|
+
|
|
753
|
+
// Create active sessions
|
|
754
|
+
const sessDbPath = join(overstoryDir, "sessions.db");
|
|
755
|
+
const sessionStore = createSessionStore(sessDbPath);
|
|
756
|
+
sessionStore.upsert({
|
|
757
|
+
id: "sess-001",
|
|
758
|
+
agentName: "builder-1",
|
|
759
|
+
capability: "builder",
|
|
760
|
+
worktreePath: "/tmp/wt1",
|
|
761
|
+
branchName: "feat/task1",
|
|
762
|
+
beadId: "task-001",
|
|
763
|
+
tmuxSession: "tmux-001",
|
|
764
|
+
state: "working",
|
|
765
|
+
pid: 12345,
|
|
766
|
+
parentAgent: null,
|
|
767
|
+
depth: 0,
|
|
768
|
+
runId: "run-001",
|
|
769
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(), // 2 min ago
|
|
770
|
+
lastActivity: new Date().toISOString(),
|
|
771
|
+
escalationLevel: 0,
|
|
772
|
+
stalledSince: null,
|
|
773
|
+
});
|
|
774
|
+
sessionStore.close();
|
|
775
|
+
|
|
776
|
+
// Create snapshots
|
|
777
|
+
const metricsDbPath = join(overstoryDir, "metrics.db");
|
|
778
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
779
|
+
metricsStore.recordSnapshot({
|
|
780
|
+
agentName: "builder-1",
|
|
781
|
+
inputTokens: 1000,
|
|
782
|
+
outputTokens: 500,
|
|
783
|
+
cacheReadTokens: 200,
|
|
784
|
+
cacheCreationTokens: 100,
|
|
785
|
+
estimatedCostUsd: 0.15,
|
|
786
|
+
modelUsed: "claude-sonnet-4-5",
|
|
787
|
+
createdAt: new Date().toISOString(),
|
|
788
|
+
});
|
|
789
|
+
metricsStore.close();
|
|
790
|
+
|
|
791
|
+
await costsCommand(["--live"]);
|
|
792
|
+
const out = output();
|
|
793
|
+
|
|
794
|
+
expect(out).toContain("Live Token Usage");
|
|
795
|
+
expect(out).toContain("1 active agents");
|
|
796
|
+
expect(out).toContain("builder-1");
|
|
797
|
+
expect(out).toContain("builder");
|
|
798
|
+
expect(out).toContain("1,000"); // inputTokens
|
|
799
|
+
expect(out).toContain("500"); // outputTokens
|
|
800
|
+
expect(out).toContain("300"); // cache total (200 + 100)
|
|
801
|
+
expect(out).toContain("$0.15");
|
|
802
|
+
expect(out).toContain("Burn rate");
|
|
803
|
+
expect(out).toContain("tokens/min");
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("JSON output with --live returns expected structure", async () => {
|
|
807
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
808
|
+
|
|
809
|
+
// Create active sessions
|
|
810
|
+
const sessDbPath = join(overstoryDir, "sessions.db");
|
|
811
|
+
const sessionStore = createSessionStore(sessDbPath);
|
|
812
|
+
sessionStore.upsert({
|
|
813
|
+
id: "sess-001",
|
|
814
|
+
agentName: "builder-1",
|
|
815
|
+
capability: "builder",
|
|
816
|
+
worktreePath: "/tmp/wt1",
|
|
817
|
+
branchName: "feat/task1",
|
|
818
|
+
beadId: "task-001",
|
|
819
|
+
tmuxSession: "tmux-001",
|
|
820
|
+
state: "working",
|
|
821
|
+
pid: 12345,
|
|
822
|
+
parentAgent: null,
|
|
823
|
+
depth: 0,
|
|
824
|
+
runId: "run-001",
|
|
825
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(), // 2 min ago
|
|
826
|
+
lastActivity: new Date().toISOString(),
|
|
827
|
+
escalationLevel: 0,
|
|
828
|
+
stalledSince: null,
|
|
829
|
+
});
|
|
830
|
+
sessionStore.close();
|
|
831
|
+
|
|
832
|
+
// Create snapshots
|
|
833
|
+
const metricsDbPath = join(overstoryDir, "metrics.db");
|
|
834
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
835
|
+
metricsStore.recordSnapshot({
|
|
836
|
+
agentName: "builder-1",
|
|
837
|
+
inputTokens: 1000,
|
|
838
|
+
outputTokens: 500,
|
|
839
|
+
cacheReadTokens: 200,
|
|
840
|
+
cacheCreationTokens: 100,
|
|
841
|
+
estimatedCostUsd: 0.15,
|
|
842
|
+
modelUsed: "claude-sonnet-4-5",
|
|
843
|
+
createdAt: new Date().toISOString(),
|
|
844
|
+
});
|
|
845
|
+
metricsStore.close();
|
|
846
|
+
|
|
847
|
+
await costsCommand(["--live", "--json"]);
|
|
848
|
+
const out = output();
|
|
849
|
+
|
|
850
|
+
const parsed = JSON.parse(out.trim()) as {
|
|
851
|
+
agents: unknown[];
|
|
852
|
+
totals: Record<string, unknown>;
|
|
853
|
+
};
|
|
854
|
+
expect(parsed.agents).toHaveLength(1);
|
|
855
|
+
expect(parsed.totals).toBeDefined();
|
|
856
|
+
expect(parsed.totals.inputTokens).toBe(1000);
|
|
857
|
+
expect(parsed.totals.outputTokens).toBe(500);
|
|
858
|
+
expect(parsed.totals.cacheTokens).toBe(300);
|
|
859
|
+
expect(parsed.totals.costUsd).toBe(0.15);
|
|
860
|
+
expect(parsed.totals.burnRatePerMin).toBeGreaterThan(0);
|
|
861
|
+
expect(parsed.totals.tokensPerMin).toBeGreaterThan(0);
|
|
862
|
+
|
|
863
|
+
const agent = parsed.agents[0] as Record<string, unknown>;
|
|
864
|
+
expect(agent.agentName).toBe("builder-1");
|
|
865
|
+
expect(agent.capability).toBe("builder");
|
|
866
|
+
expect(agent.inputTokens).toBe(1000);
|
|
867
|
+
expect(agent.outputTokens).toBe(500);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
test("--live with --agent filters by agent", async () => {
|
|
871
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
872
|
+
|
|
873
|
+
// Create active sessions
|
|
874
|
+
const sessDbPath = join(overstoryDir, "sessions.db");
|
|
875
|
+
const sessionStore = createSessionStore(sessDbPath);
|
|
876
|
+
sessionStore.upsert({
|
|
877
|
+
id: "sess-001",
|
|
878
|
+
agentName: "builder-1",
|
|
879
|
+
capability: "builder",
|
|
880
|
+
worktreePath: "/tmp/wt1",
|
|
881
|
+
branchName: "feat/task1",
|
|
882
|
+
beadId: "task-001",
|
|
883
|
+
tmuxSession: "tmux-001",
|
|
884
|
+
state: "working",
|
|
885
|
+
pid: 12345,
|
|
886
|
+
parentAgent: null,
|
|
887
|
+
depth: 0,
|
|
888
|
+
runId: "run-001",
|
|
889
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(),
|
|
890
|
+
lastActivity: new Date().toISOString(),
|
|
891
|
+
escalationLevel: 0,
|
|
892
|
+
stalledSince: null,
|
|
893
|
+
});
|
|
894
|
+
sessionStore.upsert({
|
|
895
|
+
id: "sess-002",
|
|
896
|
+
agentName: "scout-1",
|
|
897
|
+
capability: "scout",
|
|
898
|
+
worktreePath: "/tmp/wt2",
|
|
899
|
+
branchName: "feat/task2",
|
|
900
|
+
beadId: "task-002",
|
|
901
|
+
tmuxSession: "tmux-002",
|
|
902
|
+
state: "working",
|
|
903
|
+
pid: 12346,
|
|
904
|
+
parentAgent: null,
|
|
905
|
+
depth: 0,
|
|
906
|
+
runId: "run-001",
|
|
907
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(),
|
|
908
|
+
lastActivity: new Date().toISOString(),
|
|
909
|
+
escalationLevel: 0,
|
|
910
|
+
stalledSince: null,
|
|
911
|
+
});
|
|
912
|
+
sessionStore.close();
|
|
913
|
+
|
|
914
|
+
// Create snapshots
|
|
915
|
+
const metricsDbPath = join(overstoryDir, "metrics.db");
|
|
916
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
917
|
+
metricsStore.recordSnapshot({
|
|
918
|
+
agentName: "builder-1",
|
|
919
|
+
inputTokens: 1000,
|
|
920
|
+
outputTokens: 500,
|
|
921
|
+
cacheReadTokens: 0,
|
|
922
|
+
cacheCreationTokens: 0,
|
|
923
|
+
estimatedCostUsd: 0.15,
|
|
924
|
+
modelUsed: "claude-sonnet-4-5",
|
|
925
|
+
createdAt: new Date().toISOString(),
|
|
926
|
+
});
|
|
927
|
+
metricsStore.recordSnapshot({
|
|
928
|
+
agentName: "scout-1",
|
|
929
|
+
inputTokens: 2000,
|
|
930
|
+
outputTokens: 1000,
|
|
931
|
+
cacheReadTokens: 0,
|
|
932
|
+
cacheCreationTokens: 0,
|
|
933
|
+
estimatedCostUsd: 0.25,
|
|
934
|
+
modelUsed: "claude-sonnet-4-5",
|
|
935
|
+
createdAt: new Date().toISOString(),
|
|
936
|
+
});
|
|
937
|
+
metricsStore.close();
|
|
938
|
+
|
|
939
|
+
await costsCommand(["--live", "--json", "--agent", "builder-1"]);
|
|
940
|
+
const out = output();
|
|
941
|
+
|
|
942
|
+
const parsed = JSON.parse(out.trim()) as { agents: Record<string, unknown>[] };
|
|
943
|
+
expect(parsed.agents).toHaveLength(1);
|
|
944
|
+
expect(parsed.agents[0]?.agentName).toBe("builder-1");
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
test("--live shows burn rate in output", async () => {
|
|
948
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
949
|
+
|
|
950
|
+
// Create active sessions
|
|
951
|
+
const sessDbPath = join(overstoryDir, "sessions.db");
|
|
952
|
+
const sessionStore = createSessionStore(sessDbPath);
|
|
953
|
+
sessionStore.upsert({
|
|
954
|
+
id: "sess-001",
|
|
955
|
+
agentName: "builder-1",
|
|
956
|
+
capability: "builder",
|
|
957
|
+
worktreePath: "/tmp/wt1",
|
|
958
|
+
branchName: "feat/task1",
|
|
959
|
+
beadId: "task-001",
|
|
960
|
+
tmuxSession: "tmux-001",
|
|
961
|
+
state: "working",
|
|
962
|
+
pid: 12345,
|
|
963
|
+
parentAgent: null,
|
|
964
|
+
depth: 0,
|
|
965
|
+
runId: "run-001",
|
|
966
|
+
startedAt: new Date(Date.now() - 120_000).toISOString(), // 2 min ago
|
|
967
|
+
lastActivity: new Date().toISOString(),
|
|
968
|
+
escalationLevel: 0,
|
|
969
|
+
stalledSince: null,
|
|
970
|
+
});
|
|
971
|
+
sessionStore.close();
|
|
972
|
+
|
|
973
|
+
// Create snapshots
|
|
974
|
+
const metricsDbPath = join(overstoryDir, "metrics.db");
|
|
975
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
976
|
+
metricsStore.recordSnapshot({
|
|
977
|
+
agentName: "builder-1",
|
|
978
|
+
inputTokens: 1000,
|
|
979
|
+
outputTokens: 500,
|
|
980
|
+
cacheReadTokens: 0,
|
|
981
|
+
cacheCreationTokens: 0,
|
|
982
|
+
estimatedCostUsd: 0.3,
|
|
983
|
+
modelUsed: "claude-sonnet-4-5",
|
|
984
|
+
createdAt: new Date().toISOString(),
|
|
985
|
+
});
|
|
986
|
+
metricsStore.close();
|
|
987
|
+
|
|
988
|
+
await costsCommand(["--live"]);
|
|
989
|
+
const out = output();
|
|
990
|
+
|
|
991
|
+
expect(out).toContain("Burn rate:");
|
|
992
|
+
expect(out).toContain("/min");
|
|
993
|
+
expect(out).toContain("tokens/min");
|
|
994
|
+
expect(out).toContain("Elapsed:");
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
test("--live with no metrics.db shows empty JSON or message", async () => {
|
|
998
|
+
await costsCommand(["--live", "--json"]);
|
|
999
|
+
const out = output();
|
|
1000
|
+
|
|
1001
|
+
const parsed = JSON.parse(out.trim()) as {
|
|
1002
|
+
agents: unknown[];
|
|
1003
|
+
totals: Record<string, unknown>;
|
|
1004
|
+
};
|
|
1005
|
+
expect(parsed.agents).toEqual([]);
|
|
1006
|
+
expect(parsed.totals.costUsd).toBe(0);
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// === --self flag ===
|
|
1011
|
+
|
|
1012
|
+
describe("--self flag", () => {
|
|
1013
|
+
let tempHome: string;
|
|
1014
|
+
let originalHome: string | undefined;
|
|
1015
|
+
|
|
1016
|
+
/** Helper to create a transcript JSONL with known token values. */
|
|
1017
|
+
function makeTranscriptContent(): string {
|
|
1018
|
+
const entry = {
|
|
1019
|
+
type: "assistant",
|
|
1020
|
+
message: {
|
|
1021
|
+
model: "claude-sonnet-4-20250514",
|
|
1022
|
+
usage: {
|
|
1023
|
+
input_tokens: 5000,
|
|
1024
|
+
output_tokens: 2000,
|
|
1025
|
+
cache_read_input_tokens: 15000,
|
|
1026
|
+
cache_creation_input_tokens: 3000,
|
|
1027
|
+
},
|
|
1028
|
+
},
|
|
1029
|
+
};
|
|
1030
|
+
return `${JSON.stringify(entry)}\n`;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
beforeEach(async () => {
|
|
1034
|
+
originalHome = process.env.HOME;
|
|
1035
|
+
tempHome = await mkdtemp(join(tmpdir(), "costs-self-home-"));
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
afterEach(async () => {
|
|
1039
|
+
process.env.HOME = originalHome;
|
|
1040
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
test("--self shows orchestrator cost when transcript exists", async () => {
|
|
1044
|
+
// Use process.cwd() to get the symlink-resolved path (on macOS /var -> /private/var).
|
|
1045
|
+
// config.project.root comes from resolveProjectRoot which uses process.cwd() internally,
|
|
1046
|
+
// so we must match that for the project key.
|
|
1047
|
+
const resolvedRoot = process.cwd();
|
|
1048
|
+
const projectKey = resolvedRoot.replace(/\//g, "-");
|
|
1049
|
+
const projectDir = join(tempHome, ".claude", "projects", projectKey);
|
|
1050
|
+
await mkdir(projectDir, { recursive: true });
|
|
1051
|
+
await Bun.write(join(projectDir, "session-abc123.jsonl"), makeTranscriptContent());
|
|
1052
|
+
|
|
1053
|
+
process.env.HOME = tempHome;
|
|
1054
|
+
|
|
1055
|
+
await costsCommand(["--self"]);
|
|
1056
|
+
const out = output();
|
|
1057
|
+
|
|
1058
|
+
expect(out).toContain("Orchestrator Session Cost");
|
|
1059
|
+
expect(out).toContain("claude-sonnet-4-20250514");
|
|
1060
|
+
expect(out).toContain("5,000"); // input tokens formatted
|
|
1061
|
+
expect(out).toContain("2,000"); // output tokens formatted
|
|
1062
|
+
expect(out).toContain("18,000"); // cache total (15000 + 3000)
|
|
1063
|
+
// Should have some cost estimate
|
|
1064
|
+
expect(out).toContain("$");
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
test("--self --json outputs JSON with expected fields", async () => {
|
|
1068
|
+
// Use process.cwd() to match the symlink-resolved root used by config
|
|
1069
|
+
const resolvedRoot = process.cwd();
|
|
1070
|
+
const projectKey = resolvedRoot.replace(/\//g, "-");
|
|
1071
|
+
const projectDir = join(tempHome, ".claude", "projects", projectKey);
|
|
1072
|
+
await mkdir(projectDir, { recursive: true });
|
|
1073
|
+
await Bun.write(join(projectDir, "session-abc123.jsonl"), makeTranscriptContent());
|
|
1074
|
+
|
|
1075
|
+
process.env.HOME = tempHome;
|
|
1076
|
+
|
|
1077
|
+
await costsCommand(["--self", "--json"]);
|
|
1078
|
+
const out = output();
|
|
1079
|
+
|
|
1080
|
+
const parsed = JSON.parse(out.trim()) as Record<string, unknown>;
|
|
1081
|
+
expect(parsed.source).toBe("self");
|
|
1082
|
+
expect(typeof parsed.transcriptPath).toBe("string");
|
|
1083
|
+
expect(parsed.model).toBe("claude-sonnet-4-20250514");
|
|
1084
|
+
expect(parsed.inputTokens).toBe(5000);
|
|
1085
|
+
expect(parsed.outputTokens).toBe(2000);
|
|
1086
|
+
expect(parsed.cacheReadTokens).toBe(15000);
|
|
1087
|
+
expect(parsed.cacheCreationTokens).toBe(3000);
|
|
1088
|
+
expect(parsed.estimatedCostUsd).toBeDefined();
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
test("--self shows error when no transcript found", async () => {
|
|
1092
|
+
// No .claude directory — just set HOME to tempHome with nothing in it
|
|
1093
|
+
process.env.HOME = tempHome;
|
|
1094
|
+
|
|
1095
|
+
await costsCommand(["--self"]);
|
|
1096
|
+
const out = output();
|
|
1097
|
+
|
|
1098
|
+
expect(out).toContain("No orchestrator transcript found");
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
test("--self --json outputs error JSON when no transcript found", async () => {
|
|
1102
|
+
// No .claude directory
|
|
1103
|
+
process.env.HOME = tempHome;
|
|
1104
|
+
|
|
1105
|
+
await costsCommand(["--self", "--json"]);
|
|
1106
|
+
const out = output();
|
|
1107
|
+
|
|
1108
|
+
const parsed = JSON.parse(out.trim()) as Record<string, unknown>;
|
|
1109
|
+
expect(parsed.error).toBe("no_transcript");
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
test("--self in help text", async () => {
|
|
1113
|
+
await costsCommand(["--help"]);
|
|
1114
|
+
const out = output();
|
|
1115
|
+
|
|
1116
|
+
expect(out).toContain("--self");
|
|
1117
|
+
});
|
|
1118
|
+
});
|
|
1119
|
+
});
|