@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.
Files changed (2) hide show
  1. package/index.js +118 -41
  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,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.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
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: `${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.'
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.preToolUseHooks + setup.postToolUseHooks + setup.userPromptSubmitHooks + setup.stopHooks + setup.subagentStopHooks + setup.notificationHooks} across all event types (${setup.hookFiles.length} hook file(s) in ~/.claude/hooks/)`);
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(`No Claude Code sessions found in the last ${days} days.`);
457
- pr(`Looked in: ${projectsDirs.join(', ') || CLAUDE_DIRS.join(', ')}`);
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.1",
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": {