cchubber 0.3.5 → 0.3.7

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