@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.
Files changed (2) hide show
  1. package/index.js +126 -28
  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,9 +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,
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
- const count = (entries) => (entries || []).reduce((n, e) => n + (e.hooks || []).length, 0);
163
- out.preToolUseHooks = count(hooks.PreToolUse);
164
- out.postToolUseHooks = count(hooks.PostToolUse);
165
- out.userPromptSubmitHooks = count(hooks.UserPromptSubmit);
166
- } 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
+ }
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. Lives at the setup layer, applies to everyone.
278
- const hookSignals = setup.preToolUseHooks + setup.postToolUseHooks + setup.userPromptSubmitHooks;
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 = 65;
342
+ else if (hookSignals === 1) hookScore = 68;
282
343
  else if (hookSignals === 2) hookScore = 82;
283
- else hookScore = Math.min(100, 88 + hookSignals * 2);
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 PreToolUse, PostToolUse, or UserPromptSubmit hooks installed. Anything could happen overnight.'
289
- : `${setup.preToolUseHooks} PreToolUse, ${setup.postToolUseHooks} PostToolUse, ${setup.userPromptSubmitHooks} UserPromptSubmit hook(s) installed.`,
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: npm i @uxcontinuum/claude-loop-sentinel'
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.5); // titled sessions help up to +50
299
- if (human.avgPromptLen > 0 && human.avgPromptLen < 80) hScore -= 10; // too terse
300
- 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;
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: `${human.titledPct}% of your human sessions are titled. Avg prompt: ${human.avgPromptLen} chars.`,
306
- fix: human.titledPct < 30
307
- ? '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.'
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.preToolUseHooks + setup.postToolUseHooks + setup.userPromptSubmitHooks} (${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/)`);
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(`No Claude Code sessions found in the last ${days} days.`);
436
- 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();
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.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": {