@uxcontinuum/ccaudit 1.0.0 → 1.0.2
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/index.js +126 -28
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -44,11 +44,11 @@ const CLAUDE_DIRS = findClaudeDirs();
|
|
|
44
44
|
// ── SESSION TYPE CLASSIFIER ───────────────────────────────────────────────────
|
|
45
45
|
// Agent-spawned worktrees end with a 32-char hex hash (your orchestrator's
|
|
46
46
|
// pattern). Named project dirs are human.
|
|
47
|
-
// Agent
|
|
48
|
-
//
|
|
47
|
+
// Agent / subagent sessions are detected via the JSONL fields themselves
|
|
48
|
+
// (isSidechain, userType, agentId). Directory naming is a weak fallback only.
|
|
49
49
|
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/;
|
|
50
50
|
const HEX_TAIL_RE = /-[0-9a-f]{20,}$/;
|
|
51
|
-
function
|
|
51
|
+
function fallbackAgentDirGuess(name) {
|
|
52
52
|
if (UUID_RE.test(name)) return true;
|
|
53
53
|
if (HEX_TAIL_RE.test(name)) return true;
|
|
54
54
|
return false;
|
|
@@ -83,17 +83,34 @@ function parseSession(filePath, cutoffMs) {
|
|
|
83
83
|
const toolCalls = [];
|
|
84
84
|
const timestamps = [];
|
|
85
85
|
let title = null;
|
|
86
|
+
let slug = null;
|
|
87
|
+
let cwd = null;
|
|
86
88
|
let outputTokens = 0;
|
|
87
89
|
let inputTokens = 0;
|
|
90
|
+
let isSidechain = false;
|
|
91
|
+
let userType = null;
|
|
92
|
+
let entrypoint = null;
|
|
93
|
+
let claudeVersion = null;
|
|
94
|
+
let messageCount = 0;
|
|
88
95
|
|
|
89
96
|
for (const raw of lines) {
|
|
90
97
|
if (!raw) continue;
|
|
91
98
|
let msg;
|
|
92
99
|
try { msg = JSON.parse(raw); } catch (_) { continue; }
|
|
93
100
|
|
|
101
|
+
// Capture session-level metadata from the first message that has it.
|
|
102
|
+
if (cwd === null && typeof msg.cwd === 'string') cwd = msg.cwd;
|
|
103
|
+
if (slug === null && typeof msg.slug === 'string') slug = msg.slug;
|
|
104
|
+
if (userType === null && typeof msg.userType === 'string') userType = msg.userType;
|
|
105
|
+
if (entrypoint === null && typeof msg.entrypoint === 'string') entrypoint = msg.entrypoint;
|
|
106
|
+
if (claudeVersion === null && typeof msg.version === 'string') claudeVersion = msg.version;
|
|
107
|
+
if (msg.isSidechain === true) isSidechain = true;
|
|
108
|
+
|
|
94
109
|
if (msg.type === 'custom-title') { title = msg.title || ''; continue; }
|
|
95
110
|
if (msg.type !== 'user' && msg.type !== 'assistant') continue;
|
|
96
111
|
|
|
112
|
+
messageCount++;
|
|
113
|
+
|
|
97
114
|
if (msg.timestamp) {
|
|
98
115
|
const t = Date.parse(msg.timestamp);
|
|
99
116
|
if (!isNaN(t) && t >= cutoffMs) timestamps.push(t);
|
|
@@ -124,15 +141,28 @@ function parseSession(filePath, cutoffMs) {
|
|
|
124
141
|
if (!timestamps.length) return null;
|
|
125
142
|
|
|
126
143
|
const projDir = projDirName(filePath);
|
|
144
|
+
// Multi-signal agent detector. Any of these is sufficient:
|
|
145
|
+
// - isSidechain: subagent inside another Claude session
|
|
146
|
+
// - userType non-external: internal automation invocation
|
|
147
|
+
// - dir-name matches UUID/hex pattern: orchestrator-spawned worktree
|
|
148
|
+
const isAgent = isSidechain ||
|
|
149
|
+
(userType && userType !== 'external') ||
|
|
150
|
+
fallbackAgentDirGuess(projDir);
|
|
151
|
+
|
|
127
152
|
return {
|
|
128
153
|
projDir,
|
|
129
|
-
isAgent
|
|
154
|
+
isAgent,
|
|
130
155
|
title: title || '',
|
|
156
|
+
slug: slug || '',
|
|
157
|
+
cwd: cwd || '',
|
|
131
158
|
userPrompts,
|
|
132
159
|
toolCalls,
|
|
133
160
|
timestamps,
|
|
134
161
|
outputTokens,
|
|
135
162
|
inputTokens,
|
|
163
|
+
claudeVersion,
|
|
164
|
+
entrypoint,
|
|
165
|
+
messageCount,
|
|
136
166
|
};
|
|
137
167
|
}
|
|
138
168
|
|
|
@@ -142,9 +172,11 @@ function inspectClaudeDir(claudeDir) {
|
|
|
142
172
|
claudeDir,
|
|
143
173
|
hasSettings: false,
|
|
144
174
|
settingsValid: false,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
175
|
+
settingsParseError: null,
|
|
176
|
+
hooksByEvent: {}, // dynamic, captures any event type configured
|
|
177
|
+
totalHooks: 0,
|
|
178
|
+
autoMemoryEnabled: false,
|
|
179
|
+
mcpServers: 0,
|
|
148
180
|
hookFiles: [],
|
|
149
181
|
hasClaudeMd: false,
|
|
150
182
|
claudeMdBytes: 0,
|
|
@@ -159,11 +191,34 @@ function inspectClaudeDir(claudeDir) {
|
|
|
159
191
|
const s = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
160
192
|
out.settingsValid = true;
|
|
161
193
|
const hooks = s.hooks || {};
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
194
|
+
for (const [evt, entries] of Object.entries(hooks)) {
|
|
195
|
+
if (!Array.isArray(entries)) continue;
|
|
196
|
+
const n = entries.reduce((acc, e) => acc + (Array.isArray(e?.hooks) ? e.hooks.length : 0), 0);
|
|
197
|
+
if (n > 0) {
|
|
198
|
+
out.hooksByEvent[evt] = n;
|
|
199
|
+
out.totalHooks += n;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
out.autoMemoryEnabled = s.autoMemoryEnabled === true;
|
|
203
|
+
// MCP servers can live in settings.json or ~/.claude.json. Count both.
|
|
204
|
+
if (s.mcpServers && typeof s.mcpServers === 'object') {
|
|
205
|
+
out.mcpServers = Object.keys(s.mcpServers).length;
|
|
206
|
+
}
|
|
207
|
+
} catch (e) {
|
|
208
|
+
out.settingsParseError = e.message;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (_) {}
|
|
212
|
+
|
|
213
|
+
// ~/.claude.json (user-level MCP + global config). Optional.
|
|
214
|
+
try {
|
|
215
|
+
const cj = path.join(claudeDir, '..', '.claude.json');
|
|
216
|
+
const st = fs.statSync(cj);
|
|
217
|
+
if (st.isFile()) {
|
|
218
|
+
const parsed = JSON.parse(fs.readFileSync(cj, 'utf8'));
|
|
219
|
+
if (parsed?.mcpServers && typeof parsed.mcpServers === 'object') {
|
|
220
|
+
out.mcpServers = Math.max(out.mcpServers, Object.keys(parsed.mcpServers).length);
|
|
221
|
+
}
|
|
167
222
|
}
|
|
168
223
|
} catch (_) {}
|
|
169
224
|
|
|
@@ -209,6 +264,10 @@ function aggregate(sessions) {
|
|
|
209
264
|
const prompts = subset.flatMap(s => s.userPrompts);
|
|
210
265
|
const tools = subset.flatMap(s => s.toolCalls);
|
|
211
266
|
const titled = subset.filter(s => s.title).length;
|
|
267
|
+
const slugged = subset.filter(s => s.slug).length;
|
|
268
|
+
|
|
269
|
+
const cwds = new Set();
|
|
270
|
+
for (const s of subset) if (s.cwd) cwds.add(s.cwd);
|
|
212
271
|
|
|
213
272
|
const toolCounts = {};
|
|
214
273
|
for (const t of tools) toolCounts[t] = (toolCounts[t] || 0) + 1;
|
|
@@ -228,6 +287,8 @@ function aggregate(sessions) {
|
|
|
228
287
|
sessions: subset.length,
|
|
229
288
|
prompts: prompts.length,
|
|
230
289
|
titledPct: Math.round(100 * titled / subset.length),
|
|
290
|
+
sluggedPct: Math.round(100 * slugged / subset.length),
|
|
291
|
+
uniqueCwds: cwds.size,
|
|
231
292
|
avgPromptLen,
|
|
232
293
|
justCount,
|
|
233
294
|
pleaseCount,
|
|
@@ -274,37 +335,54 @@ function grade(stats, setup) {
|
|
|
274
335
|
const human = stats.human || {};
|
|
275
336
|
const agent = stats.agent || {};
|
|
276
337
|
|
|
277
|
-
// 1. Hook coverage.
|
|
278
|
-
const hookSignals = setup.
|
|
338
|
+
// 1. Hook coverage. Generic count across whatever event types are configured.
|
|
339
|
+
const hookSignals = setup.totalHooks;
|
|
279
340
|
let hookScore;
|
|
280
341
|
if (hookSignals === 0) hookScore = 35;
|
|
281
|
-
else if (hookSignals === 1) hookScore =
|
|
342
|
+
else if (hookSignals === 1) hookScore = 68;
|
|
282
343
|
else if (hookSignals === 2) hookScore = 82;
|
|
283
|
-
else
|
|
344
|
+
else if (hookSignals === 3) hookScore = 90;
|
|
345
|
+
else hookScore = Math.min(100, 92 + hookSignals);
|
|
346
|
+
if (setup.autoMemoryEnabled) hookScore = Math.min(100, hookScore + 3);
|
|
347
|
+
const hookBreakdown = Object.entries(setup.hooksByEvent)
|
|
348
|
+
.map(([evt, n]) => `${n} ${evt}`)
|
|
349
|
+
.concat(setup.autoMemoryEnabled ? ['autoMemory plugin'] : [])
|
|
350
|
+
.join(', ');
|
|
284
351
|
dims.push({
|
|
285
352
|
name: 'Hook coverage',
|
|
286
353
|
score: hookScore,
|
|
287
354
|
detail: hookSignals === 0
|
|
288
|
-
? 'No
|
|
289
|
-
: `${
|
|
355
|
+
? 'No hooks installed across any event. Anything could happen overnight.'
|
|
356
|
+
: `${hookBreakdown}.`,
|
|
290
357
|
fix: hookSignals === 0
|
|
291
|
-
? 'Install claude-loop-sentinel for runaway-loop protection:
|
|
358
|
+
? 'Install claude-loop-sentinel for runaway-loop protection: https://github.com/turleydesigns/claude-loop-sentinel'
|
|
292
359
|
: null,
|
|
293
360
|
});
|
|
294
361
|
|
|
295
362
|
// 2. Project hygiene (human sessions only).
|
|
363
|
+
// Three signals: custom titles (strongest), slugs (informal auto-titles),
|
|
364
|
+
// and CWD diversity (project-scoped work via tmux or shell). Any of these
|
|
365
|
+
// counts as organizational hygiene.
|
|
296
366
|
if (human.sessions) {
|
|
297
367
|
let hScore = 50;
|
|
298
|
-
hScore += Math.round(human.titledPct * 0.
|
|
299
|
-
|
|
300
|
-
|
|
368
|
+
hScore += Math.round(human.titledPct * 0.35); // formal title bonus
|
|
369
|
+
hScore += Math.round(human.sluggedPct * 0.15); // slug bonus (smaller)
|
|
370
|
+
// CWD diversity: launching Claude from named project dirs is good hygiene
|
|
371
|
+
if (human.uniqueCwds >= 3) hScore += 8;
|
|
372
|
+
if (human.uniqueCwds >= 10) hScore += 7;
|
|
373
|
+
if (human.uniqueCwds >= 25) hScore += 5;
|
|
374
|
+
if (human.avgPromptLen > 0 && human.avgPromptLen < 80) hScore -= 8;
|
|
375
|
+
if (human.avgPromptLen > 1500) hScore -= 6;
|
|
301
376
|
hScore = Math.max(0, Math.min(100, hScore));
|
|
377
|
+
const titleNote = human.titledPct > 0
|
|
378
|
+
? `${human.titledPct}% titled`
|
|
379
|
+
: (human.sluggedPct > 0 ? `${human.sluggedPct}% have auto-slugs` : 'no titles, no slugs');
|
|
302
380
|
dims.push({
|
|
303
381
|
name: 'Project hygiene (human)',
|
|
304
382
|
score: hScore,
|
|
305
|
-
detail: `${
|
|
306
|
-
fix: human.titledPct <
|
|
307
|
-
? 'Title your sessions
|
|
383
|
+
detail: `${titleNote}, launched from ${human.uniqueCwds} distinct working dirs. Avg prompt: ${human.avgPromptLen} chars.`,
|
|
384
|
+
fix: (human.titledPct < 20 && human.uniqueCwds < 5)
|
|
385
|
+
? 'Title your important sessions, or launch from project dirs so each session is scoped.'
|
|
308
386
|
: null,
|
|
309
387
|
});
|
|
310
388
|
}
|
|
@@ -403,9 +481,13 @@ function renderCard(stats, setup, graded) {
|
|
|
403
481
|
if (stats.agent) {
|
|
404
482
|
pr(` ${C.dim}Agent sessions:${C.reset} ${stats.agent.sessions} sessions, ${(stats.agent.outputTokens / 1e6).toFixed(2)}M output tokens`);
|
|
405
483
|
}
|
|
406
|
-
pr(` ${C.dim}Hooks installed:${C.reset} ${setup.
|
|
484
|
+
pr(` ${C.dim}Hooks installed:${C.reset} ${setup.totalHooks} across ${Object.keys(setup.hooksByEvent).length} event type(s) (${setup.hookFiles.length} hook file(s) in ~/.claude/hooks/)`);
|
|
407
485
|
pr(` ${C.dim}CLAUDE.md:${C.reset} ${setup.hasClaudeMd ? `${setup.claudeMdBytes} bytes` : 'not found'}`);
|
|
486
|
+
pr(` ${C.dim}MCP servers:${C.reset} ${setup.mcpServers}`);
|
|
408
487
|
pr(` ${C.dim}Skills installed:${C.reset} ${setup.skillCount}`);
|
|
488
|
+
if (setup.settingsParseError) {
|
|
489
|
+
pr(` ${C.yellow}settings.json parse error:${C.reset} ${setup.settingsParseError}`);
|
|
490
|
+
}
|
|
409
491
|
|
|
410
492
|
pr();
|
|
411
493
|
pr(` ${C.dim}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C.reset}`);
|
|
@@ -432,8 +514,24 @@ const files = projectsDirs.flatMap(d => findJsonl(d, cutoffMs));
|
|
|
432
514
|
const sessions = files.map(f => parseSession(f, cutoffMs)).filter(Boolean);
|
|
433
515
|
|
|
434
516
|
if (!sessions.length) {
|
|
435
|
-
pr(
|
|
436
|
-
pr(`
|
|
517
|
+
pr();
|
|
518
|
+
pr(` ${C.bold}ccaudit${C.reset}: no Claude Code session activity in the last ${days} days.`);
|
|
519
|
+
if (!projectsDirs.length) {
|
|
520
|
+
pr(` ${C.dim}No ~/.claude/projects/ directory found. Looked in: ${CLAUDE_DIRS.join(', ')}${C.reset}`);
|
|
521
|
+
pr(` ${C.dim}Either Claude Code is not installed, you have a non-default home dir, or this is a brand-new setup.${C.reset}`);
|
|
522
|
+
} else {
|
|
523
|
+
pr(` ${C.dim}Scanned: ${projectsDirs.join(', ')}${C.reset}`);
|
|
524
|
+
pr(` ${C.dim}Try --days 90 or --days 365 to widen the window.${C.reset}`);
|
|
525
|
+
}
|
|
526
|
+
// Still surface the setup audit even with no sessions, so brand-new users get value.
|
|
527
|
+
const setupOnly = inspectClaudeDir(CLAUDE_DIRS[0]);
|
|
528
|
+
pr();
|
|
529
|
+
pr(` ${C.bold}Setup snapshot:${C.reset}`);
|
|
530
|
+
pr(` Hooks: ${setupOnly.totalHooks} (${Object.keys(setupOnly.hooksByEvent).join(', ') || 'none'})`);
|
|
531
|
+
pr(` CLAUDE.md: ${setupOnly.hasClaudeMd ? `${setupOnly.claudeMdBytes} bytes` : 'not found'}`);
|
|
532
|
+
pr(` MCP servers: ${setupOnly.mcpServers}`);
|
|
533
|
+
pr(` Skills: ${setupOnly.skillCount}`);
|
|
534
|
+
pr();
|
|
437
535
|
process.exit(0);
|
|
438
536
|
}
|
|
439
537
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uxcontinuum/ccaudit",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "A diagnostic for your Claude Code setup. Reads ~/.claude/ locally, grades you across hook coverage, project hygiene, tool balance, prompt tells, and pipeline ops. Zero install: npx @uxcontinuum/ccaudit",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|