costclaw 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.
- package/README.md +54 -0
- package/dist/index.js +1275 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# costclaw
|
|
2
|
+
|
|
3
|
+
A local cost and setup audit for [Claude Code](https://claude.com/claude-code).
|
|
4
|
+
Point it at your session logs and it shows where token spend leaks, scores your
|
|
5
|
+
setup across six pillars, and ranks fixes by the dollars they recover.
|
|
6
|
+
|
|
7
|
+
Your prompts never leave your machine. The audit parses the logs already on your
|
|
8
|
+
disk and prints a report. It uploads nothing.
|
|
9
|
+
|
|
10
|
+
## Use it
|
|
11
|
+
|
|
12
|
+
Requires Node 20+.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx costclaw audit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
That reads `~/.claude/projects` (override with `--path <dir>`) and prints:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
CostClaw audit
|
|
22
|
+
Source: ~/.claude/projects (N sessions across M projects)
|
|
23
|
+
|
|
24
|
+
Recoverable spend (cache-miss exposure): $XXX.XX
|
|
25
|
+
|
|
26
|
+
Spend analyzed: $X,XXX.XX Cache hit: 96.3% Active hours: XXX.X
|
|
27
|
+
Overall setup score: 91 / 100 (Dialed in)
|
|
28
|
+
CLAUDE.md quality n/a
|
|
29
|
+
Context hygiene [###################.] 95
|
|
30
|
+
Prompting patterns [##################..] 92
|
|
31
|
+
Session management [####################] 100
|
|
32
|
+
Tool and MCP config [####################] 100
|
|
33
|
+
Cost discipline [##############......] 70
|
|
34
|
+
|
|
35
|
+
Top fixes by recoverable spend:
|
|
36
|
+
- [$XXX.XX] Recover spend lost to cache misses
|
|
37
|
+
Keep the start of each session stable so the prompt cache is reused.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Options
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
costclaw audit [--path <dir>] [--claude-md <file>] [--json]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- `--path <dir>` audit a specific projects directory (default `~/.claude/projects`).
|
|
47
|
+
- `--claude-md <file>` score a CLAUDE.md file's quality.
|
|
48
|
+
- `--json` print the raw, derived `AuditRecord` instead of the human report.
|
|
49
|
+
|
|
50
|
+
## Privacy
|
|
51
|
+
|
|
52
|
+
The only thing the tool produces is a derived `AuditRecord`: totals and generated
|
|
53
|
+
prose, no prompt text, no file paths, no secrets. A hosted dashboard is on the
|
|
54
|
+
roadmap and would receive only that derived record, never your logs.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/audit-command.ts
|
|
4
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
5
|
+
|
|
6
|
+
// ../../packages/engine/src/types.ts
|
|
7
|
+
function emptyTotals() {
|
|
8
|
+
return { input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0 };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ../../packages/engine/src/pricing.ts
|
|
12
|
+
var PRICING_SNAPSHOT_DATE = "2026-05-13";
|
|
13
|
+
var PRICES_PER_MTOK = Object.freeze({
|
|
14
|
+
"claude-opus-4-7": { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 },
|
|
15
|
+
"claude-opus-4-7[1m]": { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 },
|
|
16
|
+
"claude-opus-4-6": { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 },
|
|
17
|
+
"claude-sonnet-4-6": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
18
|
+
"claude-sonnet-4-5": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
19
|
+
"claude-haiku-4-5": { input: 1, output: 5, cache_write: 1.25, cache_read: 0.1 },
|
|
20
|
+
"claude-haiku-4-5-20251001": { input: 1, output: 5, cache_write: 1.25, cache_read: 0.1 }
|
|
21
|
+
});
|
|
22
|
+
var FALLBACK = { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 };
|
|
23
|
+
function priceFor(model) {
|
|
24
|
+
if (!model) return FALLBACK;
|
|
25
|
+
const exact = PRICES_PER_MTOK[model];
|
|
26
|
+
if (exact) return exact;
|
|
27
|
+
const noTag = model.replace(/\[[^\]]*\]$/, "");
|
|
28
|
+
if (PRICES_PER_MTOK[noTag]) return PRICES_PER_MTOK[noTag];
|
|
29
|
+
const noDate = noTag.replace(/-\d{8}$/, "");
|
|
30
|
+
if (PRICES_PER_MTOK[noDate]) return PRICES_PER_MTOK[noDate];
|
|
31
|
+
return FALLBACK;
|
|
32
|
+
}
|
|
33
|
+
var num = (v) => Number(v) || 0;
|
|
34
|
+
function costForUsage(model, usage) {
|
|
35
|
+
const p = priceFor(model);
|
|
36
|
+
const i = num(usage?.input_tokens);
|
|
37
|
+
const o = num(usage?.output_tokens);
|
|
38
|
+
const cw = num(usage?.cache_creation_input_tokens);
|
|
39
|
+
const cr = num(usage?.cache_read_input_tokens);
|
|
40
|
+
return (i * p.input + o * p.output + cw * p.cache_write + cr * p.cache_read) / 1e6;
|
|
41
|
+
}
|
|
42
|
+
function cacheSavingsForUsage(model, usage) {
|
|
43
|
+
const p = priceFor(model);
|
|
44
|
+
const cr = num(usage?.cache_read_input_tokens);
|
|
45
|
+
return cr * (p.input - p.cache_read) / 1e6;
|
|
46
|
+
}
|
|
47
|
+
function cacheHitRate(totals) {
|
|
48
|
+
const denom = num(totals.input_tokens) + num(totals.cache_creation_tokens) + num(totals.cache_read_tokens);
|
|
49
|
+
if (!denom) return 0;
|
|
50
|
+
return num(totals.cache_read_tokens) / denom;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ../../packages/engine/src/sanitize.ts
|
|
54
|
+
function sanitizeText(input) {
|
|
55
|
+
let out = input;
|
|
56
|
+
out = out.replace(/[‘’‚‛]/g, "'");
|
|
57
|
+
out = out.replace(/[“”„‟]/g, '"');
|
|
58
|
+
out = out.replace(/…/g, "...");
|
|
59
|
+
out = out.replace(/(\d)\s*[—–]\s*(\d)/g, "$1-$2");
|
|
60
|
+
out = out.replace(/\s*[—–]\s*/g, ", ");
|
|
61
|
+
out = out.replace(/ +,/g, ",");
|
|
62
|
+
out = out.replace(/,\s*,/g, ",");
|
|
63
|
+
out = out.replace(/[ \t]{2,}/g, " ");
|
|
64
|
+
out = out.replace(/[ \t]+$/gm, "");
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ../../packages/engine/src/parser.ts
|
|
69
|
+
function safeParse(line) {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(line);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function usageKey(rec, seq) {
|
|
77
|
+
const requestId = rec.requestId || rec.message?.requestId;
|
|
78
|
+
if (requestId) return `req:${requestId}`;
|
|
79
|
+
const messageId = rec.message?.id;
|
|
80
|
+
if (messageId) return `msg:${messageId}`;
|
|
81
|
+
if (rec.uuid) return `row:${rec.uuid}`;
|
|
82
|
+
return `row:#${seq}`;
|
|
83
|
+
}
|
|
84
|
+
function projectFromCwd(cwd) {
|
|
85
|
+
if (!cwd) return null;
|
|
86
|
+
const parts = cwd.split(/[\\/]+/).filter(Boolean);
|
|
87
|
+
return parts.length ? parts[parts.length - 1] : null;
|
|
88
|
+
}
|
|
89
|
+
function targetFor(name, input) {
|
|
90
|
+
const s = (v, n) => typeof v === "string" && v.length ? v.slice(0, n) : null;
|
|
91
|
+
if (["Read", "Edit", "Write", "NotebookEdit"].includes(name)) return s(input.file_path, 200);
|
|
92
|
+
if (["Grep", "Glob"].includes(name)) return s(input.path ?? input.glob ?? input.pattern, 160);
|
|
93
|
+
if (["Bash", "PowerShell"].includes(name)) {
|
|
94
|
+
const cmd = typeof input.command === "string" ? input.command.trim().split(/\s+/)[0] : null;
|
|
95
|
+
return cmd ? cmd.slice(0, 60) : null;
|
|
96
|
+
}
|
|
97
|
+
if (name.startsWith("Task")) return s(input.subject ?? input.description, 80);
|
|
98
|
+
if (["WebFetch", "WebSearch"].includes(name)) return s(input.url ?? input.query, 60);
|
|
99
|
+
if (name === "Agent") return s(input.subagent_type ?? input.description, 60);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
var isToolUse = (b) => !!b && typeof b === "object" && b.type === "tool_use" && typeof b.name === "string";
|
|
103
|
+
function extractTools(content) {
|
|
104
|
+
const names = [];
|
|
105
|
+
const events = [];
|
|
106
|
+
if (Array.isArray(content)) {
|
|
107
|
+
for (const block of content) {
|
|
108
|
+
if (isToolUse(block)) {
|
|
109
|
+
const name = block.name;
|
|
110
|
+
const input = block.input ?? {};
|
|
111
|
+
names.push(name);
|
|
112
|
+
events.push({ name, target: targetFor(name, input) });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { names, events };
|
|
117
|
+
}
|
|
118
|
+
function parseSession(text, opts = {}) {
|
|
119
|
+
const s = {
|
|
120
|
+
sessionUuid: null,
|
|
121
|
+
projectSlug: null,
|
|
122
|
+
startedAt: null,
|
|
123
|
+
endedAt: null,
|
|
124
|
+
durationMinutes: null,
|
|
125
|
+
modelPrimary: null,
|
|
126
|
+
jsonlRecords: 0,
|
|
127
|
+
assistantRecords: 0,
|
|
128
|
+
userRecords: 0,
|
|
129
|
+
fragmentsWithUsage: 0,
|
|
130
|
+
duplicateFragmentsSkipped: 0,
|
|
131
|
+
modelRequests: 0,
|
|
132
|
+
totals: emptyTotals(),
|
|
133
|
+
cost_usd: 0,
|
|
134
|
+
cache_savings_usd: 0,
|
|
135
|
+
toolUsage: {},
|
|
136
|
+
toolEvents: [],
|
|
137
|
+
skippedLines: 0
|
|
138
|
+
};
|
|
139
|
+
const seen = /* @__PURE__ */ new Set();
|
|
140
|
+
const modelCounts = /* @__PURE__ */ new Map();
|
|
141
|
+
let cwd = null;
|
|
142
|
+
let seq = 0;
|
|
143
|
+
for (const line of text.split("\n")) {
|
|
144
|
+
if (!line.trim()) continue;
|
|
145
|
+
const rec = safeParse(line);
|
|
146
|
+
if (!rec) {
|
|
147
|
+
s.skippedLines += 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
s.jsonlRecords += 1;
|
|
151
|
+
if (rec.sessionId && s.sessionUuid === null) s.sessionUuid = rec.sessionId;
|
|
152
|
+
if (typeof rec.cwd === "string" && cwd === null) cwd = rec.cwd;
|
|
153
|
+
if (rec.timestamp) {
|
|
154
|
+
if (s.startedAt === null || rec.timestamp < s.startedAt) s.startedAt = rec.timestamp;
|
|
155
|
+
if (s.endedAt === null || rec.timestamp > s.endedAt) s.endedAt = rec.timestamp;
|
|
156
|
+
}
|
|
157
|
+
if (rec.type !== "user" && rec.type !== "assistant") continue;
|
|
158
|
+
if (rec.type === "user") s.userRecords += 1;
|
|
159
|
+
else s.assistantRecords += 1;
|
|
160
|
+
const model = rec.message?.model;
|
|
161
|
+
if (model) modelCounts.set(model, (modelCounts.get(model) ?? 0) + 1);
|
|
162
|
+
if (rec.type === "assistant") {
|
|
163
|
+
const { names, events } = extractTools(rec.message?.content);
|
|
164
|
+
for (const n of names) s.toolUsage[n] = (s.toolUsage[n] ?? 0) + 1;
|
|
165
|
+
const requestId = rec.requestId || rec.message?.requestId || null;
|
|
166
|
+
for (const e of events) s.toolEvents.push({ name: e.name, requestId, target: e.target });
|
|
167
|
+
const usage = rec.message?.usage;
|
|
168
|
+
if (usage) {
|
|
169
|
+
seq += 1;
|
|
170
|
+
const key = usageKey(rec, seq);
|
|
171
|
+
if (seen.has(key)) {
|
|
172
|
+
s.duplicateFragmentsSkipped += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
seen.add(key);
|
|
176
|
+
s.fragmentsWithUsage += 1;
|
|
177
|
+
s.modelRequests += 1;
|
|
178
|
+
s.totals.input_tokens += Number(usage.input_tokens) || 0;
|
|
179
|
+
s.totals.output_tokens += Number(usage.output_tokens) || 0;
|
|
180
|
+
s.totals.cache_creation_tokens += Number(usage.cache_creation_input_tokens) || 0;
|
|
181
|
+
s.totals.cache_read_tokens += Number(usage.cache_read_input_tokens) || 0;
|
|
182
|
+
s.cost_usd += costForUsage(model, usage);
|
|
183
|
+
s.cache_savings_usd += cacheSavingsForUsage(model, usage);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
s.projectSlug = opts.projectSlug ?? projectFromCwd(cwd);
|
|
188
|
+
let best = null;
|
|
189
|
+
let bestCount = 0;
|
|
190
|
+
for (const [m, c] of modelCounts) if (c > bestCount) {
|
|
191
|
+
best = m;
|
|
192
|
+
bestCount = c;
|
|
193
|
+
}
|
|
194
|
+
s.modelPrimary = best;
|
|
195
|
+
if (s.startedAt && s.endedAt) {
|
|
196
|
+
const ms = Date.parse(s.endedAt) - Date.parse(s.startedAt);
|
|
197
|
+
s.durationMinutes = Number.isNaN(ms) ? null : ms / 6e4;
|
|
198
|
+
}
|
|
199
|
+
return s;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ../../packages/engine/src/aggregate.ts
|
|
203
|
+
function addTotals(into, from) {
|
|
204
|
+
into.input_tokens += from.input_tokens;
|
|
205
|
+
into.output_tokens += from.output_tokens;
|
|
206
|
+
into.cache_creation_tokens += from.cache_creation_tokens;
|
|
207
|
+
into.cache_read_tokens += from.cache_read_tokens;
|
|
208
|
+
}
|
|
209
|
+
function mostFrequent(values) {
|
|
210
|
+
const counts = /* @__PURE__ */ new Map();
|
|
211
|
+
for (const v of values) if (v) counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
212
|
+
let best = null;
|
|
213
|
+
let bestCount = 0;
|
|
214
|
+
for (const [v, c] of counts) if (c > bestCount) {
|
|
215
|
+
best = v;
|
|
216
|
+
bestCount = c;
|
|
217
|
+
}
|
|
218
|
+
return best;
|
|
219
|
+
}
|
|
220
|
+
function buildProjects(sessions) {
|
|
221
|
+
const groups = /* @__PURE__ */ new Map();
|
|
222
|
+
for (const s of sessions) {
|
|
223
|
+
const slug = s.projectSlug || "uploaded";
|
|
224
|
+
const list = groups.get(slug) ?? [];
|
|
225
|
+
list.push(s);
|
|
226
|
+
groups.set(slug, list);
|
|
227
|
+
}
|
|
228
|
+
const projects = [];
|
|
229
|
+
for (const [slug, list] of groups) {
|
|
230
|
+
const totals = emptyTotals();
|
|
231
|
+
let cost = 0;
|
|
232
|
+
let savings = 0;
|
|
233
|
+
for (const s of list) {
|
|
234
|
+
addTotals(totals, s.totals);
|
|
235
|
+
cost += s.cost_usd;
|
|
236
|
+
savings += s.cache_savings_usd;
|
|
237
|
+
}
|
|
238
|
+
const sorted = [...list].sort((a, b) => b.cost_usd - a.cost_usd);
|
|
239
|
+
projects.push({
|
|
240
|
+
slug,
|
|
241
|
+
sessions: sorted,
|
|
242
|
+
sessionsCount: list.length,
|
|
243
|
+
totals,
|
|
244
|
+
cost_usd: cost,
|
|
245
|
+
cache_savings_usd: savings,
|
|
246
|
+
cacheHitRate: cacheHitRate(totals),
|
|
247
|
+
modelPrimary: mostFrequent(list.map((s) => s.modelPrimary))
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return projects.sort((a, b) => b.cost_usd - a.cost_usd);
|
|
251
|
+
}
|
|
252
|
+
function buildSummary(projects) {
|
|
253
|
+
const totals = emptyTotals();
|
|
254
|
+
const modelBreakdown = {};
|
|
255
|
+
let cost = 0;
|
|
256
|
+
let savings = 0;
|
|
257
|
+
let sessionsCount = 0;
|
|
258
|
+
let durationHours = 0;
|
|
259
|
+
for (const p of projects) {
|
|
260
|
+
addTotals(totals, p.totals);
|
|
261
|
+
cost += p.cost_usd;
|
|
262
|
+
savings += p.cache_savings_usd;
|
|
263
|
+
sessionsCount += p.sessionsCount;
|
|
264
|
+
for (const s of p.sessions) {
|
|
265
|
+
const key = s.modelPrimary || "unknown";
|
|
266
|
+
modelBreakdown[key] = (modelBreakdown[key] ?? 0) + s.cost_usd;
|
|
267
|
+
if (s.durationMinutes && s.durationMinutes > 0) durationHours += s.durationMinutes / 60;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
projectsCount: projects.length,
|
|
272
|
+
sessionsCount,
|
|
273
|
+
cost_usd: cost,
|
|
274
|
+
cache_savings_usd: savings,
|
|
275
|
+
totals,
|
|
276
|
+
modelBreakdown,
|
|
277
|
+
cacheHitRate: cacheHitRate(totals),
|
|
278
|
+
durationHours
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ../../packages/engine/src/optimizer.ts
|
|
283
|
+
var HIT_FLOOR = 0.5;
|
|
284
|
+
var STREAK = 3;
|
|
285
|
+
var HOT_PROJECT_SHARE = 0.6;
|
|
286
|
+
var HEAVY_TOOL_FIRES = 100;
|
|
287
|
+
var SHORT_SESSION_MAX_MINUTES = 10;
|
|
288
|
+
var SHORT_SESSION_MIN_COST = 5;
|
|
289
|
+
function severityForSavings(savings) {
|
|
290
|
+
if (savings >= 50) return "critical";
|
|
291
|
+
if (savings >= 10) return "warn";
|
|
292
|
+
return "info";
|
|
293
|
+
}
|
|
294
|
+
function ruleBadCacheHit(ctx) {
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const proj of ctx.projects) {
|
|
297
|
+
const sorted = [...proj.sessions].sort((a, b) => (a.startedAt || "").localeCompare(b.startedAt || ""));
|
|
298
|
+
if (sorted.length < STREAK) continue;
|
|
299
|
+
const tail = sorted.slice(-STREAK);
|
|
300
|
+
if (!tail.every((s) => cacheHitRate(s.totals) < HIT_FLOOR)) continue;
|
|
301
|
+
const last = tail[tail.length - 1];
|
|
302
|
+
if (!last) continue;
|
|
303
|
+
const p = priceFor(proj.modelPrimary);
|
|
304
|
+
let savings = 0;
|
|
305
|
+
for (const s of tail) {
|
|
306
|
+
savings += (s.totals.cache_creation_tokens + s.totals.input_tokens) * (p.input - p.cache_read) / 1e6;
|
|
307
|
+
}
|
|
308
|
+
out.push({
|
|
309
|
+
ruleId: "BAD_CACHE_HIT",
|
|
310
|
+
severity: severityForSavings(savings),
|
|
311
|
+
project: proj.slug,
|
|
312
|
+
title: "Low cache hit rate across recent sessions",
|
|
313
|
+
description: `The last ${tail.length} sessions in ${proj.slug} reused little from the prompt cache, so input tokens were re-paid at full price.`,
|
|
314
|
+
suggestedAction: "Stabilize the start of each session (steady CLAUDE.md, consistent first reads) so the prompt cache is reused.",
|
|
315
|
+
estimatedMonthlySavingsUsd: savings,
|
|
316
|
+
evidence: { sessionsConsidered: tail.length, recentHitRate: cacheHitRate(last.totals) }
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
function ruleHotProject(ctx) {
|
|
322
|
+
const out = [];
|
|
323
|
+
if (ctx.summary.cost_usd <= 0) return out;
|
|
324
|
+
for (const proj of ctx.projects) {
|
|
325
|
+
const share = proj.cost_usd / ctx.summary.cost_usd;
|
|
326
|
+
if (share < HOT_PROJECT_SHARE) continue;
|
|
327
|
+
out.push({
|
|
328
|
+
ruleId: "HOT_PROJECT",
|
|
329
|
+
severity: "info",
|
|
330
|
+
project: proj.slug,
|
|
331
|
+
title: "One project dominates your spend",
|
|
332
|
+
description: `${proj.slug} accounts for ${Math.round(share * 100)}% of total spend.`,
|
|
333
|
+
suggestedAction: "Focus optimization effort here first; it is where savings compound.",
|
|
334
|
+
estimatedMonthlySavingsUsd: 0,
|
|
335
|
+
evidence: { share, projectCost: proj.cost_usd, totalCost: ctx.summary.cost_usd }
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
function ruleHeavyToolLoops(ctx) {
|
|
341
|
+
const out = [];
|
|
342
|
+
for (const proj of ctx.projects) {
|
|
343
|
+
for (const s of proj.sessions) {
|
|
344
|
+
for (const [tool, fires] of Object.entries(s.toolUsage)) {
|
|
345
|
+
if (fires < HEAVY_TOOL_FIRES) continue;
|
|
346
|
+
out.push({
|
|
347
|
+
ruleId: "HEAVY_TOOL_LOOPS",
|
|
348
|
+
severity: "warn",
|
|
349
|
+
project: proj.slug,
|
|
350
|
+
title: `Heavy ${tool} usage in one session`,
|
|
351
|
+
description: `${tool} fired ${fires} times in a single session, a sign of a stuck loop or unscoped search.`,
|
|
352
|
+
suggestedAction: `Add a guard or narrow the task so ${tool} is not called in a loop.`,
|
|
353
|
+
estimatedMonthlySavingsUsd: 0,
|
|
354
|
+
evidence: { tool, fires, sessionUuid: s.sessionUuid }
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
function ruleShortSessionBloat(ctx) {
|
|
362
|
+
const out = [];
|
|
363
|
+
for (const proj of ctx.projects) {
|
|
364
|
+
for (const s of proj.sessions) {
|
|
365
|
+
if (s.durationMinutes == null) continue;
|
|
366
|
+
if (s.durationMinutes >= SHORT_SESSION_MAX_MINUTES) continue;
|
|
367
|
+
if (s.cost_usd < SHORT_SESSION_MIN_COST) continue;
|
|
368
|
+
out.push({
|
|
369
|
+
ruleId: "SHORT_SESSION_BLOAT",
|
|
370
|
+
severity: "warn",
|
|
371
|
+
project: proj.slug,
|
|
372
|
+
title: "Expensive short session",
|
|
373
|
+
description: `A ${s.durationMinutes.toFixed(1)}-minute session cost $${s.cost_usd.toFixed(2)}, a sign of a bloated context for a small task.`,
|
|
374
|
+
suggestedAction: "Start a fresh, tightly scoped session for short tasks instead of carrying a large context.",
|
|
375
|
+
estimatedMonthlySavingsUsd: 0,
|
|
376
|
+
evidence: { minutes: s.durationMinutes, cost: s.cost_usd, sessionUuid: s.sessionUuid }
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
var RULES = [
|
|
383
|
+
{ id: "BAD_CACHE_HIT", run: ruleBadCacheHit },
|
|
384
|
+
{ id: "HOT_PROJECT", run: ruleHotProject },
|
|
385
|
+
{ id: "HEAVY_TOOL_LOOPS", run: ruleHeavyToolLoops },
|
|
386
|
+
{ id: "SHORT_SESSION_BLOAT", run: ruleShortSessionBloat }
|
|
387
|
+
];
|
|
388
|
+
function runOptimizer(ctx) {
|
|
389
|
+
const findings = [];
|
|
390
|
+
for (const rule of RULES) {
|
|
391
|
+
try {
|
|
392
|
+
findings.push(...rule.run(ctx));
|
|
393
|
+
} catch (err) {
|
|
394
|
+
findings.push({
|
|
395
|
+
ruleId: "RULE_ERROR",
|
|
396
|
+
severity: "info",
|
|
397
|
+
project: null,
|
|
398
|
+
title: `Rule ${rule.id} failed`,
|
|
399
|
+
description: err instanceof Error ? err.message : String(err),
|
|
400
|
+
suggestedAction: null,
|
|
401
|
+
estimatedMonthlySavingsUsd: 0,
|
|
402
|
+
evidence: null
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return findings;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ../../packages/engine/src/report.ts
|
|
410
|
+
function analyzeUploads(files) {
|
|
411
|
+
const sessions = files.map((f) => parseSession(f.text, { projectSlug: null }));
|
|
412
|
+
const projects = buildProjects(sessions);
|
|
413
|
+
const summary = buildSummary(projects);
|
|
414
|
+
const findings = runOptimizer({ projects, summary });
|
|
415
|
+
return { summary, projects, findings };
|
|
416
|
+
}
|
|
417
|
+
function cacheMissExposureUsd(report) {
|
|
418
|
+
let total = 0;
|
|
419
|
+
for (const proj of report.projects) {
|
|
420
|
+
const p = priceFor(proj.modelPrimary);
|
|
421
|
+
total += (proj.totals.input_tokens + proj.totals.cache_creation_tokens) * (p.input - p.cache_read) / 1e6;
|
|
422
|
+
}
|
|
423
|
+
return total;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ../../packages/engine/src/claudemd.ts
|
|
427
|
+
function lerp(x, x0, x1, y0, y1) {
|
|
428
|
+
if (x1 === x0) return y0;
|
|
429
|
+
const t = (x - x0) / (x1 - x0);
|
|
430
|
+
return y0 + t * (y1 - y0);
|
|
431
|
+
}
|
|
432
|
+
function clamp(n, lo = 0, hi = 100) {
|
|
433
|
+
return Math.max(lo, Math.min(hi, n));
|
|
434
|
+
}
|
|
435
|
+
function scoreSize(chars) {
|
|
436
|
+
if (chars < 300) return clamp(lerp(chars, 0, 300, 20, 50));
|
|
437
|
+
if (chars < 800) return clamp(lerp(chars, 300, 800, 50, 85));
|
|
438
|
+
if (chars <= 6e3) return 100;
|
|
439
|
+
if (chars <= 12e3) return clamp(lerp(chars, 6e3, 12e3, 100, 70));
|
|
440
|
+
if (chars <= 2e4) return clamp(lerp(chars, 12e3, 2e4, 70, 40));
|
|
441
|
+
return 25;
|
|
442
|
+
}
|
|
443
|
+
function scoreStructure(chars, headings, bullets) {
|
|
444
|
+
if (chars < 200) return 60;
|
|
445
|
+
let score = 40;
|
|
446
|
+
if (headings >= 1) score += 20;
|
|
447
|
+
if (headings >= 3) score += 15;
|
|
448
|
+
if (bullets >= 3) score += 15;
|
|
449
|
+
if (chars > 1500 && headings === 0) score = Math.min(score, 35);
|
|
450
|
+
if (chars > 4e3 && headings < 3) score = Math.min(score, 55);
|
|
451
|
+
return clamp(score);
|
|
452
|
+
}
|
|
453
|
+
function scoreStability(chars, volatileMarkers) {
|
|
454
|
+
if (chars === 0) return 0;
|
|
455
|
+
const per1k = volatileMarkers / chars * 1e3;
|
|
456
|
+
return clamp(100 - per1k * 28);
|
|
457
|
+
}
|
|
458
|
+
function analyzeClaudeMd(text) {
|
|
459
|
+
const raw = (text ?? "").trim();
|
|
460
|
+
if (!raw) {
|
|
461
|
+
return {
|
|
462
|
+
present: false,
|
|
463
|
+
chars: 0,
|
|
464
|
+
estTokens: 0,
|
|
465
|
+
headings: 0,
|
|
466
|
+
bullets: 0,
|
|
467
|
+
volatileMarkers: 0,
|
|
468
|
+
sizeScore: null,
|
|
469
|
+
structureScore: null,
|
|
470
|
+
stabilityScore: null
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
const chars = raw.length;
|
|
474
|
+
const estTokens = Math.round(chars / 4);
|
|
475
|
+
const headings = (raw.match(/^#{1,6}\s/gm) || []).length;
|
|
476
|
+
const bullets = (raw.match(/^\s*[-*]\s/gm) || []).length;
|
|
477
|
+
const dateMarkers = (raw.match(/\b\d{4}-\d{2}-\d{2}\b/g) || []).length;
|
|
478
|
+
const wordMarkers = (raw.match(/\b(updated|last update|as of|changelog|todo|fixme|wip|in progress|currently)\b/gi) || []).length;
|
|
479
|
+
const volatileMarkers = dateMarkers + wordMarkers;
|
|
480
|
+
return {
|
|
481
|
+
present: true,
|
|
482
|
+
chars,
|
|
483
|
+
estTokens,
|
|
484
|
+
headings,
|
|
485
|
+
bullets,
|
|
486
|
+
volatileMarkers,
|
|
487
|
+
sizeScore: scoreSize(chars),
|
|
488
|
+
structureScore: scoreStructure(chars, headings, bullets),
|
|
489
|
+
stabilityScore: scoreStability(chars, volatileMarkers)
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ../../packages/engine/src/rubric.ts
|
|
494
|
+
var PILLARS = {
|
|
495
|
+
claude_md: {
|
|
496
|
+
id: "claude_md",
|
|
497
|
+
name: "CLAUDE.md quality",
|
|
498
|
+
weight: 0.18,
|
|
499
|
+
description: "Whether your project memory is the right size, well structured, and cache stable.",
|
|
500
|
+
color: "#2DD4A8"
|
|
501
|
+
},
|
|
502
|
+
context_hygiene: {
|
|
503
|
+
id: "context_hygiene",
|
|
504
|
+
name: "Context hygiene",
|
|
505
|
+
weight: 0.2,
|
|
506
|
+
description: "How much of your context is served from cache versus reprocessed every turn.",
|
|
507
|
+
color: "#34D399"
|
|
508
|
+
},
|
|
509
|
+
prompting: {
|
|
510
|
+
id: "prompting",
|
|
511
|
+
name: "Prompting patterns",
|
|
512
|
+
weight: 0.15,
|
|
513
|
+
description: "Whether you plan before acting and give the agent a way to verify its own work.",
|
|
514
|
+
color: "#60A5FA"
|
|
515
|
+
},
|
|
516
|
+
session_mgmt: {
|
|
517
|
+
id: "session_mgmt",
|
|
518
|
+
name: "Session management",
|
|
519
|
+
weight: 0.15,
|
|
520
|
+
description: "How well sessions are scoped so cost per session and per minute stays sane.",
|
|
521
|
+
color: "#F59E0B"
|
|
522
|
+
},
|
|
523
|
+
tooling: {
|
|
524
|
+
id: "tooling",
|
|
525
|
+
name: "Tool and MCP config",
|
|
526
|
+
weight: 0.14,
|
|
527
|
+
description: "Whether tools, MCP servers, and permissions are scoped instead of bloating context.",
|
|
528
|
+
color: "#A855F7"
|
|
529
|
+
},
|
|
530
|
+
cost_discipline: {
|
|
531
|
+
id: "cost_discipline",
|
|
532
|
+
name: "Cost discipline",
|
|
533
|
+
weight: 0.18,
|
|
534
|
+
description: "Whether spend is watched and the model is matched to the task.",
|
|
535
|
+
color: "#F472B6"
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
var PILLAR_ORDER = [
|
|
539
|
+
"claude_md",
|
|
540
|
+
"context_hygiene",
|
|
541
|
+
"prompting",
|
|
542
|
+
"session_mgmt",
|
|
543
|
+
"tooling",
|
|
544
|
+
"cost_discipline"
|
|
545
|
+
];
|
|
546
|
+
var GAP_THRESHOLD = 60;
|
|
547
|
+
var BANDS = [
|
|
548
|
+
{ id: "critical", label: "Burning tokens", description: "Setup is leaking time and money on most turns. The fixes below are high leverage." },
|
|
549
|
+
{ id: "leaky", label: "Leaky", description: "A working setup with clear, recoverable waste. A few changes pay for themselves." },
|
|
550
|
+
{ id: "solid", label: "Solid", description: "A sound setup with some rough edges left to tighten." },
|
|
551
|
+
{ id: "tight", label: "Tight", description: "A well-run setup. The remaining fixes are marginal." },
|
|
552
|
+
{ id: "dialed", label: "Dialed in", description: "Close to the efficient frontier for your workload. Little left to recover." }
|
|
553
|
+
];
|
|
554
|
+
function bandFor(score) {
|
|
555
|
+
if (score < 40) return BANDS[0];
|
|
556
|
+
if (score < 60) return BANDS[1];
|
|
557
|
+
if (score < 75) return BANDS[2];
|
|
558
|
+
if (score < 90) return BANDS[3];
|
|
559
|
+
return BANDS[4];
|
|
560
|
+
}
|
|
561
|
+
var QUESTIONNAIRE = [
|
|
562
|
+
{
|
|
563
|
+
id: "cmd_scope",
|
|
564
|
+
pillar: "claude_md",
|
|
565
|
+
prompt: "Your CLAUDE.md mostly contains:",
|
|
566
|
+
options: [
|
|
567
|
+
{ id: "stable", label: "Stable project facts and conventions", score: 100 },
|
|
568
|
+
{ id: "mixed", label: "A mix of conventions and notes", score: 60 },
|
|
569
|
+
{ id: "churny", label: "Lots of changing notes, logs, or TODOs", score: 25 },
|
|
570
|
+
{ id: "none", label: "I do not have a CLAUDE.md", score: 0 }
|
|
571
|
+
]
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
id: "clear_between",
|
|
575
|
+
pillar: "context_hygiene",
|
|
576
|
+
prompt: "Between unrelated tasks, do you clear or compact the context?",
|
|
577
|
+
options: [
|
|
578
|
+
{ id: "always", label: "Usually or always", score: 100 },
|
|
579
|
+
{ id: "sometimes", label: "Sometimes", score: 55 },
|
|
580
|
+
{ id: "never", label: "Rarely or never", score: 10 }
|
|
581
|
+
]
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
id: "plan_first",
|
|
585
|
+
pillar: "prompting",
|
|
586
|
+
prompt: "For multi-step tasks, do you plan or write a short spec before coding?",
|
|
587
|
+
options: [
|
|
588
|
+
{ id: "usually", label: "Usually", score: 100 },
|
|
589
|
+
{ id: "sometimes", label: "Sometimes", score: 55 },
|
|
590
|
+
{ id: "rarely", label: "Rarely", score: 15 }
|
|
591
|
+
]
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
id: "success_criteria",
|
|
595
|
+
pillar: "prompting",
|
|
596
|
+
prompt: "Do you give the agent success criteria or tests so it can verify its own work?",
|
|
597
|
+
options: [
|
|
598
|
+
{ id: "usually", label: "Usually", score: 100 },
|
|
599
|
+
{ id: "sometimes", label: "Sometimes", score: 60 },
|
|
600
|
+
{ id: "rarely", label: "Rarely", score: 20 }
|
|
601
|
+
]
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
id: "session_scope",
|
|
605
|
+
pillar: "session_mgmt",
|
|
606
|
+
prompt: "How do you scope your sessions?",
|
|
607
|
+
options: [
|
|
608
|
+
{ id: "one_task", label: "One task per session, then reset", score: 100 },
|
|
609
|
+
{ id: "loose", label: "Loosely, a few tasks per session", score: 55 },
|
|
610
|
+
{ id: "marathon", label: "One long-running session", score: 20 }
|
|
611
|
+
]
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
id: "mcp_scoped",
|
|
615
|
+
pillar: "tooling",
|
|
616
|
+
prompt: "How are your MCP servers and custom tools configured?",
|
|
617
|
+
options: [
|
|
618
|
+
{ id: "minimal", label: "Minimal and scoped to what I use", score: 100 },
|
|
619
|
+
{ id: "few", label: "A few, mostly used", score: 70 },
|
|
620
|
+
{ id: "many", label: "Many, some unused and always loaded", score: 25 },
|
|
621
|
+
{ id: "unsure", label: "Not sure", score: 40 }
|
|
622
|
+
]
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
id: "permission_allowlist",
|
|
626
|
+
pillar: "tooling",
|
|
627
|
+
prompt: "Do you use a permission allowlist or settings to cut approval prompts?",
|
|
628
|
+
options: [
|
|
629
|
+
{ id: "yes", label: "Yes", score: 100 },
|
|
630
|
+
{ id: "partial", label: "Partly", score: 60 },
|
|
631
|
+
{ id: "no", label: "No", score: 25 }
|
|
632
|
+
]
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
id: "cost_monitor",
|
|
636
|
+
pillar: "cost_discipline",
|
|
637
|
+
prompt: "Do you track token spend or set a budget?",
|
|
638
|
+
options: [
|
|
639
|
+
{ id: "active", label: "I actively monitor it", score: 100 },
|
|
640
|
+
{ id: "glance", label: "I glance at the billing page", score: 55 },
|
|
641
|
+
{ id: "never", label: "I do not track it", score: 15 }
|
|
642
|
+
]
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
id: "model_choice",
|
|
646
|
+
pillar: "cost_discipline",
|
|
647
|
+
prompt: "Do you match the model to the task?",
|
|
648
|
+
options: [
|
|
649
|
+
{ id: "match", label: "I use a cheaper model for routine work", score: 100 },
|
|
650
|
+
{ id: "sometimes", label: "Sometimes", score: 60 },
|
|
651
|
+
{ id: "top", label: "I run the top model for everything", score: 30 }
|
|
652
|
+
]
|
|
653
|
+
}
|
|
654
|
+
];
|
|
655
|
+
function questionById(id) {
|
|
656
|
+
return QUESTIONNAIRE.find((q) => q.id === id);
|
|
657
|
+
}
|
|
658
|
+
function optionScore(question, optionId) {
|
|
659
|
+
if (!optionId) return null;
|
|
660
|
+
const opt = question.options.find((o) => o.id === optionId);
|
|
661
|
+
return opt ? opt.score : null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ../../packages/engine/src/scoring.ts
|
|
665
|
+
function clamp2(n, lo = 0, hi = 100) {
|
|
666
|
+
return Math.max(lo, Math.min(hi, n));
|
|
667
|
+
}
|
|
668
|
+
function makeCheck(id, pillar, label, score, basis, signal) {
|
|
669
|
+
const assessed = score !== null;
|
|
670
|
+
return {
|
|
671
|
+
id,
|
|
672
|
+
pillar,
|
|
673
|
+
label,
|
|
674
|
+
assessed,
|
|
675
|
+
score: assessed ? Math.round(score) : null,
|
|
676
|
+
basis,
|
|
677
|
+
signal,
|
|
678
|
+
gap: assessed && score < GAP_THRESHOLD
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
function questionnaireCheck(id, label, answers) {
|
|
682
|
+
const q = questionById(id);
|
|
683
|
+
if (!q) return makeCheck(id, "claude_md", label, null, "Unknown question.", "questionnaire");
|
|
684
|
+
const score = optionScore(q, answers[id]);
|
|
685
|
+
const chosen = q.options.find((o) => o.id === answers[id]);
|
|
686
|
+
const basis = chosen ? `You answered: "${chosen.label}."` : "Not answered.";
|
|
687
|
+
return makeCheck(id, q.pillar, label, score, basis, "questionnaire");
|
|
688
|
+
}
|
|
689
|
+
function aggregateTools(report) {
|
|
690
|
+
let total = 0;
|
|
691
|
+
let bash = 0;
|
|
692
|
+
for (const p of report.projects) {
|
|
693
|
+
for (const s of p.sessions) {
|
|
694
|
+
for (const [tool, n] of Object.entries(s.toolUsage)) {
|
|
695
|
+
total += n;
|
|
696
|
+
if (tool === "Bash") bash += n;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return { total, bash };
|
|
701
|
+
}
|
|
702
|
+
function hasAnyDuration(report) {
|
|
703
|
+
for (const p of report.projects) {
|
|
704
|
+
for (const s of p.sessions) {
|
|
705
|
+
if (s.durationMinutes != null) return true;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
function buildChecks(report, cmd, answers) {
|
|
711
|
+
const checks = [];
|
|
712
|
+
const s = report.summary;
|
|
713
|
+
checks.push(
|
|
714
|
+
makeCheck(
|
|
715
|
+
"cmd_size",
|
|
716
|
+
"claude_md",
|
|
717
|
+
"Size",
|
|
718
|
+
cmd.present ? cmd.sizeScore : null,
|
|
719
|
+
cmd.present ? `Your CLAUDE.md is ${cmd.chars.toLocaleString()} characters, about ${cmd.estTokens.toLocaleString()} tokens loaded on every turn.` : "No CLAUDE.md was uploaded.",
|
|
720
|
+
"claudemd"
|
|
721
|
+
)
|
|
722
|
+
);
|
|
723
|
+
checks.push(
|
|
724
|
+
makeCheck(
|
|
725
|
+
"cmd_structure",
|
|
726
|
+
"claude_md",
|
|
727
|
+
"Structure",
|
|
728
|
+
cmd.present ? cmd.structureScore : null,
|
|
729
|
+
cmd.present ? `${cmd.headings} heading${cmd.headings === 1 ? "" : "s"} and ${cmd.bullets} bullet point${cmd.bullets === 1 ? "" : "s"} across ${cmd.chars.toLocaleString()} characters.` : "No CLAUDE.md was uploaded.",
|
|
730
|
+
"claudemd"
|
|
731
|
+
)
|
|
732
|
+
);
|
|
733
|
+
checks.push(
|
|
734
|
+
makeCheck(
|
|
735
|
+
"cmd_stability",
|
|
736
|
+
"claude_md",
|
|
737
|
+
"Cache stability",
|
|
738
|
+
cmd.present ? cmd.stabilityScore : null,
|
|
739
|
+
cmd.present ? `${cmd.volatileMarkers} volatile marker${cmd.volatileMarkers === 1 ? "" : "s"} (dates, TODOs, "updated" notes) that change between sessions and reset the cache.` : "No CLAUDE.md was uploaded.",
|
|
740
|
+
"claudemd"
|
|
741
|
+
)
|
|
742
|
+
);
|
|
743
|
+
checks.push(questionnaireCheck("cmd_scope", "Content scope", answers));
|
|
744
|
+
const contextTokens = s.totals.input_tokens + s.totals.cache_creation_tokens + s.totals.cache_read_tokens;
|
|
745
|
+
checks.push(
|
|
746
|
+
makeCheck(
|
|
747
|
+
"cache_hit",
|
|
748
|
+
"context_hygiene",
|
|
749
|
+
"Cache hit rate",
|
|
750
|
+
contextTokens > 0 ? clamp2(s.cacheHitRate * 100) : null,
|
|
751
|
+
contextTokens > 0 ? `Your overall cache hit rate is ${(s.cacheHitRate * 100).toFixed(1)}% across ${s.sessionsCount} session${s.sessionsCount === 1 ? "" : "s"}.` : "No token usage was found in the logs.",
|
|
752
|
+
"metrics"
|
|
753
|
+
)
|
|
754
|
+
);
|
|
755
|
+
const cc = s.totals.cache_creation_tokens;
|
|
756
|
+
const cr = s.totals.cache_read_tokens;
|
|
757
|
+
const churnAssessable = cc + cr > 0;
|
|
758
|
+
const churnRatio = churnAssessable ? cc / (cc + cr) : 0;
|
|
759
|
+
const writesPerRead = cr > 0 ? cc / cr : cc > 0 ? Infinity : 0;
|
|
760
|
+
checks.push(
|
|
761
|
+
makeCheck(
|
|
762
|
+
"cache_churn",
|
|
763
|
+
"context_hygiene",
|
|
764
|
+
"Cache churn",
|
|
765
|
+
churnAssessable ? clamp2(100 - churnRatio * 200) : null,
|
|
766
|
+
churnAssessable ? `You wrote ${writesPerRead === Infinity ? "cache with no reads at all" : writesPerRead.toFixed(2) + " cache tokens for every one read"}; healthy caching reads far more than it writes.` : "No cache token usage was found.",
|
|
767
|
+
"metrics"
|
|
768
|
+
)
|
|
769
|
+
);
|
|
770
|
+
checks.push(questionnaireCheck("clear_between", "Clearing between tasks", answers));
|
|
771
|
+
checks.push(questionnaireCheck("plan_first", "Planning first", answers));
|
|
772
|
+
checks.push(questionnaireCheck("success_criteria", "Self-verification", answers));
|
|
773
|
+
const heavyLoops = report.findings.filter((f) => f.ruleId === "HEAVY_TOOL_LOOPS");
|
|
774
|
+
const thrashSessions = new Set(
|
|
775
|
+
heavyLoops.map((f) => f.evidence?.["sessionUuid"]).filter((v) => typeof v === "string")
|
|
776
|
+
).size;
|
|
777
|
+
const thrashShare = s.sessionsCount > 0 ? thrashSessions / s.sessionsCount : 0;
|
|
778
|
+
const thrashPct = Math.round(thrashShare * 100);
|
|
779
|
+
checks.push(
|
|
780
|
+
makeCheck(
|
|
781
|
+
"tool_thrash",
|
|
782
|
+
"prompting",
|
|
783
|
+
"Tool looping",
|
|
784
|
+
s.sessionsCount > 0 ? clamp2(100 - thrashShare * 180) : null,
|
|
785
|
+
s.sessionsCount === 0 ? "No sessions to assess." : thrashSessions === 0 ? "No single tool fired 100 or more times in a session." : `${thrashSessions} of ${s.sessionsCount} sessions (${thrashPct}%) fired one tool 100 or more times.`,
|
|
786
|
+
"metrics"
|
|
787
|
+
)
|
|
788
|
+
);
|
|
789
|
+
const shortBloat = report.findings.filter((f) => f.ruleId === "SHORT_SESSION_BLOAT");
|
|
790
|
+
const durations = hasAnyDuration(report);
|
|
791
|
+
const bloatSessions = new Set(
|
|
792
|
+
shortBloat.map((f) => f.evidence?.["sessionUuid"]).filter((v) => typeof v === "string")
|
|
793
|
+
).size;
|
|
794
|
+
const bloatShare = s.sessionsCount > 0 ? bloatSessions / s.sessionsCount : 0;
|
|
795
|
+
const bloatPct = Math.round(bloatShare * 100);
|
|
796
|
+
checks.push(
|
|
797
|
+
makeCheck(
|
|
798
|
+
"short_session_bloat",
|
|
799
|
+
"session_mgmt",
|
|
800
|
+
"Cost per minute",
|
|
801
|
+
durations && s.sessionsCount > 0 ? clamp2(100 - bloatShare * 180) : null,
|
|
802
|
+
!durations ? "Sessions had no usable timestamps to measure duration." : bloatSessions === 0 ? "No short session burned an outsized amount for its length." : `${bloatSessions} of ${s.sessionsCount} sessions (${bloatPct}%) cost over $5 in under 10 minutes.`,
|
|
803
|
+
"metrics"
|
|
804
|
+
)
|
|
805
|
+
);
|
|
806
|
+
checks.push(questionnaireCheck("session_scope", "Session scoping", answers));
|
|
807
|
+
checks.push(questionnaireCheck("mcp_scoped", "MCP and tool scope", answers));
|
|
808
|
+
checks.push(questionnaireCheck("permission_allowlist", "Permission allowlist", answers));
|
|
809
|
+
const tools = aggregateTools(report);
|
|
810
|
+
const bashShare = tools.total > 0 ? tools.bash / tools.total : 0;
|
|
811
|
+
checks.push(
|
|
812
|
+
makeCheck(
|
|
813
|
+
"bash_reliance",
|
|
814
|
+
"tooling",
|
|
815
|
+
"Tool fit",
|
|
816
|
+
tools.total >= 20 ? clamp2(100 - Math.max(0, bashShare - 0.3) * 200) : null,
|
|
817
|
+
tools.total >= 20 ? `Bash was ${(bashShare * 100).toFixed(0)}% of your ${tools.total.toLocaleString()} tool calls; dedicated tools like Grep, Glob, and Read are cheaper and cleaner than shelling out.` : "Too few tool calls to judge tool fit.",
|
|
818
|
+
"metrics"
|
|
819
|
+
)
|
|
820
|
+
);
|
|
821
|
+
const exposure = cacheMissExposureUsd(report);
|
|
822
|
+
const spend = s.cost_usd;
|
|
823
|
+
const exposureShare = spend > 0 ? exposure / spend : 0;
|
|
824
|
+
checks.push(
|
|
825
|
+
makeCheck(
|
|
826
|
+
"recoverable_exposure",
|
|
827
|
+
"cost_discipline",
|
|
828
|
+
"Recoverable spend",
|
|
829
|
+
spend > 0 ? clamp2(100 - exposureShare * 120) : null,
|
|
830
|
+
spend > 0 ? `$${exposure.toFixed(2)} of your $${spend.toFixed(2)} spend is cache-miss exposure that a stable prefix could largely recover.` : "No spend was found in the logs.",
|
|
831
|
+
"metrics"
|
|
832
|
+
)
|
|
833
|
+
);
|
|
834
|
+
checks.push(questionnaireCheck("cost_monitor", "Spend monitoring", answers));
|
|
835
|
+
checks.push(questionnaireCheck("model_choice", "Model fit", answers));
|
|
836
|
+
return checks;
|
|
837
|
+
}
|
|
838
|
+
function scorePillars(report, cmd, answers) {
|
|
839
|
+
const checks = buildChecks(report, cmd, answers);
|
|
840
|
+
const pillars = PILLAR_ORDER.map((id) => {
|
|
841
|
+
const cfg = PILLARS[id];
|
|
842
|
+
const pillarChecks = checks.filter((c) => c.pillar === id);
|
|
843
|
+
const assessedChecks = pillarChecks.filter((c) => c.assessed);
|
|
844
|
+
const assessed = assessedChecks.length > 0;
|
|
845
|
+
const score = assessed ? Math.round(assessedChecks.reduce((sum, c) => sum + c.score, 0) / assessedChecks.length) : null;
|
|
846
|
+
return { id, name: cfg.name, weight: cfg.weight, assessed, score, checks: pillarChecks };
|
|
847
|
+
});
|
|
848
|
+
let weighted = 0;
|
|
849
|
+
let weightSum = 0;
|
|
850
|
+
for (const p of pillars) {
|
|
851
|
+
if (p.assessed && p.score != null) {
|
|
852
|
+
weighted += p.score * p.weight;
|
|
853
|
+
weightSum += p.weight;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const overall = weightSum > 0 ? Math.round(weighted / weightSum) : 0;
|
|
857
|
+
return { pillars, overall, band: bandFor(overall) };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ../../packages/engine/src/synthesize.ts
|
|
861
|
+
var CHECK_FIX = {
|
|
862
|
+
cmd_structure: {
|
|
863
|
+
title: "Give CLAUDE.md clear structure",
|
|
864
|
+
action: "Break the file into sections with headings and short bulleted rules so the agent finds the right guidance fast instead of scanning a wall of text."
|
|
865
|
+
},
|
|
866
|
+
cmd_stability: {
|
|
867
|
+
title: "Remove volatile content from CLAUDE.md",
|
|
868
|
+
action: "Move dates, TODOs, and 'updated' notes into a separate scratch file. A stable CLAUDE.md keeps the cache warm across sessions instead of resetting it."
|
|
869
|
+
},
|
|
870
|
+
cmd_scope: {
|
|
871
|
+
title: "Keep CLAUDE.md to stable facts",
|
|
872
|
+
action: "Limit CLAUDE.md to durable conventions and project facts. Changing notes belong elsewhere so the cached prefix stays constant."
|
|
873
|
+
},
|
|
874
|
+
clear_between: {
|
|
875
|
+
title: "Clear or compact between unrelated tasks",
|
|
876
|
+
action: "Run /clear or compact when you switch to an unrelated task so stale context stops riding along and inflating every turn."
|
|
877
|
+
},
|
|
878
|
+
plan_first: {
|
|
879
|
+
title: "Plan before acting on multi-step work",
|
|
880
|
+
action: "Use plan mode or write a short spec for multi-step tasks. Planning first cuts the reactive tool loops that burn tokens."
|
|
881
|
+
},
|
|
882
|
+
success_criteria: {
|
|
883
|
+
title: "Give the agent success criteria",
|
|
884
|
+
action: "State the done condition or point at a test so the agent can verify its own work instead of guessing and retrying."
|
|
885
|
+
},
|
|
886
|
+
session_scope: {
|
|
887
|
+
title: "Scope sessions to one task",
|
|
888
|
+
action: "Start a fresh session per task and reset between them. Long marathon sessions carry a growing, expensive context."
|
|
889
|
+
},
|
|
890
|
+
mcp_scoped: {
|
|
891
|
+
title: "Trim unused MCP servers and tools",
|
|
892
|
+
action: "Disable MCP servers and tools you do not use. Every loaded tool definition is context you pay for on every turn."
|
|
893
|
+
},
|
|
894
|
+
permission_allowlist: {
|
|
895
|
+
title: "Set a permission allowlist",
|
|
896
|
+
action: "Add a settings allowlist for safe, frequent commands to cut approval prompts and the round trips they cost."
|
|
897
|
+
},
|
|
898
|
+
bash_reliance: {
|
|
899
|
+
title: "Use dedicated tools over Bash",
|
|
900
|
+
action: "Replace shelling out for search and file reads with Grep, Glob, and Read. They are cheaper, cleaner, and easier to permission."
|
|
901
|
+
},
|
|
902
|
+
cost_monitor: {
|
|
903
|
+
title: "Track your spend",
|
|
904
|
+
action: "Watch token spend or set a budget so a runaway session is caught early rather than at the monthly bill."
|
|
905
|
+
},
|
|
906
|
+
model_choice: {
|
|
907
|
+
title: "Match the model to the task",
|
|
908
|
+
action: "Use a cheaper model for routine edits and reserve the top model for the hard reasoning. Same work, lower bill."
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
function severityRank(s) {
|
|
912
|
+
return s === "critical" ? 3 : s === "warn" ? 2 : 1;
|
|
913
|
+
}
|
|
914
|
+
function sizeFix(cmd) {
|
|
915
|
+
if (cmd.chars > 6e3) {
|
|
916
|
+
return {
|
|
917
|
+
title: "Trim your CLAUDE.md",
|
|
918
|
+
action: "Your CLAUDE.md is large and rides on every turn. Cut it to the durable conventions the agent actually needs and move the rest into docs it can open on demand."
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
return {
|
|
922
|
+
title: "Flesh out your CLAUDE.md",
|
|
923
|
+
action: "Your CLAUDE.md is thin. Add the conventions, commands, and constraints the agent keeps getting wrong so it stops re-deriving them every session."
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
function synthesize(report, pillars, cmd) {
|
|
927
|
+
const headlineWasteUsd = cacheMissExposureUsd(report);
|
|
928
|
+
const headlineWasteLabel = "Recoverable spend (cache-miss exposure)";
|
|
929
|
+
const spend = report.summary.cost_usd;
|
|
930
|
+
const exposure = headlineWasteUsd;
|
|
931
|
+
const fixes = [];
|
|
932
|
+
const covered = /* @__PURE__ */ new Set();
|
|
933
|
+
const round22 = (n) => Math.round(n * 100) / 100;
|
|
934
|
+
const usd2 = (n) => `$${round22(n).toFixed(2)}`;
|
|
935
|
+
const sevForUsd = (n) => n >= 50 ? "critical" : n >= 10 ? "warn" : "info";
|
|
936
|
+
const exposureMaterial = exposure >= 1 && (spend === 0 || exposure / spend >= 0.05);
|
|
937
|
+
if (exposureMaterial) {
|
|
938
|
+
const ranked = report.projects.map((p) => {
|
|
939
|
+
const rate = priceFor(p.modelPrimary);
|
|
940
|
+
const contrib = (p.totals.input_tokens + p.totals.cache_creation_tokens) * (rate.input - rate.cache_read) / 1e6;
|
|
941
|
+
return { slug: p.slug, contrib };
|
|
942
|
+
}).filter((r) => r.contrib > 0).sort((a, b) => b.contrib - a.contrib);
|
|
943
|
+
const worst = ranked.slice(0, 3).map((r) => r.slug);
|
|
944
|
+
fixes.push({
|
|
945
|
+
id: "cache_recovery",
|
|
946
|
+
title: "Recover spend lost to cache misses",
|
|
947
|
+
pillar: "context_hygiene",
|
|
948
|
+
pillarName: PILLARS.context_hygiene.name,
|
|
949
|
+
severity: sevForUsd(exposure),
|
|
950
|
+
wasteUsd: round22(exposure),
|
|
951
|
+
problem: sanitizeText(
|
|
952
|
+
`Up to ${usd2(exposure)} of input spend was paid at full price instead of the cache-read rate. Biggest contributors: ${worst.length ? worst.join(", ") : "your largest projects"}.`
|
|
953
|
+
),
|
|
954
|
+
action: sanitizeText(
|
|
955
|
+
"Keep the start of each session stable (a steady CLAUDE.md and consistent first reads) so the prompt cache is reused across turns and sessions."
|
|
956
|
+
),
|
|
957
|
+
basis: sanitizeText("Cache-miss exposure summed across projects at the published input minus cache-read delta. Upper bound on recoverable spend.")
|
|
958
|
+
});
|
|
959
|
+
covered.add("cache_hit");
|
|
960
|
+
covered.add("cache_churn");
|
|
961
|
+
covered.add("recoverable_exposure");
|
|
962
|
+
}
|
|
963
|
+
const heavyByTool = /* @__PURE__ */ new Map();
|
|
964
|
+
let shortCount = 0;
|
|
965
|
+
for (const f of report.findings) {
|
|
966
|
+
if (f.ruleId === "HEAVY_TOOL_LOOPS" && f.evidence) {
|
|
967
|
+
const tool = String(f.evidence["tool"] ?? "a tool");
|
|
968
|
+
const entry = heavyByTool.get(tool) ?? { sessions: /* @__PURE__ */ new Set(), maxFires: 0 };
|
|
969
|
+
const sid = f.evidence["sessionUuid"];
|
|
970
|
+
if (typeof sid === "string") entry.sessions.add(sid);
|
|
971
|
+
entry.maxFires = Math.max(entry.maxFires, Number(f.evidence["fires"]) || 0);
|
|
972
|
+
heavyByTool.set(tool, entry);
|
|
973
|
+
covered.add("tool_thrash");
|
|
974
|
+
} else if (f.ruleId === "SHORT_SESSION_BLOAT") {
|
|
975
|
+
shortCount += 1;
|
|
976
|
+
covered.add("short_session_bloat");
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
for (const [tool, e] of heavyByTool) {
|
|
980
|
+
const n = Math.max(e.sessions.size, 1);
|
|
981
|
+
fixes.push({
|
|
982
|
+
id: `heavy_tool_${tool}`,
|
|
983
|
+
title: `Heavy ${tool} usage in ${n} session(s)`,
|
|
984
|
+
pillar: "prompting",
|
|
985
|
+
pillarName: PILLARS.prompting.name,
|
|
986
|
+
severity: "warn",
|
|
987
|
+
wasteUsd: 0,
|
|
988
|
+
problem: sanitizeText(`${tool} fired ${e.maxFires}+ times in a single session across ${n} session(s), a sign of stuck loops or unscoped work.`),
|
|
989
|
+
action: sanitizeText(`Add a guard or narrow tasks so ${tool} is not called in a tight loop.`),
|
|
990
|
+
basis: sanitizeText("Aggregated from per-session tool-use counts over the heavy-loop threshold.")
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
if (shortCount > 0) {
|
|
994
|
+
fixes.push({
|
|
995
|
+
id: "short_sessions",
|
|
996
|
+
title: `${shortCount} short session(s) cost more than expected`,
|
|
997
|
+
pillar: "session_mgmt",
|
|
998
|
+
pillarName: PILLARS.session_mgmt.name,
|
|
999
|
+
severity: "warn",
|
|
1000
|
+
wasteUsd: 0,
|
|
1001
|
+
problem: sanitizeText(`${shortCount} short session(s) carried a large context for a small task.`),
|
|
1002
|
+
action: sanitizeText("Start a fresh, tightly scoped session for short tasks instead of carrying a big context."),
|
|
1003
|
+
basis: sanitizeText("Sessions under the short-duration threshold with above-floor cost.")
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
for (const p of pillars) {
|
|
1007
|
+
for (const c of p.checks) {
|
|
1008
|
+
if (!c.gap || covered.has(c.id)) continue;
|
|
1009
|
+
const tmpl = c.id === "cmd_size" ? sizeFix(cmd) : CHECK_FIX[c.id];
|
|
1010
|
+
if (!tmpl) continue;
|
|
1011
|
+
const severity = (c.score ?? 100) < 40 ? "warn" : "info";
|
|
1012
|
+
fixes.push({
|
|
1013
|
+
id: `check_${c.id}`,
|
|
1014
|
+
title: tmpl.title,
|
|
1015
|
+
pillar: p.id,
|
|
1016
|
+
pillarName: p.name,
|
|
1017
|
+
severity,
|
|
1018
|
+
wasteUsd: 0,
|
|
1019
|
+
problem: c.basis,
|
|
1020
|
+
action: tmpl.action,
|
|
1021
|
+
basis: `${p.name} gap (${c.label}).`
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
fixes.sort((a, b) => {
|
|
1026
|
+
if (b.wasteUsd !== a.wasteUsd) return b.wasteUsd - a.wasteUsd;
|
|
1027
|
+
if (severityRank(b.severity) !== severityRank(a.severity)) return severityRank(b.severity) - severityRank(a.severity);
|
|
1028
|
+
return PILLARS[b.pillar].weight - PILLARS[a.pillar].weight;
|
|
1029
|
+
});
|
|
1030
|
+
return {
|
|
1031
|
+
headlineWasteUsd,
|
|
1032
|
+
headlineWasteLabel,
|
|
1033
|
+
fixes
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ../../packages/engine/src/audit.ts
|
|
1038
|
+
import { randomUUID } from "crypto";
|
|
1039
|
+
function round2(n) {
|
|
1040
|
+
return Math.round(n * 100) / 100;
|
|
1041
|
+
}
|
|
1042
|
+
function round4(n) {
|
|
1043
|
+
return Math.round(n * 1e4) / 1e4;
|
|
1044
|
+
}
|
|
1045
|
+
function toUsageMetrics(report) {
|
|
1046
|
+
const s = report.summary;
|
|
1047
|
+
const topProjects = report.projects.slice().sort((a, b) => b.cost_usd - a.cost_usd).slice(0, 8).map((p) => ({
|
|
1048
|
+
slug: p.slug,
|
|
1049
|
+
costUsd: round2(p.cost_usd),
|
|
1050
|
+
sessionsCount: p.sessionsCount,
|
|
1051
|
+
cacheHitRate: round4(p.cacheHitRate),
|
|
1052
|
+
modelPrimary: p.modelPrimary
|
|
1053
|
+
}));
|
|
1054
|
+
const topSessions = report.projects.flatMap((p) => p.sessions.map((sess) => ({ projectSlug: p.slug, sess }))).sort((a, b) => b.sess.cost_usd - a.sess.cost_usd).slice(0, 8).map(({ projectSlug, sess }) => ({
|
|
1055
|
+
projectSlug,
|
|
1056
|
+
sessionUuid: sess.sessionUuid,
|
|
1057
|
+
costUsd: round2(sess.cost_usd),
|
|
1058
|
+
durationMinutes: sess.durationMinutes == null ? null : round2(sess.durationMinutes),
|
|
1059
|
+
modelPrimary: sess.modelPrimary,
|
|
1060
|
+
cacheHitRate: round4(cacheHitRate(sess.totals))
|
|
1061
|
+
}));
|
|
1062
|
+
const modelBreakdown = {};
|
|
1063
|
+
for (const [model, usd2] of Object.entries(s.modelBreakdown)) modelBreakdown[model] = round2(usd2);
|
|
1064
|
+
return {
|
|
1065
|
+
spendUsd: round2(s.cost_usd),
|
|
1066
|
+
cacheSavingsUsd: round2(s.cache_savings_usd),
|
|
1067
|
+
cacheHitRate: round4(s.cacheHitRate),
|
|
1068
|
+
totalTokens: { ...s.totals },
|
|
1069
|
+
sessionsCount: s.sessionsCount,
|
|
1070
|
+
projectsCount: s.projectsCount,
|
|
1071
|
+
durationHours: round2(s.durationHours),
|
|
1072
|
+
modelBreakdown,
|
|
1073
|
+
topProjects,
|
|
1074
|
+
topSessions,
|
|
1075
|
+
pricingSnapshotDate: PRICING_SNAPSHOT_DATE
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
function cleanFix(f) {
|
|
1079
|
+
return {
|
|
1080
|
+
...f,
|
|
1081
|
+
title: sanitizeText(f.title),
|
|
1082
|
+
problem: sanitizeText(f.problem),
|
|
1083
|
+
action: sanitizeText(f.action),
|
|
1084
|
+
basis: sanitizeText(f.basis),
|
|
1085
|
+
wasteUsd: round2(f.wasteUsd)
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
function cleanCheck(c) {
|
|
1089
|
+
return { ...c, basis: sanitizeText(c.basis) };
|
|
1090
|
+
}
|
|
1091
|
+
function cleanPillar(p) {
|
|
1092
|
+
return { ...p, checks: p.checks.map(cleanCheck) };
|
|
1093
|
+
}
|
|
1094
|
+
function buildAudit(input, opts = {}) {
|
|
1095
|
+
const report = analyzeUploads(input.logs);
|
|
1096
|
+
const cmd = analyzeClaudeMd(input.claudeMd);
|
|
1097
|
+
const { pillars, overall, band } = scorePillars(report, cmd, input.questionnaire);
|
|
1098
|
+
const { headlineWasteUsd, headlineWasteLabel, fixes } = synthesize(report, pillars, cmd);
|
|
1099
|
+
const answered = Object.values(input.questionnaire).filter((v) => typeof v === "string" && v.length > 0).length;
|
|
1100
|
+
return {
|
|
1101
|
+
id: randomUUID(),
|
|
1102
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1103
|
+
contactEmail: input.contactEmail?.trim() || null,
|
|
1104
|
+
inputsSummary: {
|
|
1105
|
+
logFiles: input.logs.length,
|
|
1106
|
+
hasClaudeMd: cmd.present,
|
|
1107
|
+
claudeMdChars: cmd.chars,
|
|
1108
|
+
questionnaireAnswered: answered
|
|
1109
|
+
},
|
|
1110
|
+
usageMetrics: toUsageMetrics(report),
|
|
1111
|
+
pillarScores: pillars.map(cleanPillar),
|
|
1112
|
+
overallScore: overall,
|
|
1113
|
+
band: { ...band, description: sanitizeText(band.description) },
|
|
1114
|
+
headlineWasteUsd: round2(headlineWasteUsd),
|
|
1115
|
+
headlineWasteLabel: sanitizeText(headlineWasteLabel),
|
|
1116
|
+
fixes: fixes.map(cleanFix),
|
|
1117
|
+
writer: opts.writer ?? "template",
|
|
1118
|
+
paid: false
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// ../../packages/engine/src/validate.ts
|
|
1123
|
+
var MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
1124
|
+
var MAX_TOTAL_BYTES = 60 * 1024 * 1024;
|
|
1125
|
+
var MAX_CLAUDEMD_BYTES = 1 * 1024 * 1024;
|
|
1126
|
+
|
|
1127
|
+
// src/read-sessions.ts
|
|
1128
|
+
import { readdir, readFile } from "fs/promises";
|
|
1129
|
+
import { join } from "path";
|
|
1130
|
+
import { homedir } from "os";
|
|
1131
|
+
function defaultProjectsPath() {
|
|
1132
|
+
return process.env.CLAUDE_PROJECTS_PATH || join(homedir(), ".claude", "projects");
|
|
1133
|
+
}
|
|
1134
|
+
async function readSessions(rootPath) {
|
|
1135
|
+
const logs = [];
|
|
1136
|
+
const skipped = [];
|
|
1137
|
+
let projectDirs;
|
|
1138
|
+
try {
|
|
1139
|
+
projectDirs = await readdir(rootPath, { withFileTypes: true });
|
|
1140
|
+
} catch {
|
|
1141
|
+
return { logs, skipped };
|
|
1142
|
+
}
|
|
1143
|
+
for (const dir of projectDirs) {
|
|
1144
|
+
if (!dir.isDirectory()) continue;
|
|
1145
|
+
const projPath = join(rootPath, dir.name);
|
|
1146
|
+
let files;
|
|
1147
|
+
try {
|
|
1148
|
+
files = await readdir(projPath, { withFileTypes: true });
|
|
1149
|
+
} catch {
|
|
1150
|
+
skipped.push(dir.name);
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
for (const f of files) {
|
|
1154
|
+
if (!f.isFile() || !f.name.endsWith(".jsonl")) continue;
|
|
1155
|
+
const rel = `${dir.name}/${f.name}`;
|
|
1156
|
+
try {
|
|
1157
|
+
const text = await readFile(join(projPath, f.name), "utf8");
|
|
1158
|
+
logs.push({ name: rel, text });
|
|
1159
|
+
} catch {
|
|
1160
|
+
skipped.push(rel);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return { logs, skipped };
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// src/format.ts
|
|
1168
|
+
function usd(n) {
|
|
1169
|
+
return `$${(Math.round(n * 100) / 100).toFixed(2)}`;
|
|
1170
|
+
}
|
|
1171
|
+
function bar(score, width = 20) {
|
|
1172
|
+
if (score === null) return "n/a";
|
|
1173
|
+
const filled = Math.round(score / 100 * width);
|
|
1174
|
+
return `[${"#".repeat(filled)}${".".repeat(width - filled)}] ${score}`;
|
|
1175
|
+
}
|
|
1176
|
+
function formatAudit(record, meta) {
|
|
1177
|
+
const m = record.usageMetrics;
|
|
1178
|
+
const lines = [];
|
|
1179
|
+
lines.push("CostClaw audit");
|
|
1180
|
+
lines.push(`Source: ${meta.root} (${meta.sessionCount} sessions across ${m.projectsCount} projects)`);
|
|
1181
|
+
lines.push("");
|
|
1182
|
+
lines.push(`${record.headlineWasteLabel}: ${usd(record.headlineWasteUsd)}`);
|
|
1183
|
+
lines.push("");
|
|
1184
|
+
lines.push(`Spend analyzed: ${usd(m.spendUsd)} Cache hit: ${(m.cacheHitRate * 100).toFixed(1)}% Active hours: ${m.durationHours.toFixed(1)}`);
|
|
1185
|
+
lines.push(`Pricing snapshot: ${m.pricingSnapshotDate}`);
|
|
1186
|
+
lines.push("");
|
|
1187
|
+
lines.push(`Overall setup score: ${record.overallScore} / 100 (${record.band.label})`);
|
|
1188
|
+
for (const p of record.pillarScores) {
|
|
1189
|
+
lines.push(` ${p.name.padEnd(22)} ${bar(p.score)}`);
|
|
1190
|
+
}
|
|
1191
|
+
lines.push("");
|
|
1192
|
+
const dollarFixes = record.fixes.filter((f) => f.wasteUsd > 0);
|
|
1193
|
+
const otherIssues = record.fixes.filter((f) => f.wasteUsd === 0);
|
|
1194
|
+
lines.push("Top fixes by recoverable spend:");
|
|
1195
|
+
if (dollarFixes.length === 0) {
|
|
1196
|
+
lines.push(" No directly recoverable dollars found. See issues below.");
|
|
1197
|
+
} else {
|
|
1198
|
+
for (const f of dollarFixes.slice(0, 6)) {
|
|
1199
|
+
lines.push(` - [${usd(f.wasteUsd)}] ${f.title}`);
|
|
1200
|
+
lines.push(` ${f.action}`);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (otherIssues.length > 0) {
|
|
1204
|
+
lines.push("");
|
|
1205
|
+
lines.push("Other issues to tighten:");
|
|
1206
|
+
for (const f of otherIssues.slice(0, 6)) {
|
|
1207
|
+
lines.push(` - ${f.title}`);
|
|
1208
|
+
}
|
|
1209
|
+
if (otherIssues.length > 6) {
|
|
1210
|
+
lines.push(` ... and ${otherIssues.length - 6} more`);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (meta.skipped.length > 0) {
|
|
1214
|
+
lines.push("");
|
|
1215
|
+
lines.push(`Note: ${meta.skipped.length} file(s) could not be read and were skipped.`);
|
|
1216
|
+
}
|
|
1217
|
+
lines.push("");
|
|
1218
|
+
lines.push("Tip: answer the hygiene questionnaire on the web for a fuller score. Nothing left your machine.");
|
|
1219
|
+
return lines.join("\n");
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// src/audit-command.ts
|
|
1223
|
+
async function runAudit(opts) {
|
|
1224
|
+
const root = opts.path || defaultProjectsPath();
|
|
1225
|
+
const { logs, skipped } = await readSessions(root);
|
|
1226
|
+
if (logs.length === 0) {
|
|
1227
|
+
throw new Error(`No .jsonl sessions found under ${root}. Set CLAUDE_PROJECTS_PATH or pass --path <dir>.`);
|
|
1228
|
+
}
|
|
1229
|
+
let claudeMd;
|
|
1230
|
+
if (opts.claudeMdPath) {
|
|
1231
|
+
claudeMd = await readFile2(opts.claudeMdPath, "utf8");
|
|
1232
|
+
}
|
|
1233
|
+
const record = buildAudit({ logs, claudeMd, questionnaire: {} });
|
|
1234
|
+
const output = opts.json ? JSON.stringify(record, null, 2) : formatAudit(record, { root, sessionCount: logs.length, skipped });
|
|
1235
|
+
return { output, record };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// src/index.ts
|
|
1239
|
+
process.on("unhandledRejection", (reason) => {
|
|
1240
|
+
console.error("Unhandled rejection:", reason);
|
|
1241
|
+
process.exit(1);
|
|
1242
|
+
});
|
|
1243
|
+
function getFlag(args, name) {
|
|
1244
|
+
const i = args.indexOf(name);
|
|
1245
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : void 0;
|
|
1246
|
+
}
|
|
1247
|
+
async function main() {
|
|
1248
|
+
const [command, ...args] = process.argv.slice(2);
|
|
1249
|
+
if (command === "audit") {
|
|
1250
|
+
const { output } = await runAudit({
|
|
1251
|
+
path: getFlag(args, "--path"),
|
|
1252
|
+
claudeMdPath: getFlag(args, "--claude-md"),
|
|
1253
|
+
json: args.includes("--json")
|
|
1254
|
+
});
|
|
1255
|
+
process.stdout.write(output + "\n");
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
process.stderr.write(
|
|
1259
|
+
[
|
|
1260
|
+
"CostClaw - privacy-first Claude Code cost and setup audit",
|
|
1261
|
+
"",
|
|
1262
|
+
"Usage:",
|
|
1263
|
+
" costclaw audit [--path <dir>] [--claude-md <file>] [--json]",
|
|
1264
|
+
"",
|
|
1265
|
+
"Reads your Claude Code logs from ~/.claude/projects (or --path) on this machine,",
|
|
1266
|
+
"and prints a cost report plus a six-pillar setup score. Nothing is uploaded.",
|
|
1267
|
+
""
|
|
1268
|
+
].join("\n") + "\n"
|
|
1269
|
+
);
|
|
1270
|
+
process.exit(command ? 1 : 0);
|
|
1271
|
+
}
|
|
1272
|
+
main().catch((err) => {
|
|
1273
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1274
|
+
process.exit(1);
|
|
1275
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "costclaw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local cost and setup audit for Claude Code. Finds recoverable token spend and scores your setup across six pillars, with your prompts never leaving your machine.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "costclaw": "dist/index.js" },
|
|
7
|
+
"files": ["dist"],
|
|
8
|
+
"engines": { "node": ">=20" },
|
|
9
|
+
"license": "UNLICENSED",
|
|
10
|
+
"homepage": "https://costclaw.dev",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/ucsandman/costclaw.git",
|
|
14
|
+
"directory": "apps/cli"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"claude-code",
|
|
18
|
+
"claude",
|
|
19
|
+
"anthropic",
|
|
20
|
+
"cost",
|
|
21
|
+
"finops",
|
|
22
|
+
"audit",
|
|
23
|
+
"tokens",
|
|
24
|
+
"cli"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"audit": "tsx src/index.ts audit",
|
|
28
|
+
"dev": "tsx src/index.ts",
|
|
29
|
+
"build": "tsup",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@costclaw/engine": "*",
|
|
36
|
+
"@types/node": "^22.10.0",
|
|
37
|
+
"tsup": "^8.3.5",
|
|
38
|
+
"tsx": "^4.19.0",
|
|
39
|
+
"typescript": "^5.7.0",
|
|
40
|
+
"vitest": "^2.1.8"
|
|
41
|
+
}
|
|
42
|
+
}
|