@safetnsr/vet 1.27.0 → 1.29.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/fleet.d.ts +29 -0
- package/dist/checks/fleet.js +286 -0
- package/dist/checks/review.d.ts +3 -0
- package/dist/checks/review.js +189 -0
- package/dist/cli.js +32 -3
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
export type FleetStatus = 'OK' | 'SILENT_FAIL' | 'CONFLICT';
|
|
3
|
+
export interface FileTouch {
|
|
4
|
+
session: string;
|
|
5
|
+
action: 'write' | 'edit' | 'create' | 'delete';
|
|
6
|
+
timestamp: string;
|
|
7
|
+
}
|
|
8
|
+
export interface FleetSession {
|
|
9
|
+
id: string;
|
|
10
|
+
file: string;
|
|
11
|
+
startTime: string | null;
|
|
12
|
+
endTime: string | null;
|
|
13
|
+
durationMin: number;
|
|
14
|
+
filesWritten: number;
|
|
15
|
+
filesRead: number;
|
|
16
|
+
toolCalls: number;
|
|
17
|
+
status: FleetStatus;
|
|
18
|
+
conflicts: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface FleetResult {
|
|
21
|
+
sessions: FleetSession[];
|
|
22
|
+
conflicts: Map<string, string[]>;
|
|
23
|
+
silentFails: string[];
|
|
24
|
+
totalFiles: number;
|
|
25
|
+
totalToolCalls: number;
|
|
26
|
+
}
|
|
27
|
+
export declare function analyzeFleet(sessionsDir?: string, since?: string): FleetResult;
|
|
28
|
+
export declare function checkFleet(sessionsDir?: string, since?: string): CheckResult;
|
|
29
|
+
export declare function runFleetCommand(format: string, sessionsDir?: string, since?: string): Promise<void>;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { c } from '../util.js';
|
|
4
|
+
import { findSessionFiles } from './receipt.js';
|
|
5
|
+
const WRITE_TOOLS = new Set(['Write', 'write_to_file', 'write', 'MultiEdit']);
|
|
6
|
+
const EDIT_TOOLS = new Set(['Edit', 'edit_file', 'edit']);
|
|
7
|
+
const READ_TOOLS = new Set(['Read', 'read_file', 'read', 'View']);
|
|
8
|
+
function classifyTool(name) {
|
|
9
|
+
if (WRITE_TOOLS.has(name))
|
|
10
|
+
return 'write';
|
|
11
|
+
if (EDIT_TOOLS.has(name))
|
|
12
|
+
return 'edit';
|
|
13
|
+
if (READ_TOOLS.has(name))
|
|
14
|
+
return 'read';
|
|
15
|
+
if (name === 'Bash' || name === 'bash' || name === 'execute_command')
|
|
16
|
+
return 'bash';
|
|
17
|
+
return 'other';
|
|
18
|
+
}
|
|
19
|
+
function extractFilePath(input) {
|
|
20
|
+
// Try common field names
|
|
21
|
+
for (const key of ['file_path', 'path', 'filePath', 'filename']) {
|
|
22
|
+
if (typeof input[key] === 'string')
|
|
23
|
+
return input[key];
|
|
24
|
+
}
|
|
25
|
+
// For Edit tool, check old_string path
|
|
26
|
+
if (typeof input['file'] === 'string')
|
|
27
|
+
return input['file'];
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function parseSessionJsonl(filePath, sinceMs) {
|
|
31
|
+
const calls = [];
|
|
32
|
+
let content;
|
|
33
|
+
try {
|
|
34
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return calls;
|
|
38
|
+
}
|
|
39
|
+
for (const line of content.split('\n')) {
|
|
40
|
+
if (!line.trim())
|
|
41
|
+
continue;
|
|
42
|
+
let entry;
|
|
43
|
+
try {
|
|
44
|
+
entry = JSON.parse(line);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const timestamp = entry['timestamp'] || '';
|
|
50
|
+
if (sinceMs && timestamp) {
|
|
51
|
+
const entryTime = new Date(timestamp).getTime();
|
|
52
|
+
if (!isNaN(entryTime) && entryTime < sinceMs)
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// Extract tool_use blocks from content arrays
|
|
56
|
+
const contents = [];
|
|
57
|
+
if (Array.isArray(entry['content'])) {
|
|
58
|
+
contents.push(...entry['content']);
|
|
59
|
+
}
|
|
60
|
+
if (entry['message'] && typeof entry['message'] === 'object') {
|
|
61
|
+
const msg = entry['message'];
|
|
62
|
+
if (Array.isArray(msg['content'])) {
|
|
63
|
+
contents.push(...msg['content']);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const block of contents) {
|
|
67
|
+
if (typeof block !== 'object' || block === null)
|
|
68
|
+
continue;
|
|
69
|
+
const b = block;
|
|
70
|
+
if (b['type'] !== 'tool_use')
|
|
71
|
+
continue;
|
|
72
|
+
const toolName = b['name'] || '';
|
|
73
|
+
const input = b['input'] || {};
|
|
74
|
+
const action = classifyTool(toolName);
|
|
75
|
+
const fp = extractFilePath(input);
|
|
76
|
+
calls.push({ timestamp, toolName, filePath: fp, action });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return calls;
|
|
80
|
+
}
|
|
81
|
+
// ── Core analysis ────────────────────────────────────────────────────────────
|
|
82
|
+
function parseSinceFlag(since) {
|
|
83
|
+
if (!since)
|
|
84
|
+
return null;
|
|
85
|
+
// "8h", "24h", "1d", "30m"
|
|
86
|
+
const match = since.match(/^(\d+)(h|d|m)$/i);
|
|
87
|
+
if (match) {
|
|
88
|
+
const val = parseInt(match[1], 10);
|
|
89
|
+
const unit = match[2].toLowerCase();
|
|
90
|
+
const ms = unit === 'h' ? val * 3600000 : unit === 'd' ? val * 86400000 : val * 60000;
|
|
91
|
+
return Date.now() - ms;
|
|
92
|
+
}
|
|
93
|
+
// ISO date or timestamp
|
|
94
|
+
const ts = new Date(since).getTime();
|
|
95
|
+
return isNaN(ts) ? null : ts;
|
|
96
|
+
}
|
|
97
|
+
function sessionIdFromPath(filePath) {
|
|
98
|
+
const base = path.basename(filePath, '.jsonl');
|
|
99
|
+
// Shorten long UUIDs for display
|
|
100
|
+
if (base.length > 20)
|
|
101
|
+
return base.slice(0, 8);
|
|
102
|
+
return base;
|
|
103
|
+
}
|
|
104
|
+
export function analyzeFleet(sessionsDir, since) {
|
|
105
|
+
const sinceMs = parseSinceFlag(since);
|
|
106
|
+
const sessionFiles = findSessionFiles(sessionsDir);
|
|
107
|
+
// Filter by mtime if --since
|
|
108
|
+
const filteredFiles = sinceMs
|
|
109
|
+
? sessionFiles.filter(f => {
|
|
110
|
+
try {
|
|
111
|
+
return fs.statSync(f).mtimeMs >= sinceMs;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
: sessionFiles;
|
|
118
|
+
// Limit to last 20 sessions to avoid scanning entire history
|
|
119
|
+
const recentFiles = filteredFiles.slice(-20);
|
|
120
|
+
const fileTouches = new Map(); // filepath → touches
|
|
121
|
+
const sessions = [];
|
|
122
|
+
for (const sessionFile of recentFiles) {
|
|
123
|
+
const sid = sessionIdFromPath(sessionFile);
|
|
124
|
+
const calls = parseSessionJsonl(sessionFile, sinceMs);
|
|
125
|
+
if (calls.length === 0) {
|
|
126
|
+
sessions.push({
|
|
127
|
+
id: sid, file: sessionFile,
|
|
128
|
+
startTime: null, endTime: null, durationMin: 0,
|
|
129
|
+
filesWritten: 0, filesRead: 0, toolCalls: 0,
|
|
130
|
+
status: 'OK', conflicts: [],
|
|
131
|
+
});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const timestamps = calls.filter(c => c.timestamp).map(c => c.timestamp);
|
|
135
|
+
const startTime = timestamps.length > 0 ? timestamps[0] : null;
|
|
136
|
+
const endTime = timestamps.length > 0 ? timestamps[timestamps.length - 1] : null;
|
|
137
|
+
const durationMs = startTime && endTime
|
|
138
|
+
? new Date(endTime).getTime() - new Date(startTime).getTime()
|
|
139
|
+
: 0;
|
|
140
|
+
const durationMin = Math.round(durationMs / 60000);
|
|
141
|
+
const writtenFiles = new Set();
|
|
142
|
+
const readFiles = new Set();
|
|
143
|
+
for (const call of calls) {
|
|
144
|
+
if ((call.action === 'write' || call.action === 'edit') && call.filePath) {
|
|
145
|
+
writtenFiles.add(call.filePath);
|
|
146
|
+
// Track file touches for conflict detection
|
|
147
|
+
const touches = fileTouches.get(call.filePath) || [];
|
|
148
|
+
touches.push({ session: sid, action: call.action, timestamp: call.timestamp });
|
|
149
|
+
fileTouches.set(call.filePath, touches);
|
|
150
|
+
}
|
|
151
|
+
else if (call.action === 'read' && call.filePath) {
|
|
152
|
+
readFiles.add(call.filePath);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Silent fail: session had tool calls but zero writes
|
|
156
|
+
const isSilentFail = calls.length > 3 && writtenFiles.size === 0;
|
|
157
|
+
sessions.push({
|
|
158
|
+
id: sid, file: sessionFile,
|
|
159
|
+
startTime, endTime, durationMin,
|
|
160
|
+
filesWritten: writtenFiles.size,
|
|
161
|
+
filesRead: readFiles.size,
|
|
162
|
+
toolCalls: calls.length,
|
|
163
|
+
status: isSilentFail ? 'SILENT_FAIL' : 'OK',
|
|
164
|
+
conflicts: [],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// Detect cross-agent conflicts
|
|
168
|
+
const conflicts = new Map();
|
|
169
|
+
for (const [fp, touches] of fileTouches) {
|
|
170
|
+
const uniqueSessions = [...new Set(touches.map(t => t.session))];
|
|
171
|
+
if (uniqueSessions.length >= 2) {
|
|
172
|
+
conflicts.set(fp, uniqueSessions);
|
|
173
|
+
// Mark sessions as conflicting
|
|
174
|
+
for (const s of sessions) {
|
|
175
|
+
if (uniqueSessions.includes(s.id) && s.status !== 'SILENT_FAIL') {
|
|
176
|
+
s.status = 'CONFLICT';
|
|
177
|
+
s.conflicts.push(fp);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const silentFails = sessions.filter(s => s.status === 'SILENT_FAIL').map(s => s.id);
|
|
183
|
+
return {
|
|
184
|
+
sessions,
|
|
185
|
+
conflicts,
|
|
186
|
+
silentFails,
|
|
187
|
+
totalFiles: fileTouches.size,
|
|
188
|
+
totalToolCalls: sessions.reduce((sum, s) => sum + s.toolCalls, 0),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// ── CheckResult integration ──────────────────────────────────────────────────
|
|
192
|
+
export function checkFleet(sessionsDir, since) {
|
|
193
|
+
const result = analyzeFleet(sessionsDir, since);
|
|
194
|
+
const issues = [];
|
|
195
|
+
for (const sid of result.silentFails) {
|
|
196
|
+
issues.push({
|
|
197
|
+
severity: 'error',
|
|
198
|
+
message: `session ${sid}: SILENT FAIL — ran but produced 0 file changes`,
|
|
199
|
+
fixable: false,
|
|
200
|
+
fixHint: 'check session logs for errors or stuck loops',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
for (const [fp, sids] of result.conflicts) {
|
|
204
|
+
issues.push({
|
|
205
|
+
severity: 'warning',
|
|
206
|
+
message: `file conflict: ${fp} — written by sessions: ${sids.join(', ')}`,
|
|
207
|
+
file: fp,
|
|
208
|
+
fixable: false,
|
|
209
|
+
fixHint: 'review file for merge conflicts or overwritten changes',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const score = Math.max(0, 100
|
|
213
|
+
- result.silentFails.length * 25
|
|
214
|
+
- result.conflicts.size * 15);
|
|
215
|
+
const summary = result.sessions.length === 0
|
|
216
|
+
? 'no session files found'
|
|
217
|
+
: `${result.sessions.length} sessions — ${result.silentFails.length} silent fails, ${result.conflicts.size} file conflicts`;
|
|
218
|
+
return { name: 'fleet', score, maxScore: 100, issues, summary };
|
|
219
|
+
}
|
|
220
|
+
// ── Subcommand output ────────────────────────────────────────────────────────
|
|
221
|
+
export async function runFleetCommand(format, sessionsDir, since) {
|
|
222
|
+
const result = analyzeFleet(sessionsDir, since);
|
|
223
|
+
if (format === 'json') {
|
|
224
|
+
console.log(JSON.stringify({
|
|
225
|
+
sessions: result.sessions.map(s => ({
|
|
226
|
+
id: s.id,
|
|
227
|
+
startTime: s.startTime,
|
|
228
|
+
endTime: s.endTime,
|
|
229
|
+
durationMin: s.durationMin,
|
|
230
|
+
filesWritten: s.filesWritten,
|
|
231
|
+
toolCalls: s.toolCalls,
|
|
232
|
+
status: s.status,
|
|
233
|
+
conflicts: s.conflicts,
|
|
234
|
+
})),
|
|
235
|
+
conflicts: Object.fromEntries(result.conflicts),
|
|
236
|
+
silentFails: result.silentFails,
|
|
237
|
+
totalFiles: result.totalFiles,
|
|
238
|
+
totalToolCalls: result.totalToolCalls,
|
|
239
|
+
}, null, 2));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
console.log(`\n ${c.bold}vet fleet${c.reset} — multi-agent session audit\n`);
|
|
243
|
+
if (result.sessions.length === 0) {
|
|
244
|
+
console.log(` ${c.dim}no session files found${c.reset}`);
|
|
245
|
+
console.log(` ${c.dim}default path: ~/.claude/projects/*/sessions/*.jsonl${c.reset}\n`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// Session table
|
|
249
|
+
const padId = Math.max(8, ...result.sessions.map(s => s.id.length));
|
|
250
|
+
const header = ` ${'SESSION'.padEnd(padId)} ${'DURATION'.padEnd(8)} ${'FILES'.padEnd(7)} ${'CALLS'.padEnd(7)} STATUS`;
|
|
251
|
+
console.log(` ${c.dim}${header}${c.reset}`);
|
|
252
|
+
console.log(` ${c.dim}${'─'.repeat(header.length)}${c.reset}`);
|
|
253
|
+
for (const s of result.sessions) {
|
|
254
|
+
const dur = s.durationMin > 0 ? `${s.durationMin}m` : '—';
|
|
255
|
+
const files = String(s.filesWritten);
|
|
256
|
+
const calls = String(s.toolCalls);
|
|
257
|
+
const statusColor = s.status === 'SILENT_FAIL' ? c.red
|
|
258
|
+
: s.status === 'CONFLICT' ? c.yellow
|
|
259
|
+
: c.green;
|
|
260
|
+
const statusIcon = s.status === 'SILENT_FAIL' ? '✗'
|
|
261
|
+
: s.status === 'CONFLICT' ? '⚠'
|
|
262
|
+
: '✓';
|
|
263
|
+
console.log(` ${s.id.padEnd(padId)} ${dur.padEnd(8)} ${files.padEnd(7)} ${calls.padEnd(7)} ${statusColor}${statusIcon} ${s.status}${c.reset}`);
|
|
264
|
+
}
|
|
265
|
+
console.log();
|
|
266
|
+
// Conflicts
|
|
267
|
+
if (result.conflicts.size > 0) {
|
|
268
|
+
console.log(` ${c.yellow}${c.bold}conflicts${c.reset} ${c.dim}(same file written by multiple sessions)${c.reset}`);
|
|
269
|
+
for (const [fp, sids] of result.conflicts) {
|
|
270
|
+
console.log(` ${c.yellow}⚠${c.reset} ${fp} ${c.dim}— sessions: ${sids.join(', ')}${c.reset}`);
|
|
271
|
+
}
|
|
272
|
+
console.log();
|
|
273
|
+
}
|
|
274
|
+
// Silent failures
|
|
275
|
+
if (result.silentFails.length > 0) {
|
|
276
|
+
console.log(` ${c.red}${c.bold}silent failures${c.reset} ${c.dim}(sessions with 0 file changes)${c.reset}`);
|
|
277
|
+
for (const sid of result.silentFails) {
|
|
278
|
+
const s = result.sessions.find(x => x.id === sid);
|
|
279
|
+
console.log(` ${c.red}✗${c.reset} ${sid} ${c.dim}— ${s?.toolCalls ?? 0} tool calls, 0 writes${c.reset}`);
|
|
280
|
+
}
|
|
281
|
+
console.log();
|
|
282
|
+
}
|
|
283
|
+
// Summary
|
|
284
|
+
const ok = result.sessions.filter(s => s.status === 'OK').length;
|
|
285
|
+
console.log(` ${c.dim}summary: ${result.sessions.length} sessions. ${ok} ok, ${result.silentFails.length} silent fails, ${result.conflicts.size} conflicts.${c.reset}\n`);
|
|
286
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { join, relative } from 'node:path';
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { readFile, c } from '../util.js';
|
|
4
|
+
const SECTIONS = [
|
|
5
|
+
{
|
|
6
|
+
name: 'FOCUS AREAS',
|
|
7
|
+
points: 20,
|
|
8
|
+
patterns: [/focus/i, /priority/i, /look for/i, /check for/i, /pay attention/i, /concentrate on/i],
|
|
9
|
+
missingImpact: 'Claude Code Review will leave generic comments',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: 'OUT-OF-SCOPE',
|
|
13
|
+
points: 20,
|
|
14
|
+
patterns: [/out of scope/i, /out-of-scope/i, /ignore/i, /skip/i, /don't review/i, /exclude/i, /not relevant/i],
|
|
15
|
+
missingImpact: 'Reviews may flag irrelevant code patterns',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'PERSONA',
|
|
19
|
+
points: 20,
|
|
20
|
+
patterns: [/act as/i, /you are/i, /persona/i, /role/i, /reviewer/i, /behave as/i],
|
|
21
|
+
missingImpact: 'Reviewer has no defined expertise or tone',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'TOOL LIST',
|
|
25
|
+
points: 20,
|
|
26
|
+
patterns: [/tools/i, /allowed tools/i, /disallowed/i, /permitted/i, /use the following/i],
|
|
27
|
+
missingImpact: 'No tool restrictions — agent may use unexpected tools',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'EXAMPLES',
|
|
31
|
+
points: 20,
|
|
32
|
+
patterns: [], // special: checks for fenced code blocks
|
|
33
|
+
missingImpact: 'No example review comments — output style unpredictable',
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
37
|
+
function findReviewFiles(dir, maxDepth) {
|
|
38
|
+
const results = [];
|
|
39
|
+
function walk(d, depth) {
|
|
40
|
+
if (depth > maxDepth)
|
|
41
|
+
return;
|
|
42
|
+
let entries;
|
|
43
|
+
try {
|
|
44
|
+
entries = readdirSync(d);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (entry === 'node_modules' || entry === '.git' || entry === 'dist' || entry === 'build')
|
|
51
|
+
continue;
|
|
52
|
+
const full = join(d, entry);
|
|
53
|
+
try {
|
|
54
|
+
const stat = statSync(full);
|
|
55
|
+
if (stat.isFile() && entry === 'REVIEW.md') {
|
|
56
|
+
results.push(full);
|
|
57
|
+
}
|
|
58
|
+
else if (stat.isDirectory()) {
|
|
59
|
+
walk(full, depth + 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch { /* skip */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
walk(dir, 0);
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
68
|
+
function scoreFile(content) {
|
|
69
|
+
const sections = [];
|
|
70
|
+
for (const section of SECTIONS) {
|
|
71
|
+
let passed = false;
|
|
72
|
+
if (section.name === 'EXAMPLES') {
|
|
73
|
+
// Check for fenced code blocks
|
|
74
|
+
passed = /```/.test(content);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
passed = section.patterns.some(re => re.test(content));
|
|
78
|
+
}
|
|
79
|
+
sections.push({
|
|
80
|
+
name: section.name,
|
|
81
|
+
passed,
|
|
82
|
+
points: section.points,
|
|
83
|
+
missingImpact: section.missingImpact,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const score = sections.reduce((sum, s) => sum + (s.passed ? s.points : 0), 0);
|
|
87
|
+
return { score, sections };
|
|
88
|
+
}
|
|
89
|
+
// ── Main check ───────────────────────────────────────────────────────────────
|
|
90
|
+
export async function checkReview(cwd) {
|
|
91
|
+
const files = findReviewFiles(cwd, 3);
|
|
92
|
+
const issues = [];
|
|
93
|
+
if (files.length === 0) {
|
|
94
|
+
return {
|
|
95
|
+
name: 'review',
|
|
96
|
+
score: 0,
|
|
97
|
+
maxScore: 100,
|
|
98
|
+
issues: [{
|
|
99
|
+
severity: 'info',
|
|
100
|
+
message: 'No REVIEW.md found — create one to enable Claude Code Review',
|
|
101
|
+
fixable: false,
|
|
102
|
+
}],
|
|
103
|
+
summary: 'no REVIEW.md found',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
let totalScore = 0;
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
const content = readFile(file) || '';
|
|
109
|
+
const result = scoreFile(content);
|
|
110
|
+
totalScore += result.score;
|
|
111
|
+
const rel = relative(cwd, file);
|
|
112
|
+
if (result.score === 0) {
|
|
113
|
+
issues.push({
|
|
114
|
+
severity: 'warning',
|
|
115
|
+
message: `${rel}: score 0/100 — no behavioral sections detected`,
|
|
116
|
+
file: rel,
|
|
117
|
+
fixable: false,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
else if (result.score < 100) {
|
|
121
|
+
const missing = result.sections.filter(s => !s.passed).map(s => s.name);
|
|
122
|
+
issues.push({
|
|
123
|
+
severity: 'warning',
|
|
124
|
+
message: `${rel}: score ${result.score}/100 — missing: ${missing.join(', ')}`,
|
|
125
|
+
file: rel,
|
|
126
|
+
fixable: false,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const avgScore = Math.round(totalScore / files.length);
|
|
131
|
+
const summary = files.length === 1
|
|
132
|
+
? `REVIEW.md score ${avgScore}/100`
|
|
133
|
+
: `${files.length} REVIEW.md files — average score ${avgScore}/100`;
|
|
134
|
+
return {
|
|
135
|
+
name: 'review',
|
|
136
|
+
score: avgScore,
|
|
137
|
+
maxScore: 100,
|
|
138
|
+
issues,
|
|
139
|
+
summary,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// ── Subcommand output ────────────────────────────────────────────────────────
|
|
143
|
+
export async function runReviewCommand(cwd, format) {
|
|
144
|
+
const files = findReviewFiles(cwd, 3);
|
|
145
|
+
if (format === 'json') {
|
|
146
|
+
const results = files.map(file => {
|
|
147
|
+
const content = readFile(file) || '';
|
|
148
|
+
const result = scoreFile(content);
|
|
149
|
+
return { file: relative(cwd, file), score: result.score, sections: result.sections };
|
|
150
|
+
});
|
|
151
|
+
console.log(JSON.stringify({
|
|
152
|
+
files: results,
|
|
153
|
+
score: files.length > 0 ? Math.round(results.reduce((s, r) => s + r.score, 0) / results.length) : 0,
|
|
154
|
+
}, null, 2));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
console.log(`\n ${c.bold}vet review${c.reset} — REVIEW.md behavioral completeness\n`);
|
|
158
|
+
if (files.length === 0) {
|
|
159
|
+
console.log(` ${c.dim}no REVIEW.md found${c.reset}`);
|
|
160
|
+
console.log(` ${c.dim}create one to guide Claude Code Review behavior${c.reset}\n`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
for (const file of files) {
|
|
164
|
+
const content = readFile(file) || '';
|
|
165
|
+
const result = scoreFile(content);
|
|
166
|
+
const rel = relative(cwd, file);
|
|
167
|
+
console.log(` ${c.bold}${rel}${c.reset}`);
|
|
168
|
+
console.log(` ${c.dim}${'─'.repeat(50)}${c.reset}`);
|
|
169
|
+
for (const section of result.sections) {
|
|
170
|
+
if (section.passed) {
|
|
171
|
+
console.log(` ${c.green}✓${c.reset} ${section.name} ${c.dim}(${section.points}pts)${c.reset}`);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
console.log(` ${c.red}✗${c.reset} ${section.name} ${c.dim}(0/${section.points}pts)${c.reset}`);
|
|
175
|
+
console.log(` ${c.dim}→ ${section.missingImpact}${c.reset}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const scoreColor = result.score >= 80 ? c.green : result.score >= 40 ? c.yellow : c.red;
|
|
179
|
+
console.log(`\n score ${scoreColor}${result.score}/100${c.reset}\n`);
|
|
180
|
+
}
|
|
181
|
+
if (files.length > 1) {
|
|
182
|
+
const avg = Math.round(files.reduce((sum, f) => {
|
|
183
|
+
const content = readFile(f) || '';
|
|
184
|
+
return sum + scoreFile(content).score;
|
|
185
|
+
}, 0) / files.length);
|
|
186
|
+
const avgColor = avg >= 80 ? c.green : avg >= 40 ? c.yellow : c.red;
|
|
187
|
+
console.log(` ${c.bold}average${c.reset} ${avgColor}${avg}/100${c.reset}\n`);
|
|
188
|
+
}
|
|
189
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -35,6 +35,8 @@ import { checkExplain, runExplainCommand } from './checks/explain.js';
|
|
|
35
35
|
import { checkContext, runContextCommand } from './checks/context.js';
|
|
36
36
|
import { checkSplit, runSplitCommand } from './checks/split.js';
|
|
37
37
|
import { runTriageCommand } from './checks/triage.js';
|
|
38
|
+
import { runFleetCommand } from './checks/fleet.js';
|
|
39
|
+
import { checkReview, runReviewCommand } from './checks/review.js';
|
|
38
40
|
import { checkSourceSecurity } from './checks/source-security.js';
|
|
39
41
|
import { checkCompleteness } from './checks/completeness.js';
|
|
40
42
|
import { score } from './scorer.js';
|
|
@@ -96,6 +98,8 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
96
98
|
npx @safetnsr/vet split [--since HEAD~1] [--apply] [--force] [--json] split AI mega-commits into atomic commits
|
|
97
99
|
npx @safetnsr/vet sandbox [dir] score agent runtime blast radius
|
|
98
100
|
npx @safetnsr/vet triage [--since HEAD~1] [--json] rank diff files by review urgency
|
|
101
|
+
npx @safetnsr/vet fleet [--sessions dir] [--since 8h] [--json] multi-agent session audit
|
|
102
|
+
npx @safetnsr/vet review [dir] score REVIEW.md behavioral completeness
|
|
99
103
|
|
|
100
104
|
${c.dim}categories:${c.reset}
|
|
101
105
|
security (30%) scan, secrets, config, model usage
|
|
@@ -132,7 +136,7 @@ if (flags.has('--version') || flags.has('-v')) {
|
|
|
132
136
|
}
|
|
133
137
|
process.exit(0);
|
|
134
138
|
}
|
|
135
|
-
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split', 'sandbox', 'triage'];
|
|
139
|
+
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split', 'sandbox', 'triage', 'fleet', 'review'];
|
|
136
140
|
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
137
141
|
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
138
142
|
const isCI = flags.has('--ci');
|
|
@@ -331,6 +335,30 @@ if (command === 'triage') {
|
|
|
331
335
|
}
|
|
332
336
|
process.exit(0);
|
|
333
337
|
}
|
|
338
|
+
if (command === 'fleet') {
|
|
339
|
+
try {
|
|
340
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
341
|
+
const sessionsDir = args.find(a => a.startsWith('--sessions='))?.split('=')[1]
|
|
342
|
+
|| (args.includes('--sessions') ? args[args.indexOf('--sessions') + 1] : undefined);
|
|
343
|
+
await runFleetCommand(format, sessionsDir, since);
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
console.error(`${c.red}fleet failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
process.exit(0);
|
|
350
|
+
}
|
|
351
|
+
if (command === 'review') {
|
|
352
|
+
try {
|
|
353
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
354
|
+
await runReviewCommand(cwd, format);
|
|
355
|
+
}
|
|
356
|
+
catch (e) {
|
|
357
|
+
console.error(`${c.red}review failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
process.exit(0);
|
|
361
|
+
}
|
|
334
362
|
if (!isGitRepo(cwd)) {
|
|
335
363
|
console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
|
|
336
364
|
process.exit(1);
|
|
@@ -440,7 +468,7 @@ async function runChecks() {
|
|
|
440
468
|
}
|
|
441
469
|
}
|
|
442
470
|
// Run ALL independent checks in parallel
|
|
443
|
-
const [scanResult, sourceSecurityResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult, clonesResult, contextResult, splitResult, sandboxResult,] = await Promise.all([
|
|
471
|
+
const [scanResult, sourceSecurityResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult, loopResult, completenessResult, bloatResult, guardResult, explainResult, architectureResult, aireadyResult, deepResult, semanticResult, hotspotsResult, clonesResult, contextResult, splitResult, sandboxResult, reviewResult,] = await Promise.all([
|
|
444
472
|
withTimeout('scan', () => checkScan(cwd)),
|
|
445
473
|
withTimeout('source-security', () => checkSourceSecurity(cwd)),
|
|
446
474
|
withTimeout('secrets', () => checkSecrets(cwd)),
|
|
@@ -472,6 +500,7 @@ async function runChecks() {
|
|
|
472
500
|
withTimeout('context', () => checkContext(cwd)),
|
|
473
501
|
withTimeout('split', () => checkSplit(cwd)),
|
|
474
502
|
withTimeout('sandbox', () => checkSandbox(cwd)),
|
|
503
|
+
withTimeout('review', () => checkReview(cwd)),
|
|
475
504
|
]);
|
|
476
505
|
// Git-dependent checks (diff + history) — parallel with each other
|
|
477
506
|
const [diffResult, historyResult] = await Promise.all([
|
|
@@ -486,7 +515,7 @@ async function runChecks() {
|
|
|
486
515
|
debt: [readyResult, historyResult, debtResult, bloatResult, clonesResult, splitResult],
|
|
487
516
|
deps: [depsResult],
|
|
488
517
|
architecture: [architectureResult],
|
|
489
|
-
aiready: [aireadyResult, deepResult, semanticResult, contextResult],
|
|
518
|
+
aiready: [aireadyResult, deepResult, semanticResult, contextResult, reviewResult],
|
|
490
519
|
history: [hotspotsResult],
|
|
491
520
|
});
|
|
492
521
|
}
|