diff --git a/README.md b/README.md index 6b56072..ddf315f 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ codeburn yield # last 7 days (default) codeburn yield -p today # today only codeburn yield -p 30days # last 30 days codeburn yield -p month # this calendar month -codeburn yield --format json # productive/reverted/abandoned spend as JSON +codeburn yield --format json # productive/reverted/abandoned/ambiguous spend as JSON ``` Did the spend actually ship? `codeburn yield` correlates AI sessions with git commits by timestamp: @@ -213,6 +213,9 @@ Did the spend actually ship? `codeburn yield` correlates AI sessions with git co | Productive | Commits from this session landed in main | | Reverted | Commits were later reverted | | Abandoned | No commits near session, or commits never merged | +| Ambiguous | Session ran parallel to another and its window's commits were attributed to the tighter one | + +Attribution is timestamp-window based (heuristic): each commit is credited to at most one session — the tightest window containing it. The JSON report carries `methodology: "timestamp-window"`. Requires a git repository. Run from your project directory. @@ -591,7 +594,7 @@ codeburn report --format json | jq '.projects' codeburn today --format json | jq '.overview.cost' ``` -For lighter output, use `status --format json` (today and month totals only), `optimize --format json` (setup health, findings, and copy-paste fixes), `yield --format json` (productive/reverted/abandoned spend), or file exports (`export -f json`). +For lighter output, use `status --format json` (today and month totals only), `optimize --format json` (setup health, findings, and copy-paste fixes), `yield --format json` (productive/reverted/abandoned/ambiguous spend), or file exports (`export -f json`). diff --git a/src/yield.ts b/src/yield.ts index 40a52eb..eeee66d 100644 --- a/src/yield.ts +++ b/src/yield.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'child_process' import { parseAllSessions } from './parser.js' import type { DateRange, SessionSummary } from './types.js' -export type YieldCategory = 'productive' | 'reverted' | 'abandoned' +export type YieldCategory = 'productive' | 'reverted' | 'abandoned' | 'ambiguous' export type SessionYield = { sessionId: string @@ -16,6 +16,7 @@ export type YieldSummary = { productive: { cost: number; sessions: number } reverted: { cost: number; sessions: number } abandoned: { cost: number; sessions: number } + ambiguous: { cost: number; sessions: number } total: { cost: number; sessions: number } details: SessionYield[] } @@ -30,9 +31,11 @@ export type YieldJsonReport = { productive: YieldBucketJson reverted: YieldBucketJson abandoned: YieldBucketJson + ambiguous: YieldBucketJson total: { costUSD: number; sessions: number } productiveToRevertedCostRatio: number | null } + methodology: 'timestamp-window' details: SessionYieldJson[] } @@ -82,6 +85,18 @@ type CommitInfo = { wasReverted: boolean } +type SessionWindow = { + readonly start: Date + readonly end: Date + readonly sessionId: string +} + +type SessionAttribution = { + readonly window: SessionWindow | null + readonly commits: CommitInfo[] + lostCandidacy: boolean +} + /** * Find SHAs that were the target of a `git revert` ANYWHERE in the repo's * history (not just the time window). The standard `git revert` body @@ -141,41 +156,88 @@ function getCommitsInRange(cwd: string, since: Date, until: Date, mainBranch: st }) } +function sessionWindow(session: SessionSummary): SessionWindow | null { + if (!session.firstTimestamp) return null + + const start = new Date(session.firstTimestamp) + const lastTs = session.lastTimestamp ?? session.firstTimestamp + const end = new Date(new Date(lastTs).getTime() + 60 * 60 * 1000) + return { start, end, sessionId: session.sessionId } +} + +function attributeCommits( + sessions: SessionSummary[], + commits: CommitInfo[], +): SessionAttribution[] { + const attributions: SessionAttribution[] = sessions.map(session => ({ + window: sessionWindow(session), + commits: [], + lostCandidacy: false, + })) + + for (const commit of commits) { + const candidates = attributions.filter((attribution): attribution is SessionAttribution & { window: SessionWindow } => + attribution.window !== null && + commit.timestamp >= attribution.window.start && + commit.timestamp <= attribution.window.end, + ) + + const owner = candidates.reduce((current, candidate) => { + if (current === null) return candidate + + const currentSpan = current.window.end.getTime() - current.window.start.getTime() + const candidateSpan = candidate.window.end.getTime() - candidate.window.start.getTime() + if (candidateSpan !== currentSpan) return candidateSpan < currentSpan ? candidate : current + if (candidate.window.start.getTime() !== current.window.start.getTime()) { + return candidate.window.start < current.window.start ? candidate : current + } + // sessionId, not array position: session order is cache-state dependent. + return candidate.window.sessionId < current.window.sessionId ? candidate : current + }, null) + + for (const candidate of candidates) { + if (candidate !== owner) candidate.lostCandidacy = true + } + owner?.commits.push(commit) + } + + return attributions +} + function categorizeSession( session: SessionSummary, - commits: CommitInfo[] + commits: CommitInfo[], + lostCandidacy: boolean, ): { category: YieldCategory; commitCount: number } { if (!session.firstTimestamp) { return { category: 'abandoned', commitCount: 0 } } - const sessionStart = new Date(session.firstTimestamp) - const lastTs = session.lastTimestamp ?? session.firstTimestamp - const sessionEnd = new Date(new Date(lastTs).getTime() + 60 * 60 * 1000) // +1 hour - - const relevantCommits = commits.filter(c => - c.timestamp >= sessionStart && c.timestamp <= sessionEnd - ) - - if (relevantCommits.length === 0) { - return { category: 'abandoned', commitCount: 0 } + if (commits.length === 0) { + // Ambiguous only when this session's window actually contained a commit + // that a tighter window won — mere overlap with a credited session is not + // enough to withhold the abandoned classification. + return { + category: lostCandidacy ? 'ambiguous' : 'abandoned', + commitCount: 0, + } } - const inMainCount = relevantCommits.filter(c => c.inMain).length + const inMainCount = commits.filter(c => c.inMain).length // A session is "reverted" when at least half of its in-main commits were // later reverted out (revert detected via "This reverts commit " // anywhere later in history, not in the same time window). - const revertedCount = relevantCommits.filter(c => c.inMain && c.wasReverted).length + const revertedCount = commits.filter(c => c.inMain && c.wasReverted).length if (revertedCount > 0 && revertedCount >= inMainCount / 2) { - return { category: 'reverted', commitCount: relevantCommits.length } + return { category: 'reverted', commitCount: commits.length } } if (inMainCount > 0) { return { category: 'productive', commitCount: inMainCount } } - return { category: 'abandoned', commitCount: relevantCommits.length } + return { category: 'abandoned', commitCount: commits.length } } export async function computeYield(range: DateRange, cwd: string, provider: string = 'all'): Promise { @@ -185,6 +247,7 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri productive: { cost: 0, sessions: 0 }, reverted: { cost: 0, sessions: 0 }, abandoned: { cost: 0, sessions: 0 }, + ambiguous: { cost: 0, sessions: 0 }, total: { cost: 0, sessions: 0 }, details: [], } @@ -194,18 +257,43 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri ? getCommitsInRange(cwd, range.start, range.end, getMainBranch(cwd)) : [] + // Group sessions by resolved repo before attributing: every project entry + // whose projectPath is missing (or not a git repo) falls back to the same + // cwd and shares one commit list, so attribution must run once per repo or + // two such entries could each award the same commit to one of their sessions. + type RepoGroup = { commits: CommitInfo[]; sessions: SessionSummary[]; projectNames: string[] } + const repoGroups = new Map() for (const project of projects) { - // Try project-specific git repo first, fall back to cwd const projectCwd = project.projectPath && isGitRepo(project.projectPath) ? project.projectPath : cwd - const projectCommits = projectCwd !== cwd && isGitRepo(projectCwd) - ? getCommitsInRange(projectCwd, range.start, range.end, getMainBranch(projectCwd)) - : commits - + let group = repoGroups.get(projectCwd) + if (!group) { + group = { + commits: projectCwd === cwd + ? commits + : getCommitsInRange(projectCwd, range.start, range.end, getMainBranch(projectCwd)), + sessions: [], + projectNames: [], + } + repoGroups.set(projectCwd, group) + } for (const session of project.sessions) { - const { category, commitCount } = categorizeSession(session, projectCommits) + group.sessions.push(session) + group.projectNames.push(project.project) + } + } + + for (const group of repoGroups.values()) { + const attributions = attributeCommits(group.sessions, group.commits) + for (const [index, session] of group.sessions.entries()) { + const attribution = attributions[index] + const { category, commitCount } = categorizeSession( + session, + attribution?.commits ?? [], + attribution?.lostCandidacy ?? false, + ) summary[category].cost += session.totalCostUSD summary[category].sessions += 1 @@ -214,7 +302,7 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri summary.details.push({ sessionId: session.sessionId, - project: project.project, + project: group.projectNames[index] ?? session.project, cost: session.totalCostUSD, category, commitCount, @@ -226,7 +314,7 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri } export function formatYieldSummary(summary: YieldSummary): string { - const { productive, reverted, abandoned, total } = summary + const { productive, reverted, abandoned, ambiguous, total } = summary const pct = (n: number) => total.cost > 0 ? Math.round((n / total.cost) * 100) : 0 const fmt = (n: number) => `$${n.toFixed(2)}` @@ -236,6 +324,9 @@ export function formatYieldSummary(summary: YieldSummary): string { `Productive: ${fmt(productive.cost).padStart(8)} (${pct(productive.cost)}%) - ${productive.sessions} sessions shipped to main`, `Reverted: ${fmt(reverted.cost).padStart(8)} (${pct(reverted.cost)}%) - ${reverted.sessions} sessions were reverted`, `Abandoned: ${fmt(abandoned.cost).padStart(8)} (${pct(abandoned.cost)}%) - ${abandoned.sessions} sessions never committed`, + `Ambiguous: ${fmt(ambiguous.cost).padStart(8)} (${pct(ambiguous.cost)}%) - ${ambiguous.sessions} sessions lost commits to concurrent sessions`, + '', + 'Attribution: timestamp-window based (heuristic)', '', `Total: ${fmt(total.cost).padStart(8)} - ${total.sessions} sessions`, '', @@ -270,6 +361,7 @@ export function buildYieldJsonReport( productive: bucket(summary.productive), reverted: bucket(summary.reverted), abandoned: bucket(summary.abandoned), + ambiguous: bucket(summary.ambiguous), total: { costUSD: summary.total.cost, sessions: summary.total.sessions, @@ -278,6 +370,7 @@ export function buildYieldJsonReport( ? Math.round((summary.productive.cost / summary.reverted.cost) * 100) / 100 : null, }, + methodology: 'timestamp-window', details: summary.details.map(detail => ({ sessionId: detail.sessionId, project: detail.project, diff --git a/tests/yield-overlap-attribution.test.ts b/tests/yield-overlap-attribution.test.ts new file mode 100644 index 0000000..f1dc37d --- /dev/null +++ b/tests/yield-overlap-attribution.test.ts @@ -0,0 +1,198 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +import type { ProjectSummary, SessionSummary } from '../src/types.js' +import { computeYield } from '../src/yield.js' + +const { parseAllSessionsMock } = vi.hoisted(() => ({ + parseAllSessionsMock: vi.fn(), +})) + +vi.mock('../src/parser.js', () => ({ + parseAllSessions: parseAllSessionsMock, +})) + +function git(cwd: string, args: string[], env: Record = {}): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf-8', + env: { ...process.env, ...env }, + }).trim() +} + +function makeSession(overrides: Partial): SessionSummary { + return { + sessionId: 'session', + project: 'app', + firstTimestamp: '2026-01-01T10:00:00.000Z', + lastTimestamp: '2026-01-01T11:00:00.000Z', + totalCostUSD: 1, + totalSavingsUSD: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {}, + subagentBreakdown: {}, + ...overrides, + } +} + +describe('yield attribution for overlapping sessions (issue #641)', () => { + let repoDir: string + + beforeAll(async () => { + repoDir = await mkdtemp(join(tmpdir(), 'codeburn-yield-overlap-')) + git(repoDir, ['init', '-b', 'main']) + git(repoDir, ['config', 'user.email', 'test@example.com']) + git(repoDir, ['config', 'user.name', 'Test']) + await writeFile(join(repoDir, 'file.txt'), 'hello\n') + git(repoDir, ['add', '.']) + // One commit on main at 10:30, inside both sessions' time windows. + git(repoDir, ['commit', '-m', 'feat: shipped by session A'], { + GIT_AUTHOR_DATE: '2026-01-01T10:30:00Z', + GIT_COMMITTER_DATE: '2026-01-01T10:30:00Z', + }) + }) + + afterAll(async () => { + await rm(repoDir, { recursive: true, force: true }) + }) + + it('does not credit a session that made no commits with an overlapping session\'s commit', async () => { + // Session A ran 10:15-10:45 and made the 10:30 commit. + // Session B ran 10:00-11:00 in the same repo and shipped nothing. + const sessionA = makeSession({ + sessionId: 'session-a', + firstTimestamp: '2026-01-01T10:15:00.000Z', + lastTimestamp: '2026-01-01T10:45:00.000Z', + totalCostUSD: 5, + }) + const sessionB = makeSession({ + sessionId: 'session-b', + firstTimestamp: '2026-01-01T10:00:00.000Z', + lastTimestamp: '2026-01-01T11:00:00.000Z', + totalCostUSD: 3, + }) + + const project: Partial = { + project: 'app', + projectPath: repoDir, + // Keep the broader session first so attribution must use window span, + // not project/session order. + sessions: [sessionB, sessionA], + } + parseAllSessionsMock.mockResolvedValue([project as ProjectSummary]) + + const summary = await computeYield( + { + start: new Date('2026-01-01T00:00:00.000Z'), + end: new Date('2026-01-02T00:00:00.000Z'), + }, + repoDir, + ) + + const detailB = summary.details.find(d => d.sessionId === 'session-b') + expect(detailB).toBeDefined() + + // Session B shipped nothing: it must not land in the "productive" bucket + // on the strength of session A's commit. + expect(detailB!.category).toBe('ambiguous') + expect(detailB!.category).not.toBe('productive') + expect(detailB!.commitCount).toBe(0) + + // The single commit should be credited to exactly one session. + const productiveSessions = summary.details.filter(d => d.category === 'productive') + expect(productiveSessions.map(d => d.sessionId)).toEqual(['session-a']) + expect(summary.ambiguous).toEqual({ cost: 3, sessions: 1 }) + }) + + it('classifies a lone session with its commit as productive (single-session pin)', async () => { + const session = makeSession({ + sessionId: 'solo', + firstTimestamp: '2026-01-01T10:15:00.000Z', + lastTimestamp: '2026-01-01T10:45:00.000Z', + totalCostUSD: 2, + }) + parseAllSessionsMock.mockResolvedValue([ + { project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary, + ]) + + const summary = await computeYield( + { start: new Date('2026-01-01T00:00:00.000Z'), end: new Date('2026-01-02T00:00:00.000Z') }, + repoDir, + ) + + expect(summary.details).toHaveLength(1) + expect(summary.details[0]).toMatchObject({ sessionId: 'solo', category: 'productive', commitCount: 1 }) + expect(summary.productive).toEqual({ cost: 2, sessions: 1 }) + expect(summary.ambiguous).toEqual({ cost: 0, sessions: 0 }) + }) + + it('breaks equal-window ties deterministically by sessionId, not array order', async () => { + const windowFields = { + firstTimestamp: '2026-01-01T10:15:00.000Z', + lastTimestamp: '2026-01-01T10:45:00.000Z', + } + const first = makeSession({ sessionId: 'aaa-session', ...windowFields, totalCostUSD: 1 }) + const second = makeSession({ sessionId: 'zzz-session', ...windowFields, totalCostUSD: 1 }) + + for (const order of [[first, second], [second, first]]) { + parseAllSessionsMock.mockResolvedValue([ + { project: 'app', projectPath: repoDir, sessions: order } as ProjectSummary, + ]) + const summary = await computeYield( + { start: new Date('2026-01-01T00:00:00.000Z'), end: new Date('2026-01-02T00:00:00.000Z') }, + repoDir, + ) + const productive = summary.details.filter(d => d.category === 'productive') + expect(productive.map(d => d.sessionId)).toEqual(['aaa-session']) + expect(summary.details.find(d => d.sessionId === 'zzz-session')!.category).toBe('ambiguous') + } + }) + + it('holds the single-owner invariant across project entries sharing the cwd fallback', async () => { + // Two project entries with no usable projectPath both resolve to the same + // cwd and share one commit list; the commit must still be credited once. + const sessionX = makeSession({ + sessionId: 'proj1-session', + project: 'proj1', + firstTimestamp: '2026-01-01T10:15:00.000Z', + lastTimestamp: '2026-01-01T10:45:00.000Z', + totalCostUSD: 1, + }) + const sessionY = makeSession({ + sessionId: 'proj2-session', + project: 'proj2', + firstTimestamp: '2026-01-01T10:00:00.000Z', + lastTimestamp: '2026-01-01T11:00:00.000Z', + totalCostUSD: 1, + }) + parseAllSessionsMock.mockResolvedValue([ + { project: 'proj1', projectPath: undefined, sessions: [sessionX] } as unknown as ProjectSummary, + { project: 'proj2', projectPath: undefined, sessions: [sessionY] } as unknown as ProjectSummary, + ]) + + const summary = await computeYield( + { start: new Date('2026-01-01T00:00:00.000Z'), end: new Date('2026-01-02T00:00:00.000Z') }, + repoDir, + ) + + const productive = summary.details.filter(d => d.category === 'productive') + expect(productive.map(d => d.sessionId)).toEqual(['proj1-session']) + expect(productive[0]!.commitCount).toBe(1) + expect(summary.details.find(d => d.sessionId === 'proj2-session')!.category).toBe('ambiguous') + }) +}) diff --git a/tests/yield.test.ts b/tests/yield.test.ts index 8a6f31d..7b4cfba 100644 --- a/tests/yield.test.ts +++ b/tests/yield.test.ts @@ -8,6 +8,7 @@ describe('buildYieldJsonReport', () => { productive: { cost: 8, sessions: 2 }, reverted: { cost: 2, sessions: 1 }, abandoned: { cost: 10, sessions: 1 }, + ambiguous: { cost: 0, sessions: 0 }, total: { cost: 20, sessions: 4 }, details: [ { sessionId: 's1', project: 'app', cost: 8, category: 'productive', commitCount: 2 }, @@ -32,8 +33,15 @@ describe('buildYieldJsonReport', () => { }) expect(report.summary.reverted.costPercent).toBe(10) expect(report.summary.abandoned.sessionPercent).toBe(25) + expect(report.summary.ambiguous).toEqual({ + costUSD: 0, + sessions: 0, + costPercent: 0, + sessionPercent: 0, + }) expect(report.summary.total).toEqual({ costUSD: 20, sessions: 4 }) expect(report.summary.productiveToRevertedCostRatio).toBe(4) + expect(report.methodology).toBe('timestamp-window') expect(report.details).toHaveLength(2) expect(report.details[0]).toMatchObject({ sessionId: 's1', costUSD: 8, category: 'productive' }) expect(report.details[0]).not.toHaveProperty('cost') @@ -44,6 +52,7 @@ describe('buildYieldJsonReport', () => { productive: { cost: 1, sessions: 1 }, reverted: { cost: 0, sessions: 0 }, abandoned: { cost: 0, sessions: 0 }, + ambiguous: { cost: 0, sessions: 0 }, total: { cost: 1, sessions: 1 }, details: [], }