@safetnsr/vet 1.12.0 → 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/cli.js +18 -3
- 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
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -21,6 +21,7 @@ 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
23
|
import { checkSubsidy, runSubsidyCommand } from './checks/subsidy.js';
|
|
24
|
+
import { checkLoop, runLoopCommand } from './checks/loop.js';
|
|
24
25
|
import { score } from './scorer.js';
|
|
25
26
|
import { reportPretty, reportJSON, reportBadge } from './reporter.js';
|
|
26
27
|
import { clearCache } from './file-cache.js';
|
|
@@ -70,6 +71,7 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
70
71
|
npx @safetnsr/vet permissions [dir] audit Claude Code config for dangerous grants
|
|
71
72
|
npx @safetnsr/vet compact [log] compaction forensics for claude code sessions
|
|
72
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
|
|
73
75
|
|
|
74
76
|
${c.dim}categories:${c.reset}
|
|
75
77
|
security (30%) scan, secrets, config, model usage
|
|
@@ -105,7 +107,7 @@ if (flags.has('--version') || flags.has('-v')) {
|
|
|
105
107
|
}
|
|
106
108
|
process.exit(0);
|
|
107
109
|
}
|
|
108
|
-
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy'];
|
|
110
|
+
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop'];
|
|
109
111
|
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
110
112
|
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
111
113
|
const isCI = flags.has('--ci');
|
|
@@ -213,6 +215,18 @@ if (command === 'subsidy') {
|
|
|
213
215
|
}
|
|
214
216
|
process.exit(0);
|
|
215
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
|
+
}
|
|
216
230
|
if (!isGitRepo(cwd)) {
|
|
217
231
|
console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
|
|
218
232
|
process.exit(1);
|
|
@@ -264,7 +278,7 @@ async function runChecks() {
|
|
|
264
278
|
}
|
|
265
279
|
}
|
|
266
280
|
// Run ALL independent checks in parallel
|
|
267
|
-
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, 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([
|
|
268
282
|
withTimeout('scan', () => checkScan(cwd)),
|
|
269
283
|
withTimeout('secrets', () => checkSecrets(cwd)),
|
|
270
284
|
withTimeout('config', () => checkConfig(cwd, ignore)),
|
|
@@ -281,6 +295,7 @@ async function runChecks() {
|
|
|
281
295
|
withTimeout('memory', () => checkMemory(cwd)),
|
|
282
296
|
withTimeout('verify', () => checkVerify(cwd, since)),
|
|
283
297
|
withTimeout('tests', () => checkTests(cwd, ignore)),
|
|
298
|
+
withTimeout('loop', () => checkLoop(cwd)),
|
|
284
299
|
]);
|
|
285
300
|
// Git-dependent checks (diff + history) — parallel with each other
|
|
286
301
|
const [diffResult, historyResult] = await Promise.all([
|
|
@@ -291,7 +306,7 @@ async function runChecks() {
|
|
|
291
306
|
clearCache();
|
|
292
307
|
return score(cwd, {
|
|
293
308
|
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult],
|
|
294
|
-
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult],
|
|
309
|
+
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult, loopResult],
|
|
295
310
|
debt: [readyResult, historyResult, debtResult],
|
|
296
311
|
deps: [depsResult],
|
|
297
312
|
});
|