@safetnsr/vet 1.11.1 → 1.13.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/dist/checks/loop.d.ts +39 -0
- package/dist/checks/loop.js +391 -0
- package/dist/checks/subsidy.d.ts +29 -0
- package/dist/checks/subsidy.js +217 -0
- package/dist/cli.js +42 -4
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
interface SessionEntry {
|
|
3
|
+
type?: string;
|
|
4
|
+
role?: string;
|
|
5
|
+
content?: unknown;
|
|
6
|
+
model?: string;
|
|
7
|
+
usage?: {
|
|
8
|
+
input_tokens?: number;
|
|
9
|
+
output_tokens?: number;
|
|
10
|
+
cache_read_input_tokens?: number;
|
|
11
|
+
cache_creation_input_tokens?: number;
|
|
12
|
+
};
|
|
13
|
+
message?: {
|
|
14
|
+
role?: string;
|
|
15
|
+
content?: unknown;
|
|
16
|
+
model?: string;
|
|
17
|
+
usage?: SessionEntry['usage'];
|
|
18
|
+
};
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
interface Iteration {
|
|
22
|
+
index: number;
|
|
23
|
+
fileChanges: number;
|
|
24
|
+
uniqueFiles: Set<string>;
|
|
25
|
+
testCount: number;
|
|
26
|
+
outcome: 'pass' | 'fail' | 'unknown';
|
|
27
|
+
inputTokens: number;
|
|
28
|
+
outputTokens: number;
|
|
29
|
+
cost: number;
|
|
30
|
+
}
|
|
31
|
+
export declare function analyzeSession(entries: SessionEntry[]): {
|
|
32
|
+
iterations: Iteration[];
|
|
33
|
+
totalCost: number;
|
|
34
|
+
allFiles: Set<string>;
|
|
35
|
+
model: string;
|
|
36
|
+
};
|
|
37
|
+
export declare function checkLoop(cwd: string): Promise<CheckResult>;
|
|
38
|
+
export declare function runLoopCommand(format: 'ascii' | 'json', sessionPath?: string): Promise<void>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { c } from '../util.js';
|
|
5
|
+
import { findLatestSession } from './receipt.js';
|
|
6
|
+
// ── Pricing table (per 1M tokens) ────────────────────────────────────────────
|
|
7
|
+
const PRICING = {
|
|
8
|
+
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
9
|
+
'claude-sonnet-4-5': { input: 3, output: 15 },
|
|
10
|
+
'claude-opus-4-6': { input: 15, output: 75 },
|
|
11
|
+
'claude-haiku-3-5': { input: 0.25, output: 1.25 },
|
|
12
|
+
};
|
|
13
|
+
const FALLBACK_PRICING = { input: 3, output: 15 };
|
|
14
|
+
function getPricing(model) {
|
|
15
|
+
for (const [key, price] of Object.entries(PRICING)) {
|
|
16
|
+
if (model.includes(key))
|
|
17
|
+
return price;
|
|
18
|
+
}
|
|
19
|
+
// Partial match
|
|
20
|
+
if (model.includes('opus'))
|
|
21
|
+
return { input: 15, output: 75 };
|
|
22
|
+
if (model.includes('haiku'))
|
|
23
|
+
return { input: 0.25, output: 1.25 };
|
|
24
|
+
return FALLBACK_PRICING;
|
|
25
|
+
}
|
|
26
|
+
function calcCost(inputTokens, outputTokens, model) {
|
|
27
|
+
const price = getPricing(model);
|
|
28
|
+
return (inputTokens / 1_000_000) * price.input + (outputTokens / 1_000_000) * price.output;
|
|
29
|
+
}
|
|
30
|
+
// ── Test command detection ────────────────────────────────────────────────────
|
|
31
|
+
const TEST_PATTERNS = [
|
|
32
|
+
/\bjest\b/,
|
|
33
|
+
/\bvitest\b/,
|
|
34
|
+
/\bpytest\b/,
|
|
35
|
+
/npm\s+test\b/,
|
|
36
|
+
/npm\s+run\s+test\b/,
|
|
37
|
+
/node\s+--test\b/,
|
|
38
|
+
/npx\s+vitest\b/,
|
|
39
|
+
/npx\s+jest\b/,
|
|
40
|
+
/\bmake\s+test\b/,
|
|
41
|
+
/\bcargo\s+test\b/,
|
|
42
|
+
];
|
|
43
|
+
function isTestCommand(cmd) {
|
|
44
|
+
return TEST_PATTERNS.some(p => p.test(cmd));
|
|
45
|
+
}
|
|
46
|
+
// ── File write detection ──────────────────────────────────────────────────────
|
|
47
|
+
function isFileWrite(name, input) {
|
|
48
|
+
// Tool name contains str_replace or write
|
|
49
|
+
if (/str_replace|write|edit/i.test(name)) {
|
|
50
|
+
const fp = input['path'] || input['file_path'] || undefined;
|
|
51
|
+
return { isWrite: true, filePath: fp };
|
|
52
|
+
}
|
|
53
|
+
// Bash command with redirect
|
|
54
|
+
if (name === 'bash' || name === 'shell') {
|
|
55
|
+
const cmd = input['command'] || '';
|
|
56
|
+
const hasRedirect = /(?:>>?|tee\s|cat\s*>|sed\s+-i)\s*\S/.test(cmd);
|
|
57
|
+
if (hasRedirect) {
|
|
58
|
+
// Try to extract the target file
|
|
59
|
+
const match = cmd.match(/(?:>>?\s*|tee\s+|cat\s*>\s*)([^\s|;&]+)/);
|
|
60
|
+
return { isWrite: true, filePath: match?.[1] };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { isWrite: false };
|
|
64
|
+
}
|
|
65
|
+
// ── Test outcome extraction ───────────────────────────────────────────────────
|
|
66
|
+
function extractTestCount(text) {
|
|
67
|
+
// "12 passing", "12 passed", "12 tests"
|
|
68
|
+
const m = text.match(/(\d+)\s+(?:passing|passed|tests?)/i);
|
|
69
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
70
|
+
}
|
|
71
|
+
function extractOutcome(text) {
|
|
72
|
+
const lower = text.toLowerCase();
|
|
73
|
+
// Non-zero explicit failure count wins immediately
|
|
74
|
+
if (/\b[1-9]\d*\s+fail(?:ed|ing|ure)?\b/i.test(text))
|
|
75
|
+
return 'fail';
|
|
76
|
+
// FAIL (all-caps marker used by Jest etc.)
|
|
77
|
+
if (/\bFAIL\b/.test(text))
|
|
78
|
+
return 'fail';
|
|
79
|
+
// Exit code failure
|
|
80
|
+
if (/exit code [1-9]/i.test(lower))
|
|
81
|
+
return 'fail';
|
|
82
|
+
// "0 failing" — explicitly zero failures
|
|
83
|
+
if (/\b0\s+fail(?:ed|ing|ure)?\b/i.test(lower))
|
|
84
|
+
return 'pass';
|
|
85
|
+
// Positive pass signals
|
|
86
|
+
if (/\bpassing\b|\bpassed\b/.test(lower))
|
|
87
|
+
return 'pass';
|
|
88
|
+
if (/test result:\s*ok\b/i.test(text))
|
|
89
|
+
return 'pass';
|
|
90
|
+
if (/all tests passed/i.test(text))
|
|
91
|
+
return 'pass';
|
|
92
|
+
// Generic fail words (no count context)
|
|
93
|
+
if (/\b(?:failed|failing|failure)\b/i.test(text))
|
|
94
|
+
return 'fail';
|
|
95
|
+
if (/\berror\b/.test(lower))
|
|
96
|
+
return 'fail';
|
|
97
|
+
if (/\bpass\b/.test(lower))
|
|
98
|
+
return 'pass';
|
|
99
|
+
return 'unknown';
|
|
100
|
+
}
|
|
101
|
+
// ── Core parsing ─────────────────────────────────────────────────────────────
|
|
102
|
+
async function parseEntries(filePath) {
|
|
103
|
+
const entries = [];
|
|
104
|
+
const rl = createInterface({
|
|
105
|
+
input: fs.createReadStream(filePath, { encoding: 'utf-8' }),
|
|
106
|
+
crlfDelay: Infinity,
|
|
107
|
+
});
|
|
108
|
+
for await (const line of rl) {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (!trimmed)
|
|
111
|
+
continue;
|
|
112
|
+
try {
|
|
113
|
+
entries.push(JSON.parse(trimmed));
|
|
114
|
+
}
|
|
115
|
+
catch { /* skip malformed */ }
|
|
116
|
+
}
|
|
117
|
+
return entries;
|
|
118
|
+
}
|
|
119
|
+
function getToolUseBlocks(content) {
|
|
120
|
+
if (!Array.isArray(content))
|
|
121
|
+
return [];
|
|
122
|
+
return content.filter((b) => b && typeof b === 'object' && b.type === 'tool_use');
|
|
123
|
+
}
|
|
124
|
+
function getTextContent(content) {
|
|
125
|
+
if (typeof content === 'string')
|
|
126
|
+
return content;
|
|
127
|
+
if (Array.isArray(content)) {
|
|
128
|
+
return content.map(b => {
|
|
129
|
+
if (typeof b === 'string')
|
|
130
|
+
return b;
|
|
131
|
+
if (b && typeof b === 'object') {
|
|
132
|
+
const obj = b;
|
|
133
|
+
if (typeof obj['text'] === 'string')
|
|
134
|
+
return obj['text'];
|
|
135
|
+
if (typeof obj['content'] === 'string')
|
|
136
|
+
return obj['content'];
|
|
137
|
+
}
|
|
138
|
+
return '';
|
|
139
|
+
}).join('\n');
|
|
140
|
+
}
|
|
141
|
+
return '';
|
|
142
|
+
}
|
|
143
|
+
export function analyzeSession(entries) {
|
|
144
|
+
let model = 'claude-sonnet-4-6'; // default
|
|
145
|
+
const iterations = [];
|
|
146
|
+
// Detect model
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if (typeof entry['model'] === 'string' && entry['model']) {
|
|
149
|
+
model = entry['model'];
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
if (entry['message'] && typeof entry['message']['model'] === 'string') {
|
|
153
|
+
model = entry['message']['model'];
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// We segment by test command invocations.
|
|
158
|
+
// Each time we see a test command tool_use, we close the current iteration and start a new one.
|
|
159
|
+
// The iteration collects file writes BEFORE the test command.
|
|
160
|
+
let currentIteration = {
|
|
161
|
+
index: 1,
|
|
162
|
+
fileChanges: 0,
|
|
163
|
+
uniqueFiles: new Set(),
|
|
164
|
+
testCount: 0,
|
|
165
|
+
outcome: 'unknown',
|
|
166
|
+
inputTokens: 0,
|
|
167
|
+
outputTokens: 0,
|
|
168
|
+
cost: 0,
|
|
169
|
+
};
|
|
170
|
+
let hasStarted = false;
|
|
171
|
+
let pendingTestEntry = false; // next tool_result belongs to a test command
|
|
172
|
+
const allFiles = new Set();
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
// Accumulate token usage
|
|
175
|
+
const usage = entry['usage'] || entry['message']?.['usage'];
|
|
176
|
+
if (usage) {
|
|
177
|
+
const inp = (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
|
|
178
|
+
const out = usage.output_tokens || 0;
|
|
179
|
+
currentIteration.inputTokens += inp;
|
|
180
|
+
currentIteration.outputTokens += out;
|
|
181
|
+
}
|
|
182
|
+
// Assistant messages with tool_use blocks
|
|
183
|
+
const contentToCheck = entry['content'] || entry['message']?.['content'];
|
|
184
|
+
const blocks = getToolUseBlocks(contentToCheck);
|
|
185
|
+
for (const block of blocks) {
|
|
186
|
+
const name = block.name || '';
|
|
187
|
+
const input = block.input || {};
|
|
188
|
+
const cmd = input['command'] || '';
|
|
189
|
+
// Detect test command
|
|
190
|
+
if ((name === 'bash' || name === 'shell') && isTestCommand(cmd)) {
|
|
191
|
+
if (hasStarted) {
|
|
192
|
+
// Close current iteration
|
|
193
|
+
currentIteration.cost = calcCost(currentIteration.inputTokens, currentIteration.outputTokens, model);
|
|
194
|
+
iterations.push(currentIteration);
|
|
195
|
+
currentIteration = {
|
|
196
|
+
index: iterations.length + 1,
|
|
197
|
+
fileChanges: 0,
|
|
198
|
+
uniqueFiles: new Set(),
|
|
199
|
+
testCount: 0,
|
|
200
|
+
outcome: 'unknown',
|
|
201
|
+
inputTokens: 0,
|
|
202
|
+
outputTokens: 0,
|
|
203
|
+
cost: 0,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
hasStarted = true;
|
|
207
|
+
pendingTestEntry = true;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
// Detect file writes (only if we've started or not yet — track globally)
|
|
211
|
+
const { isWrite, filePath } = isFileWrite(name, input);
|
|
212
|
+
if (isWrite) {
|
|
213
|
+
currentIteration.fileChanges++;
|
|
214
|
+
if (filePath) {
|
|
215
|
+
currentIteration.uniqueFiles.add(filePath);
|
|
216
|
+
allFiles.add(filePath);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Tool results (test outcomes)
|
|
221
|
+
if (pendingTestEntry && (entry['type'] === 'tool_result' || entry['role'] === 'tool')) {
|
|
222
|
+
const text = getTextContent(entry['content']);
|
|
223
|
+
const count = extractTestCount(text);
|
|
224
|
+
if (count > 0)
|
|
225
|
+
currentIteration.testCount = count;
|
|
226
|
+
currentIteration.outcome = extractOutcome(text);
|
|
227
|
+
pendingTestEntry = false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Finalize last iteration if we started one
|
|
231
|
+
if (hasStarted) {
|
|
232
|
+
currentIteration.cost = calcCost(currentIteration.inputTokens, currentIteration.outputTokens, model);
|
|
233
|
+
iterations.push(currentIteration);
|
|
234
|
+
}
|
|
235
|
+
const totalCost = iterations.reduce((s, it) => s + it.cost, 0);
|
|
236
|
+
return { iterations, totalCost, allFiles, model };
|
|
237
|
+
}
|
|
238
|
+
// ── Score calculation ─────────────────────────────────────────────────────────
|
|
239
|
+
function calculateScore(iterations, totalCost, allFiles) {
|
|
240
|
+
const issues = [];
|
|
241
|
+
const runawayFlags = [];
|
|
242
|
+
let penalty = 0;
|
|
243
|
+
if (iterations.length > 10) {
|
|
244
|
+
runawayFlags.push(`${iterations.length} iterations (threshold: 10)`);
|
|
245
|
+
penalty += 30;
|
|
246
|
+
issues.push({ severity: 'error', message: `runaway: ${iterations.length} iterations (threshold: 10)`, fixable: false });
|
|
247
|
+
}
|
|
248
|
+
if (totalCost > 1) {
|
|
249
|
+
runawayFlags.push(`$${totalCost.toFixed(2)} total cost (threshold: $1.00)`);
|
|
250
|
+
penalty += 20;
|
|
251
|
+
issues.push({ severity: 'error', message: `runaway: $${totalCost.toFixed(2)} total cost (threshold: $1.00)`, fixable: false });
|
|
252
|
+
}
|
|
253
|
+
if (allFiles.size > 20) {
|
|
254
|
+
runawayFlags.push(`${allFiles.size} unique files touched (threshold: 20)`);
|
|
255
|
+
penalty += 10;
|
|
256
|
+
issues.push({ severity: 'warning', message: `runaway: ${allFiles.size} unique files touched (threshold: 20)`, fixable: false });
|
|
257
|
+
}
|
|
258
|
+
for (const it of iterations) {
|
|
259
|
+
if (it.fileChanges > 5) {
|
|
260
|
+
penalty += 5;
|
|
261
|
+
issues.push({ severity: 'warning', message: `iteration ${it.index}: ${it.fileChanges} file changes (threshold: 5)`, fixable: false });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const score = Math.max(0, 100 - penalty);
|
|
265
|
+
return { score, issues, runawayFlags };
|
|
266
|
+
}
|
|
267
|
+
// ── Check function (for full vet run) ────────────────────────────────────────
|
|
268
|
+
export async function checkLoop(cwd) {
|
|
269
|
+
const sessionFile = findLatestSession();
|
|
270
|
+
if (!sessionFile) {
|
|
271
|
+
return {
|
|
272
|
+
name: 'loop',
|
|
273
|
+
score: 100,
|
|
274
|
+
maxScore: 100,
|
|
275
|
+
issues: [{ severity: 'info', message: 'no claude session files found (~/.claude/projects/)', fixable: false }],
|
|
276
|
+
summary: 'no session logs found',
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
let entries;
|
|
280
|
+
try {
|
|
281
|
+
entries = await parseEntries(sessionFile);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return {
|
|
285
|
+
name: 'loop',
|
|
286
|
+
score: 100,
|
|
287
|
+
maxScore: 100,
|
|
288
|
+
issues: [{ severity: 'warning', message: 'could not parse session file', fixable: false }],
|
|
289
|
+
summary: 'session parse error',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (entries.length === 0) {
|
|
293
|
+
return {
|
|
294
|
+
name: 'loop',
|
|
295
|
+
score: 100,
|
|
296
|
+
maxScore: 100,
|
|
297
|
+
issues: [{ severity: 'info', message: 'session file is empty', fixable: false }],
|
|
298
|
+
summary: 'empty session file',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const { iterations, totalCost, allFiles } = analyzeSession(entries);
|
|
302
|
+
if (iterations.length === 0) {
|
|
303
|
+
return {
|
|
304
|
+
name: 'loop',
|
|
305
|
+
score: 100,
|
|
306
|
+
maxScore: 100,
|
|
307
|
+
issues: [{ severity: 'info', message: 'no /loop iterations detected (no test commands found)', fixable: false }],
|
|
308
|
+
summary: 'no loop iterations detected',
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const { score, issues, runawayFlags } = calculateScore(iterations, totalCost, allFiles);
|
|
312
|
+
const failCount = iterations.filter(it => it.outcome === 'fail').length;
|
|
313
|
+
const passCount = iterations.filter(it => it.outcome === 'pass').length;
|
|
314
|
+
return {
|
|
315
|
+
name: 'loop',
|
|
316
|
+
score,
|
|
317
|
+
maxScore: 100,
|
|
318
|
+
issues,
|
|
319
|
+
summary: `${iterations.length} iteration${iterations.length !== 1 ? 's' : ''}: ${passCount} pass, ${failCount} fail${runawayFlags.length > 0 ? ` — runaway: ${runawayFlags.join(', ')}` : ''}`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
// ── Standalone subcommand ─────────────────────────────────────────────────────
|
|
323
|
+
export async function runLoopCommand(format, sessionPath) {
|
|
324
|
+
const filePath = sessionPath || findLatestSession();
|
|
325
|
+
if (!filePath) {
|
|
326
|
+
console.error('no claude session files found in ~/.claude/projects/');
|
|
327
|
+
process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
if (!fs.existsSync(filePath) && !sessionPath) {
|
|
330
|
+
console.error('session file not found');
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
const entries = await parseEntries(filePath);
|
|
334
|
+
const { iterations, totalCost, allFiles } = analyzeSession(entries);
|
|
335
|
+
const { score, issues, runawayFlags } = calculateScore(iterations, totalCost, allFiles);
|
|
336
|
+
if (format === 'json') {
|
|
337
|
+
const result = {
|
|
338
|
+
name: 'loop',
|
|
339
|
+
score,
|
|
340
|
+
maxScore: 100,
|
|
341
|
+
issues,
|
|
342
|
+
summary: iterations.length === 0
|
|
343
|
+
? 'no loop iterations detected'
|
|
344
|
+
: `${iterations.length} iteration${iterations.length !== 1 ? 's' : ''} found`,
|
|
345
|
+
};
|
|
346
|
+
console.log(JSON.stringify(result, null, 2));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
// ASCII output
|
|
350
|
+
const sessionId = path.basename(filePath, '.jsonl').slice(0, 40);
|
|
351
|
+
console.log(`\n ${c.bold}vet loop${c.reset} — /loop session forensics\n`);
|
|
352
|
+
console.log(` session: ${sessionId}`);
|
|
353
|
+
console.log(` iterations: ${iterations.length}`);
|
|
354
|
+
console.log(` total cost: $${totalCost.toFixed(2)}\n`);
|
|
355
|
+
if (iterations.length === 0) {
|
|
356
|
+
console.log(` ${c.green}no /loop iterations detected${c.reset}\n`);
|
|
357
|
+
console.log(` score: 100/100\n`);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// Table header
|
|
361
|
+
const colW = { num: 3, files: 6, tests: 6, outcome: 8, cost: 8 };
|
|
362
|
+
const header = [
|
|
363
|
+
'#'.padEnd(colW.num),
|
|
364
|
+
'files'.padEnd(colW.files),
|
|
365
|
+
'tests'.padEnd(colW.tests),
|
|
366
|
+
'outcome'.padEnd(colW.outcome),
|
|
367
|
+
'cost',
|
|
368
|
+
].join(' ');
|
|
369
|
+
console.log(` ${c.dim}${header}${c.reset}`);
|
|
370
|
+
for (const it of iterations) {
|
|
371
|
+
const outcomeStr = it.outcome === 'pass'
|
|
372
|
+
? `${c.green}pass${c.reset}`
|
|
373
|
+
: it.outcome === 'fail'
|
|
374
|
+
? `${c.red}fail${c.reset}`
|
|
375
|
+
: `${c.dim}?${c.reset}`;
|
|
376
|
+
const numStr = String(it.index).padEnd(colW.num);
|
|
377
|
+
const filesStr = String(it.fileChanges).padEnd(colW.files);
|
|
378
|
+
const testsStr = String(it.testCount || '-').padEnd(colW.tests);
|
|
379
|
+
const costStr = `$${it.cost.toFixed(2)}`;
|
|
380
|
+
// outcome padding needs to account for color codes (invisible)
|
|
381
|
+
const outcomePadded = outcomeStr + ' '.repeat(Math.max(0, colW.outcome - it.outcome.length));
|
|
382
|
+
console.log(` ${numStr} ${filesStr} ${testsStr} ${outcomePadded} ${costStr}`);
|
|
383
|
+
}
|
|
384
|
+
console.log('');
|
|
385
|
+
for (const flag of runawayFlags) {
|
|
386
|
+
console.log(` ${c.yellow}⚠ runaway: ${flag}${c.reset}`);
|
|
387
|
+
}
|
|
388
|
+
if (runawayFlags.length > 0)
|
|
389
|
+
console.log('');
|
|
390
|
+
console.log(` score: ${score}/100\n`);
|
|
391
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
interface ModelCost {
|
|
3
|
+
inputTokens: number;
|
|
4
|
+
outputTokens: number;
|
|
5
|
+
cost: number;
|
|
6
|
+
}
|
|
7
|
+
export interface SubsidyData {
|
|
8
|
+
sessionCount: number;
|
|
9
|
+
periodStart: string;
|
|
10
|
+
periodEnd: string;
|
|
11
|
+
models: Record<string, ModelCost>;
|
|
12
|
+
totalCost: number;
|
|
13
|
+
subscriptionCost: number;
|
|
14
|
+
subsidized: number;
|
|
15
|
+
subsidyRate: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function computeSubsidy(entries: Array<Record<string, unknown>>, plan: string): {
|
|
18
|
+
models: Record<string, ModelCost>;
|
|
19
|
+
totalCost: number;
|
|
20
|
+
subscriptionCost: number;
|
|
21
|
+
subsidized: number;
|
|
22
|
+
subsidyRate: number;
|
|
23
|
+
};
|
|
24
|
+
export declare function checkSubsidy(cwd: string): Promise<CheckResult>;
|
|
25
|
+
export declare function runSubsidyCommand(format: 'ascii' | 'json', options?: {
|
|
26
|
+
since?: string;
|
|
27
|
+
plan?: string;
|
|
28
|
+
}): Promise<void>;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { findSessionFiles, parseSessionFile } from './receipt.js';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
const PRICING = {
|
|
4
|
+
'claude-opus-4-6': { input: 15, output: 75 },
|
|
5
|
+
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
6
|
+
'claude-sonnet-4-5': { input: 3, output: 15 },
|
|
7
|
+
'claude-haiku-3-5': { input: 0.80, output: 4 },
|
|
8
|
+
'gpt-5.4': { input: 2.50, output: 10 },
|
|
9
|
+
'gpt-4o': { input: 2.50, output: 10 },
|
|
10
|
+
'gemini-2.5-pro': { input: 1.25, output: 10 },
|
|
11
|
+
};
|
|
12
|
+
const FALLBACK_PRICING = { input: 3, output: 15 };
|
|
13
|
+
const SUBSCRIPTION_TIERS = {
|
|
14
|
+
'claude-pro': 20,
|
|
15
|
+
'claude-max-5x': 100,
|
|
16
|
+
'claude-max-20x': 200,
|
|
17
|
+
'chatgpt-plus': 20,
|
|
18
|
+
'chatgpt-pro': 200,
|
|
19
|
+
};
|
|
20
|
+
const DEFAULT_SUBSCRIPTION = 20;
|
|
21
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
22
|
+
function getPricing(model) {
|
|
23
|
+
// Try exact match first, then prefix match
|
|
24
|
+
if (PRICING[model])
|
|
25
|
+
return PRICING[model];
|
|
26
|
+
for (const [key, pricing] of Object.entries(PRICING)) {
|
|
27
|
+
if (model.startsWith(key))
|
|
28
|
+
return pricing;
|
|
29
|
+
}
|
|
30
|
+
return FALLBACK_PRICING;
|
|
31
|
+
}
|
|
32
|
+
function getSubscriptionCost(plan) {
|
|
33
|
+
return SUBSCRIPTION_TIERS[plan] ?? DEFAULT_SUBSCRIPTION;
|
|
34
|
+
}
|
|
35
|
+
export function computeSubsidy(entries, plan) {
|
|
36
|
+
const models = {};
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
// Extract model and usage from entry or entry.message
|
|
39
|
+
let model;
|
|
40
|
+
let usage;
|
|
41
|
+
if (entry.model && typeof entry.model === 'string')
|
|
42
|
+
model = entry.model;
|
|
43
|
+
if (entry.usage && typeof entry.usage === 'object')
|
|
44
|
+
usage = entry.usage;
|
|
45
|
+
const msg = entry.message;
|
|
46
|
+
if (msg) {
|
|
47
|
+
if (!model && msg.model && typeof msg.model === 'string')
|
|
48
|
+
model = msg.model;
|
|
49
|
+
if (!usage && msg.usage && typeof msg.usage === 'object')
|
|
50
|
+
usage = msg.usage;
|
|
51
|
+
}
|
|
52
|
+
if (!model || !usage)
|
|
53
|
+
continue;
|
|
54
|
+
const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
|
|
55
|
+
const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0;
|
|
56
|
+
if (inputTokens === 0 && outputTokens === 0)
|
|
57
|
+
continue;
|
|
58
|
+
const pricing = getPricing(model);
|
|
59
|
+
const cost = (inputTokens / 1_000_000) * pricing.input + (outputTokens / 1_000_000) * pricing.output;
|
|
60
|
+
if (!models[model])
|
|
61
|
+
models[model] = { inputTokens: 0, outputTokens: 0, cost: 0 };
|
|
62
|
+
models[model].inputTokens += inputTokens;
|
|
63
|
+
models[model].outputTokens += outputTokens;
|
|
64
|
+
models[model].cost += cost;
|
|
65
|
+
}
|
|
66
|
+
const totalCost = Object.values(models).reduce((sum, m) => sum + m.cost, 0);
|
|
67
|
+
const subscriptionCost = getSubscriptionCost(plan);
|
|
68
|
+
const subsidized = Math.max(0, totalCost - subscriptionCost);
|
|
69
|
+
const subsidyRate = totalCost > 0 ? (subsidized / totalCost) * 100 : 0;
|
|
70
|
+
return { models, totalCost, subscriptionCost, subsidized, subsidyRate };
|
|
71
|
+
}
|
|
72
|
+
// ── ASCII card ───────────────────────────────────────────────────────────────
|
|
73
|
+
function renderCard(data) {
|
|
74
|
+
const W = 43;
|
|
75
|
+
const hr = '─'.repeat(W);
|
|
76
|
+
const pad = (s, w = W) => {
|
|
77
|
+
const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
78
|
+
const diff = w - visible.length;
|
|
79
|
+
return diff > 0 ? s + ' '.repeat(diff) : s;
|
|
80
|
+
};
|
|
81
|
+
const line = (s) => `│ ${pad(s, W - 2)} │`;
|
|
82
|
+
const lines = [];
|
|
83
|
+
lines.push(`┌${hr}┐`);
|
|
84
|
+
const title = 'YOUR AI COST THIS MONTH';
|
|
85
|
+
const lp = Math.floor((W - 2 - title.length) / 2);
|
|
86
|
+
const rp = W - 2 - title.length - lp;
|
|
87
|
+
lines.push(`│${' '.repeat(lp + 1)}${title}${' '.repeat(rp + 1)}│`);
|
|
88
|
+
lines.push(`├${hr}┤`);
|
|
89
|
+
lines.push(line(`sessions analyzed:${' '.repeat(Math.max(1, W - 2 - 18 - String(data.sessionCount).length))}${data.sessionCount}`));
|
|
90
|
+
const period = `${data.periodStart} — ${data.periodEnd}`;
|
|
91
|
+
lines.push(line(`period:${' '.repeat(Math.max(1, W - 2 - 7 - period.length))}${period}`));
|
|
92
|
+
lines.push(`├${hr}┤`);
|
|
93
|
+
// Per-model breakdown sorted by cost desc
|
|
94
|
+
const sorted = Object.entries(data.models).sort((a, b) => b[1].cost - a[1].cost);
|
|
95
|
+
for (const [model, mc] of sorted) {
|
|
96
|
+
const pct = data.totalCost > 0 ? Math.round((mc.cost / data.totalCost) * 100) : 0;
|
|
97
|
+
const costStr = `$${mc.cost.toFixed(2)}`;
|
|
98
|
+
const pctStr = `(${pct}%)`;
|
|
99
|
+
const gap = Math.max(1, W - 2 - model.length - costStr.length - pctStr.length - 4);
|
|
100
|
+
lines.push(line(`${model}${' '.repeat(gap)}${costStr} ${pctStr}`));
|
|
101
|
+
}
|
|
102
|
+
lines.push(`├${hr}┤`);
|
|
103
|
+
const fmtRow = (label, value) => {
|
|
104
|
+
const gap = Math.max(1, W - 2 - label.length - value.length);
|
|
105
|
+
return line(`${label}${' '.repeat(gap)}${value}`);
|
|
106
|
+
};
|
|
107
|
+
lines.push(fmtRow('USED (list price):', `$${data.totalCost.toFixed(2)}`));
|
|
108
|
+
lines.push(fmtRow('PAID (subscription):', `$${data.subscriptionCost.toFixed(2)}`));
|
|
109
|
+
lines.push(fmtRow('SUBSIDIZED:', `$${data.subsidized.toFixed(2)}`));
|
|
110
|
+
lines.push(fmtRow('SUBSIDY RATE:', `${data.subsidyRate.toFixed(1)}%`));
|
|
111
|
+
lines.push(`└${hr}┘`);
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
}
|
|
114
|
+
// ── Check for vet scan ───────────────────────────────────────────────────────
|
|
115
|
+
export async function checkSubsidy(cwd) {
|
|
116
|
+
const files = findSessionFiles();
|
|
117
|
+
const issues = [];
|
|
118
|
+
if (files.length === 0) {
|
|
119
|
+
return {
|
|
120
|
+
name: 'subsidy',
|
|
121
|
+
score: 100,
|
|
122
|
+
maxScore: 100,
|
|
123
|
+
issues: [{ severity: 'info', message: 'no session logs found', fixable: false }],
|
|
124
|
+
summary: 'no session logs found',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const allEntries = [];
|
|
128
|
+
for (const f of files) {
|
|
129
|
+
try {
|
|
130
|
+
const { entries } = await parseSessionFile(f);
|
|
131
|
+
allEntries.push(...entries);
|
|
132
|
+
}
|
|
133
|
+
catch { /* skip */ }
|
|
134
|
+
}
|
|
135
|
+
const result = computeSubsidy(allEntries, 'claude-pro');
|
|
136
|
+
if (Object.keys(result.models).length === 0) {
|
|
137
|
+
return {
|
|
138
|
+
name: 'subsidy',
|
|
139
|
+
score: 100,
|
|
140
|
+
maxScore: 100,
|
|
141
|
+
issues: [{ severity: 'info', message: 'no token usage data in sessions', fixable: false }],
|
|
142
|
+
summary: 'no token usage data in sessions',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
issues.push({
|
|
146
|
+
severity: 'info',
|
|
147
|
+
message: `API list price: $${result.totalCost.toFixed(2)}, subscription: $${result.subscriptionCost.toFixed(2)}, subsidy: ${result.subsidyRate.toFixed(1)}%`,
|
|
148
|
+
fixable: false,
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
name: 'subsidy',
|
|
152
|
+
score: 100,
|
|
153
|
+
maxScore: 100,
|
|
154
|
+
issues,
|
|
155
|
+
summary: `used $${result.totalCost.toFixed(2)} at list price (paid $${result.subscriptionCost.toFixed(2)}, subsidy ${result.subsidyRate.toFixed(1)}%)`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// ── Subcommand ───────────────────────────────────────────────────────────────
|
|
159
|
+
export async function runSubsidyCommand(format, options) {
|
|
160
|
+
const plan = options?.plan || 'claude-pro';
|
|
161
|
+
const since = options?.since;
|
|
162
|
+
let files = findSessionFiles();
|
|
163
|
+
if (since) {
|
|
164
|
+
const sinceDate = new Date(since);
|
|
165
|
+
if (!isNaN(sinceDate.getTime())) {
|
|
166
|
+
files = files.filter(f => {
|
|
167
|
+
try {
|
|
168
|
+
return statSync(f).mtimeMs >= sinceDate.getTime();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (files.length === 0) {
|
|
177
|
+
if (format === 'json') {
|
|
178
|
+
console.log(JSON.stringify({ error: 'no session files found' }));
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
console.error('no claude session files found in ~/.claude/projects/');
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const allEntries = [];
|
|
186
|
+
let earliest = Infinity;
|
|
187
|
+
let latest = -Infinity;
|
|
188
|
+
for (const f of files) {
|
|
189
|
+
try {
|
|
190
|
+
const { entries } = await parseSessionFile(f);
|
|
191
|
+
allEntries.push(...entries);
|
|
192
|
+
const stat = statSync(f);
|
|
193
|
+
if (stat.mtimeMs < earliest)
|
|
194
|
+
earliest = stat.mtimeMs;
|
|
195
|
+
if (stat.mtimeMs > latest)
|
|
196
|
+
latest = stat.mtimeMs;
|
|
197
|
+
}
|
|
198
|
+
catch { /* skip */ }
|
|
199
|
+
}
|
|
200
|
+
const result = computeSubsidy(allEntries, plan);
|
|
201
|
+
const data = {
|
|
202
|
+
sessionCount: files.length,
|
|
203
|
+
periodStart: earliest === Infinity ? 'unknown' : new Date(earliest).toISOString().slice(0, 10),
|
|
204
|
+
periodEnd: latest === -Infinity ? 'unknown' : new Date(latest).toISOString().slice(0, 10),
|
|
205
|
+
models: result.models,
|
|
206
|
+
totalCost: result.totalCost,
|
|
207
|
+
subscriptionCost: result.subscriptionCost,
|
|
208
|
+
subsidized: result.subsidized,
|
|
209
|
+
subsidyRate: result.subsidyRate,
|
|
210
|
+
};
|
|
211
|
+
if (format === 'json') {
|
|
212
|
+
console.log(JSON.stringify(data, null, 2));
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
console.log(renderCard(data));
|
|
216
|
+
}
|
|
217
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,8 @@ import { checkTests } from './checks/tests.js';
|
|
|
20
20
|
import { checkMap, renderMapReport } from './checks/map.js';
|
|
21
21
|
import { checkPermissions } from './checks/permissions.js';
|
|
22
22
|
import { checkCompact, runCompactCommand } from './checks/compact.js';
|
|
23
|
+
import { checkSubsidy, runSubsidyCommand } from './checks/subsidy.js';
|
|
24
|
+
import { checkLoop, runLoopCommand } from './checks/loop.js';
|
|
23
25
|
import { score } from './scorer.js';
|
|
24
26
|
import { reportPretty, reportJSON, reportBadge } from './reporter.js';
|
|
25
27
|
import { clearCache } from './file-cache.js';
|
|
@@ -35,6 +37,13 @@ for (let i = 0; i < args.length; i++) {
|
|
|
35
37
|
flagMap.set('since', args[i + 1]);
|
|
36
38
|
i++;
|
|
37
39
|
}
|
|
40
|
+
else if (args[i].startsWith('--plan=')) {
|
|
41
|
+
flagMap.set('plan', args[i].split('=')[1]);
|
|
42
|
+
}
|
|
43
|
+
else if (args[i] === '--plan' && args[i + 1]) {
|
|
44
|
+
flagMap.set('plan', args[i + 1]);
|
|
45
|
+
i++;
|
|
46
|
+
}
|
|
38
47
|
else if (args[i].startsWith('--max-files=')) {
|
|
39
48
|
flagMap.set('max-files', args[i].split('=')[1]);
|
|
40
49
|
}
|
|
@@ -61,6 +70,8 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
61
70
|
npx @safetnsr/vet map [dir] show agent visibility map
|
|
62
71
|
npx @safetnsr/vet permissions [dir] audit Claude Code config for dangerous grants
|
|
63
72
|
npx @safetnsr/vet compact [log] compaction forensics for claude code sessions
|
|
73
|
+
npx @safetnsr/vet subsidy [--plan tier] [--since date] show AI cost vs subscription
|
|
74
|
+
npx @safetnsr/vet loop [log] /loop session forensics — per-iteration timeline
|
|
64
75
|
|
|
65
76
|
${c.dim}categories:${c.reset}
|
|
66
77
|
security (30%) scan, secrets, config, model usage
|
|
@@ -96,7 +107,7 @@ if (flags.has('--version') || flags.has('-v')) {
|
|
|
96
107
|
}
|
|
97
108
|
process.exit(0);
|
|
98
109
|
}
|
|
99
|
-
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact'];
|
|
110
|
+
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop'];
|
|
100
111
|
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
101
112
|
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
102
113
|
const isCI = flags.has('--ci');
|
|
@@ -191,6 +202,31 @@ if (command === 'compact') {
|
|
|
191
202
|
}
|
|
192
203
|
process.exit(0);
|
|
193
204
|
}
|
|
205
|
+
if (command === 'subsidy') {
|
|
206
|
+
try {
|
|
207
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
208
|
+
const plan = flagMap.get('plan') || 'claude-pro';
|
|
209
|
+
const since = flagMap.get('since');
|
|
210
|
+
await runSubsidyCommand(format, { since, plan });
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
console.error(`${c.red}subsidy failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
process.exit(0);
|
|
217
|
+
}
|
|
218
|
+
if (command === 'loop') {
|
|
219
|
+
try {
|
|
220
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
221
|
+
const sessionArg = positional.find(p => p !== 'loop' && !COMMANDS.includes(p));
|
|
222
|
+
await runLoopCommand(format, sessionArg);
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
console.error(`${c.red}loop failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
process.exit(0);
|
|
229
|
+
}
|
|
194
230
|
if (!isGitRepo(cwd)) {
|
|
195
231
|
console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
|
|
196
232
|
process.exit(1);
|
|
@@ -242,7 +278,7 @@ async function runChecks() {
|
|
|
242
278
|
}
|
|
243
279
|
}
|
|
244
280
|
// Run ALL independent checks in parallel
|
|
245
|
-
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult,] = await Promise.all([
|
|
281
|
+
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult,] = await Promise.all([
|
|
246
282
|
withTimeout('scan', () => checkScan(cwd)),
|
|
247
283
|
withTimeout('secrets', () => checkSecrets(cwd)),
|
|
248
284
|
withTimeout('config', () => checkConfig(cwd, ignore)),
|
|
@@ -255,9 +291,11 @@ async function runChecks() {
|
|
|
255
291
|
withTimeout('deps', () => checkDeps(cwd)),
|
|
256
292
|
withTimeout('receipt', () => checkReceipt(cwd)),
|
|
257
293
|
withTimeout('compact', () => checkCompact(cwd)),
|
|
294
|
+
withTimeout('subsidy', () => checkSubsidy(cwd)),
|
|
258
295
|
withTimeout('memory', () => checkMemory(cwd)),
|
|
259
296
|
withTimeout('verify', () => checkVerify(cwd, since)),
|
|
260
297
|
withTimeout('tests', () => checkTests(cwd, ignore)),
|
|
298
|
+
withTimeout('loop', () => checkLoop(cwd)),
|
|
261
299
|
]);
|
|
262
300
|
// Git-dependent checks (diff + history) — parallel with each other
|
|
263
301
|
const [diffResult, historyResult] = await Promise.all([
|
|
@@ -267,8 +305,8 @@ async function runChecks() {
|
|
|
267
305
|
// Clear file cache after all checks complete
|
|
268
306
|
clearCache();
|
|
269
307
|
return score(cwd, {
|
|
270
|
-
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult],
|
|
271
|
-
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult],
|
|
308
|
+
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult],
|
|
309
|
+
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult],
|
|
272
310
|
debt: [readyResult, historyResult, debtResult],
|
|
273
311
|
deps: [depsResult],
|
|
274
312
|
});
|