@safetnsr/vet 1.27.0 → 1.28.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,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
+ }
package/dist/cli.js CHANGED
@@ -35,6 +35,7 @@ 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';
38
39
  import { checkSourceSecurity } from './checks/source-security.js';
39
40
  import { checkCompleteness } from './checks/completeness.js';
40
41
  import { score } from './scorer.js';
@@ -96,6 +97,7 @@ if (flags.has('--help') || flags.has('-h')) {
96
97
  npx @safetnsr/vet split [--since HEAD~1] [--apply] [--force] [--json] split AI mega-commits into atomic commits
97
98
  npx @safetnsr/vet sandbox [dir] score agent runtime blast radius
98
99
  npx @safetnsr/vet triage [--since HEAD~1] [--json] rank diff files by review urgency
100
+ npx @safetnsr/vet fleet [--sessions dir] [--since 8h] [--json] multi-agent session audit
99
101
 
100
102
  ${c.dim}categories:${c.reset}
101
103
  security (30%) scan, secrets, config, model usage
@@ -132,7 +134,7 @@ if (flags.has('--version') || flags.has('-v')) {
132
134
  }
133
135
  process.exit(0);
134
136
  }
135
- const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split', 'sandbox', 'triage'];
137
+ const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy', 'loop', 'bloat', 'guard', 'explain', 'context', 'split', 'sandbox', 'triage', 'fleet'];
136
138
  const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
137
139
  const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
138
140
  const isCI = flags.has('--ci');
@@ -331,6 +333,19 @@ if (command === 'triage') {
331
333
  }
332
334
  process.exit(0);
333
335
  }
336
+ if (command === 'fleet') {
337
+ try {
338
+ const format = isJSON ? 'json' : 'ascii';
339
+ const sessionsDir = args.find(a => a.startsWith('--sessions='))?.split('=')[1]
340
+ || (args.includes('--sessions') ? args[args.indexOf('--sessions') + 1] : undefined);
341
+ await runFleetCommand(format, sessionsDir, since);
342
+ }
343
+ catch (e) {
344
+ console.error(`${c.red}fleet failed:${c.reset}`, e instanceof Error ? e.message : e);
345
+ process.exit(1);
346
+ }
347
+ process.exit(0);
348
+ }
334
349
  if (!isGitRepo(cwd)) {
335
350
  console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
336
351
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {