claude-roi 0.1.0

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.
@@ -0,0 +1,170 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export function getGitUser() {
6
+ try {
7
+ const name = execSync('git config user.name', { encoding: 'utf-8' }).trim();
8
+ const email = execSync('git config user.email', { encoding: 'utf-8' }).trim();
9
+ return { name, email };
10
+ } catch {
11
+ return { name: 'unknown', email: 'unknown' };
12
+ }
13
+ }
14
+
15
+ function detectDefaultBranch(repoPath) {
16
+ // 1. Check what the remote HEAD points to (most reliable)
17
+ try {
18
+ const ref = execSync(
19
+ `git -C "${repoPath}" symbolic-ref refs/remotes/origin/HEAD`,
20
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
21
+ ).trim();
22
+ // Returns e.g. "refs/remotes/origin/main"
23
+ const branch = ref.replace('refs/remotes/origin/', '');
24
+ if (branch) return branch;
25
+ } catch {
26
+ // No remote HEAD set, fall through
27
+ }
28
+
29
+ // 2. Fallback: check for common default branch names
30
+ try {
31
+ const branches = execSync(`git -C "${repoPath}" branch --list`, { encoding: 'utf-8' });
32
+ const branchList = branches.split('\n').map(b => b.replace('*', '').trim()).filter(Boolean);
33
+ for (const name of ['main', 'master', 'develop', 'development', 'staging', 'trunk']) {
34
+ if (branchList.includes(name)) return name;
35
+ }
36
+ // 3. Last resort: return the first branch
37
+ if (branchList.length > 0) return branchList[0];
38
+ } catch {
39
+ // ignore
40
+ }
41
+
42
+ return null;
43
+ }
44
+
45
+ function getMainBranchHashes(repoPath) {
46
+ const mainBranch = detectDefaultBranch(repoPath);
47
+ if (!mainBranch) return { hashes: new Set(), branchName: null };
48
+
49
+ try {
50
+ const raw = execSync(
51
+ `git -C "${repoPath}" log ${mainBranch} --format=%H`,
52
+ { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 }
53
+ );
54
+ return {
55
+ hashes: new Set(raw.trim().split('\n').filter(Boolean)),
56
+ branchName: mainBranch,
57
+ };
58
+ } catch {
59
+ return { hashes: new Set(), branchName: mainBranch };
60
+ }
61
+ }
62
+
63
+ function parseGitLog(raw) {
64
+ const commits = [];
65
+ let current = null;
66
+
67
+ for (const line of raw.split('\n')) {
68
+ if (line.startsWith('COMMIT:')) {
69
+ if (current) commits.push(current);
70
+ const rest = line.slice(7);
71
+ // Format: hash|email|timestamp|subject|decorations
72
+ // Subject may contain | so we split carefully
73
+ const pipeIdx1 = rest.indexOf('|');
74
+ const pipeIdx2 = rest.indexOf('|', pipeIdx1 + 1);
75
+ const pipeIdx3 = rest.indexOf('|', pipeIdx2 + 1);
76
+
77
+ if (pipeIdx1 === -1 || pipeIdx2 === -1 || pipeIdx3 === -1) continue;
78
+
79
+ const hash = rest.slice(0, pipeIdx1);
80
+ const email = rest.slice(pipeIdx1 + 1, pipeIdx2);
81
+ const timestamp = rest.slice(pipeIdx2 + 1, pipeIdx3);
82
+ const remaining = rest.slice(pipeIdx3 + 1);
83
+
84
+ // Last field after last | is decorations (may be empty)
85
+ const lastPipe = remaining.lastIndexOf('|');
86
+ let subject, decorations;
87
+ if (lastPipe !== -1) {
88
+ subject = remaining.slice(0, lastPipe);
89
+ decorations = remaining.slice(lastPipe + 1).trim();
90
+ } else {
91
+ subject = remaining;
92
+ decorations = '';
93
+ }
94
+
95
+ current = {
96
+ hash,
97
+ authorEmail: email,
98
+ timestamp,
99
+ timestampMs: new Date(timestamp).getTime(),
100
+ subject,
101
+ decorations,
102
+ branches: [],
103
+ onMain: false,
104
+ files: [],
105
+ totalAdded: 0,
106
+ totalDeleted: 0,
107
+ netLines: 0,
108
+ };
109
+
110
+ // Parse decorations for branch info
111
+ if (decorations) {
112
+ const refs = decorations.split(',').map(r => r.trim());
113
+ for (const ref of refs) {
114
+ const cleaned = ref
115
+ .replace('HEAD -> ', '')
116
+ .replace('origin/', '')
117
+ .trim();
118
+ if (cleaned && !cleaned.startsWith('tag:')) {
119
+ current.branches.push(cleaned);
120
+ }
121
+ }
122
+ }
123
+ } else if (current && line.trim()) {
124
+ // numstat line: "2\t2\tpath/to/file" or "-\t-\tbinary_file"
125
+ const parts = line.split('\t');
126
+ if (parts.length >= 3) {
127
+ const added = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0;
128
+ const deleted = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0;
129
+ const filePath = parts.slice(2).join('\t'); // handle filenames with tabs
130
+ current.files.push({ path: filePath, added, deleted });
131
+ current.totalAdded += added;
132
+ current.totalDeleted += deleted;
133
+ current.netLines += (added - deleted);
134
+ }
135
+ }
136
+ }
137
+ if (current) commits.push(current);
138
+ return commits;
139
+ }
140
+
141
+ export function analyzeGitRepo(repoPath, days) {
142
+ if (!existsSync(path.join(repoPath, '.git'))) {
143
+ return { repoPath, commits: [], allCommits: [], defaultBranch: null };
144
+ }
145
+
146
+ const user = getGitUser();
147
+
148
+ try {
149
+ const raw = execSync(
150
+ `git -C "${repoPath}" log --all --since="${days} days ago" --format="COMMIT:%H|%ae|%aI|%s|%D" --numstat`,
151
+ { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 }
152
+ );
153
+
154
+ const allCommits = parseGitLog(raw);
155
+
156
+ // Get default branch hashes for onMain tagging
157
+ const { hashes: mainHashes, branchName } = getMainBranchHashes(repoPath);
158
+ for (const commit of allCommits) {
159
+ commit.onMain = mainHashes.has(commit.hash);
160
+ }
161
+
162
+ // Filter to current user
163
+ const userCommits = allCommits.filter(c => c.authorEmail === user.email);
164
+
165
+ return { repoPath, commits: userCommits, allCommits, defaultBranch: branchName };
166
+ } catch (err) {
167
+ process.stderr.write(`Warning: Git analysis failed for ${repoPath}: ${err.message}\n`);
168
+ return { repoPath, commits: [], allCommits: [], defaultBranch: null };
169
+ }
170
+ }
package/src/index.js ADDED
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { parseAllProjects } from './claude-parser.js';
7
+ import { analyzeGitRepo, getGitUser } from './git-analyzer.js';
8
+ import { correlateSessions } from './correlator.js';
9
+ import { computeMetrics } from './metrics.js';
10
+ import { loadCache, saveCache, deleteCache, getStaleFiles } from './cache.js';
11
+ import { createServer } from './server.js';
12
+
13
+ const VERSION = '0.1.0';
14
+
15
+ async function main() {
16
+ const program = new Command();
17
+ program
18
+ .name('claude-roi')
19
+ .description('Correlate Claude Code token usage with git output to measure AI coding agent ROI')
20
+ .version(VERSION)
21
+ .option('-p, --port <number>', 'port to serve dashboard', '3457')
22
+ .option('-d, --days <number>', 'number of days to look back', '30')
23
+ .option('--no-open', 'do not auto-open browser')
24
+ .option('--json', 'output raw JSON to stdout instead of starting server')
25
+ .option('--project <name>', 'filter to specific project')
26
+ .option('--refresh', 'force full re-parse, ignore cache');
27
+
28
+ program.parse();
29
+ const opts = program.opts();
30
+ const port = parseInt(opts.port, 10);
31
+ const days = parseInt(opts.days, 10);
32
+
33
+ console.log(`\x1b[36mclaude-roi\x1b[0m v${VERSION}`);
34
+
35
+ const claudeDir = path.join(os.homedir(), '.claude', 'projects');
36
+
37
+ // Step 1: Parse sessions (with caching)
38
+ let sessions;
39
+ let fileIndex;
40
+ const startParse = Date.now();
41
+
42
+ if (opts.refresh) {
43
+ deleteCache();
44
+ console.log('Cache cleared, performing full parse...');
45
+ }
46
+
47
+ const cached = opts.refresh ? null : loadCache();
48
+
49
+ if (cached) {
50
+ // Incremental parse: only process new/modified files
51
+ const stale = getStaleFiles(claudeDir, cached.fileIndex);
52
+ const newCount = stale.newFiles.length;
53
+ const modifiedCount = stale.modifiedFiles.length;
54
+ const deletedCount = stale.deletedFiles.length;
55
+ const cachedCount = Object.keys(cached.fileIndex).length - modifiedCount - deletedCount;
56
+
57
+ if (newCount === 0 && modifiedCount === 0 && deletedCount === 0) {
58
+ // Nothing changed, use cache as-is
59
+ sessions = cached.sessions;
60
+ fileIndex = cached.fileIndex;
61
+ console.log(`Parsing sessions... ${cached.sessions.length} cached (${Date.now() - startParse}ms)`);
62
+ } else {
63
+ // Parse only new/modified files
64
+ const { sessions: freshSessions, fileIndex: freshIndex } = await parseAllProjects(claudeDir, days, opts.project);
65
+
66
+ // For a simpler approach: just do a full re-parse when files change
67
+ // This avoids complex merging logic while still benefiting from caching
68
+ // when nothing has changed
69
+ sessions = freshSessions;
70
+ fileIndex = freshIndex;
71
+ console.log(`Parsing sessions... ${newCount} new, ${modifiedCount} updated, ${Math.max(0, cachedCount)} cached (${Date.now() - startParse}ms)`);
72
+ }
73
+ } else {
74
+ // Full parse
75
+ const result = await parseAllProjects(claudeDir, days, opts.project);
76
+ sessions = result.sessions;
77
+ fileIndex = result.fileIndex;
78
+ console.log(`Parsing sessions... ${sessions.length} parsed (${Date.now() - startParse}ms)`);
79
+ }
80
+
81
+ if (sessions.length === 0) {
82
+ console.log('\x1b[33mNo Claude Code sessions found.\x1b[0m');
83
+ console.log('Make sure you have used Claude Code and session files exist in ~/.claude/projects/');
84
+ process.exit(0);
85
+ }
86
+
87
+ // Step 2: Analyze git repos
88
+ const startGit = Date.now();
89
+ const repoPathsSet = new Set(sessions.map(s => s.repoPath).filter(Boolean));
90
+ const commitsByRepo = {};
91
+ for (const repoPath of repoPathsSet) {
92
+ commitsByRepo[repoPath] = analyzeGitRepo(repoPath, days);
93
+ }
94
+ console.log(`Analyzing ${repoPathsSet.size} git repo(s)... done (${Date.now() - startGit}ms)`);
95
+
96
+ // Step 3: Correlate sessions with commits
97
+ const { correlatedSessions, organicCommits } = correlateSessions(sessions, commitsByRepo);
98
+ console.log('Correlating sessions with commits... done');
99
+
100
+ // Step 4: Compute metrics
101
+ const payload = computeMetrics(correlatedSessions, organicCommits, commitsByRepo, days);
102
+ payload.meta.gitUser = getGitUser();
103
+
104
+ // Save cache for next run
105
+ saveCache(sessions, fileIndex);
106
+
107
+ // Step 5: Output
108
+ if (opts.json) {
109
+ process.stdout.write(JSON.stringify(payload, null, 2));
110
+ process.exit(0);
111
+ }
112
+
113
+ // Start server
114
+ const app = createServer(payload);
115
+ const server = app.listen(port, () => {
116
+ const url = `http://localhost:${port}`;
117
+ console.log(`\x1b[32mDashboard:\x1b[0m ${url}`);
118
+
119
+ if (opts.open !== false) {
120
+ import('open').then(mod => mod.default(url)).catch(() => {
121
+ console.log('Could not auto-open browser. Visit the URL above.');
122
+ });
123
+ }
124
+ });
125
+
126
+ server.on('error', (err) => {
127
+ if (err.code === 'EADDRINUSE') {
128
+ console.error(`\x1b[31mPort ${port} is already in use.\x1b[0m Try: claude-roi --port ${port + 1}`);
129
+ process.exit(1);
130
+ }
131
+ throw err;
132
+ });
133
+ }
134
+
135
+ main().catch(err => {
136
+ console.error('\x1b[31mError:\x1b[0m', err.message);
137
+ process.exit(1);
138
+ });
package/src/metrics.js ADDED
@@ -0,0 +1,396 @@
1
+ import { getModelFamily } from './claude-parser.js';
2
+
3
+ const CHURN_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours
4
+
5
+ function computeLineSurvival(commitsByRepo) {
6
+ let totalAdded = 0;
7
+ let totalChurned = 0;
8
+
9
+ for (const analysis of Object.values(commitsByRepo)) {
10
+ const userCommits = analysis.commits;
11
+ if (!userCommits.length) continue;
12
+
13
+ // Group commits by file
14
+ const fileTimeline = new Map();
15
+ for (const commit of userCommits) {
16
+ for (const file of commit.files) {
17
+ if (!fileTimeline.has(file.path)) fileTimeline.set(file.path, []);
18
+ fileTimeline.get(file.path).push({
19
+ timestampMs: commit.timestampMs,
20
+ added: file.added,
21
+ deleted: file.deleted,
22
+ });
23
+ }
24
+ }
25
+
26
+ // For each file, check for churn within 24h
27
+ for (const entries of fileTimeline.values()) {
28
+ entries.sort((a, b) => a.timestampMs - b.timestampMs);
29
+ for (let i = 0; i < entries.length; i++) {
30
+ totalAdded += entries[i].added;
31
+ if (i + 1 < entries.length) {
32
+ const gap = entries[i + 1].timestampMs - entries[i].timestampMs;
33
+ if (gap <= CHURN_WINDOW_MS) {
34
+ const churned = Math.min(entries[i + 1].deleted, entries[i].added);
35
+ totalChurned += churned;
36
+ }
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ const surviving = totalAdded - totalChurned;
43
+ // Round to nearest 5% to avoid false precision
44
+ const rawRate = totalAdded > 0 ? (surviving / totalAdded) * 100 : 100;
45
+ const survivalRate = Math.round(rawRate / 5) * 5;
46
+
47
+ return { totalAdded, totalChurned, surviving, survivalRate };
48
+ }
49
+
50
+ function computeEfficiencyGrade(costPerCommit, survivalRate) {
51
+ // Grade based on cost per commit (more meaningful than raw token count)
52
+ if (costPerCommit <= 2 && survivalRate >= 90) return 'A';
53
+ if (costPerCommit <= 5 && survivalRate >= 75) return 'B';
54
+ if (costPerCommit <= 15 && survivalRate >= 50) return 'C';
55
+ if (costPerCommit <= 40 && survivalRate >= 25) return 'D';
56
+ return 'F';
57
+ }
58
+
59
+ function computeSessionGrade(session) {
60
+ if (session.commitCount === 0) return 'F';
61
+ const costPerCommit = session.cost.totalCost / session.commitCount;
62
+ // Use 80 as a default survival (we don't have per-session survival)
63
+ return computeEfficiencyGrade(costPerCommit, 80);
64
+ }
65
+
66
+ function generateInsights(summary, correlatedSessions, modelBreakdown, sessionBuckets) {
67
+ const insights = [];
68
+
69
+ // Orphaned session rate
70
+ const orphanedCount = correlatedSessions.filter(s => s.isOrphaned).length;
71
+ if (orphanedCount > 0) {
72
+ const pct = Math.round((orphanedCount / correlatedSessions.length) * 100);
73
+ insights.push({
74
+ type: 'warning',
75
+ text: `${pct}% of your sessions (${orphanedCount}/${correlatedSessions.length}) produced zero commits — potential wasted effort.`,
76
+ });
77
+ }
78
+
79
+ // Model comparison
80
+ const modelFamilies = Object.entries(modelBreakdown).filter(([, d]) => d.sessions > 0);
81
+ if (modelFamilies.length > 1) {
82
+ const sorted = modelFamilies.sort((a, b) => (a[1].avgCostPerCommit || Infinity) - (b[1].avgCostPerCommit || Infinity));
83
+ const best = sorted[0];
84
+ const worst = sorted[sorted.length - 1];
85
+ if (best[1].avgCostPerCommit && worst[1].avgCostPerCommit) {
86
+ const ratio = (worst[1].avgCostPerCommit / best[1].avgCostPerCommit).toFixed(1);
87
+ insights.push({
88
+ type: 'info',
89
+ text: `${capitalise(worst[0])} sessions cost ${ratio}x more per commit than ${capitalise(best[0])}.`,
90
+ });
91
+ }
92
+ }
93
+
94
+ // Session length sweet spot
95
+ const bucketEntries = Object.entries(sessionBuckets).filter(([, d]) => d.sessions > 0 && d.avgCostPerCommit !== null);
96
+ if (bucketEntries.length > 1) {
97
+ const bestBucket = bucketEntries.reduce((a, b) =>
98
+ (a[1].avgCostPerCommit || Infinity) < (b[1].avgCostPerCommit || Infinity) ? a : b
99
+ );
100
+ insights.push({
101
+ type: 'tip',
102
+ text: `Sessions with ${bestBucket[0]} messages have the best cost-per-commit ($${bestBucket[1].avgCostPerCommit.toFixed(2)}).`,
103
+ });
104
+ }
105
+
106
+ // Peak productivity hours
107
+ if (summary.totalCommits > 0) {
108
+ // Best day of week
109
+ const bestDay = summary.bestDay;
110
+ const worstDay = summary.worstDay;
111
+ if (bestDay) {
112
+ insights.push({
113
+ type: 'success',
114
+ text: `${bestDay.date} was your most productive AI day — ${bestDay.commits} commits for $${bestDay.cost.toFixed(2)}.`,
115
+ });
116
+ }
117
+ if (worstDay && worstDay.date !== bestDay?.date) {
118
+ insights.push({
119
+ type: 'warning',
120
+ text: `${worstDay.date} had the worst ROI — ${worstDay.commits} commits for $${worstDay.cost.toFixed(2)}.`,
121
+ });
122
+ }
123
+ }
124
+
125
+ // Commits on main
126
+ const totalOnMain = correlatedSessions.reduce((s, cs) => s + cs.commitsOnMain, 0);
127
+ if (summary.totalCommits > 0) {
128
+ const pct = Math.round((totalOnMain / summary.totalCommits) * 100);
129
+ let mainText;
130
+ if (pct < 30) {
131
+ mainText = `${pct}% of AI-assisted commits landed on production — this is normal if you primarily work on feature branches.`;
132
+ } else if (pct >= 70) {
133
+ mainText = `${pct}% of AI-assisted commits landed directly on production.`;
134
+ } else {
135
+ mainText = `${pct}% of AI-assisted commits landed on production.`;
136
+ }
137
+ insights.push({
138
+ type: pct >= 50 ? 'success' : 'info',
139
+ text: mainText,
140
+ });
141
+ }
142
+
143
+ // Cost distribution
144
+ if (summary.totalCost > 0) {
145
+ const top20 = correlatedSessions
146
+ .sort((a, b) => b.cost.totalCost - a.cost.totalCost)
147
+ .slice(0, Math.max(1, Math.ceil(correlatedSessions.length * 0.2)));
148
+ const top20Cost = top20.reduce((s, c) => s + c.cost.totalCost, 0);
149
+ const pct = Math.round((top20Cost / summary.totalCost) * 100);
150
+ if (pct >= 60) {
151
+ insights.push({
152
+ type: 'info',
153
+ text: `Top 20% of sessions account for ${pct}% of total cost.`,
154
+ });
155
+ }
156
+ }
157
+
158
+ // Average session duration insight
159
+ const avgDuration = correlatedSessions.reduce((s, c) => s + c.durationMinutes, 0) / correlatedSessions.length;
160
+ if (avgDuration > 0) {
161
+ insights.push({
162
+ type: 'info',
163
+ text: `Average session duration: ${Math.round(avgDuration)} minutes.`,
164
+ });
165
+ }
166
+
167
+ // Average commit delay (time between session end and commit)
168
+ const delays = [];
169
+ for (const session of correlatedSessions) {
170
+ if (session.commits.length === 0) continue;
171
+ const sessionEnd = new Date(session.endTime).getTime();
172
+ for (const c of session.commits) {
173
+ const delay = c.timestampMs - sessionEnd;
174
+ if (delay >= 0) delays.push(delay);
175
+ }
176
+ }
177
+ if (delays.length > 0) {
178
+ const avgDelayMs = delays.reduce((s, d) => s + d, 0) / delays.length;
179
+ const avgDelayHours = avgDelayMs / (1000 * 60 * 60);
180
+ if (avgDelayHours < 1) {
181
+ insights.push({ type: 'success', text: `On average, commits happen ${Math.round(avgDelayHours * 60)} minutes after a session ends.` });
182
+ } else {
183
+ insights.push({ type: 'info', text: `On average, commits happen ${avgDelayHours.toFixed(1)} hours after a session ends.` });
184
+ }
185
+ }
186
+
187
+ // Uncommitted files insight
188
+ const totalUncommitted = correlatedSessions.reduce((s, c) => s + (c.uncommittedFiles?.length || 0), 0);
189
+ const totalWritten = correlatedSessions.reduce((s, c) => s + (c.filesWritten?.length || 0), 0);
190
+ if (totalWritten > 0 && totalUncommitted > 0) {
191
+ const pct = Math.round((totalUncommitted / totalWritten) * 100);
192
+ if (pct >= 20) {
193
+ insights.push({ type: 'info', text: `${pct}% of files Claude edited (${totalUncommitted}/${totalWritten}) were not found in any commit.` });
194
+ }
195
+ }
196
+
197
+ return insights;
198
+ }
199
+
200
+ function capitalise(s) {
201
+ return s.charAt(0).toUpperCase() + s.slice(1);
202
+ }
203
+
204
+ export function computeMetrics(correlatedSessions, organicCommits, commitsByRepo, days) {
205
+ // ---- Summary ----
206
+ const totalCost = correlatedSessions.reduce((s, c) => s + c.cost.totalCost, 0);
207
+ const totalSessions = correlatedSessions.length;
208
+ const totalCommits = correlatedSessions.reduce((s, c) => s + c.commitCount, 0);
209
+ const totalLinesAdded = correlatedSessions.reduce((s, c) => s + c.linesAdded, 0);
210
+ const totalLinesDeleted = correlatedSessions.reduce((s, c) => s + c.linesDeleted, 0);
211
+ const totalNetLines = totalLinesAdded - totalLinesDeleted;
212
+ const totalFilesChanged = new Set(
213
+ correlatedSessions.flatMap(c => c.commits.flatMap(co => co.files.map(f => f.path)))
214
+ ).size;
215
+ const totalInputTokens = correlatedSessions.reduce((s, c) => s + c.totalInputTokens, 0);
216
+ const totalOutputTokens = correlatedSessions.reduce((s, c) => s + c.totalOutputTokens, 0);
217
+ const orphanedCount = correlatedSessions.filter(s => s.isOrphaned).length;
218
+ const totalCommitsOnMain = correlatedSessions.reduce((s, c) => s + c.commitsOnMain, 0);
219
+
220
+ const lineSurvival = computeLineSurvival(commitsByRepo);
221
+
222
+ const avgCost = totalCommits > 0 ? totalCost / totalCommits : 0;
223
+ const overallGrade = totalCommits > 0
224
+ ? computeEfficiencyGrade(avgCost, lineSurvival.survivalRate)
225
+ : 'F';
226
+
227
+ // ---- Daily timeline ----
228
+ const dailyMap = new Map();
229
+ for (const session of correlatedSessions) {
230
+ const d = new Date(session.startTime);
231
+ const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
232
+ if (!dailyMap.has(date)) {
233
+ dailyMap.set(date, { date, cost: 0, sessions: 0, commits: 0, linesAdded: 0, linesDeleted: 0, netLines: 0 });
234
+ }
235
+ const day = dailyMap.get(date);
236
+ day.cost += session.cost.totalCost;
237
+ day.sessions++;
238
+ day.commits += session.commitCount;
239
+ day.linesAdded += session.linesAdded;
240
+ day.linesDeleted += session.linesDeleted;
241
+ day.netLines += session.netLines;
242
+ }
243
+ const daily = [...dailyMap.values()].sort((a, b) => a.date.localeCompare(b.date));
244
+
245
+ // Best/worst days
246
+ const daysWithCommits = daily.filter(d => d.commits > 0);
247
+ const bestDay = daysWithCommits.length > 0
248
+ ? daysWithCommits.reduce((a, b) => (b.commits / Math.max(b.cost, 0.01)) > (a.commits / Math.max(a.cost, 0.01)) ? b : a)
249
+ : null;
250
+ const worstDay = daysWithCommits.length > 0
251
+ ? daysWithCommits.reduce((a, b) => (b.commits / Math.max(b.cost, 0.01)) < (a.commits / Math.max(a.cost, 0.01)) ? b : a)
252
+ : null;
253
+
254
+ // ---- Model breakdown ----
255
+ const modelBreakdown = {};
256
+ for (const session of correlatedSessions) {
257
+ for (const [model, data] of Object.entries(session.modelBreakdown)) {
258
+ const family = getModelFamily(model) || 'unknown';
259
+ if (!modelBreakdown[family]) {
260
+ modelBreakdown[family] = { cost: 0, tokens: 0, sessions: 0, commits: 0, avgCostPerCommit: null };
261
+ }
262
+ modelBreakdown[family].cost += data.cost;
263
+ modelBreakdown[family].tokens += data.tokens;
264
+ }
265
+ // Count sessions/commits per primary model
266
+ const primaryFamily = getModelFamily(session.model) || 'unknown';
267
+ if (!modelBreakdown[primaryFamily]) {
268
+ modelBreakdown[primaryFamily] = { cost: 0, tokens: 0, sessions: 0, commits: 0, avgCostPerCommit: null };
269
+ }
270
+ modelBreakdown[primaryFamily].sessions++;
271
+ modelBreakdown[primaryFamily].commits += session.commitCount;
272
+ }
273
+ for (const data of Object.values(modelBreakdown)) {
274
+ data.avgCostPerCommit = data.commits > 0 ? data.cost / data.commits : null;
275
+ }
276
+
277
+ // ---- Tool breakdown ----
278
+ const toolBreakdown = {};
279
+ for (const session of correlatedSessions) {
280
+ for (const [tool, count] of Object.entries(session.toolCalls)) {
281
+ toolBreakdown[tool] = (toolBreakdown[tool] || 0) + count;
282
+ }
283
+ }
284
+
285
+ // ---- Session length buckets ----
286
+ const buckets = { '1-50': [], '51-100': [], '101-200': [], '200+': [] };
287
+ for (const session of correlatedSessions) {
288
+ const msgCount = session.userMessageCount + session.assistantMessageCount;
289
+ if (msgCount <= 50) buckets['1-50'].push(session);
290
+ else if (msgCount <= 100) buckets['51-100'].push(session);
291
+ else if (msgCount <= 200) buckets['101-200'].push(session);
292
+ else buckets['200+'].push(session);
293
+ }
294
+ const sessionBuckets = {};
295
+ for (const [label, sessions] of Object.entries(buckets)) {
296
+ const cost = sessions.reduce((s, c) => s + c.cost.totalCost, 0);
297
+ const commits = sessions.reduce((s, c) => s + c.commitCount, 0);
298
+ sessionBuckets[label] = {
299
+ sessions: sessions.length,
300
+ cost,
301
+ commits,
302
+ avgCostPerCommit: commits > 0 ? cost / commits : null,
303
+ };
304
+ }
305
+
306
+ // ---- Heatmap (hour x day-of-week) ----
307
+ const heatmap = Array.from({ length: 7 }, () => Array(24).fill(0));
308
+ const heatmapCost = Array.from({ length: 7 }, () => Array(24).fill(0));
309
+ for (const session of correlatedSessions) {
310
+ // Place each commit at its actual timestamp, not the session start
311
+ for (const commit of session.commits) {
312
+ const d = new Date(commit.timestamp);
313
+ const dayOfWeek = d.getDay(); // 0=Sun
314
+ const hour = d.getHours();
315
+ heatmap[dayOfWeek][hour]++;
316
+ }
317
+ // Cost is still attributed to session start time
318
+ const sd = new Date(session.startTime);
319
+ heatmapCost[sd.getDay()][sd.getHours()] += session.cost.totalCost;
320
+ }
321
+
322
+ // ---- Per-project breakdown ----
323
+ const projectMap = new Map();
324
+ for (const session of correlatedSessions) {
325
+ const key = session.repoPath || 'unknown';
326
+ if (!projectMap.has(key)) {
327
+ projectMap.set(key, {
328
+ repoPath: key,
329
+ repoName: session.projectName || key.split('/').pop(),
330
+ totalCost: 0, sessions: 0, commits: 0, linesAdded: 0, commitsOnMain: 0,
331
+ });
332
+ }
333
+ const p = projectMap.get(key);
334
+ p.totalCost += session.cost.totalCost;
335
+ p.sessions++;
336
+ p.commits += session.commitCount;
337
+ p.linesAdded += session.linesAdded;
338
+ p.commitsOnMain += session.commitsOnMain;
339
+ }
340
+ const projects = [...projectMap.values()].map(p => ({
341
+ ...p,
342
+ avgCostPerLine: p.linesAdded > 0 ? p.totalCost / p.linesAdded : null,
343
+ mainBranchPct: p.commits > 0 ? Math.round((p.commitsOnMain / p.commits) * 100) : 0,
344
+ }));
345
+
346
+ // Add grades to sessions
347
+ const sessionsWithGrades = correlatedSessions.map(s => ({
348
+ ...s,
349
+ grade: computeSessionGrade(s),
350
+ }));
351
+
352
+ const summary = {
353
+ totalCost,
354
+ totalSessions,
355
+ totalCommits,
356
+ totalLinesAdded,
357
+ totalLinesDeleted,
358
+ totalNetLines,
359
+ totalFilesChanged,
360
+ avgCostPerCommit: totalCommits > 0 ? totalCost / totalCommits : null,
361
+ avgCostPerLine: totalLinesAdded > 0 ? totalCost / totalLinesAdded : null,
362
+ totalInputTokens,
363
+ totalOutputTokens,
364
+ orphanedSessionRate: totalSessions > 0 ? Math.round((orphanedCount / totalSessions) * 100) : 0,
365
+ lineSurvivalRate: lineSurvival.survivalRate,
366
+ overallGrade,
367
+ totalCommitsOnMain,
368
+ mainBranchPct: totalCommits > 0 ? Math.round((totalCommitsOnMain / totalCommits) * 100) : 0,
369
+ organicCommitCount: organicCommits.length,
370
+ bestDay,
371
+ worstDay,
372
+ };
373
+
374
+ const insights = generateInsights(summary, correlatedSessions, modelBreakdown, sessionBuckets);
375
+
376
+ return {
377
+ meta: {
378
+ generatedAt: new Date().toISOString(),
379
+ daysAnalyzed: days,
380
+ defaultBranches: Object.fromEntries(
381
+ Object.entries(commitsByRepo).map(([repo, a]) => [repo.split('/').pop(), a.defaultBranch]).filter(([, b]) => b)
382
+ ),
383
+ },
384
+ summary,
385
+ insights,
386
+ daily,
387
+ projects,
388
+ sessions: sessionsWithGrades,
389
+ modelBreakdown,
390
+ toolBreakdown,
391
+ sessionBuckets,
392
+ lineSurvival,
393
+ heatmap: { commits: heatmap, cost: heatmapCost },
394
+ organicCommits,
395
+ };
396
+ }