@uxcontinuum/ccaudit 1.0.1 → 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 +118 -41
- 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,13 +172,11 @@ function inspectClaudeDir(claudeDir) {
|
|
|
142
172
|
claudeDir,
|
|
143
173
|
hasSettings: false,
|
|
144
174
|
settingsValid: false,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
stopHooks: 0,
|
|
149
|
-
subagentStopHooks: 0,
|
|
150
|
-
notificationHooks: 0,
|
|
175
|
+
settingsParseError: null,
|
|
176
|
+
hooksByEvent: {}, // dynamic, captures any event type configured
|
|
177
|
+
totalHooks: 0,
|
|
151
178
|
autoMemoryEnabled: false,
|
|
179
|
+
mcpServers: 0,
|
|
152
180
|
hookFiles: [],
|
|
153
181
|
hasClaudeMd: false,
|
|
154
182
|
claudeMdBytes: 0,
|
|
@@ -163,15 +191,34 @@ function inspectClaudeDir(claudeDir) {
|
|
|
163
191
|
const s = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
164
192
|
out.settingsValid = true;
|
|
165
193
|
const hooks = s.hooks || {};
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
}
|
|
175
222
|
}
|
|
176
223
|
} catch (_) {}
|
|
177
224
|
|
|
@@ -217,6 +264,10 @@ function aggregate(sessions) {
|
|
|
217
264
|
const prompts = subset.flatMap(s => s.userPrompts);
|
|
218
265
|
const tools = subset.flatMap(s => s.toolCalls);
|
|
219
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);
|
|
220
271
|
|
|
221
272
|
const toolCounts = {};
|
|
222
273
|
for (const t of tools) toolCounts[t] = (toolCounts[t] || 0) + 1;
|
|
@@ -236,6 +287,8 @@ function aggregate(sessions) {
|
|
|
236
287
|
sessions: subset.length,
|
|
237
288
|
prompts: prompts.length,
|
|
238
289
|
titledPct: Math.round(100 * titled / subset.length),
|
|
290
|
+
sluggedPct: Math.round(100 * slugged / subset.length),
|
|
291
|
+
uniqueCwds: cwds.size,
|
|
239
292
|
avgPromptLen,
|
|
240
293
|
justCount,
|
|
241
294
|
pleaseCount,
|
|
@@ -282,10 +335,8 @@ function grade(stats, setup) {
|
|
|
282
335
|
const human = stats.human || {};
|
|
283
336
|
const agent = stats.agent || {};
|
|
284
337
|
|
|
285
|
-
// 1. Hook coverage.
|
|
286
|
-
const hookSignals =
|
|
287
|
-
setup.preToolUseHooks + setup.postToolUseHooks + setup.userPromptSubmitHooks +
|
|
288
|
-
setup.stopHooks + setup.subagentStopHooks + setup.notificationHooks;
|
|
338
|
+
// 1. Hook coverage. Generic count across whatever event types are configured.
|
|
339
|
+
const hookSignals = setup.totalHooks;
|
|
289
340
|
let hookScore;
|
|
290
341
|
if (hookSignals === 0) hookScore = 35;
|
|
291
342
|
else if (hookSignals === 1) hookScore = 68;
|
|
@@ -293,15 +344,10 @@ function grade(stats, setup) {
|
|
|
293
344
|
else if (hookSignals === 3) hookScore = 90;
|
|
294
345
|
else hookScore = Math.min(100, 92 + hookSignals);
|
|
295
346
|
if (setup.autoMemoryEnabled) hookScore = Math.min(100, hookScore + 3);
|
|
296
|
-
const hookBreakdown =
|
|
297
|
-
|
|
298
|
-
setup.
|
|
299
|
-
|
|
300
|
-
setup.stopHooks ? `${setup.stopHooks} Stop` : null,
|
|
301
|
-
setup.subagentStopHooks ? `${setup.subagentStopHooks} SubagentStop` : null,
|
|
302
|
-
setup.notificationHooks ? `${setup.notificationHooks} Notification` : null,
|
|
303
|
-
setup.autoMemoryEnabled ? 'autoMemory plugin' : null,
|
|
304
|
-
].filter(Boolean).join(', ');
|
|
347
|
+
const hookBreakdown = Object.entries(setup.hooksByEvent)
|
|
348
|
+
.map(([evt, n]) => `${n} ${evt}`)
|
|
349
|
+
.concat(setup.autoMemoryEnabled ? ['autoMemory plugin'] : [])
|
|
350
|
+
.join(', ');
|
|
305
351
|
dims.push({
|
|
306
352
|
name: 'Hook coverage',
|
|
307
353
|
score: hookScore,
|
|
@@ -314,18 +360,29 @@ function grade(stats, setup) {
|
|
|
314
360
|
});
|
|
315
361
|
|
|
316
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.
|
|
317
366
|
if (human.sessions) {
|
|
318
367
|
let hScore = 50;
|
|
319
|
-
hScore += Math.round(human.titledPct * 0.
|
|
320
|
-
|
|
321
|
-
|
|
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;
|
|
322
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');
|
|
323
380
|
dims.push({
|
|
324
381
|
name: 'Project hygiene (human)',
|
|
325
382
|
score: hScore,
|
|
326
|
-
detail: `${
|
|
327
|
-
fix: human.titledPct <
|
|
328
|
-
? '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.'
|
|
329
386
|
: null,
|
|
330
387
|
});
|
|
331
388
|
}
|
|
@@ -424,9 +481,13 @@ function renderCard(stats, setup, graded) {
|
|
|
424
481
|
if (stats.agent) {
|
|
425
482
|
pr(` ${C.dim}Agent sessions:${C.reset} ${stats.agent.sessions} sessions, ${(stats.agent.outputTokens / 1e6).toFixed(2)}M output tokens`);
|
|
426
483
|
}
|
|
427
|
-
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/)`);
|
|
428
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}`);
|
|
429
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
|
+
}
|
|
430
491
|
|
|
431
492
|
pr();
|
|
432
493
|
pr(` ${C.dim}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C.reset}`);
|
|
@@ -453,8 +514,24 @@ const files = projectsDirs.flatMap(d => findJsonl(d, cutoffMs));
|
|
|
453
514
|
const sessions = files.map(f => parseSession(f, cutoffMs)).filter(Boolean);
|
|
454
515
|
|
|
455
516
|
if (!sessions.length) {
|
|
456
|
-
pr(
|
|
457
|
-
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();
|
|
458
535
|
process.exit(0);
|
|
459
536
|
}
|
|
460
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": {
|