@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.
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.11.1",
3
+ "version": "1.13.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {