@uxcontinuum/ccaudit 1.0.1 → 1.0.3

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.
Files changed (2) hide show
  1. package/index.js +204 -51
  2. 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 worktrees use either UUIDv4 names (orchestrator-spawned) or ULID-style
48
- // hex suffixes appended to a path. Human dirs are word-segmented.
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 isAgentProjectDir(name) {
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: isAgentProjectDir(projDir),
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
- preToolUseHooks: 0,
146
- postToolUseHooks: 0,
147
- userPromptSubmitHooks: 0,
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
- const count = (entries) => (entries || []).reduce((n, e) => n + (e.hooks || []).length, 0);
167
- out.preToolUseHooks = count(hooks.PreToolUse);
168
- out.postToolUseHooks = count(hooks.PostToolUse);
169
- out.userPromptSubmitHooks = count(hooks.UserPromptSubmit);
170
- out.stopHooks = count(hooks.Stop);
171
- out.subagentStopHooks = count(hooks.SubagentStop);
172
- out.notificationHooks = count(hooks.Notification);
173
- out.autoMemoryEnabled = s.autoMemoryEnabled === true;
174
- } catch (_) {}
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. Lives at the setup layer, applies to everyone.
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
- setup.preToolUseHooks ? `${setup.preToolUseHooks} PreToolUse` : null,
298
- setup.postToolUseHooks ? `${setup.postToolUseHooks} PostToolUse` : null,
299
- setup.userPromptSubmitHooks ? `${setup.userPromptSubmitHooks} UserPromptSubmit` : null,
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,37 +360,68 @@ 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. Long prompts are a tell only when CWD
366
+ // count is low (single project + walls of text = unscoped sprawl).
317
367
  if (human.sessions) {
318
368
  let hScore = 50;
319
- hScore += Math.round(human.titledPct * 0.5); // titled sessions help up to +50
320
- if (human.avgPromptLen > 0 && human.avgPromptLen < 80) hScore -= 10; // too terse
321
- if (human.avgPromptLen > 1500) hScore -= 8; // walls of text
369
+ hScore += Math.round(human.titledPct * 0.35); // formal title bonus
370
+ hScore += Math.round(human.sluggedPct * 0.15); // slug bonus
371
+ // CWD diversity: launching from named project dirs is real hygiene.
372
+ if (human.uniqueCwds >= 3) hScore += 10;
373
+ if (human.uniqueCwds >= 10) hScore += 10;
374
+ if (human.uniqueCwds >= 25) hScore += 6;
375
+ if (human.avgPromptLen > 0 && human.avgPromptLen < 80) hScore -= 6;
376
+ // Walls of text are only a problem when work is unscoped.
377
+ if (human.avgPromptLen > 2500 && human.uniqueCwds < 5) hScore -= 8;
322
378
  hScore = Math.max(0, Math.min(100, hScore));
379
+ const titleNote = human.titledPct > 0
380
+ ? `${human.titledPct}% titled`
381
+ : (human.sluggedPct > 0 ? `${human.sluggedPct}% have auto-slugs` : 'no titles, no slugs');
323
382
  dims.push({
324
383
  name: 'Project hygiene (human)',
325
384
  score: hScore,
326
- detail: `${human.titledPct}% of your human sessions are titled. Avg prompt: ${human.avgPromptLen} chars.`,
327
- fix: human.titledPct < 30
328
- ? 'Title your sessions. Untitled sessions are unsearchable history.'
385
+ detail: `${titleNote}, launched from ${human.uniqueCwds} distinct working dirs. Avg prompt: ${human.avgPromptLen} chars.`,
386
+ fix: (human.titledPct < 20 && human.uniqueCwds < 5)
387
+ ? 'Title your important sessions, or launch from project dirs so each session is scoped.'
329
388
  : null,
330
389
  });
331
390
  }
332
391
 
333
- // 3. Tool balance (human sessions only).
392
+ // 3. Tool balance (human sessions only). Adaptive: don't punish Bash
393
+ // dominance if absolute Edit volume is high, because that's "busy operator"
394
+ // not "bash hammer." Only penalize when Edit absolute volume is also low.
334
395
  if (human.sessions && human.totalTools > 0) {
335
396
  let bScore = 75;
336
- if (human.bashPct > 65) bScore -= 18; // bash hammer
337
- if (human.readPct + human.grepPct + human.editPct < 15) bScore -= 12;
338
- if (human.editPct > 10 && human.editPct < 55) bScore += 8; // healthy editing
339
- if (human.agentPct > 2) bScore += 7; // delegates work
397
+ const editAbs = Math.round(human.editPct * human.totalTools / 100);
398
+ const readAbs = Math.round(human.readPct * human.totalTools / 100);
399
+
400
+ // Bash dominance penalty scales with how thin the rest of the toolkit is.
401
+ if (human.bashPct > 65) {
402
+ if (editAbs > 500) bScore -= 6; // big absolute Edit volume — busy operator
403
+ else if (editAbs > 100) bScore -= 12;
404
+ else bScore -= 18; // truly a bash hammer
405
+ } else if (human.bashPct > 50) {
406
+ bScore -= 4;
407
+ }
408
+
409
+ if (human.readPct + human.grepPct + human.editPct < 15) bScore -= 10;
410
+ if (human.editPct > 10 && human.editPct < 55) bScore += 8;
411
+ if (human.agentPct > 2) bScore += 7;
412
+ if (human.agentPct > 5) bScore += 5;
413
+
340
414
  bScore = Math.max(0, Math.min(100, bScore));
415
+
416
+ const fix = human.bashPct > 65 && editAbs < 200
417
+ ? 'You are running things, not editing things. Use Edit/Write more.'
418
+ : null;
419
+
341
420
  dims.push({
342
421
  name: 'Tool balance (human)',
343
422
  score: bScore,
344
- detail: `Bash ${human.bashPct}%, Edit+Write ${human.editPct}%, Read ${human.readPct}%, Grep+Glob ${human.grepPct}%, Agent/Task ${human.agentPct}%.`,
345
- fix: human.bashPct > 65
346
- ? 'You are running things, not editing things. Use Edit/Write more.'
347
- : null,
423
+ detail: `Bash ${human.bashPct}%, Edit+Write ${human.editPct}% (${editAbs} calls), Read ${human.readPct}%, Grep+Glob ${human.grepPct}%, Agent/Task ${human.agentPct}%.`,
424
+ fix,
348
425
  });
349
426
  }
350
427
 
@@ -424,9 +501,13 @@ function renderCard(stats, setup, graded) {
424
501
  if (stats.agent) {
425
502
  pr(` ${C.dim}Agent sessions:${C.reset} ${stats.agent.sessions} sessions, ${(stats.agent.outputTokens / 1e6).toFixed(2)}M output tokens`);
426
503
  }
427
- pr(` ${C.dim}Hooks installed:${C.reset} ${setup.preToolUseHooks + setup.postToolUseHooks + setup.userPromptSubmitHooks + setup.stopHooks + setup.subagentStopHooks + setup.notificationHooks} across all event types (${setup.hookFiles.length} hook file(s) in ~/.claude/hooks/)`);
504
+ 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
505
  pr(` ${C.dim}CLAUDE.md:${C.reset} ${setup.hasClaudeMd ? `${setup.claudeMdBytes} bytes` : 'not found'}`);
506
+ pr(` ${C.dim}MCP servers:${C.reset} ${setup.mcpServers}`);
429
507
  pr(` ${C.dim}Skills installed:${C.reset} ${setup.skillCount}`);
508
+ if (setup.settingsParseError) {
509
+ pr(` ${C.yellow}settings.json parse error:${C.reset} ${setup.settingsParseError}`);
510
+ }
430
511
 
431
512
  pr();
432
513
  pr(` ${C.dim}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C.reset}`);
@@ -453,8 +534,24 @@ const files = projectsDirs.flatMap(d => findJsonl(d, cutoffMs));
453
534
  const sessions = files.map(f => parseSession(f, cutoffMs)).filter(Boolean);
454
535
 
455
536
  if (!sessions.length) {
456
- pr(`No Claude Code sessions found in the last ${days} days.`);
457
- pr(`Looked in: ${projectsDirs.join(', ') || CLAUDE_DIRS.join(', ')}`);
537
+ pr();
538
+ pr(` ${C.bold}ccaudit${C.reset}: no Claude Code session activity in the last ${days} days.`);
539
+ if (!projectsDirs.length) {
540
+ pr(` ${C.dim}No ~/.claude/projects/ directory found. Looked in: ${CLAUDE_DIRS.join(', ')}${C.reset}`);
541
+ 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}`);
542
+ } else {
543
+ pr(` ${C.dim}Scanned: ${projectsDirs.join(', ')}${C.reset}`);
544
+ pr(` ${C.dim}Try --days 90 or --days 365 to widen the window.${C.reset}`);
545
+ }
546
+ // Still surface the setup audit even with no sessions, so brand-new users get value.
547
+ const setupOnly = inspectClaudeDir(CLAUDE_DIRS[0]);
548
+ pr();
549
+ pr(` ${C.bold}Setup snapshot:${C.reset}`);
550
+ pr(` Hooks: ${setupOnly.totalHooks} (${Object.keys(setupOnly.hooksByEvent).join(', ') || 'none'})`);
551
+ pr(` CLAUDE.md: ${setupOnly.hasClaudeMd ? `${setupOnly.claudeMdBytes} bytes` : 'not found'}`);
552
+ pr(` MCP servers: ${setupOnly.mcpServers}`);
553
+ pr(` Skills: ${setupOnly.skillCount}`);
554
+ pr();
458
555
  process.exit(0);
459
556
  }
460
557
 
@@ -462,4 +559,60 @@ const stats = aggregate(sessions);
462
559
  const setup = inspectClaudeDir(CLAUDE_DIRS[0]);
463
560
  const graded = grade(stats, setup);
464
561
 
465
- renderCard(stats, setup, graded);
562
+ if (hasFlag('--json')) {
563
+ // Programmatic output. Stable shape for downstream tools and the future
564
+ // public-benchmark backend. No personal content (prompts, slugs, CWDs).
565
+ const payload = {
566
+ schema: 'ccaudit/1',
567
+ generated_at: new Date().toISOString(),
568
+ window_days: days,
569
+ overall: { score: graded.overall, letter: graded.letter },
570
+ dimensions: graded.dims.map(d => ({
571
+ name: d.name,
572
+ score: d.score,
573
+ letter: letterFor(d.score),
574
+ detail: d.detail,
575
+ fix: d.fix,
576
+ })),
577
+ setup: {
578
+ total_hooks: setup.totalHooks,
579
+ hooks_by_event: setup.hooksByEvent,
580
+ auto_memory_enabled: setup.autoMemoryEnabled,
581
+ mcp_servers: setup.mcpServers,
582
+ hook_files: setup.hookFiles.length,
583
+ has_claude_md: setup.hasClaudeMd,
584
+ claude_md_bytes: setup.claudeMdBytes,
585
+ skills_installed: setup.skillCount,
586
+ settings_parse_error: setup.settingsParseError,
587
+ },
588
+ human: stats.human ? {
589
+ sessions: stats.human.sessions,
590
+ prompts: stats.human.prompts,
591
+ titled_pct: stats.human.titledPct,
592
+ slugged_pct: stats.human.sluggedPct,
593
+ unique_cwds: stats.human.uniqueCwds,
594
+ avg_prompt_len: stats.human.avgPromptLen,
595
+ just_count: stats.human.justCount,
596
+ please_count: stats.human.pleaseCount,
597
+ total_tools: stats.human.totalTools,
598
+ tool_distribution: {
599
+ bash_pct: stats.human.bashPct,
600
+ edit_write_pct: stats.human.editPct,
601
+ read_pct: stats.human.readPct,
602
+ grep_glob_pct: stats.human.grepPct,
603
+ agent_task_pct: stats.human.agentPct,
604
+ },
605
+ output_tokens: stats.human.outputTokens,
606
+ input_tokens: stats.human.inputTokens,
607
+ } : null,
608
+ agent: stats.agent ? {
609
+ sessions: stats.agent.sessions,
610
+ prompts: stats.agent.prompts,
611
+ output_tokens: stats.agent.outputTokens,
612
+ input_tokens: stats.agent.inputTokens,
613
+ } : null,
614
+ };
615
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
616
+ } else {
617
+ renderCard(stats, setup, graded);
618
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uxcontinuum/ccaudit",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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": {