+
| Pull request |
+ Models |
Cost |
Sessions |
Calls |
Active |
+ |
- {rows.map(pr => )}
+ {rows.map(pr => (
+ setExpandedUrl(current => current === pr.url ? null : pr.url)}
+ />
+ ))}
+ {otherCount > 0 && (
+ // A muted summary line, kept out of the sorted rows: its cost is an
+ // aggregate of the capped-away PRs and can exceed a visible row.
+
+
+ | Other ({otherCount.toLocaleString('en-US')} more PRs) |
+ |
+ {formatUsd(otherCost)} |
+ |
+ |
+ |
+ |
+
+
+ )}
-
- {formatUsd(distinctCost)} across {distinctSessions.toLocaleString('en-US')} distinct {sessionWord} produced pull requests.
- {' '}Attribution is by reference: a session referencing several PRs counts toward each, so the rows above are not summed.
-
+ {summable ? (
+
+ {formatUsd(displayedAttributed)} attributed to the rows above, across {distinctSessions.toLocaleString('en-US')} PR-linked {sessionWord(distinctSessions)}.
+ {' '}Each turn's cost goes to the PR it was working on, so the rows are summable.
+
+ ) : (
+
+ {formatUsd(distinctCost)} across {distinctSessions.toLocaleString('en-US')} distinct {sessionWord(distinctSessions)} produced pull requests.
+ {' '}Attribution is by reference: a session referencing several PRs counts toward each, so the rows above are not summed.
+
+ )}
+ {unattributed > 0 && (
+
Not tied to a specific PR: {formatUsd(unattributed)}
+ )}
>
)
}
-function PrRowView({ pr }: { pr: PrRow }) {
+const APPROX_TITLE = 'Approximate: the transcript expired before per-turn capture, so this PR’s share is an even split of the whole session.'
+
+function PrRowView({ pr, expanded, onToggle }: { pr: PrRow; expanded: boolean; onToggle: () => void }) {
+ const models = pr.models ?? []
+ const categories = pr.categories ?? []
+ const catMax = categories.length ? Math.max(...categories.map(cat => cat.cost)) : 0
+
return (
-
- |
- openPr(event, pr.url)}>{pr.label}
- |
- {formatUsd(pr.cost)} |
- {pr.sessions.toLocaleString('en-US')} |
- {pr.calls.toLocaleString('en-US')} |
- {spanLabel(pr.firstStarted, pr.lastEnded)} |
-
+ <>
+
rowKeyDown(event, onToggle)}
+ >
+ |
+ openPr(event, pr.url)}>{pr.label}
+ |
+ {models.length ? modelsLabel(models) : ''} |
+
+ {pr.approx ? '~' : ''}{formatUsd(pr.cost)}
+ |
+ {pr.sessions.toLocaleString('en-US')} |
+ {pr.calls.toLocaleString('en-US')} |
+ {spanLabel(pr.firstStarted, pr.lastEnded)} |
+ › |
+
+ {expanded && (
+
+
+ {categories.length > 0 ? (
+
+ {categories.map(cat => (
+
+
+ 0 ? cat.cost / catMax * 100 : 0}%` }} />
+
+
+ {cat.name}
+ {formatUsd(cat.cost)}
+
+
+ ))}
+
+ ) : (
+ No per-turn detail (estimated from a whole-session split).
+ )}
+ |
+
+ )}
+ >
)
}
diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css
index 68c4e3d..ed45b6b 100644
--- a/app/renderer/styles/plain.css
+++ b/app/renderer/styles/plain.css
@@ -580,10 +580,31 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
.ov-models th:first-child, .ov-models td:first-child { width: 100%; text-align: left; }
.ov-models .ov-model-name { overflow: hidden; color: var(--ink); font-weight: var(--fw-medium); text-overflow: ellipsis; }
.ov-models td.mono { font-family: var(--mono); color: var(--ink); }
+.pr-scroll { overflow-x: auto; }
+.pr-table { min-width: 640px; }
.pr-link { color: var(--accent-text); font-weight: var(--fw-medium); text-decoration: none; cursor: pointer; }
.pr-link:hover { text-decoration: underline; }
+.pr-table th.pr-models, .pr-table td.pr-models { overflow: hidden; max-width: 180px; color: var(--mut); text-align: left; text-overflow: ellipsis; }
.pr-table td.pr-span { color: var(--mut2); font-variant-numeric: tabular-nums; white-space: nowrap; }
+.pr-table th.pr-chevron-cell, .pr-table td.pr-chevron-cell { width: 26px; padding: 0 8px; text-align: center; }
+.pr-chevron { display: inline-block; color: var(--mut2); font-family: system-ui, sans-serif; font-size: 17px; line-height: 1; transition: transform 140ms ease; }
+.pr-row { cursor: pointer; }
+.pr-row:hover { background: var(--hover); }
+.pr-row:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent); }
+.pr-row[aria-expanded="true"] .pr-chevron { transform: rotate(90deg); }
+.pr-table td.pr-detail-cell { height: auto; padding: 11px 14px; background: color-mix(in srgb, var(--panel) 84%, var(--hover)); text-align: left; white-space: normal; }
+.pr-cats { display: flex; flex-direction: column; gap: 9px; max-width: 520px; }
+.pr-cat { display: flex; flex-direction: column; gap: 5px; }
+.pr-cat-bar { height: 3px; overflow: hidden; border-radius: 2px; background: var(--fill); }
+.pr-cat-bar span { display: block; height: 100%; border-radius: inherit; background: var(--accent); }
+.pr-cat-main { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 12px; }
+.pr-cat-name { overflow: hidden; color: var(--ink); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; }
+.pr-cat-main strong { flex: 0 0 auto; font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; }
+.pr-cat-empty { margin: 0; color: var(--mut2); font-size: var(--fs-meta); }
+.pr-table tfoot .pr-other-row td { border-top: 1px solid var(--line); color: var(--mut2); font-style: italic; }
+.pr-table td.pr-other-label { color: var(--mut2); font-weight: var(--fw-medium); font-style: italic; text-align: left; }
.pr-footnote { margin: 12px 2px 2px; color: var(--mut); font-size: var(--fs-meta); line-height: 1.55; }
+.pr-unattributed { margin: 4px 2px 2px; color: var(--mut2); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; }
.opt-waste { min-width: 0; }
.opt-summary { padding: 0 0 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.opt-findings { display: grid; min-width: 0; }
diff --git a/src/main.ts b/src/main.ts
index 4e059bc..4bbe434 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2037,8 +2037,10 @@ program
process.stdout.write('No sessions with captured PR links in this period. Links are captured as sessions are parsed; older transcripts gain them on their next re-parse.\n')
return
}
- const { cost, sessions } = prLinkedTotals(projects)
+ const { unattributedCost, sessions } = prLinkedTotals(projects)
const { renderTable: renderTextTable } = await import('./text-table.js')
+ const modelsCell = (models: string[]): string =>
+ models.length === 0 ? '' : models.slice(0, 2).join(', ') + (models.length > 2 ? ` +${models.length - 2}` : '')
const table = renderTextTable(
[
{ header: 'PR' },
@@ -2046,20 +2048,28 @@ program
{ header: 'Saved', right: true },
{ header: 'Sessions', right: true },
{ header: 'Calls', right: true },
+ { header: 'Models' },
{ header: 'First' },
{ header: 'Last' },
],
prRows.map(r => [
r.label,
- `$${r.cost.toFixed(2)}`,
+ `${r.approx ? '~' : ''}$${r.cost.toFixed(2)}`,
`$${r.savingsUSD.toFixed(2)}`,
String(r.sessions),
String(r.calls),
+ modelsCell(r.models),
r.firstStarted.slice(0, 10),
r.lastEnded.slice(0, 10),
]),
)
- process.stdout.write(table + `\nDistinct PR-linked spend: $${cost.toFixed(2)} across ${sessions} session${sessions === 1 ? '' : 's'}. A session referencing several PRs counts toward each, so rows exceed this total when links overlap.\n`)
+ // Footer reconciles to the ROUNDED row values actually printed (not the
+ // exact float sum), so the visible column adds up to the stated total.
+ const shownAttributed = prRows.reduce((sum, r) => sum + Number(r.cost.toFixed(2)), 0)
+ const approxNote = prRows.some(r => r.approx)
+ ? ' ~ marks rows estimated from a whole-session even split (transcript expired before per-turn capture).'
+ : ''
+ process.stdout.write(table + `\nRows sum to $${shownAttributed.toFixed(2)} attributed across ${sessions} PR-linked session${sessions === 1 ? '' : 's'}. $${unattributedCost.toFixed(2)} of that spend was not tied to a specific PR.${approxNote}\n`)
return
}
const rows = aggregateSessions(projects)
diff --git a/src/menubar-json.ts b/src/menubar-json.ts
index 82adf71..d47482e 100644
--- a/src/menubar-json.ts
+++ b/src/menubar-json.ts
@@ -43,9 +43,9 @@ export type PeriodData = {
/// Share (0-1) of cost-bearing calls that resolved a price.
pricingCoverage?: number
/// Spend attributed by referenced pull request (from Claude session
- /// transcripts). Rows are by-reference — a session referencing several PRs
- /// counts toward EACH — so never sum them; `distinctCost`/`distinctSessions`
- /// are the multi-link-safe total. Absent when no PR links were observed.
+ /// transcripts), at turn granularity. Rows carry attributed cost/calls and ARE
+ /// summable; `attributedCost`/`unattributedCost` split the PR-linked spend.
+ /// Absent when no PR links were observed.
pullRequests?: PullRequestsPayload
/// Per-branch spend, last-seen branch carried forward across each session's
/// turns. A `null` branch is unbranched spend inside a branch-bearing session.
@@ -57,11 +57,21 @@ export type PeriodData = {
export type PullRequestsPayload = {
/// Per-PR rows, cost-descending, capped at the top 20 by the producer.
rows: PrRow[]
- /// Distinct-session spend across every PR-linked session — the figure safe to
- /// present as a total, since the per-PR rows double-count sessions that
- /// reference more than one PR.
+ /// Distinct-session spend across every PR-linked session. Equals
+ /// `attributedCost + unattributedCost`; kept for backward compatibility.
distinctCost: number
distinctSessions: number
+ /// Sum of EVERY PR's attributed cost (all rows, not just the sent top 20).
+ attributedCost: number
+ /// PR-linked spend not tied to any specific PR (pre-reference session
+ /// overhead). `attributedCost + unattributedCost === distinctCost`.
+ unattributedCost: number
+ /// Count of PRs beyond the sent `rows` (0 when nothing was capped). The app
+ /// renders an "Other (N more PRs)" summary row so the visible table still
+ /// reconciles to `attributedCost`.
+ otherPrCount: number
+ /// Attributed cost of those capped-away PRs (0 when nothing was capped).
+ otherPrCost: number
}
export type ProviderCost = {
diff --git a/src/parser.ts b/src/parser.ts
index 807639d..ec8e1aa 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -912,6 +912,12 @@ export function compactEntry(raw: JournalEntry): JournalEntry {
if (raw.cwd !== undefined) entry.cwd = raw.cwd
// Preserved so groupIntoTurns can stamp each turn's git branch (rich capture).
if (typeof raw.gitBranch === 'string' && raw.gitBranch) entry.gitBranch = raw.gitBranch
+ // Preserved so groupIntoTurns can attribute each PR reference to its turn.
+ // Only `pr-link` entries carry `prUrl`; every other field of theirs is dropped.
+ if (raw.type === 'pr-link') {
+ const prUrl = (raw as Record
)['prUrl']
+ if (typeof prUrl === 'string' && prUrl) (entry as Record)['prUrl'] = prUrl
+ }
const att = (raw as Record)['attachment']
if (att && typeof att === 'object') {
@@ -1440,6 +1446,10 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set,
// from the user entry (gitBranch is on every user/assistant entry); a
// continuation turn with no leading user text falls back to its first call.
let currentBranch: string | undefined
+ // GitHub PR URLs referenced within the turn currently being accumulated. A
+ // `pr-link` entry is emitted after the assistant creates/references a PR, so it
+ // lands inside the same turn (before the next user message) and attaches here.
+ let currentPrRefs: string[] = []
for (const entry of entries) {
const entryBranch = typeof entry.gitBranch === 'string' && entry.gitBranch ? entry.gitBranch : undefined
@@ -1453,6 +1463,7 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set,
timestamp: currentTimestamp,
sessionId: currentSessionId,
...(currentBranch ? { gitBranch: currentBranch } : {}),
+ ...(currentPrRefs.length > 0 ? { prRefs: [...currentPrRefs].sort() } : {}),
})
}
currentUserMessage = text
@@ -1460,6 +1471,7 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set,
currentTimestamp = entry.timestamp ?? ''
currentSessionId = entry.sessionId ?? ''
currentBranch = entryBranch
+ currentPrRefs = []
}
} else if (entry.type === 'assistant') {
if (entryBranch && !currentBranch) currentBranch = entryBranch
@@ -1469,6 +1481,9 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set,
const call = parseApiCall(entry, toolResultMeta)
if (call) currentCalls.push(call)
for (const advisorCall of parseAdvisorCalls(entry)) currentCalls.push(advisorCall)
+ } else if (entry.type === 'pr-link') {
+ const url = (entry as Record)['prUrl']
+ if (typeof url === 'string' && url && !currentPrRefs.includes(url)) currentPrRefs.push(url)
}
}
@@ -1479,6 +1494,7 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set,
timestamp: currentTimestamp,
sessionId: currentSessionId,
...(currentBranch ? { gitBranch: currentBranch } : {}),
+ ...(currentPrRefs.length > 0 ? { prRefs: [...currentPrRefs].sort() } : {}),
})
}
@@ -1841,14 +1857,19 @@ async function scanProjectDirs(
}
discoverProgress.finish()
- if (readOnly) {
- for (const [filePath, cached] of Object.entries(section.files)) {
- if (allDiscoveredFiles.has(filePath)) continue
- const dirName = cached.canonicalProjectName
- ?? cached.turns[0]?.calls[0]?.project
- ?? basename(dirname(filePath))
- unchangedFiles.push({ filePath, dirName, cached })
- }
+ // Orphans: cached sessions whose source file is no longer discovered. In
+ // read-only mode surface them all (the snapshot is authoritative, nothing is
+ // being pruned). In write mode surface only PR-bearing orphans: their transcript
+ // is gone and can never re-parse, but they carry attributable PR spend the by-PR
+ // report must keep (as a legacy even-split); the eviction below preserves the
+ // same set so `section.files` still holds them when summaries are built.
+ for (const [filePath, cached] of Object.entries(section.files)) {
+ if (allDiscoveredFiles.has(filePath)) continue
+ if (!readOnly && !cached.prLinks?.length) continue
+ const dirName = cached.canonicalProjectName
+ ?? cached.turns[0]?.calls[0]?.project
+ ?? basename(dirname(filePath))
+ unchangedFiles.push({ filePath, dirName, cached })
}
// Pre-seed dedup set from cached (unchanged) files
@@ -1907,6 +1928,10 @@ async function scanProjectDirs(
if (!newTurns[0]!.userMessage.trim() && mergedTurns.length > 0) {
const last = mergedTurns[mergedTurns.length - 1]!
last.calls = mergeBoundaryCalls(last.calls, newTurns[0]!.calls)
+ // A PR referenced in the appended continuation belongs to this same
+ // turn: union its refs in so the shortcut matches a full re-parse.
+ const refs = Array.from(new Set([...(last.prRefs ?? []), ...(newTurns[0]!.prRefs ?? [])])).sort()
+ if (refs.length > 0) last.prRefs = refs
startIdx = 1
}
for (let i = startIdx; i < newTurns.length; i++) mergedTurns.push(newTurns[i]!)
@@ -2008,10 +2033,12 @@ async function scanProjectDirs(
if (!readOnly && dirs.length > 0) {
for (const cachedPath of Object.keys(section.files)) {
- if (!allDiscoveredFiles.has(cachedPath)) {
- delete section.files[cachedPath]
- ;(diskCache as { _dirty?: boolean })._dirty = true
- }
+ if (allDiscoveredFiles.has(cachedPath)) continue
+ // Keep PR-bearing orphans: their transcript is gone and can never re-parse,
+ // but they carry attributable PR spend (surfaced above as a legacy split).
+ if (section.files[cachedPath]?.prLinks?.length) continue
+ delete section.files[cachedPath]
+ ;(diskCache as { _dirty?: boolean })._dirty = true
}
}
@@ -2031,8 +2058,23 @@ async function scanProjectDirs(
// full ordered turn list) means a later date slice can drop the anchor turn
// without the surviving turns losing their branch.
let carriedBranch: string | undefined
+ // The PR set active going into the report range: carried across the FULL turn
+ // list, frozen the moment the first in-range turn is reached. Lets per-turn PR
+ // attribution seed from a reference made before the window (see
+ // attributeSessionPrSpend); the branch carry above solves the same problem.
+ let carriedPrRefs: string[] | undefined
+ let prRefsAtRangeStart: string[] | undefined
+ let frozePrRefs = !dateRange
let classifiedTurns = cachedFile.turns.map(turn => {
if (turn.gitBranch) carriedBranch = turn.gitBranch
+ if (dateRange && !frozePrRefs) {
+ const firstTs = turn.calls[0]?.timestamp
+ if (firstTs && new Date(firstTs) >= dateRange.start) {
+ prRefsAtRangeStart = carriedPrRefs
+ frozePrRefs = true
+ }
+ }
+ if (turn.prRefs?.length) carriedPrRefs = turn.prRefs
return cachedTurnToClassified(turn, carriedBranch)
})
// Captured from the FULL turn list, before the date slice below can drop the
@@ -2060,6 +2102,7 @@ async function scanProjectDirs(
session.agentType = cachedFile.agentType
if (everHadBranch) session.everHadBranch = true
if (cachedFile.prLinks?.length) session.prLinks = [...new Set(cachedFile.prLinks)].sort()
+ if (prRefsAtRangeStart?.length) session.prRefsAtRangeStart = prRefsAtRangeStart
if (cachedFile.title) session.title = cachedFile.title
if (session.apiCalls > 0) {
@@ -2235,6 +2278,9 @@ function parsedTurnToCachedTurn(turn: ParsedTurn): CachedTurn {
sessionId: turn.sessionId,
userMessage: turn.userMessage.slice(0, 2000),
calls: turn.assistantCalls.map(apiCallToCachedCall),
+ // Stored per-turn directly (already sorted/deduped in groupIntoTurns), unlike
+ // gitBranch's change-detection dedup, so each turn's refs are self-contained.
+ ...(turn.prRefs?.length ? { prRefs: turn.prRefs } : {}),
}
}
@@ -2344,6 +2390,7 @@ function cachedTurnToClassified(turn: CachedTurn, resolvedBranch?: string): Clas
timestamp: turn.timestamp,
sessionId: turn.sessionId,
...(branch ? { gitBranch: branch } : {}),
+ ...(turn.prRefs?.length ? { prRefs: turn.prRefs } : {}),
}
return classifyTurn(parsed)
}
diff --git a/src/session-cache.ts b/src/session-cache.ts
index 99bb31d..ae068f7 100644
--- a/src/session-cache.ts
+++ b/src/session-cache.ts
@@ -61,6 +61,10 @@ export type CachedTurn = {
// previous turn's branch (a report carries the last stored value forward).
// Rich-session-capture; optional, Claude only.
gitBranch?: string
+ // Claude: GitHub PR URLs referenced during this turn, sorted and deduplicated.
+ // Stored per-turn directly (unlike gitBranch, no change-detection), so a turn's
+ // own refs are self-contained. Drives turn-level PR spend attribution. Optional.
+ prRefs?: string[]
}
export type FileFingerprint = {
@@ -123,7 +127,10 @@ export type SessionCache = {
// Cached kiro entries from v4 carry costUSD: undefined and would keep being
// re-priced from estimated tokens forever, since historical session files
// never change. Bump forces a one-time re-parse so metered credit costs land.
-export const CACHE_VERSION = 5
+// v6: per-turn `prRefs` capture for turn-level PR spend attribution. Existing
+// cache turns carry no prRefs; bumping forces a one-time re-parse so surviving
+// transcripts populate the field. (Daily-cache versioning is untouched.)
+export const CACHE_VERSION = 6
// The cache filename is version-suffixed so different binaries (e.g. an old
// launchd menubar on a prior release and a newer desktop app) each own a
@@ -321,6 +328,7 @@ function validateTurn(t: unknown): t is CachedTurn {
&& typeof o['sessionId'] === 'string'
&& typeof o['userMessage'] === 'string'
&& isOptionalString(o['gitBranch'])
+ && (o['prRefs'] === undefined || isStringArray(o['prRefs']))
&& Array.isArray(o['calls'])
&& (o['calls'] as unknown[]).every(validateCall)
}
@@ -336,6 +344,8 @@ function validateCachedFile(f: unknown): f is CachedFile {
&& isOptionalString(o['title'])
&& (o['prLinks'] === undefined || isStringArray(o['prLinks']))
&& isOptionalBool(o['isSidechain'])
+ && isOptionalString(o['agentType'])
+ && isOptionalBool(o['failed'])
&& Array.isArray(o['turns'])
&& (o['turns'] as unknown[]).every(validateTurn)
}
@@ -356,21 +366,83 @@ function validateCache(raw: unknown): raw is SessionCache {
return Object.values(o['providers'] as Record).every(validateProviderSection)
}
+// The immediately-prior versioned file. On the 5 -> 6 bump we adopt its
+// still-relevant entries (see adoptV5Cache) rather than abandoning them; the
+// file itself is never written or deleted (old binaries still own it).
+const V5_CACHE_FILE = 'session-cache.v5.json'
+
+// Lightweight top-level check: a version-5 cache with a providers object. The
+// individual files are validated per-entry in adoptV5Cache so one corrupt entry
+// cannot drop every valid expired-transcript PR session along with it.
+function isV5CacheEnvelope(raw: unknown): raw is { version: number; providers: Record } {
+ if (!raw || typeof raw !== 'object') return false
+ const o = raw as Record
+ return o['version'] === 5
+ && !!o['providers'] && typeof o['providers'] === 'object' && !Array.isArray(o['providers'])
+}
+
+// One-time migration for the 5 -> 6 bump (per-turn prRefs capture). A fresh v6
+// cache would abandon v5 wholesale, so any PR-linked session whose transcript was
+// since deleted would vanish instead of taking the by-PR legacy even-split path.
+// Carry forward exactly the v5 entries whose source no longer exists AND that
+// carry prLinks (they can never re-parse, but they hold attributable PR spend);
+// present sources are intentionally dropped so they re-parse fresh under v6 and
+// gain per-turn refs. Each file is validated individually, so a single corrupt
+// entry is skipped rather than discarding the whole cache. Each carried section
+// takes the CURRENT envFingerprint so the scan reuses it and appends the
+// freshly-parsed present sources. The daily cache (durable cost history) is not
+// touched.
+async function adoptV5Cache(): Promise {
+ try {
+ const raw = await readFile(join(getCacheDir(), V5_CACHE_FILE), 'utf-8')
+ const parsed = JSON.parse(raw)
+ if (!isV5CacheEnvelope(parsed)) return null
+ const migrated: SessionCache = { version: CACHE_VERSION, providers: {}, complete: false }
+ for (const [provider, section] of Object.entries(parsed.providers)) {
+ if (!section || typeof section !== 'object') continue
+ const rawFiles = (section as Record)['files']
+ const files: Record = {}
+ if (rawFiles && typeof rawFiles === 'object' && !Array.isArray(rawFiles)) {
+ for (const [path, file] of Object.entries(rawFiles as Record)) {
+ if (!validateCachedFile(file)) continue
+ if (!existsSync(path) && file.prLinks?.length) files[path] = file
+ }
+ }
+ migrated.providers[provider] = {
+ envFingerprint: computeEnvFingerprint(provider),
+ files,
+ ...((section as Record)['durable'] ? { durable: true } : {}),
+ }
+ }
+ return migrated
+ } catch {
+ return null
+ }
+}
+
export async function loadCache(): Promise {
try {
const raw = await readFile(getCachePath(), 'utf-8')
const parsed = JSON.parse(raw)
- if (!validateCache(parsed)) return emptyCache()
+ if (!validateCache(parsed)) return afterMissingVersionedCache()
return parsed
} catch {
- // Versioned file absent/unreadable: try a one-time adoption of the legacy
- // unversioned file. validateCache requires version === CACHE_VERSION, so a
- // different-version legacy file is ignored (left intact). We copy it into the
- // versioned file once via saveCache; the legacy file is never modified.
- return adoptLegacyCache()
+ return afterMissingVersionedCache()
}
}
+// The versioned (v6) file is absent/unreadable. Prefer adopting the prior v5
+// file's expired-source PR orphans; failing that, fall back to the legacy
+// unversioned file. Either way the versioned file is minted on the next save.
+async function afterMissingVersionedCache(): Promise {
+ const v5 = await adoptV5Cache()
+ if (v5) return v5
+ // validateCache requires version === CACHE_VERSION, so a different-version
+ // legacy file is ignored (left intact). We copy it into the versioned file once
+ // via saveCache; the legacy file is never modified.
+ return adoptLegacyCache()
+}
+
async function adoptLegacyCache(): Promise {
try {
const raw = await readFile(getLegacyCachePath(), 'utf-8')
diff --git a/src/sessions-report.ts b/src/sessions-report.ts
index bba367f..90f707f 100644
--- a/src/sessions-report.ts
+++ b/src/sessions-report.ts
@@ -1,4 +1,6 @@
-import type { ProjectSummary, SessionSummary } from './types.js'
+import { getShortModelName } from './models.js'
+import { CATEGORY_LABELS } from './types.js'
+import type { ProjectSummary, SessionSummary, TaskCategory } from './types.js'
export type SessionRow = {
sessionId: string
@@ -95,6 +97,17 @@ export type PrRow = {
calls: number
firstStarted: string
lastEnded: string
+ /// True when any contributing session used the legacy even-split fallback
+ /// (session-level prLinks but no surviving per-turn refs), so this row's share
+ /// is an approximation rather than genuine turn-level attribution.
+ approx: boolean
+ /// Short model names that processed this PR's attributed calls, ordered by
+ /// attributed cost descending, deduplicated.
+ models: string[]
+ /// Attributed cost per task category (from the turns' classification), ordered
+ /// by cost descending. Omitted for legacy approx rows: with no turn-level
+ /// attribution there is no honest per-category split.
+ categories?: Array<{ name: string; cost: number }>
}
const GITHUB_PR_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/
@@ -104,47 +117,231 @@ export function shortenPrUrl(url: string): string {
return m ? `${m[1]}/${m[2]}#${m[3]}` : url
}
-/// Spend attributed to each pull request a session's transcript referenced.
-/// A session that mentions two PRs counts fully toward BOTH rows: attribution
-/// is by reference, not a split, so rows must never be summed into a grand
-/// total (the caller reports distinct-session spend separately).
+/// One PR's slice of a session's spend. `models`/`categories` map a key (raw
+/// model name / task category) to the attributed cost carried under it.
+export type PrContribution = {
+ cost: number; calls: number; savingsUSD: number; approx: boolean
+ models: Map
+ categories: Map
+}
+
+/// A single session's PR-attributed spend: `perUrl` is the turn-level split
+/// across the PRs it referenced; `unattributed` is the spend that belongs to no
+/// specific PR (turns before the session's first PR reference).
+export type SessionPrAttribution = {
+ perUrl: Map
+ unattributed: { cost: number; calls: number; savingsUSD: number }
+}
+
+// Minimal structural shape a SessionSummary satisfies, so the state machine is
+// unit-testable without constructing a full session fixture.
+type AttributableSession = {
+ turns: Array<{ prRefs?: string[]; category?: string; assistantCalls: Array<{ costUSD: number; savingsUSD?: number; model?: string }> }>
+ prLinks?: string[]
+ totalCostUSD: number
+ apiCalls: number
+ totalSavingsUSD: number
+ /// The PR set carried into the in-range turn slice: the refs of the last turn
+ /// BEFORE the report's range start that referenced any PR. Seeds `current` so a
+ /// PR referenced before the range still owns its later, in-range, ref-less turns
+ /// (mirrors the branch carry-forward). Set by the parser; absent in unit tests.
+ prRefsAtRangeStart?: string[]
+}
+
+function addToMap(m: Map, key: string, value: number): void {
+ m.set(key, (m.get(key) ?? 0) + value)
+}
+
+function ensureContribution(map: Map, url: string): PrContribution {
+ let e = map.get(url)
+ if (!e) {
+ e = { cost: 0, calls: 0, savingsUSD: 0, approx: false, models: new Map(), categories: new Map() }
+ map.set(url, e)
+ }
+ return e
+}
+
+// Split an integer `total` across `n` buckets as evenly as possible, giving the
+// first `total % n` buckets the extra unit (largest-remainder, deterministic by
+// bucket order). Keeps per-PR call counts integral so aggregated rows never
+// over- or under-count from independent per-row rounding (a 1-call, 2-PR turn
+// allocates [1, 0], not [0.5, 0.5] that would each round up to 1).
+export function allocateEven(total: number, n: number): number[] {
+ const base = Math.floor(total / n)
+ const extra = total - base * n
+ return Array.from({ length: n }, (_, i) => base + (i < extra ? 1 : 0))
+}
+
+/// Attribute a session's spend to the PRs it referenced, at TURN granularity.
+///
+/// Walk the turns in order carrying `current` = the PR set of the most recent
+/// turn that referenced any PR (seeded from `prRefsAtRangeStart` so a reference
+/// made before the report window still owns its in-range follow-up turns). Each
+/// turn's cost/savings are split evenly across a multi-PR set (a merge-sweep turn
+/// touching several PRs); calls are split by largest-remainder so they stay whole.
+/// Each contribution also records the models of its calls and the turn's task
+/// category, both weighted by the same split share. Turns before the first
+/// reference land in `unattributed` (genuine session overhead).
+///
+/// Legacy fallback: a session whose transcript already expired keeps its
+/// session-level `prLinks` but has NO per-turn `prRefs`. With no turn boundaries
+/// to attribute by, split the whole session evenly across its prLinks, mark every
+/// portion `approx`, and carry the session's model union (its calls still name
+/// their models) but NO category breakdown, since none can be honestly assigned.
+export function attributeSessionPrSpend(session: AttributableSession): SessionPrAttribution {
+ const perUrl = new Map()
+ const unattributed = { cost: 0, calls: 0, savingsUSD: 0 }
+
+ const hasTurnRefs = session.turns.some(t => t.prRefs?.length) || !!session.prRefsAtRangeStart?.length
+ if (!hasTurnRefs) {
+ const links = session.prLinks
+ if (links?.length) {
+ const legacyModels = new Map()
+ for (const turn of session.turns) {
+ for (const call of turn.assistantCalls) {
+ if (call.model) addToMap(legacyModels, call.model, call.costUSD)
+ }
+ }
+ const share = 1 / links.length
+ const callAlloc = allocateEven(session.apiCalls, links.length)
+ links.forEach((url, i) => {
+ const e = ensureContribution(perUrl, url)
+ e.cost += session.totalCostUSD * share
+ e.calls += callAlloc[i]!
+ e.savingsUSD += session.totalSavingsUSD * share
+ e.approx = true
+ for (const [m, mc] of legacyModels) addToMap(e.models, m, mc * share)
+ })
+ }
+ return { perUrl, unattributed }
+ }
+
+ let current: string[] | null = session.prRefsAtRangeStart?.length ? session.prRefsAtRangeStart : null
+ for (const turn of session.turns) {
+ if (turn.prRefs?.length) current = turn.prRefs
+ const cost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
+ const calls = turn.assistantCalls.length
+ const savings = turn.assistantCalls.reduce((s, c) => s + (c.savingsUSD ?? 0), 0)
+ if (cost === 0 && calls === 0 && savings === 0) continue
+ if (current === null) {
+ unattributed.cost += cost
+ unattributed.calls += calls
+ unattributed.savingsUSD += savings
+ continue
+ }
+ const modelCostInTurn = new Map()
+ for (const call of turn.assistantCalls) {
+ if (call.model) addToMap(modelCostInTurn, call.model, call.costUSD)
+ }
+ const share = 1 / current.length
+ const callAlloc = allocateEven(calls, current.length)
+ current.forEach((url, i) => {
+ const e = ensureContribution(perUrl, url)
+ e.cost += cost * share
+ e.calls += callAlloc[i]!
+ e.savingsUSD += savings * share
+ if (turn.category) addToMap(e.categories, turn.category, cost * share)
+ for (const [m, mc] of modelCostInTurn) addToMap(e.models, m, mc * share)
+ })
+ }
+ return { perUrl, unattributed }
+}
+
+/// Spend attributed to each pull request at turn granularity (see
+/// attributeSessionPrSpend). Rows carry ATTRIBUTED cost/calls and ARE summable;
+/// `sessions` counts the distinct sessions that contributed any spend to the PR;
+/// `approx` marks rows fed by the legacy even-split fallback; `models` and
+/// `categories` are the attributed model/category breakdowns. Sorted by cost, desc.
export function aggregateByPr(projects: ProjectSummary[]): PrRow[] {
- const byUrl = new Map()
+ const byUrl = new Map; firstStarted: string; lastEnded: string
+ models: Map; categories: Map
+ }>()
for (const project of projects) {
for (const session of project.sessions) {
if (!session.prLinks?.length) continue
- for (const url of session.prLinks) {
+ // Key on project + sessionId: a transcript basename (sessionId) can repeat
+ // across projects, so sessionId alone would undercount distinct sessions.
+ const sessionKey = `${session.project} ${session.sessionId}`
+ const { perUrl } = attributeSessionPrSpend(session)
+ for (const [url, c] of perUrl) {
+ if (c.cost === 0 && c.calls === 0 && c.savingsUSD === 0) continue
const row = byUrl.get(url) ?? {
- url, label: shortenPrUrl(url),
- cost: 0, savingsUSD: 0, sessions: 0, calls: 0,
- firstStarted: session.firstTimestamp, lastEnded: session.lastTimestamp,
+ cost: 0, savingsUSD: 0, calls: 0, approx: false, legacyCost: 0,
+ sessions: new Set(), firstStarted: session.firstTimestamp, lastEnded: session.lastTimestamp,
+ models: new Map(), categories: new Map(),
}
- row.cost += session.totalCostUSD
- row.savingsUSD += session.totalSavingsUSD
- row.sessions += 1
- row.calls += session.apiCalls
+ row.cost += c.cost
+ row.savingsUSD += c.savingsUSD
+ row.calls += c.calls
+ row.sessions.add(sessionKey)
+ // A legacy (approx) contribution carries no per-turn categories; track its
+ // cost so a mixed row can reconcile its category breakdown to the total.
+ if (c.approx) { row.approx = true; row.legacyCost += c.cost }
+ for (const [m, mc] of c.models) addToMap(row.models, m, mc)
+ for (const [cat, cc] of c.categories) addToMap(row.categories, cat, cc)
if (session.firstTimestamp < row.firstStarted) row.firstStarted = session.firstTimestamp
if (session.lastTimestamp > row.lastEnded) row.lastEnded = session.lastTimestamp
byUrl.set(url, row)
}
}
}
- return [...byUrl.values()].sort((a, b) => b.cost - a.cost)
+ return [...byUrl.entries()]
+ .map(([url, r]) => {
+ // Collapse raw model names to short display names, summing costs that map
+ // to the same short name, then order by attributed cost (name asc breaks
+ // ties for a stable order) and cap at the top 4 to bound the payload.
+ const shortCosts = new Map()
+ for (const [raw, mc] of r.models) addToMap(shortCosts, getShortModelName(raw), mc)
+ const models = [...shortCosts.entries()]
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .slice(0, 4)
+ .map(([name]) => name)
+ const categories = [...r.categories.entries()]
+ .map(([cat, cost]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, cost }))
+ // Mixed row: live per-turn categories exist AND part of the row came from a
+ // legacy even-split (no turn data). Add a synthetic line for the legacy
+ // share so the expansion reconciles with the row cost instead of silently
+ // dropping it. A legacy-only row keeps no categories (it surfaces as "no
+ // per-turn detail"), so there is nothing to reconcile there.
+ if (categories.length > 0 && r.legacyCost > 0) {
+ categories.push({ name: 'Legacy estimate (no per-turn detail)', cost: r.legacyCost })
+ }
+ categories.sort((a, b) => b.cost - a.cost || a.name.localeCompare(b.name))
+ return {
+ url, label: shortenPrUrl(url),
+ cost: r.cost, savingsUSD: r.savingsUSD,
+ sessions: r.sessions.size, calls: r.calls,
+ firstStarted: r.firstStarted, lastEnded: r.lastEnded,
+ approx: r.approx,
+ models,
+ ...(categories.length ? { categories } : {}),
+ }
+ })
+ .sort((a, b) => b.cost - a.cost)
}
-/// Distinct-session totals across every PR-linked session, safe to present as
-/// "spend that produced PRs" without the multi-link double count.
-export function prLinkedTotals(projects: ProjectSummary[]): { cost: number; sessions: number } {
- let cost = 0
+/// Totals across every PR-linked session. `attributedCost` is the sum of the
+/// per-PR rows (a summable total, unlike the old by-reference rows);
+/// `unattributedCost` is the pre-reference overhead not tied to any specific PR.
+/// `cost` = attributed + unattributed = the distinct-session spend that produced
+/// PRs. `sessions` counts distinct PR-linked sessions.
+export function prLinkedTotals(projects: ProjectSummary[]): { cost: number; sessions: number; attributedCost: number; unattributedCost: number } {
+ let attributedCost = 0
+ let unattributedCost = 0
let sessions = 0
for (const project of projects) {
for (const session of project.sessions) {
if (!session.prLinks?.length) continue
- cost += session.totalCostUSD
sessions += 1
+ const { perUrl, unattributed } = attributeSessionPrSpend(session)
+ for (const c of perUrl.values()) attributedCost += c.cost
+ unattributedCost += unattributed.cost
}
}
- return { cost, sessions }
+ return { cost: attributedCost + unattributedCost, sessions, attributedCost, unattributedCost }
}
export type BranchRow = {
diff --git a/src/types.ts b/src/types.ts
index 248289c..609c600 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -94,6 +94,11 @@ export type ParsedTurn = {
// the turn's entries). Captured for cost-per-branch reporting; deduped at the
// cache boundary (stored per-turn only when it changes). Optional; Claude only.
gitBranch?: string
+ // Claude Code: GitHub PR URLs referenced during this turn (`pr-link` entries
+ // that landed inside the turn's span), sorted and deduplicated. Drives
+ // turn-level PR spend attribution; the session-level `prLinks` union is built
+ // separately. Absent when the turn referenced no PR. Optional; Claude only.
+ prRefs?: string[]
}
export type ParsedApiCall = {
@@ -202,6 +207,12 @@ export type SessionSummary = {
/// GitHub PR URLs captured from the session transcript (session-level,
/// deduplicated). Absent when none were observed.
prLinks?: string[]
+ /// The PR set active at the start of the in-range turn slice: the refs of the
+ /// last turn BEFORE the report's range start that referenced any PR. Captured
+ /// pre-filter (like `everHadBranch`) so per-turn PR attribution can carry a
+ /// reference made before the window into its later, in-range, ref-less turns.
+ /// Absent when no PR was referenced before the range (or no range filter).
+ prRefsAtRangeStart?: string[]
/// Human session title captured from the transcript (last ai-title entry).
/// Absent when the transcript never produced one.
title?: string
diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts
index c728b72..4e70848 100644
--- a/src/usage-aggregator.ts
+++ b/src/usage-aggregator.ts
@@ -666,10 +666,16 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
const prRows = aggregateByPr(scanProjects)
if (prRows.length > 0) {
const prTotals = prLinkedTotals(scanProjects)
+ const shownRows = prRows.slice(0, TOP_PULL_REQUESTS)
+ const otherRows = prRows.slice(TOP_PULL_REQUESTS)
currentData.pullRequests = {
- rows: prRows.slice(0, TOP_PULL_REQUESTS),
+ rows: shownRows,
distinctCost: prTotals.cost,
distinctSessions: prTotals.sessions,
+ attributedCost: prTotals.attributedCost,
+ unattributedCost: prTotals.unattributedCost,
+ otherPrCount: otherRows.length,
+ otherPrCost: otherRows.reduce((sum, r) => sum + r.cost, 0),
}
}
const branchRows = aggregateByBranch(scanProjects)
diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts
index 2958489..81b35e3 100644
--- a/tests/menubar-json.test.ts
+++ b/tests/menubar-json.test.ts
@@ -46,6 +46,29 @@ describe('buildMenubarPayload', () => {
expect(payload.current.outputTokens).toBe(675600)
})
+ it('passes the pull-requests payload (models, categories, cap remainder) through verbatim', () => {
+ const period: PeriodData = {
+ ...emptyPeriod('7 Days'),
+ pullRequests: {
+ rows: [
+ { url: 'https://github.com/o/r/pull/1', label: 'o/r#1', cost: 40, savingsUSD: 0, sessions: 1, calls: 12, firstStarted: '2026-07-20T10:00:00Z', lastEnded: '2026-07-20T11:00:00Z', approx: false, models: ['fable', 'opus'], categories: [{ name: 'Coding', cost: 30 }, { name: 'Debugging', cost: 10 }] },
+ ],
+ distinctCost: 45,
+ distinctSessions: 1,
+ attributedCost: 40,
+ unattributedCost: 5,
+ otherPrCount: 3,
+ otherPrCost: 12.5,
+ },
+ }
+ const payload = buildMenubarPayload(period, [], null)
+ expect(payload.current.pullRequests).toEqual(period.pullRequests)
+ expect(payload.current.pullRequests!.rows[0]!.models).toEqual(['fable', 'opus'])
+ expect(payload.current.pullRequests!.rows[0]!.categories).toEqual([{ name: 'Coding', cost: 30 }, { name: 'Debugging', cost: 10 }])
+ expect(payload.current.pullRequests!.otherPrCount).toBe(3)
+ expect(payload.current.pullRequests!.otherPrCost).toBe(12.5)
+ })
+
it('exposes period-scoped cache tokens on current, decoupled from the 365-day history backfill (#583)', () => {
const period: PeriodData = {
label: '30 Days',
diff --git a/tests/parser-compact-entry.test.ts b/tests/parser-compact-entry.test.ts
index 6777d94..5eb30dd 100644
--- a/tests/parser-compact-entry.test.ts
+++ b/tests/parser-compact-entry.test.ts
@@ -28,6 +28,19 @@ describe('compactEntry', () => {
expect((c as Record)['someHugeField']).toBeUndefined()
})
+ it('preserves prUrl on a pr-link entry for per-turn PR attribution', () => {
+ const raw = entry({ type: 'pr-link', prUrl: 'https://github.com/o/r/pull/1', extra: 'x'.repeat(10_000) })
+ const c = compactEntry(raw)
+ expect((c as Record)['prUrl']).toBe('https://github.com/o/r/pull/1')
+ expect((c as Record)['extra']).toBeUndefined()
+ })
+
+ it('does not copy prUrl onto non-pr-link entries', () => {
+ const raw = entry({ type: 'user', prUrl: 'https://github.com/o/r/pull/1' })
+ const c = compactEntry(raw)
+ expect((c as Record)['prUrl']).toBeUndefined()
+ })
+
it('preserves deferred_tools_delta attachment with copied names', () => {
const raw = entry({
type: 'attachment',
diff --git a/tests/parser-incremental-append.test.ts b/tests/parser-incremental-append.test.ts
index 68ba35d..8c0e24c 100644
--- a/tests/parser-incremental-append.test.ts
+++ b/tests/parser-incremental-append.test.ts
@@ -77,6 +77,10 @@ function asstLine(
const readBlock = (file: string) => ({ type: 'tool_use', name: 'Read', input: { file_path: file } })
const bashBlock = (cmd: string) => ({ type: 'tool_use', name: 'Bash', input: { command: cmd } })
+function prLinkLine(ts: string, url: string): string {
+ return JSON.stringify({ type: 'pr-link', sessionId: 'sess-1', timestamp: ts, cwd: CWD, prUrl: url })
+}
+
// A representative multi-turn session: MCP inventory, tools, bash, and a
// streaming re-emit of one assistant message (same id, updated usage) inside a
// turn — exercises dedup, breakdowns, and turn assembly.
@@ -150,6 +154,59 @@ describe('incremental append parsing', () => {
await rm(warmCache, { recursive: true, force: true })
})
+ it('PR-REFS: survive the incremental append path (continuation merge unions refs)', async () => {
+ const warmCache = await mkdtemp(join(tmpdir(), 'incr-pr-'))
+ // Base: one turn that creates PR-1.
+ await writeFile(sessionPath,
+ userLine('2026-05-01T10:00:01.000Z', 'ship PR one') + '\n' +
+ asstLine('msg-a', '2026-05-01T10:00:02.000Z', { input_tokens: 100, output_tokens: 20 }, [bashBlock('gh pr create')]) + '\n' +
+ prLinkLine('2026-05-01T10:00:03.000Z', 'https://github.com/o/r/pull/1') + '\n')
+ await parseWith(warmCache)
+
+ // Append a continuation of that same turn (no leading user message) that
+ // references PR-2, then a fresh turn that references PR-3.
+ await appendFile(sessionPath,
+ asstLine('msg-b', '2026-05-01T10:00:04.000Z', { input_tokens: 50, output_tokens: 10 }, [bashBlock('gh pr create')]) + '\n' +
+ prLinkLine('2026-05-01T10:00:05.000Z', 'https://github.com/o/r/pull/2') + '\n' +
+ userLine('2026-05-01T10:10:00.000Z', 'ship PR three') + '\n' +
+ asstLine('msg-c', '2026-05-01T10:10:02.000Z', { input_tokens: 80, output_tokens: 20 }, [bashBlock('gh pr create')]) + '\n' +
+ prLinkLine('2026-05-01T10:10:03.000Z', 'https://github.com/o/r/pull/3') + '\n')
+
+ readLineCalls.length = 0
+ const warm = await parseWith(warmCache)
+ expect(offsetsFor(sessionPath).some(o => o !== undefined && o > 0)).toBe(true) // took the append path
+ const cold = await coldFullReparse()
+ expect(warm).toEqual(cold)
+
+ const turns = warm[0]!.sessions[0]!.turns
+ expect(turns[0]!.prRefs).toEqual(['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2'])
+ expect(turns[1]!.prRefs).toEqual(['https://github.com/o/r/pull/3'])
+ await rm(warmCache, { recursive: true, force: true })
+ })
+
+ it('PR-REFS: survive when a straddled append falls back to a full re-parse', async () => {
+ const warmCache = await mkdtemp(join(tmpdir(), 'incr-pr2-'))
+ await writeFile(sessionPath,
+ userLine('2026-05-01T10:00:01.000Z', 'ship PR one') + '\n' +
+ asstLine('msg-a', '2026-05-01T10:00:02.000Z', { input_tokens: 100, output_tokens: 20 }, [bashBlock('gh pr create')]) + '\n' +
+ prLinkLine('2026-05-01T10:00:03.000Z', 'https://github.com/o/r/pull/1') + '\n')
+ await parseWith(warmCache)
+
+ // Re-emit msg-a (an id already committed in the cached prefix) -> straddle ->
+ // the shortcut is abandoned and the file re-parses from byte 0.
+ await appendFile(sessionPath,
+ asstLine('msg-a', '2026-05-01T10:00:02.500Z', { input_tokens: 100, output_tokens: 40 }, [bashBlock('gh pr create')]) + '\n' +
+ prLinkLine('2026-05-01T10:00:06.000Z', 'https://github.com/o/r/pull/2') + '\n')
+
+ const warm = await parseWith(warmCache)
+ const cold = await coldFullReparse()
+ expect(warm).toEqual(cold)
+ expect(warm[0]!.sessions[0]!.turns[0]!.prRefs).toEqual([
+ 'https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2',
+ ])
+ await rm(warmCache, { recursive: true, force: true })
+ })
+
it('EDGE: append after a previously-torn line completes still equals cold', async () => {
const warmCache = await mkdtemp(join(tmpdir(), 'incr-warm2-'))
diff --git a/tests/parser-rich-capture.test.ts b/tests/parser-rich-capture.test.ts
index 2eaa23c..1422e61 100644
--- a/tests/parser-rich-capture.test.ts
+++ b/tests/parser-rich-capture.test.ts
@@ -183,6 +183,43 @@ describe('gitBranch capture + dedup', () => {
})
})
+// ── per-turn PR references (prRefs) ────────────────────────────────────
+
+function prLink(url: string, sessionId = 's1'): JournalEntry {
+ return { type: 'pr-link', timestamp: '2026-07-01T10:00:03Z', sessionId, prUrl: url } as JournalEntry
+}
+
+describe('pr-link capture + per-turn prRefs', () => {
+ it('attaches a PR referenced during a turn to that turn, and it survives caching', () => {
+ const entries = [
+ userText('open a PR', 'main'), assistant('m1', 'main'),
+ prLink('https://github.com/o/r/pull/1'),
+ userText('next task', 'main'), assistant('m2', 'main'),
+ ]
+ const turns = groupIntoTurns(entries, new Set())
+ expect(turns[0]!.prRefs).toEqual(['https://github.com/o/r/pull/1'])
+ expect(turns[1]!.prRefs).toBeUndefined()
+ const cached = parsedTurnsToCachedTurns(turns)
+ // Stored per-turn directly (no change-detection dedup like gitBranch).
+ expect(cached[0]!.prRefs).toEqual(['https://github.com/o/r/pull/1'])
+ expect(cached[1]!.prRefs).toBeUndefined()
+ })
+
+ it('sorts and dedupes multiple refs within one merge-sweep turn', () => {
+ const entries = [
+ userText('merge sweep', 'main'), assistant('m1', 'main'),
+ prLink('https://github.com/o/r/pull/2'),
+ prLink('https://github.com/o/r/pull/1'),
+ prLink('https://github.com/o/r/pull/2'),
+ ]
+ const turns = groupIntoTurns(entries, new Set())
+ expect(turns[0]!.prRefs).toEqual([
+ 'https://github.com/o/r/pull/1',
+ 'https://github.com/o/r/pull/2',
+ ])
+ })
+})
+
// ── session meta: ai-title last-wins, pr-link accumulation ─────────────
describe('collectSessionMeta', () => {
diff --git a/tests/session-cache-rich-capture.test.ts b/tests/session-cache-rich-capture.test.ts
index 7848c77..d824ea9 100644
--- a/tests/session-cache-rich-capture.test.ts
+++ b/tests/session-cache-rich-capture.test.ts
@@ -52,7 +52,7 @@ function richFile(): CachedFile {
prLinks: ['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2'],
isSidechain: true,
turns: [
- { timestamp: '2026-07-01T10:00:00Z', sessionId: 's1', userMessage: 'hi', gitBranch: 'feature/x', calls: [richCall()] },
+ { timestamp: '2026-07-01T10:00:00Z', sessionId: 's1', userMessage: 'hi', gitBranch: 'feature/x', prRefs: ['https://github.com/o/r/pull/1'], calls: [richCall()] },
],
}
}
@@ -73,6 +73,7 @@ describe('session cache round-trip for rich-capture fields', () => {
const turn = file.turns[0]!
expect(turn.gitBranch).toBe('feature/x')
+ expect(turn.prRefs).toEqual(['https://github.com/o/r/pull/1'])
const call = turn.calls[0]!
expect(call.locAdded).toBe(12)
diff --git a/tests/session-cache-v5-adoption.test.ts b/tests/session-cache-v5-adoption.test.ts
new file mode 100644
index 0000000..b8617a9
--- /dev/null
+++ b/tests/session-cache-v5-adoption.test.ts
@@ -0,0 +1,185 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
+import { join } from 'path'
+import { tmpdir } from 'os'
+
+import { parseAllSessions, clearSessionCache } from '../src/parser.js'
+import { aggregateByPr } from '../src/sessions-report.js'
+import { loadPricing } from '../src/models.js'
+
+// Finding 1: the 5 -> 6 session-cache bump must not make PR-linked sessions whose
+// transcript has since expired VANISH. loadCache adopts such expired-source
+// entries from session-cache.v5.json, and the claude scan preserves + surfaces
+// them so the by-PR legacy even-split path is actually reachable.
+
+let tmpDir: string
+let cacheDir: string
+let configDir: string
+
+beforeEach(async () => {
+ clearSessionCache()
+ tmpDir = await mkdtemp(join(tmpdir(), 'v5-adopt-'))
+ cacheDir = join(tmpDir, 'cache')
+ configDir = join(tmpDir, 'claude')
+ await mkdir(cacheDir, { recursive: true })
+ // A present, non-PR session so discovery finds a project dir (dirs.length > 0,
+ // exercising the eviction path the orphan must survive).
+ const presentDir = join(configDir, 'projects', 'present-proj')
+ await mkdir(presentDir, { recursive: true })
+ await writeFile(join(presentDir, 'present.jsonl'),
+ JSON.stringify({ type: 'user', sessionId: 'present', timestamp: '2026-07-20T09:00:00.000Z', cwd: '/present', message: { role: 'user', content: 'hi' } }) + '\n' +
+ JSON.stringify({ type: 'assistant', sessionId: 'present', timestamp: '2026-07-20T09:00:01.000Z', cwd: '/present', message: { id: 'p1', type: 'message', role: 'assistant', model: 'claude-opus-4-6', content: [], usage: { input_tokens: 10, output_tokens: 5 } } }) + '\n')
+ process.env['CLAUDE_CONFIG_DIR'] = configDir
+ process.env['CODEBURN_CACHE_DIR'] = cacheDir
+})
+
+afterEach(async () => {
+ clearSessionCache()
+ delete process.env['CLAUDE_CONFIG_DIR']
+ delete process.env['CODEBURN_CACHE_DIR']
+ await rm(tmpDir, { recursive: true, force: true })
+})
+
+function cachedCall(dedup: string, cost: number): Record {
+ return {
+ provider: 'claude', model: 'claude-opus-4-6',
+ usage: { inputTokens: 100, outputTokens: 50, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, cacheCreationOneHourTokens: 0 },
+ costUSD: cost, speed: 'standard', timestamp: '2026-07-20T10:00:00.000Z',
+ tools: [], bashCommands: [], skills: [], subagentTypes: [], deduplicationKey: dedup,
+ }
+}
+
+describe('v5 -> v6 cache adoption of expired PR sessions', () => {
+ it('keeps a PR-linked session whose transcript is gone, as a legacy approx split', async () => {
+ await loadPricing()
+ // A v5 cache whose one entry points at a transcript that no longer exists.
+ const gonePath = join(configDir, 'projects', 'gone-proj', 'gone.jsonl')
+ const v5 = {
+ version: 5,
+ complete: true,
+ providers: {
+ claude: {
+ envFingerprint: 'stale-v5-fingerprint',
+ files: {
+ [gonePath]: {
+ fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 },
+ mcpInventory: [],
+ canonicalCwd: '/gone/proj',
+ canonicalProjectName: 'gone-proj',
+ prLinks: ['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2'],
+ // Two calls, $40 each -> session total $80, no per-turn prRefs (v5).
+ turns: [{
+ timestamp: '2026-07-20T10:00:00.000Z', sessionId: 'gone', userMessage: 'shipped work',
+ calls: [cachedCall('k1', 40), cachedCall('k2', 40)],
+ }],
+ },
+ },
+ },
+ },
+ }
+ await writeFile(join(cacheDir, 'session-cache.v5.json'), JSON.stringify(v5))
+
+ const range = { start: new Date('2026-07-20T00:00:00Z'), end: new Date('2026-07-20T23:59:59Z') }
+ const projects = await parseAllSessions(range, 'claude')
+ const rows = aggregateByPr(projects)
+
+ // Both PRs survive, each carrying the even-split half of the $80 session, and
+ // are flagged approx (legacy) with no category breakdown.
+ expect(rows).toHaveLength(2)
+ expect(rows.every(r => r.approx)).toBe(true)
+ expect(rows.every(r => r.categories === undefined)).toBe(true)
+ expect(rows[0]!.cost).toBeCloseTo(40, 6)
+ expect(rows[1]!.cost).toBeCloseTo(40, 6)
+ // Model union is still surfaced on legacy rows.
+ expect(rows[0]!.models.length).toBeGreaterThan(0)
+ })
+
+ it('skips a corrupt v5 entry but still adopts a valid expired PR entry', async () => {
+ await loadPricing()
+ const goodPath = join(configDir, 'projects', 'good-proj', 'good.jsonl')
+ const badPath = join(configDir, 'projects', 'bad-proj', 'bad.jsonl')
+ const v5 = {
+ version: 5,
+ complete: true,
+ providers: {
+ claude: {
+ envFingerprint: 'stale-v5',
+ files: {
+ // Corrupt: malformed turns fail validateCachedFile and are skipped.
+ [badPath]: {
+ fingerprint: { dev: 9, ino: 9, mtimeMs: 9, sizeBytes: 9 },
+ mcpInventory: [],
+ prLinks: ['https://github.com/o/r/pull/9'],
+ turns: [{ garbage: true }],
+ },
+ // Valid expired PR entry that must survive alongside the corrupt one.
+ [goodPath]: {
+ fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 },
+ mcpInventory: [],
+ canonicalCwd: '/good/proj',
+ canonicalProjectName: 'good-proj',
+ prLinks: ['https://github.com/o/r/pull/1'],
+ turns: [{ timestamp: '2026-07-20T10:00:00.000Z', sessionId: 'good', userMessage: 'work', calls: [cachedCall('gk1', 40)] }],
+ },
+ },
+ },
+ },
+ }
+ await writeFile(join(cacheDir, 'session-cache.v5.json'), JSON.stringify(v5))
+
+ const range = { start: new Date('2026-07-20T00:00:00Z'), end: new Date('2026-07-20T23:59:59Z') }
+ const rows = aggregateByPr(await parseAllSessions(range, 'claude'))
+ const urls = rows.map(r => r.url)
+ expect(urls).toContain('https://github.com/o/r/pull/1') // valid entry survives
+ expect(urls).not.toContain('https://github.com/o/r/pull/9') // corrupt entry skipped, not fatal
+ })
+
+ it('skips a v5 entry whose optional agentType or failed field is malformed', async () => {
+ await loadPricing()
+ const badTypePath = join(configDir, 'projects', 'bad-type', 'bad-type.jsonl')
+ const badFailedPath = join(configDir, 'projects', 'bad-failed', 'bad-failed.jsonl')
+ const goodPath = join(configDir, 'projects', 'good-proj', 'good.jsonl')
+ const validTurn = { timestamp: '2026-07-20T10:00:00.000Z', sessionId: 's', userMessage: 'work', calls: [cachedCall('vk1', 40)] }
+ const v5 = {
+ version: 5,
+ complete: true,
+ providers: {
+ claude: {
+ envFingerprint: 'stale-v5',
+ files: {
+ [badTypePath]: {
+ fingerprint: { dev: 9, ino: 9, mtimeMs: 9, sizeBytes: 9 },
+ mcpInventory: [],
+ prLinks: ['https://github.com/o/r/pull/7'],
+ agentType: {},
+ turns: [validTurn],
+ },
+ [badFailedPath]: {
+ fingerprint: { dev: 8, ino: 8, mtimeMs: 8, sizeBytes: 8 },
+ mcpInventory: [],
+ prLinks: ['https://github.com/o/r/pull/8'],
+ failed: 'yes',
+ turns: [validTurn],
+ },
+ [goodPath]: {
+ fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 },
+ mcpInventory: [],
+ prLinks: ['https://github.com/o/r/pull/1'],
+ agentType: 'general-purpose',
+ failed: false,
+ turns: [validTurn],
+ },
+ },
+ },
+ },
+ }
+ await writeFile(join(cacheDir, 'session-cache.v5.json'), JSON.stringify(v5))
+
+ const range = { start: new Date('2026-07-20T00:00:00Z'), end: new Date('2026-07-20T23:59:59Z') }
+ const rows = aggregateByPr(await parseAllSessions(range, 'claude'))
+ const urls = rows.map(r => r.url)
+ expect(urls).toContain('https://github.com/o/r/pull/1') // well-formed optionals adopt fine
+ expect(urls).not.toContain('https://github.com/o/r/pull/7') // agentType: {} is corrupt, skipped
+ expect(urls).not.toContain('https://github.com/o/r/pull/8') // failed: 'yes' is corrupt, skipped
+ })
+})
diff --git a/tests/sessions-by-pr.test.ts b/tests/sessions-by-pr.test.ts
index b94ac5b..e882ee7 100644
--- a/tests/sessions-by-pr.test.ts
+++ b/tests/sessions-by-pr.test.ts
@@ -1,8 +1,13 @@
import { describe, expect, it } from 'vitest'
-import { aggregateByPr, prLinkedTotals, shortenPrUrl } from '../src/sessions-report.js'
-import type { ProjectSummary, SessionSummary } from '../src/types.js'
+import { aggregateByPr, allocateEven, attributeSessionPrSpend, prLinkedTotals, shortenPrUrl } from '../src/sessions-report.js'
+import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary, TokenUsage } from '../src/types.js'
+const A = 'https://github.com/o/r/pull/1'
+const B = 'https://github.com/o/r/pull/2'
+
+// A legacy session: session-level prLinks but NO per-turn refs (turns: []), so
+// the by-PR path takes the even-split fallback.
function session(id: string, cost: number, calls: number, prLinks?: string[], first = '2026-07-01T10:00:00Z', last = '2026-07-01T11:00:00Z'): SessionSummary {
return {
sessionId: id, project: 'p',
@@ -19,6 +24,46 @@ function session(id: string, cost: number, calls: number, prLinks?: string[], fi
}
}
+const ZERO_USAGE: TokenUsage = {
+ inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0,
+ cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
+}
+
+let keySeq = 0
+function call(cost: number): ParsedApiCall {
+ return {
+ provider: 'claude', model: 'claude', usage: ZERO_USAGE, costUSD: cost,
+ tools: [], mcpTools: [], skills: [], subagentTypes: [],
+ hasAgentSpawn: false, hasPlanMode: false, speed: 'standard',
+ timestamp: '2026-07-01T10:00:00Z', bashCommands: [], deduplicationKey: `k${keySeq++}`,
+ }
+}
+
+// A turn whose total cost `cost` is split across `calls` API calls, optionally
+// referencing `prRefs` (the PRs touched during the turn).
+function cturn(cost: number, calls = 1, prRefs?: string[]): ClassifiedTurn {
+ return {
+ userMessage: '',
+ assistantCalls: Array.from({ length: calls }, () => call(cost / calls)),
+ timestamp: '2026-07-01T10:00:00Z', sessionId: 's',
+ category: 'coding', retries: 0, hasEdits: false,
+ ...(prRefs ? { prRefs } : {}),
+ }
+}
+
+function sessionWithTurns(id: string, prLinks: string[], turns: ClassifiedTurn[], first = '2026-07-01T10:00:00Z', last = '2026-07-01T11:00:00Z'): SessionSummary {
+ return { ...session(id, 0, 0, prLinks, first, last), turns }
+}
+
+// A turn referencing `prRefs` whose calls each carry a [model, cost] pair.
+function turnModels(prRefs: string[], calls: Array<[string, number]>): ClassifiedTurn {
+ return {
+ userMessage: '', timestamp: '2026-07-01T10:00:00Z', sessionId: 's',
+ category: 'coding', retries: 0, hasEdits: false, prRefs,
+ assistantCalls: calls.map(([model, cost]) => ({ ...call(cost), model })),
+ }
+}
+
function project(sessions: SessionSummary[]): ProjectSummary {
return { project: 'p', projectPath: '/p', sessions, totalCostUSD: 0, totalSavingsUSD: 0, totalApiCalls: 0, totalProxiedCostUSD: 0 }
}
@@ -30,14 +75,126 @@ describe('shortenPrUrl', () => {
})
})
-describe('aggregateByPr', () => {
- it('groups sessions by PR, sorted by cost, tracking the date span', () => {
+describe('attributeSessionPrSpend (turn-level state machine)', () => {
+ it('attributes each turn of interleaved A,B,A,B to its own PR', () => {
+ const { perUrl, unattributed } = attributeSessionPrSpend({
+ prLinks: [A, B], totalCostUSD: 40, apiCalls: 4, totalSavingsUSD: 0,
+ turns: [
+ { prRefs: [A], assistantCalls: [{ costUSD: 10 }] },
+ { prRefs: [B], assistantCalls: [{ costUSD: 10 }] },
+ { prRefs: [A], assistantCalls: [{ costUSD: 10 }] },
+ { prRefs: [B], assistantCalls: [{ costUSD: 10 }] },
+ ],
+ })
+ expect(perUrl.get(A)!.cost).toBeCloseTo(20, 6)
+ expect(perUrl.get(B)!.cost).toBeCloseTo(20, 6)
+ expect(perUrl.get(A)!.approx).toBe(false)
+ expect(unattributed.cost).toBe(0)
+ })
+
+ it('splits a multi-PR merge-sweep turn evenly across its refs (cost and calls)', () => {
+ const { perUrl } = attributeSessionPrSpend({
+ prLinks: [A, B], totalCostUSD: 100, apiCalls: 4, totalSavingsUSD: 0,
+ turns: [{ prRefs: [A, B], assistantCalls: [{ costUSD: 25 }, { costUSD: 25 }, { costUSD: 25 }, { costUSD: 25 }] }],
+ })
+ expect(perUrl.get(A)!.cost).toBeCloseTo(50, 6)
+ expect(perUrl.get(B)!.cost).toBeCloseTo(50, 6)
+ expect(perUrl.get(A)!.calls).toBeCloseTo(2, 6)
+ expect(perUrl.get(B)!.calls).toBeCloseTo(2, 6)
+ })
+
+ it('accumulates pre-first-reference turns into the unattributed bucket', () => {
+ const { perUrl, unattributed } = attributeSessionPrSpend({
+ prLinks: [A], totalCostUSD: 40, apiCalls: 2, totalSavingsUSD: 0,
+ turns: [
+ { assistantCalls: [{ costUSD: 30 }] },
+ { prRefs: [A], assistantCalls: [{ costUSD: 10 }] },
+ ],
+ })
+ expect(unattributed.cost).toBeCloseTo(30, 6)
+ expect(perUrl.get(A)!.cost).toBeCloseTo(10, 6)
+ })
+
+ it('carries the current PR forward so a single-PR session attributes everything', () => {
+ const { perUrl, unattributed } = attributeSessionPrSpend({
+ prLinks: [A], totalCostUSD: 60, apiCalls: 3, totalSavingsUSD: 0,
+ turns: [
+ { prRefs: [A], assistantCalls: [{ costUSD: 10 }] },
+ { assistantCalls: [{ costUSD: 20 }] },
+ { assistantCalls: [{ costUSD: 30 }] },
+ ],
+ })
+ expect(perUrl.get(A)!.cost).toBeCloseTo(60, 6)
+ expect(unattributed.cost).toBe(0)
+ })
+
+ it('falls back to an even whole-session split (approx) when no turn carries refs', () => {
+ const { perUrl, unattributed } = attributeSessionPrSpend({
+ prLinks: [A, B], totalCostUSD: 100, apiCalls: 8, totalSavingsUSD: 4,
+ turns: [{ assistantCalls: [{ costUSD: 100, savingsUSD: 4 }] }],
+ })
+ expect(perUrl.get(A)!.cost).toBeCloseTo(50, 6)
+ expect(perUrl.get(B)!.cost).toBeCloseTo(50, 6)
+ expect(perUrl.get(A)!.calls).toBeCloseTo(4, 6)
+ expect(perUrl.get(A)!.approx).toBe(true)
+ expect(perUrl.get(B)!.approx).toBe(true)
+ expect(unattributed.cost).toBe(0)
+ })
+
+ it('attributed + unattributed equals the sum of turn costs', () => {
+ const { perUrl, unattributed } = attributeSessionPrSpend({
+ prLinks: [A, B], totalCostUSD: 0, apiCalls: 0, totalSavingsUSD: 0,
+ turns: [
+ { assistantCalls: [{ costUSD: 7 }] },
+ { prRefs: [A], assistantCalls: [{ costUSD: 13 }] },
+ { prRefs: [A, B], assistantCalls: [{ costUSD: 20 }] },
+ ],
+ })
+ const attributed = [...perUrl.values()].reduce((s, c) => s + c.cost, 0)
+ expect(attributed + unattributed.cost).toBeCloseTo(40, 6)
+ })
+})
+
+describe('aggregateByPr (turn-level attribution)', () => {
+ it('splits a multi-PR session across turns instead of counting the full cost to each', () => {
const rows = aggregateByPr([project([
- session('a', 100, 40, ['https://github.com/o/r/pull/1'], '2026-07-01T10:00:00Z', '2026-07-01T11:00:00Z'),
- session('b', 50, 10, ['https://github.com/o/r/pull/1'], '2026-06-20T09:00:00Z', '2026-06-20T10:00:00Z'),
- session('c', 200, 80, ['https://github.com/o/r/pull/2']),
+ sessionWithTurns('s', [A, B], [
+ cturn(10, 1, [A]),
+ cturn(30, 1, [B]),
+ cturn(20, 1), // no refs → carries B forward
+ ]),
])])
- expect(rows.map(r => r.label)).toEqual(['o/r#2', 'o/r#1'])
+ const a = rows.find(r => r.url === A)!
+ const b = rows.find(r => r.url === B)!
+ expect(a.cost).toBeCloseTo(10, 6) // NOT the full 60 a by-reference count would give
+ expect(b.cost).toBeCloseTo(50, 6)
+ expect(a.approx).toBe(false)
+ expect(a.sessions).toBe(1)
+ })
+
+ it('rows are summable: attributed rows + unattributed equal the PR-linked total', () => {
+ const projects = [project([
+ sessionWithTurns('s', [A], [
+ cturn(30, 1), // overhead before the first PR reference
+ cturn(10, 1, [A]),
+ ]),
+ ])]
+ const rows = aggregateByPr(projects)
+ const totals = prLinkedTotals(projects)
+ const rowSum = rows.reduce((s, r) => s + r.cost, 0)
+ expect(rowSum).toBeCloseTo(totals.attributedCost, 6)
+ expect(totals.attributedCost).toBeCloseTo(10, 6)
+ expect(totals.unattributedCost).toBeCloseTo(30, 6)
+ expect(totals.cost).toBeCloseTo(40, 6)
+ })
+
+ it('groups legacy sessions by PR, sorted by cost, tracking the date span', () => {
+ const rows = aggregateByPr([project([
+ session('a', 100, 40, [A], '2026-07-01T10:00:00Z', '2026-07-01T11:00:00Z'),
+ session('b', 50, 10, [A], '2026-06-20T09:00:00Z', '2026-06-20T10:00:00Z'),
+ session('c', 200, 80, [B]),
+ ])])
+ expect(rows.map(r => r.url)).toEqual([B, A])
const pr1 = rows[1]!
expect(pr1.cost).toBe(150)
expect(pr1.sessions).toBe(2)
@@ -46,13 +203,14 @@ describe('aggregateByPr', () => {
expect(pr1.lastEnded).toBe('2026-07-01T11:00:00Z')
})
- it('a session referencing several PRs counts fully toward each row', () => {
+ it('a legacy multi-PR session splits its cost evenly and marks the rows approx', () => {
const rows = aggregateByPr([project([
- session('a', 100, 40, ['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2']),
+ session('a', 100, 40, [A, B]),
])])
expect(rows).toHaveLength(2)
- expect(rows[0]!.cost).toBe(100)
- expect(rows[1]!.cost).toBe(100)
+ expect(rows[0]!.cost).toBeCloseTo(50, 6)
+ expect(rows[1]!.cost).toBeCloseTo(50, 6)
+ expect(rows.every(r => r.approx)).toBe(true)
})
it('sessions without links contribute nothing', () => {
@@ -60,13 +218,164 @@ describe('aggregateByPr', () => {
})
})
-describe('prLinkedTotals', () => {
- it('counts each PR-linked session once regardless of how many PRs it references', () => {
- const totals = prLinkedTotals([project([
- session('a', 100, 40, ['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2']),
- session('b', 50, 10, ['https://github.com/o/r/pull/1']),
- session('c', 999, 1),
- ])])
- expect(totals).toEqual({ cost: 150, sessions: 2 })
+describe('range-carry seed (finding 2)', () => {
+ it('seeds current from a PR referenced before the range, not a legacy split', () => {
+ // turn1 ref A, turn2 ref B are both before the range; the parser passes B as
+ // the seed. The 8 in-range ref-less turns ($100) must all go to B, not split
+ // $50/$50 approx across A and B.
+ const { perUrl, unattributed } = attributeSessionPrSpend({
+ prLinks: [A, B], totalCostUSD: 100, apiCalls: 8, totalSavingsUSD: 0,
+ prRefsAtRangeStart: [B],
+ turns: Array.from({ length: 8 }, () => ({ category: 'coding', assistantCalls: [{ costUSD: 12.5, model: 'm' }] })),
+ })
+ expect(perUrl.get(B)!.cost).toBeCloseTo(100, 6)
+ expect(perUrl.get(B)!.approx).toBe(false)
+ expect(perUrl.has(A)).toBe(false)
+ expect(unattributed.cost).toBe(0)
+ })
+
+ it('lets an in-range reference override the seed', () => {
+ const { perUrl } = attributeSessionPrSpend({
+ prLinks: [A, B], totalCostUSD: 0, apiCalls: 0, totalSavingsUSD: 0,
+ prRefsAtRangeStart: [A],
+ turns: [
+ { assistantCalls: [{ costUSD: 10 }] }, // seeded -> A
+ { prRefs: [B], assistantCalls: [{ costUSD: 20 }] }, // switch -> B
+ { assistantCalls: [{ costUSD: 5 }] }, // carries B
+ ],
+ })
+ expect(perUrl.get(A)!.cost).toBeCloseTo(10, 6)
+ expect(perUrl.get(B)!.cost).toBeCloseTo(25, 6)
+ })
+})
+
+describe('call allocation (finding 5)', () => {
+ it('allocateEven gives the remainder to the first buckets and sums to total', () => {
+ expect(allocateEven(1, 2)).toEqual([1, 0])
+ expect(allocateEven(5, 2)).toEqual([3, 2])
+ expect(allocateEven(4, 2)).toEqual([2, 2])
+ expect(allocateEven(0, 3)).toEqual([0, 0, 0])
+ expect(allocateEven(7, 3)).toEqual([3, 2, 2])
+ })
+
+ it('a 1-call, 2-PR turn stays whole (no 0.5 that rounds up to 1 on each row)', () => {
+ const { perUrl } = attributeSessionPrSpend({
+ prLinks: [A, B], totalCostUSD: 0, apiCalls: 0, totalSavingsUSD: 0,
+ turns: [{ prRefs: [A, B], category: 'coding', assistantCalls: [{ costUSD: 4 }] }],
+ })
+ expect(perUrl.get(A)!.calls + perUrl.get(B)!.calls).toBe(1)
+ expect(perUrl.get(A)!.calls).toBe(1)
+ expect(perUrl.get(B)!.calls).toBe(0)
+ })
+})
+
+describe('models + categories attribution', () => {
+ it('records per-model attributed cost and spreads a multi-PR turn to each PR', () => {
+ const { perUrl } = attributeSessionPrSpend({
+ prLinks: [A, B], totalCostUSD: 0, apiCalls: 0, totalSavingsUSD: 0,
+ turns: [
+ { prRefs: [A], category: 'coding', assistantCalls: [{ costUSD: 10, model: 'claude-opus-4-6' }, { costUSD: 5, model: 'claude-haiku-4' }] },
+ { prRefs: [A, B], category: 'coding', assistantCalls: [{ costUSD: 20, model: 'claude-opus-4-6' }] },
+ ],
+ })
+ // A: opus 10 + haiku 5 + half of turn2 opus (10) = opus 20, haiku 5
+ expect(perUrl.get(A)!.models.get('claude-opus-4-6')).toBeCloseTo(20, 6)
+ expect(perUrl.get(A)!.models.get('claude-haiku-4')).toBeCloseTo(5, 6)
+ // B: half of turn2 opus = 10
+ expect(perUrl.get(B)!.models.get('claude-opus-4-6')).toBeCloseTo(10, 6)
+ })
+
+ it('accumulates category cost per PR (turn-level) and omits categories for legacy', () => {
+ const { perUrl } = attributeSessionPrSpend({
+ prLinks: [A], totalCostUSD: 0, apiCalls: 0, totalSavingsUSD: 0,
+ turns: [
+ { prRefs: [A], category: 'coding', assistantCalls: [{ costUSD: 10 }] },
+ { category: 'debugging', assistantCalls: [{ costUSD: 6 }] },
+ ],
+ })
+ expect(perUrl.get(A)!.categories.get('coding')).toBeCloseTo(10, 6)
+ expect(perUrl.get(A)!.categories.get('debugging')).toBeCloseTo(6, 6)
+
+ const legacy = attributeSessionPrSpend({
+ prLinks: [A, B], totalCostUSD: 100, apiCalls: 8, totalSavingsUSD: 0,
+ turns: [{ assistantCalls: [{ costUSD: 100, model: 'claude-opus-4-6' }] }],
+ })
+ expect(legacy.perUrl.get(A)!.categories.size).toBe(0) // no faked categories
+ expect(legacy.perUrl.get(A)!.models.get('claude-opus-4-6')).toBeCloseTo(50, 6) // model union still split
+ })
+
+ it('exposes short model names and display category labels on aggregated rows', () => {
+ const rows = aggregateByPr([project([
+ sessionWithTurns('s', [A], [cturn(10, 1, [A])]),
+ ])])
+ expect(rows[0]!.models.length).toBe(1)
+ expect(rows[0]!.categories).toEqual([{ name: 'Coding', cost: 10 }])
+ })
+})
+
+describe('mixed live + legacy category reconciliation (round-3 finding 1)', () => {
+ it('adds a synthetic legacy line so a mixed row reconciles to its cost', () => {
+ const rows = aggregateByPr([project([
+ sessionWithTurns('live', [A], [cturn(10, 1, [A])]), // live: Coding $10
+ session('legacy', 90, 5, [A]), // legacy: $90, no turn data
+ ])])
+ const row = rows.find(r => r.url === A)!
+ expect(row.cost).toBeCloseTo(100, 6)
+ expect(row.approx).toBe(true)
+ const cats = row.categories!
+ expect(cats.reduce((s, c) => s + c.cost, 0)).toBeCloseTo(row.cost, 6) // reconciles
+ expect(cats.find(c => c.name === 'Coding')!.cost).toBeCloseTo(10, 6)
+ expect(cats.find(c => c.name === 'Legacy estimate (no per-turn detail)')!.cost).toBeCloseTo(90, 6)
+ })
+
+ it('a legacy-only row still omits categories (surfaces the no-detail note)', () => {
+ const rows = aggregateByPr([project([session('legacy', 90, 5, [A])])])
+ expect(rows[0]!.categories).toBeUndefined()
+ })
+})
+
+describe('model list bounds (round-3 finding 5)', () => {
+ it('caps a row to the top 4 models by attributed cost', () => {
+ const rows = aggregateByPr([project([
+ sessionWithTurns('s', [A], [turnModels([A], [['m-f', 60], ['m-e', 50], ['m-d', 40], ['m-c', 30], ['m-b', 20], ['m-a', 10]])]),
+ ])])
+ expect(rows[0]!.models).toEqual(['m-f', 'm-e', 'm-d', 'm-c'])
+ })
+
+ it('breaks model ties by name ascending for a stable order', () => {
+ const rows = aggregateByPr([project([
+ sessionWithTurns('s', [A], [turnModels([A], [['m-d', 10], ['m-a', 10], ['m-c', 10], ['m-b', 10]])]),
+ ])])
+ expect(rows[0]!.models).toEqual(['m-a', 'm-b', 'm-c', 'm-d'])
+ })
+})
+
+describe('distinct-session keying (finding 7)', () => {
+ it('counts two same-sessionId sessions in different projects as two', () => {
+ const s1 = sessionWithTurns('same-id', [A], [cturn(10, 1, [A])])
+ const s2 = { ...sessionWithTurns('same-id', [A], [cturn(10, 1, [A])]), project: 'other' }
+ const rows = aggregateByPr([project([s1, s2])])
+ expect(rows[0]!.sessions).toBe(2)
+ })
+})
+
+describe('prLinkedTotals', () => {
+ it('splits attributed vs unattributed and counts each PR-linked session once', () => {
+ const totals = prLinkedTotals([project([
+ session('a', 100, 40, [A, B]), // legacy: 50 + 50 attributed
+ session('b', 50, 10, [A]), // legacy: 50 attributed
+ session('c', 999, 1), // no links → excluded
+ ])])
+ expect(totals).toEqual({ cost: 150, sessions: 2, attributedCost: 150, unattributedCost: 0 })
+ })
+
+ it('captures the unattributed remainder from pre-reference overhead', () => {
+ const totals = prLinkedTotals([project([
+ sessionWithTurns('s', [A], [cturn(30, 1), cturn(10, 1, [A])]),
+ ])])
+ expect(totals.attributedCost).toBeCloseTo(10, 6)
+ expect(totals.unattributedCost).toBeCloseTo(30, 6)
+ expect(totals.cost).toBeCloseTo(40, 6)
+ expect(totals.sessions).toBe(1)
})
})