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.
- package/README.md +191 -0
- package/package.json +34 -0
- package/src/cache.js +86 -0
- package/src/claude-parser.js +462 -0
- package/src/correlator.js +103 -0
- package/src/dashboard.html +995 -0
- package/src/git-analyzer.js +170 -0
- package/src/index.js +138 -0
- package/src/metrics.js +396 -0
- package/src/server.js +116 -0
|
@@ -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
|
+
}
|