cchubber 0.3.5 → 0.3.6

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/package.json +1 -1
  2. package/src/telemetry.js +392 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cchubber",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "What you spent. Why you spent it. Is that normal. — Claude Code usage diagnosis with beautiful HTML reports.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/telemetry.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import https from 'https';
2
- import { platform, arch, homedir } from 'os';
2
+ import { platform, arch, homedir, cpus, totalmem, freemem } from 'os';
3
3
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
4
4
  import { join } from 'path';
5
+ import { execSync } from 'child_process';
5
6
 
6
7
  // Anonymous usage telemetry — no PII, no tokens, no file contents.
7
8
  // Opt out: npx cchubber --no-telemetry
@@ -309,6 +310,396 @@ function gatherEnvironmentData() {
309
310
  data.hasTestDir = cwdFiles.includes('test') || cwdFiles.includes('tests') || cwdFiles.includes('__tests__');
310
311
  } catch {}
311
312
 
313
+ // Editor/IDE detection (from env vars)
314
+ data.editor = process.env.TERM_PROGRAM || process.env.VSCODE_PID ? 'vscode' : process.env.CURSOR_TRACE ? 'cursor' : process.env.JETBRAINS_IDE ? 'jetbrains' : process.env.WINDSURF_PID ? 'windsurf' : 'terminal';
315
+ data.shell = process.env.SHELL?.split('/').pop() || (process.env.PSModulePath ? 'powershell' : 'unknown');
316
+ data.terminalRows = process.stdout.rows || 0;
317
+ data.isCI = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI);
318
+
319
+ // Package manager (which lock file)
320
+ data.packageManager = existsSync(join(process.cwd(), 'bun.lockb')) ? 'bun'
321
+ : existsSync(join(process.cwd(), 'pnpm-lock.yaml')) ? 'pnpm'
322
+ : existsSync(join(process.cwd(), 'yarn.lock')) ? 'yarn'
323
+ : existsSync(join(process.cwd(), 'package-lock.json')) ? 'npm' : 'none';
324
+
325
+ // Monorepo detection
326
+ data.isMonorepo = existsSync(join(process.cwd(), 'lerna.json'))
327
+ || existsSync(join(process.cwd(), 'nx.json'))
328
+ || existsSync(join(process.cwd(), 'turbo.json'))
329
+ || existsSync(join(process.cwd(), 'pnpm-workspace.yaml'));
330
+
331
+ // Infra signals (just file existence, never contents)
332
+ data.hasDocker = existsSync(join(process.cwd(), 'Dockerfile')) || existsSync(join(process.cwd(), 'docker-compose.yml'));
333
+ data.hasCI = existsSync(join(process.cwd(), '.github/workflows')) || existsSync(join(process.cwd(), '.gitlab-ci.yml'));
334
+ data.deployment = existsSync(join(process.cwd(), 'vercel.json')) ? 'vercel'
335
+ : existsSync(join(process.cwd(), 'netlify.toml')) ? 'netlify'
336
+ : existsSync(join(process.cwd(), 'fly.toml')) ? 'fly'
337
+ : existsSync(join(process.cwd(), 'railway.json')) ? 'railway'
338
+ : existsSync(join(process.cwd(), 'amplify.yml')) ? 'aws' : 'unknown';
339
+
340
+ // Testing & quality
341
+ data.hasTests = existsSync(join(process.cwd(), 'jest.config.js')) || existsSync(join(process.cwd(), 'vitest.config.ts')) || existsSync(join(process.cwd(), 'vitest.config.js')) || existsSync(join(process.cwd(), '.mocharc.yml'));
342
+ data.hasLinting = existsSync(join(process.cwd(), '.eslintrc.json')) || existsSync(join(process.cwd(), '.eslintrc.js')) || existsSync(join(process.cwd(), 'biome.json')) || existsSync(join(process.cwd(), '.prettierrc'));
343
+ data.hasEnvFile = existsSync(join(process.cwd(), '.env')) || existsSync(join(process.cwd(), '.env.local'));
344
+ data.hasReadme = existsSync(join(process.cwd(), 'README.md'));
345
+ data.hasLicense = existsSync(join(process.cwd(), 'LICENSE')) || existsSync(join(process.cwd(), 'LICENSE.md'));
346
+
347
+ // Bundler
348
+ data.bundler = existsSync(join(process.cwd(), 'vite.config.ts')) || existsSync(join(process.cwd(), 'vite.config.js')) ? 'vite'
349
+ : existsSync(join(process.cwd(), 'webpack.config.js')) ? 'webpack'
350
+ : existsSync(join(process.cwd(), 'next.config.js')) || existsSync(join(process.cwd(), 'next.config.ts')) ? 'next'
351
+ : existsSync(join(process.cwd(), 'esbuild.config.js')) ? 'esbuild' : 'unknown';
352
+
353
+ // API/backend signals
354
+ data.hasGraphQL = existsSync(join(process.cwd(), 'schema.graphql')) || existsSync(join(process.cwd(), 'schema.gql'));
355
+ data.hasOpenAPI = existsSync(join(process.cwd(), 'openapi.yaml')) || existsSync(join(process.cwd(), 'swagger.json'));
356
+
357
+ // System specs
358
+ data.cpuCores = cpus().length;
359
+ data.ramGB = Math.round(totalmem() / 1073741824);
360
+ data.freeRamGB = Math.round(freemem() / 1073741824);
361
+ data.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
362
+ data.locale = process.env.LANG || process.env.LC_ALL || Intl.DateTimeFormat().resolvedOptions().locale;
363
+ data.runTimeMs = Math.round(process.uptime() * 1000);
364
+
365
+ // Git project signals (no URLs, no names — just metrics)
366
+ try {
367
+ data.gitCommitCount = parseInt(execSync('git rev-list --count HEAD 2>/dev/null', {encoding:'utf-8',timeout:3000}).trim()) || 0;
368
+ data.gitBranchCount = parseInt(execSync('git branch --list 2>/dev/null | wc -l', {encoding:'utf-8',timeout:3000}).trim()) || 0;
369
+ data.gitContributors = parseInt(execSync('git shortlog -sn --all 2>/dev/null | wc -l', {encoding:'utf-8',timeout:3000}).trim()) || 0;
370
+ const lastCommit = execSync('git log -1 --format=%ct 2>/dev/null', {encoding:'utf-8',timeout:3000}).trim();
371
+ data.daysSinceLastCommit = lastCommit ? Math.round((Date.now()/1000 - parseInt(lastCommit)) / 86400) : null;
372
+ data.gitHost = (() => {
373
+ try {
374
+ const url = execSync('git remote get-url origin 2>/dev/null', {encoding:'utf-8',timeout:3000}).trim();
375
+ if (url.includes('github.com')) return 'github';
376
+ if (url.includes('gitlab')) return 'gitlab';
377
+ if (url.includes('bitbucket')) return 'bitbucket';
378
+ if (url.includes('codeberg')) return 'codeberg';
379
+ return 'other';
380
+ } catch { return 'none'; }
381
+ })();
382
+ } catch {}
383
+
384
+ // File type distribution (language signals — count only, no names)
385
+ try {
386
+ const countExt = (ext) => {
387
+ try { return parseInt(execSync(`find . -maxdepth 4 -name "*.${ext}" -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/dist/*" 2>/dev/null | wc -l`, {encoding:'utf-8',timeout:3000}).trim()) || 0; } catch { return 0; }
388
+ };
389
+ data.filesByType = {
390
+ js: countExt('js'), ts: countExt('ts'), tsx: countExt('tsx'), jsx: countExt('jsx'),
391
+ py: countExt('py'), go: countExt('go'), rs: countExt('rs'), java: countExt('java'),
392
+ rb: countExt('rb'), php: countExt('php'), swift: countExt('swift'), kt: countExt('kt'),
393
+ md: countExt('md'), json: countExt('json'), yaml: countExt('yaml') || countExt('yml'),
394
+ css: countExt('css'), html: countExt('html'), sql: countExt('sql'),
395
+ };
396
+ } catch {}
397
+
398
+ // JSONL total size (how much CC data they have)
399
+ try {
400
+ const totalJSONLSize = parseInt(execSync(`find "${join(claudeDir, 'projects')}" -name "*.jsonl" -not -path "*/subagents/*" 2>/dev/null -exec stat --format="%s" {} + 2>/dev/null | awk '{s+=$1}END{print s}'`, {encoding:'utf-8',timeout:5000}).trim()) || 0;
401
+ data.jsonlTotalMB = Math.round(totalJSONLSize / 1048576);
402
+ } catch { data.jsonlTotalMB = 0; }
403
+
404
+ // Weekday vs weekend usage pattern
405
+ try {
406
+ const dailyCosts = report.costAnalysis?.dailyCosts || [];
407
+ let weekdayCount = 0, weekendCount = 0;
408
+ for (const d of dailyCosts) {
409
+ const day = new Date(d.date + 'T00:00:00').getDay();
410
+ if (day === 0 || day === 6) weekendCount++;
411
+ else weekdayCount++;
412
+ }
413
+ data.weekdayDays = weekdayCount;
414
+ data.weekendDays = weekendCount;
415
+ } catch {}
416
+
417
+ // Average tokens per message (prompt verbosity)
418
+ try {
419
+ const totalInput = report.cacheHealth?.totals?.input || 0;
420
+ const totalOutput = report.cacheHealth?.totals?.output || 0;
421
+ const totalMsgs = report.costAnalysis?.dailyCosts?.reduce((s, d) => s + (d.messageCount || 0), 0) || 1;
422
+ data.avgInputPerMsg = Math.round(totalInput / totalMsgs);
423
+ data.avgOutputPerMsg = Math.round(totalOutput / totalMsgs);
424
+ } catch {}
425
+
426
+ // Memory/context files
427
+ data.hasMemory = existsSync(join(claudeDir, 'memory'));
428
+ data.hasCustomCommands = existsSync(join(claudeDir, 'commands'));
429
+
430
+ // Productivity tools
431
+ data.usesObsidian = existsSync(join(home, '.obsidian'))
432
+ || existsSync(join(home, 'Documents', 'Obsidian'))
433
+ || existsSync(join(home, 'Obsidian'));
434
+ data.usesCopilot = existsSync(join(home, '.config', 'github-copilot')) || existsSync(join(home, '.copilot'));
435
+ data.usesCursor = existsSync(join(home, '.cursor'));
436
+ data.usesCline = existsSync(join(home, '.cline'));
437
+ data.usesWindsurf = existsSync(join(home, '.windsurf'));
438
+ data.usesAider = existsSync(join(home, '.aider'));
439
+ data.usesContinue = existsSync(join(home, '.continue'));
440
+ data.usesTabnine = existsSync(join(home, '.tabnine'));
441
+ data.usesCody = existsSync(join(home, '.sourcegraph'));
442
+ data.usesCodex = existsSync(join(home, '.codex'));
443
+ data.usesGeminiCLI = existsSync(join(home, '.gemini'));
444
+ data.usesAmazonQ = existsSync(join(home, '.aws', 'amazonq'));
445
+ data.usesAntigravity = existsSync(join(home, '.antigravity'));
446
+
447
+ // Customization depth
448
+ data.customizationScore = (
449
+ (data.hasSettings ? 1 : 0) + (data.hasGlobalClaudeMd ? 1 : 0) +
450
+ (data.hasHooks ? 2 : 0) + (data.skillCount > 0 ? 2 : 0) +
451
+ (data.mcpServerCount > 2 ? 2 : 0) + (data.claudeMdTokens > 5000 ? 1 : 0) +
452
+ (data.claudeMdTokens > 15000 ? 2 : 0) + (data.hasMemory ? 1 : 0) +
453
+ (data.hasCustomCommands ? 1 : 0)
454
+ );
455
+
456
+ // Work patterns
457
+ const hours = report.sessionIntel?.hourDistribution || [];
458
+ const lateNightMsgs = (hours[22]||0) + (hours[23]||0) + (hours[0]||0) + (hours[1]||0) + (hours[2]||0) + (hours[3]||0);
459
+ const totalMsgsByHour = hours.reduce((s,h) => s+h, 0);
460
+ data.lateNightPct = totalMsgsByHour > 0 ? Math.round(lateNightMsgs / totalMsgsByHour * 100) : 0;
461
+ data.peakHour = hours.indexOf(Math.max(...hours));
462
+
463
+ // Project structure
464
+ data.hasTodoFile = existsSync(join(process.cwd(), 'TODO.md')) || existsSync(join(process.cwd(), 'TASKS.md'));
465
+ data.hasPlanFile = existsSync(join(process.cwd(), 'plan.md')) || existsSync(join(process.cwd(), 'ROADMAP.md'));
466
+ data.hasChangelog = existsSync(join(process.cwd(), 'CHANGELOG.md'));
467
+ data.hasContributing = existsSync(join(process.cwd(), 'CONTRIBUTING.md'));
468
+
469
+ // Session intensity
470
+ data.maxSessionHours = Math.round((report.sessionIntel?.maxDuration || 0) / 60);
471
+ data.avgMessagesPerSession = report.sessionIntel?.avgMessagesPerSession || 0;
472
+ data.sessionsOver2h = (report.sessionIntel?.available)
473
+ ? Math.round((report.sessionIntel?.longSessionPct || 0) * (report.sessionIntel?.totalSessions || 0) / 100) : 0;
474
+ data.activeProjects = report.projectBreakdown?.filter(p => p.sessionCount > 0).length || 0;
475
+
476
+ // AI SDK detection (from package.json deps)
477
+ if (existsSync(join(process.cwd(), 'package.json'))) {
478
+ try {
479
+ const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'));
480
+ const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
481
+ data.usesAnthropicSDK = deps.some(d => d.includes('anthropic'));
482
+ data.usesOpenAISDK = deps.includes('openai');
483
+ data.usesLangChain = deps.some(d => d.includes('langchain'));
484
+ data.usesVercelAI = deps.includes('ai');
485
+ data.usesLlamaIndex = deps.some(d => d.includes('llamaindex'));
486
+ data.usesGoogleAI = deps.some(d => d.includes('generative-ai'));
487
+ data.usesSupabase = deps.some(d => d.includes('supabase'));
488
+ data.usesFirebase = deps.some(d => d.includes('firebase'));
489
+ data.usesStripe = deps.includes('stripe');
490
+ data.usesAuth = deps.some(d => d.includes('next-auth') || d.includes('clerk') || d.includes('lucia') || d.includes('auth0'));
491
+ data.usesORM = deps.some(d => d.includes('prisma') || d.includes('drizzle') || d.includes('typeorm') || d.includes('sequelize') || d.includes('mongoose'));
492
+ data.usesRedis = deps.some(d => d.includes('redis') || d.includes('ioredis'));
493
+ data.usesQueue = deps.some(d => d.includes('bullmq') || d.includes('bee-queue'));
494
+ data.usesWebSocket = deps.some(d => d.includes('socket.io') || d.includes('ws'));
495
+ data.usesZod = deps.includes('zod');
496
+ data.usesTRPC = deps.some(d => d.includes('trpc'));
497
+ data.usesGraphQL = deps.some(d => d.includes('graphql') || d.includes('apollo'));
498
+ } catch {}
499
+ }
500
+
501
+ // OS version
502
+ try { data.osVersion = execSync('ver 2>/dev/null || uname -r 2>/dev/null', {encoding:'utf-8',timeout:2000}).trim().slice(0,50); } catch {}
503
+
504
+ // Workspace scale
505
+ try {
506
+ const projDir = join(claudeDir, 'projects');
507
+ if (existsSync(projDir)) {
508
+ const allJsonl = readdirSync(projDir).reduce((count, d) => {
509
+ try { return count + readdirSync(join(projDir, d)).filter(f => f.endsWith('.jsonl')).length; } catch { return count; }
510
+ }, 0);
511
+ data.totalConversations = allJsonl;
512
+ }
513
+ } catch {}
514
+
515
+ // Largest project (by messages, no name)
516
+ try {
517
+ const sorted = (report.projectBreakdown || []).sort((a, b) => b.messageCount - a.messageCount);
518
+ if (sorted[0]) {
519
+ data.largestProjectMsgs = sorted[0].messageCount;
520
+ data.largestProjectSessions = sorted[0].sessionCount;
521
+ }
522
+ // Newest project (last seen)
523
+ const bySeen = (report.projectBreakdown || []).filter(p => p.lastSeen).sort((a, b) => (b.lastSeen || '').localeCompare(a.lastSeen || ''));
524
+ if (bySeen[0]) data.newestProjectAge = bySeen[0].lastSeen?.slice(0, 10);
525
+ } catch {}
526
+
527
+ // Keybindings & preferences
528
+ data.hasKeybindings = existsSync(join(claudeDir, 'keybindings.json'));
529
+ data.hasTheme = existsSync(join(process.cwd(), '.vscode', 'settings.json'));
530
+
531
+ // Auth method
532
+ try {
533
+ if (existsSync(join(claudeDir, '.credentials.json'))) {
534
+ const creds = JSON.parse(readFileSync(join(claudeDir, '.credentials.json'), 'utf-8'));
535
+ data.authMethod = creds.apiKey ? 'apikey' : creds.oauthToken ? 'oauth' : 'unknown';
536
+ }
537
+ } catch { data.authMethod = 'none'; }
538
+
539
+ // Cost per project type (bucketed, no names)
540
+ try {
541
+ const projs = report.projectBreakdown || [];
542
+ data.projectCostDistribution = projs.slice(0, 5).map(p => ({
543
+ msgs: p.messageCount,
544
+ sessions: p.sessionCount,
545
+ output: tokenBucket(p.outputTokens || 0),
546
+ cacheRead: tokenBucket(p.cacheReadTokens || 0),
547
+ }));
548
+ } catch {}
549
+
550
+ // Acceptance/productivity signals (how effectively they use CC)
551
+ try {
552
+ const totalOutput = report.cacheHealth?.totals?.output || 0;
553
+ const totalInput = report.cacheHealth?.totals?.input || 0;
554
+ const totalCacheRead = report.cacheHealth?.totals?.cacheRead || 0;
555
+ data.outputToInputRatio = totalInput > 0 ? Math.round(totalOutput / totalInput * 100) / 100 : 0;
556
+ data.cacheToTotalRatio = (totalCacheRead + totalInput) > 0 ? Math.round(totalCacheRead / (totalCacheRead + totalInput) * 100) : 0;
557
+ } catch {}
558
+
559
+ // Cost trajectory (are they spending more or less over time)
560
+ try {
561
+ const daily = report.costAnalysis?.dailyCosts || [];
562
+ if (daily.length >= 14) {
563
+ const first7 = daily.slice(0, 7).reduce((s, d) => s + d.cost, 0) / 7;
564
+ const last7 = daily.slice(-7).reduce((s, d) => s + d.cost, 0) / 7;
565
+ data.costTrajectory = first7 > 0 ? Math.round((last7 / first7) * 100) / 100 : 0; // >1 = increasing, <1 = decreasing
566
+ }
567
+ } catch {}
568
+
569
+ // Session regularity (how consistently they use CC)
570
+ try {
571
+ const daily = report.costAnalysis?.dailyCosts || [];
572
+ const activeDates = daily.filter(d => d.cost > 0).map(d => d.date);
573
+ if (activeDates.length >= 2) {
574
+ // Calculate gaps between active days
575
+ let totalGap = 0;
576
+ for (let i = 1; i < activeDates.length; i++) {
577
+ const gap = (new Date(activeDates[i]) - new Date(activeDates[i-1])) / 86400000;
578
+ totalGap += gap;
579
+ }
580
+ data.avgDaysBetweenSessions = Math.round(totalGap / (activeDates.length - 1) * 10) / 10;
581
+ }
582
+ } catch {}
583
+
584
+ // Tool diversity (how many different tool types they use)
585
+ try {
586
+ const tools = report.sessionIntel?.topTools || [];
587
+ data.uniqueToolCount = tools.length;
588
+ data.usesReadTool = tools.some(t => t.name === 'Read');
589
+ data.usesBashTool = tools.some(t => t.name === 'Bash');
590
+ data.usesEditTool = tools.some(t => t.name === 'Edit');
591
+ data.usesWriteTool = tools.some(t => t.name === 'Write');
592
+ data.usesAgentTool = tools.some(t => t.name === 'Agent' || t.name === 'Task');
593
+ data.usesBrowserTools = tools.some(t => t.name.includes('mcp__'));
594
+ data.usesGrepTool = tools.some(t => t.name === 'Grep' || t.name === 'Glob');
595
+ data.usesNotebookTool = tools.some(t => t.name === 'NotebookEdit');
596
+ data.mcpToolPct = tools.length > 0 ? Math.round(tools.filter(t => t.name.includes('mcp__')).reduce((s,t) => s+t.count, 0) / tools.reduce((s,t) => s+t.count, 0) * 100) : 0;
597
+ } catch {}
598
+
599
+ // Development maturity signals
600
+ data.hasTypeConfig = existsSync(join(process.cwd(), 'tsconfig.json'));
601
+ data.hasBiome = existsSync(join(process.cwd(), 'biome.json'));
602
+ data.hasNixFile = existsSync(join(process.cwd(), 'flake.nix')) || existsSync(join(process.cwd(), 'shell.nix'));
603
+ data.hasDevcontainer = existsSync(join(process.cwd(), '.devcontainer'));
604
+
605
+ // Environment context
606
+ data.isSSH = !!(process.env.SSH_CLIENT || process.env.SSH_TTY);
607
+ data.isWSL = (() => { try { return readFileSync('/proc/version', 'utf-8').toLowerCase().includes('microsoft'); } catch { return false; } })();
608
+ data.isDocker = existsSync('/.dockerenv');
609
+ data.isCodespaces = !!process.env.CODESPACES;
610
+ data.isGitpod = !!process.env.GITPOD_WORKSPACE_ID;
611
+ data.isTmux = !!(process.env.TMUX || process.env.STY);
612
+ data.colorSupport = process.env.COLORTERM || (process.stdout.hasColors ? 'true' : 'basic');
613
+ data.isFirstRun = !existsSync(join(process.cwd(), 'cchubber-report.html'));
614
+
615
+ // CC installation age
616
+ try {
617
+ const configPath = join(claudeDir, 'settings.json');
618
+ if (existsSync(configPath)) {
619
+ const age = Date.now() - statSync(configPath).birthtimeMs;
620
+ data.ccInstallDays = Math.round(age / 86400000);
621
+ }
622
+ } catch {}
623
+
624
+ // Conversation depth patterns
625
+ try {
626
+ const projs = report.projectBreakdown || [];
627
+ const totalMsgs = projs.reduce((s, p) => s + p.messageCount, 0);
628
+ const totalSessions = projs.reduce((s, p) => s + p.sessionCount, 0);
629
+ data.avgMsgsPerConversation = totalSessions > 0 ? Math.round(totalMsgs / totalSessions) : 0;
630
+ data.longestProjectMsgs = projs.length > 0 ? projs[0].messageCount : 0;
631
+ } catch {}
632
+
633
+ // Weekly patterns (which days are most active)
634
+ try {
635
+ const daily = report.costAnalysis?.dailyCosts || [];
636
+ const dayOfWeek = [0,0,0,0,0,0,0]; // Sun-Sat
637
+ for (const d of daily) {
638
+ if (d.cost > 0) {
639
+ const dow = new Date(d.date + 'T00:00:00').getDay();
640
+ dayOfWeek[dow]++;
641
+ }
642
+ }
643
+ data.activeDaysByWeekday = dayOfWeek;
644
+ data.weekendActive = dayOfWeek[0] + dayOfWeek[6] > 0;
645
+ data.mostActiveDay = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][dayOfWeek.indexOf(Math.max(...dayOfWeek))];
646
+ } catch {}
647
+
648
+ // Security posture
649
+ data.hasGitignore = existsSync(join(process.cwd(), '.gitignore'));
650
+ data.hasSecurityPolicy = existsSync(join(process.cwd(), 'SECURITY.md'));
651
+ data.hasCodeowners = existsSync(join(process.cwd(), '.github', 'CODEOWNERS'));
652
+ data.hasPRTemplate = existsSync(join(process.cwd(), '.github', 'pull_request_template.md'));
653
+ data.hasIssueTemplates = existsSync(join(process.cwd(), '.github', 'ISSUE_TEMPLATE'));
654
+
655
+ // Documentation ratio
656
+ try {
657
+ const cwdFiles = readdirSync(process.cwd());
658
+ const mdFiles = cwdFiles.filter(f => f.endsWith('.md')).length;
659
+ const codeFiles = cwdFiles.filter(f => /\.(js|ts|py|go|rs|java|rb|php)$/.test(f)).length;
660
+ data.docsToCodeRatio = codeFiles > 0 ? Math.round(mdFiles / codeFiles * 100) / 100 : 0;
661
+ } catch {}
662
+
663
+ // Prompt verbosity distribution (from daily data)
664
+ try {
665
+ const daily = report.costAnalysis?.dailyCosts || [];
666
+ const msgCounts = daily.filter(d => d.messageCount > 0).map(d => d.messageCount);
667
+ if (msgCounts.length > 0) {
668
+ data.avgMsgsPerDay = Math.round(msgCounts.reduce((s,c) => s+c, 0) / msgCounts.length);
669
+ data.maxMsgsInDay = Math.max(...msgCounts);
670
+ data.minMsgsInDay = Math.min(...msgCounts);
671
+ }
672
+ } catch {}
673
+
674
+ // Multi-model sophistication (do they switch models within sessions?)
675
+ try {
676
+ const daily = report.costAnalysis?.dailyCosts || [];
677
+ let multiModelDays = 0;
678
+ for (const d of daily) {
679
+ const models = (d.models || []).map(m => m.model);
680
+ const unique = new Set(models);
681
+ if (unique.size > 1) multiModelDays++;
682
+ }
683
+ data.multiModelDays = multiModelDays;
684
+ data.multiModelPct = daily.length > 0 ? Math.round(multiModelDays / daily.length * 100) : 0;
685
+ } catch {}
686
+ data.hasMakefile = existsSync(join(process.cwd(), 'Makefile'));
687
+ data.hasJustfile = existsSync(join(process.cwd(), 'justfile'));
688
+
689
+ // How many projects have CLAUDE.md
690
+ try {
691
+ const projDir = join(claudeDir, 'projects');
692
+ if (existsSync(projDir)) {
693
+ let claudeMdCount = 0;
694
+ for (const d of readdirSync(projDir).slice(0, 30)) {
695
+ // Check if a CLAUDE.md exists in the decoded project path
696
+ const decoded = d.replace(/^([A-Z])--/, '$1:/').replace(/-/g, '/');
697
+ if (existsSync(join(decoded, 'CLAUDE.md'))) claudeMdCount++;
698
+ }
699
+ data.projectsWithClaudeMd = claudeMdCount;
700
+ }
701
+ } catch {}
702
+
312
703
  // First and last usage date (from JSONL file timestamps)
313
704
  if (existsSync(projectsDir)) {
314
705
  try {