From fd57316dc44b7568729a30368d070540eefa898c Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:51:52 -0700 Subject: [PATCH 1/7] fix(pr): a subagent whose description names a PR folds into that PR A subagent run folded into whatever PR its parent's last pr-link carried, so review runs for #1703, #1712, #1728 and #1729 landed on a stale closed #24. The Agent description ("Review PR 1729 ...") is now read from the subagent's .meta.json at report time; a single PR it names outranks the carried PR. --- src/parser.ts | 17 +++++ src/pr-signals.ts | 20 +++++ src/sessions-report.ts | 25 +++++- src/types.ts | 3 + tests/pr-insights.test.ts | 157 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 src/pr-signals.ts create mode 100644 tests/pr-insights.test.ts diff --git a/src/parser.ts b/src/parser.ts index da108fec9..65422f00f 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2120,6 +2120,21 @@ export async function readAgentType(filePath: string): Promise() +async function readAgentDescription(filePath: string): Promise { + if (agentDescriptions.has(filePath)) return agentDescriptions.get(filePath) + let description: string | undefined + try { + const d = (JSON.parse(await readFile(filePath.replace(/\.jsonl$/, '.meta.json'), 'utf8')) as { description?: unknown }).description + if (typeof d === 'string' && d.trim()) description = flatString(d.trim().slice(0, 200)) + } catch { /* missing or unreadable meta */ } + agentDescriptions.set(filePath, description) + return description +} + async function scanProjectDirs( dirs: Array<{ path: string; name: string; source?: SessionSourceMetadata }>, seenMsgIds: Set, @@ -2625,12 +2640,14 @@ async function scanProjectDirs( ? normalizeProjectPathKey(cachedFile.canonicalCwd) : `slug:${dirName}` const fileProject: CallProject = { key: projectKey, path: projectPath, name: projectName } + const agentDescription = cachedFile.isSidechain ? await readAgentDescription(filePath) : undefined const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined const decorate = (session: SessionSummary): SessionSummary => { if (cachedFile.workingDirectory && !isCoworkSession(cachedFile.workingDirectory, filePath)) { session.workingDirectory = cachedFile.workingDirectory } session.agentType = cachedFile.agentType + if (agentDescription) session.agentDescription = agentDescription if (everHadBranch) session.everHadBranch = true const observedPrLinks = new Set(classifiedTurns.flatMap(turn => turn.prRefs ?? [])) for (const link of cachedFile.prLinks ?? []) observedPrLinks.add(link) diff --git a/src/pr-signals.ts b/src/pr-signals.ts new file mode 100644 index 000000000..a222427e3 --- /dev/null +++ b/src/pr-signals.ts @@ -0,0 +1,20 @@ +// A subagent description names its PR with a `PR` prefix or a /pull/N URL. +// A bare `#22` is too often an issue or a worker number to count. +const NAMED_PR_RE = /\bPR\s*#?(\d{1,6})\b|github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/g + +export type NamedPr = { number: number; repo?: string } + +/// The single PR a subagent description names, 'multi' when it names more than one +/// (or lists "PRs 1689-1692"), null when it names none. +export function namedPr(prompt: string): NamedPr | 'multi' | null { + if (/\bPRs\s*#?\d/.test(prompt)) return 'multi' + const numbers = new Set() + let repo: string | undefined + for (const m of prompt.matchAll(NAMED_PR_RE)) { + numbers.add(Number(m[1] ?? m[4])) + if (m[2]) repo = `${m[2]}/${m[3]}` + } + if (numbers.size > 1) return 'multi' + const [number] = numbers + return number === undefined ? null : { number, ...(repo ? { repo } : {}) } +} diff --git a/src/sessions-report.ts b/src/sessions-report.ts index 5ffacb9ab..20d30d769 100644 --- a/src/sessions-report.ts +++ b/src/sessions-report.ts @@ -2,6 +2,7 @@ import { behavioralCallCount, behavioralTurnCount } from './behavioral-weight.js import { ESTIMATED_COST_LEGEND, isEstimatedCost, markEstimated } from './format.js' import { modelRowKey } from './models.js' import { maxOf } from './math-utils.js' +import { namedPr, type NamedPr } from './pr-signals.js' import { inferSessionProvider, sessionBillableOutputTokens } from './session-output.js' import { CATEGORY_LABELS } from './types.js' import type { ProjectSummary, SessionSummary, TaskCategory } from './types.js' @@ -507,6 +508,9 @@ export type ChildFold = { models: Map categories: Map foldedSessions: number + /// The PR the top child's Agent description names (see `namedPr`). A single named PR in + /// the parent's repo outranks the carried-forward PR when the fold resolves. + named: NamedPr | 'multi' | null } function parseMs(ts: string | undefined): number { @@ -662,6 +666,7 @@ function buildChildFold(child: SessionSummary, index: Map { + const m = GITHUB_PR_RE.exec(url) + return m ? `${m[1]}/${m[2]}` : '' + })) + const repo = named.repo ?? (repos.size === 1 ? [...repos][0] : '') + if (!repo) return carried + return [`https://github.com/${repo}/pull/${named.number}`] } /// Resolve every folded child to its parent's PR set, once. Keyed by the parent's diff --git a/src/types.ts b/src/types.ts index dc9ec7032..54c8b878f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -274,6 +274,9 @@ export type SessionSummary = { // (`workflow-subagent`, `Explore`, `general-purpose`, …); undefined for // ordinary sessions. Drives the Claude-scoped agent-type breakdown. agentType?: string + /// Claude Code only: a subagent's Agent `description` from its `.meta.json` + /// ("Review PR 1729 Claude quota dir"). Read at report time, never cached. + agentDescription?: string /// OMP nested-agent file stem. Kept separate from Claude's agentType metadata. agentName?: string /// OMP nested-agent session-header timestamp. diff --git a/tests/pr-insights.test.ts b/tests/pr-insights.test.ts new file mode 100644 index 000000000..f77fcf94c --- /dev/null +++ b/tests/pr-insights.test.ts @@ -0,0 +1,157 @@ +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 { loadPricing } from '../src/models.js' +import { buildPrAttribution } from '../src/sessions-report.js' +import type { ProjectSummary } from '../src/types.js' + +// One orchestrator session in the shape Claude Code writes it: pr-link entries, +// Agent spawns with their results, subagent transcripts whose first user line is +// the run's prompt, and Bash tool_use commands. Modeled on the 10 Oct 2026 case +// where review runs for #1729 and #1712 folded into a stale #24 link. + +let tmpDir: string +let configDir: string +const CWD = '/tmp/prins-proj' +const REPO = 'https://github.com/getagentseal/codeburn/pull/' +const PARENT = '22222222-2222-4222-8222-222222222222' +const PR = (n: number) => `${REPO}${n}` + +type Tool = { name: string; input: Record; id?: string } +let msg = 0 +const line = (o: Record) => JSON.stringify({ sessionId: PARENT, cwd: CWD, ...o }) + '\n' +const user = (ts: string, content: unknown, extra: Record = {}) => line({ type: 'user', timestamp: ts, message: { role: 'user', content }, ...extra }) +const asst = (ts: string, tools: Tool[], model = 'claude-sonnet-4-5', extra: Record = {}) => line({ + type: 'assistant', timestamp: ts, ...extra, + message: { + id: `m${msg++}`, type: 'message', role: 'assistant', model, + content: tools.map((t, i) => ({ type: 'tool_use', id: t.id ?? `tu${msg}_${i}`, name: t.name, input: t.input })), + usage: { input_tokens: 1000, output_tokens: 500 }, + }, +}) +const bash = (command: string): Tool => ({ name: 'Bash', input: { command } }) +const prLink = (ts: string, n: number) => line({ type: 'pr-link', timestamp: ts, prUrl: PR(n) }) +const spawn = (id: string, description: string): Tool => ({ name: 'Agent', id, input: { description, prompt: '', subagent_type: 'general-purpose' } }) +const spawnResult = (ts: string, toolUseId: string, agentId: string) => + user(ts, [{ type: 'tool_result', tool_use_id: toolUseId, content: 'done' }], { toolUseResult: { status: 'completed', agentId, content: 'done' } }) + +type Child = { agentId: string; spawnId: string; description: string; prompt: string; at: string; tools: Array<[string, Tool[]]> } + +const CHILDREN: Child[] = [ + { + agentId: 'aR1694', spawnId: 'toolu_r1694', description: 'Review PR 1694 tooltip', + prompt: 'Read audit-0926/PR-REVIEW-BRIEF.md and follow it. PR #1694 by iamtoruk: "fix(menubar): tooltip" (+40/-3).', + at: '2026-10-10T08:30:00.000Z', + tools: [['2026-10-10T08:31:00.000Z', [bash('gh pr view 1694 --json files,commits')]], ['2026-10-10T08:32:00.000Z', [bash('gh pr diff 1694')]]], + }, + { + agentId: 'aF1694', spawnId: 'toolu_f1694', description: 'Apply fixes to PR 1694', + prompt: 'Apply the review fixes to PR #1694 and push them onto its branch.', + at: '2026-10-10T09:00:00.000Z', + tools: [ + ['2026-10-10T09:04:00.000Z', [bash('git commit -am "fix: review nits"')]], + ['2026-10-10T09:05:00.000Z', [bash('git push')]], + ['2026-10-10T09:08:00.000Z', [bash('git push --force-with-lease')]], + ], + }, + { + agentId: 'aR1729', spawnId: 'toolu_r1729', description: 'Review PR 1729 Claude quota dir', + prompt: 'Read audit-0926/PR-REVIEW-BRIEF.md and follow it. PR #1729 by renaudjx: "fix(quota): read the Claude login of a non-default CLAUDE_CONFIG_DIR on macOS" (+114/-20).', + at: '2026-10-10T10:01:00.000Z', + tools: [['2026-10-10T10:02:00.000Z', [bash('gh pr checkout 1729 && git diff main...HEAD')]]], + }, + { + agentId: 'aR1712', spawnId: 'toolu_r1712', description: 'Review PR 1712 Antigravity', + prompt: 'Read audit-0926/PR-REVIEW-BRIEF.md and follow it. PR #1712 by dcaldr (2 merged PRs here, incl. #1620): "fix(antigravity): robust model resolution" (+80/-4).', + at: '2026-10-10T10:01:00.000Z', + tools: [['2026-10-10T10:03:00.000Z', [bash('gh pr view 1712 --json files')]]], + }, + { + agentId: 'aMulti', spawnId: 'toolu_multi', description: 'Apply review fixes to PRs 1689-1692', + prompt: 'Apply the review fixes to PRs 1689-1692, one commit each, and push.', + at: '2026-10-10T10:01:00.000Z', + tools: [['2026-10-10T10:20:00.000Z', [bash('git commit -am fix && git push')]]], + }, +] + +async function writeTranscripts(): Promise { + const projDir = join(configDir, 'projects', 'prins-proj') + const subDir = join(projDir, PARENT, 'subagents') + await mkdir(subDir, { recursive: true }) + const c = (id: string) => CHILDREN.find(ch => ch.agentId === id)! + await writeFile(join(projDir, `${PARENT}.jsonl`), [ + user('2026-10-10T07:00:00.000Z', 'look around first'), + asst('2026-10-10T07:00:05.000Z', [bash('git status')]), + // #1694: pushed and opened here. + user('2026-10-10T08:00:00.000Z', 'ship the tooltip fix'), + asst('2026-10-10T08:00:05.000Z', [bash('git push -u origin fix/tooltip && gh pr create --fill')]), + prLink('2026-10-10T08:00:20.000Z', 1694), + user('2026-10-10T08:29:00.000Z', 'get it reviewed'), + asst('2026-10-10T08:29:30.000Z', [spawn(c('aR1694').spawnId, c('aR1694').description)]), + spawnResult('2026-10-10T08:40:00.000Z', c('aR1694').spawnId, 'aR1694'), + user('2026-10-10T08:59:00.000Z', 'apply the fixes'), + asst('2026-10-10T08:59:30.000Z', [spawn(c('aF1694').spawnId, c('aF1694').description)]), + spawnResult('2026-10-10T09:10:00.000Z', c('aF1694').spawnId, 'aF1694'), + user('2026-10-10T09:30:00.000Z', 'merge it'), + asst('2026-10-10T09:30:05.000Z', [bash('gh pr merge 1694 --squash --admin')]), + // A stale pr-link for an old closed PR; nothing here names 24. + user('2026-10-10T10:00:00.000Z', 'review the open contributor PRs'), + asst('2026-10-10T10:00:05.000Z', [bash('gh pr list --state open')]), + prLink('2026-10-10T10:00:10.000Z', 24), + asst('2026-10-10T10:00:30.000Z', ['aR1729', 'aR1712', 'aMulti'].map(id => spawn(c(id).spawnId, c(id).description))), + spawnResult('2026-10-10T10:30:00.000Z', c('aR1729').spawnId, 'aR1729'), + spawnResult('2026-10-10T10:30:01.000Z', c('aR1712').spawnId, 'aR1712'), + spawnResult('2026-10-10T10:30:02.000Z', c('aMulti').spawnId, 'aMulti'), + ].join('')) + for (const ch of CHILDREN) { + const side = { isSidechain: true, agentId: ch.agentId } + await writeFile(join(subDir, `agent-${ch.agentId}.jsonl`), [ + user(ch.at, ch.prompt, side), + line({ type: 'attachment', timestamp: ch.at, ...side }), + // Claude Code injects this before the first reply, so the cached turn's + // userMessage is the reminder, never the prompt. + user(ch.at, '\nYour final report is delivered through SubagentHandback.\n', { ...side, isMeta: true }), + ...ch.tools.map(([ts, tools]) => asst(ts, tools, 'claude-opus-4-8', side)), + ].join('')) + await writeFile(join(subDir, `agent-${ch.agentId}.meta.json`), JSON.stringify({ agentType: 'general-purpose', description: ch.description, toolUseId: ch.spawnId, model: 'opus' })) + } +} + +let projects: ProjectSummary[] + +beforeEach(async () => { + clearSessionCache() + tmpDir = await mkdtemp(join(tmpdir(), 'prins-')) + configDir = join(tmpDir, 'claude') + process.env['CLAUDE_CONFIG_DIR'] = configDir + process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache') + await loadPricing() + await writeTranscripts() + projects = await parseAllSessions({ start: new Date('2026-10-10T00:00:00Z'), end: new Date('2026-10-10T23:59:59Z') }, 'claude') +}) + +afterEach(async () => { + clearSessionCache() + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CODEBURN_CACHE_DIR'] + await rm(tmpDir, { recursive: true, force: true }) +}) + +const sessionCost = (id: string) => projects.flatMap(p => p.sessions).find(s => s.sessionId === id)!.totalCostUSD + +describe('a run whose prompt names one PR outranks the carried pr-link', () => { + it('moves the #1729 and #1712 review runs off the stale #24 and keeps totals', () => { + const { rows, totals } = buildPrAttribution(projects) + const row = (n: number) => rows.find(r => r.url === PR(n)) + expect(row(1729)?.cost).toBeCloseTo(sessionCost('agent-aR1729'), 9) + expect(row(1712)?.cost).toBeCloseTo(sessionCost('agent-aR1712'), 9) + // #24 keeps its own parent turn and the multi-PR run (a range is not one PR). + const parentTurn24 = projects.flatMap(p => p.sessions).find(s => s.sessionId === PARENT)! + .turns.filter(t => t.timestamp >= '2026-10-10T10:00:00').reduce((s, t) => s + t.assistantCalls.reduce((n, c) => n + c.costUSD, 0), 0) + expect(row(24)?.cost).toBeCloseTo(parentTurn24 + sessionCost('agent-aMulti'), 9) + expect(rows.reduce((s, r) => s + r.cost, 0)).toBeCloseTo(totals.attributedCost, 9) + }) +}) From 4783d3ba33b6bc0a06d9fda58c109230d8ceb36c Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:52:56 -0700 Subject: [PATCH 2/7] fix(pr): date each PR by its own turns and count its agent runs Every PR row carried the whole session's first and last timestamp, so one long orchestrator session stamped the same span and "1 session" on all of them. Rows now span their first to last attributed turn or folded run, and a new `runs` field counts contributing sessions plus folded subagent runs. --- src/main.ts | 4 ++-- src/sessions-report.ts | 28 ++++++++++++++++++++++------ tests/pr-insights.test.ts | 15 +++++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/main.ts b/src/main.ts index 31f228658..d4fc0480f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3107,7 +3107,7 @@ program { header: 'PR' }, { header: 'Cost', right: true }, { header: 'Saved', right: true }, - { header: 'Sessions', right: true }, + { header: 'Runs', right: true }, { header: 'Calls', right: true }, { header: 'Models' }, { header: 'First' }, @@ -3117,7 +3117,7 @@ program r.label, `${r.approx ? '~' : ''}$${r.cost.toFixed(2)}`, `$${r.savingsUSD.toFixed(2)}`, - String(r.sessions), + String(r.runs), String(r.calls), modelsCell(r.models), r.firstStarted.slice(0, 10), diff --git a/src/sessions-report.ts b/src/sessions-report.ts index 20d30d769..300db6bd2 100644 --- a/src/sessions-report.ts +++ b/src/sessions-report.ts @@ -410,8 +410,13 @@ export type PrRow = { savingsUSD: number sessions: number calls: number + /// Start of the first and end of the last turn (or folded run) attributed to + /// this PR. Legacy approx shares fall back to their session's span. firstStarted: string lastEnded: string + /// Agent runs that put spend on this PR: contributing parent sessions plus + /// the subagent runs folded into it. `sessions` stays the parent count. + runs: number /// 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. @@ -438,6 +443,9 @@ export type PrContribution = { cost: number; calls: number; savingsUSD: number; approx: boolean models: Map categories: Map + /// Span of the turns attributed here; empty on legacy approx shares. + firstTs: string + lastTs: string } /// A single session's PR-attributed spend: `perUrl` is the turn-level split @@ -451,7 +459,7 @@ export type SessionPrAttribution = { // 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; supplementaryAccounting?: boolean }> }> + turns: Array<{ prRefs?: string[]; category?: string; timestamp?: string; assistantCalls: Array<{ costUSD: number; savingsUSD?: number; model?: string; supplementaryAccounting?: boolean; timestamp?: string }> }> prLinks?: string[] totalCostUSD: number apiCalls: number @@ -470,7 +478,7 @@ function addToMap(m: Map, key: string, value: number): void { 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() } + e = { cost: 0, calls: 0, savingsUSD: 0, approx: false, models: new Map(), categories: new Map(), firstTs: '', lastTs: '' } map.set(url, e) } return e @@ -875,8 +883,12 @@ export function attributeSessionPrSpend(session: AttributableSession): SessionPr } const share = 1 / current.length const callAlloc = allocateEven(calls, current.length) + const startTs = turnStartTs(turn) + const endTs = turn.assistantCalls.at(-1)?.timestamp || startTs current.forEach((url, i) => { const e = ensureContribution(perUrl, url) + if (startTs && (!e.firstTs || startTs < e.firstTs)) e.firstTs = startTs + if (endTs > e.lastTs) e.lastTs = endTs e.cost += cost * share e.calls += callAlloc[i]! e.savingsUSD += savings * share @@ -907,7 +919,7 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution { const byUrl = new Map; firstStarted: string; lastEnded: string + sessions: Set; ownSessions: Set; foldedRuns: number; firstStarted: string; lastEnded: string models: Map; categories: Map }>() const attribution = resolveSubagentAttribution(projects) @@ -925,17 +937,20 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution { url: string, sessionKey: string, firstTs: string, lastTs: string, cost: number, savings: number, calls: number, approx: boolean, models: Map, categories: Map, + foldedRuns = 0, ): void => { if (cost === 0 && calls === 0 && savings === 0) return const row = byUrl.get(url) ?? { cost: 0, savingsUSD: 0, calls: 0, approx: false, legacyCost: 0, - sessions: new Set(), firstStarted: firstTs, lastEnded: lastTs, + sessions: new Set(), ownSessions: new Set(), foldedRuns: 0, firstStarted: firstTs, lastEnded: lastTs, models: new Map(), categories: new Map(), } row.cost += cost row.savingsUSD += savings row.calls += calls row.sessions.add(sessionKey) + if (foldedRuns) row.foldedRuns += foldedRuns + else row.ownSessions.add(sessionKey) if (approx) { row.approx = true; row.legacyCost += cost } for (const [m, mc] of models) addToMap(row.models, m, mc) for (const [cat, cc] of categories) addToMap(row.categories, cat, cc) @@ -968,7 +983,7 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution { const categories = new Map() for (const [cat, cc] of rc.fold.categories) categories.set(cat, cc * share) addTo(url, sessionKey, rc.fold.firstTs, rc.fold.lastTs, - rc.fold.cost * share, rc.fold.savingsUSD * share, callAlloc[i]!, false, models, categories) + rc.fold.cost * share, rc.fold.savingsUSD * share, callAlloc[i]!, false, models, categories, rc.fold.foldedSessions) }) } } @@ -981,7 +996,7 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution { const { perUrl, unattributed } = attributeSessionPrSpend(session) for (const [url, c] of perUrl) { attributedCost += c.cost - addTo(url, sessionKey, session.firstTimestamp, session.lastTimestamp, c.cost, c.savingsUSD, c.calls, c.approx, c.models, c.categories) + addTo(url, sessionKey, c.firstTs || session.firstTimestamp, c.lastTs || session.lastTimestamp, c.cost, c.savingsUSD, c.calls, c.approx, c.models, c.categories) } unattributedCost += unattributed.cost foldChildren(session) @@ -1026,6 +1041,7 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution { cost: r.cost, savingsUSD: r.savingsUSD, sessions: r.sessions.size, calls: r.calls, firstStarted: r.firstStarted, lastEnded: r.lastEnded, + runs: r.ownSessions.size + r.foldedRuns, approx: r.approx, models, ...(categories.length ? { categories } : {}), diff --git a/tests/pr-insights.test.ts b/tests/pr-insights.test.ts index f77fcf94c..ee08dc6f7 100644 --- a/tests/pr-insights.test.ts +++ b/tests/pr-insights.test.ts @@ -155,3 +155,18 @@ describe('a run whose prompt names one PR outranks the carried pr-link', () => { expect(rows.reduce((s, r) => s + r.cost, 0)).toBeCloseTo(totals.attributedCost, 9) }) }) + +describe('PR dates and runs come from the attributed work, not the session', () => { + it('dates each PR by its own turns and counts its agent runs', () => { + const { rows } = buildPrAttribution(projects) + const row = (n: number) => rows.find(r => r.url === PR(n))! + expect(row(1694).firstStarted).toBe('2026-10-10T08:00:05.000Z') + expect(row(1694).lastEnded).toBe('2026-10-10T09:30:05.000Z') + expect(row(24).firstStarted).toBe('2026-10-10T10:00:05.000Z') + expect(row(1729).firstStarted).toBe('2026-10-10T10:02:00.000Z') + expect(row(1694).sessions).toBe(1) + expect(row(1694).runs).toBe(3) + expect(row(24).runs).toBe(2) + expect(row(1729).runs).toBe(1) + }) +}) From 0792d48247681b3e8286f9582dc91ce8c905444b Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:08:35 -0700 Subject: [PATCH 3/7] feat(pr): local one-shot, follow-up rounds, review runs, merge time and cost split per PR Each PR row now carries signals read from cached commands, no GitHub calls: openedAt (gh pr create paired with the PR's first pr-link), follow-up rounds (pushes naming its --head branch, or from a run whose description names it, in 10-minute bursts), oneShot only when the open and its branch were seen, reviewRuns and reviewCostUSD, mergedAt and timeToMergeMs, a build/review/fixes cost split that sums to the row, and linkEvidence to flag pr-link-only rows. --- src/pr-signals.ts | 122 ++++++++++++++++- src/sessions-report.ts | 278 +++++++++++++++++++++++++++++++++++++- tests/pr-insights.test.ts | 75 +++++++++- 3 files changed, 463 insertions(+), 12 deletions(-) diff --git a/src/pr-signals.ts b/src/pr-signals.ts index a222427e3..9a7516e1d 100644 --- a/src/pr-signals.ts +++ b/src/pr-signals.ts @@ -1,16 +1,20 @@ -// A subagent description names its PR with a `PR` prefix or a /pull/N URL. -// A bare `#22` is too often an issue or a worker number to count. +// Local PR signals read from cached Bash commands and subagent descriptions. +// Only a command is cached, never its result, so a failed push or merge still +// counts. + +// A description names its PR with a `PR` prefix or a /pull/N URL. A bare `#22` +// is too often an issue, a worker number, or the PR a fix grew out of. const NAMED_PR_RE = /\bPR\s*#?(\d{1,6})\b|github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/g export type NamedPr = { number: number; repo?: string } -/// The single PR a subagent description names, 'multi' when it names more than one -/// (or lists "PRs 1689-1692"), null when it names none. -export function namedPr(prompt: string): NamedPr | 'multi' | null { - if (/\bPRs\s*#?\d/.test(prompt)) return 'multi' +/// The single PR a subagent description names, 'multi' when it names more +/// than one (or lists "PRs 1689-1692"), null when it names none. +export function namedPr(text: string): NamedPr | 'multi' | null { + if (/\bPRs\s*#?\d/.test(text)) return 'multi' const numbers = new Set() let repo: string | undefined - for (const m of prompt.matchAll(NAMED_PR_RE)) { + for (const m of text.matchAll(NAMED_PR_RE)) { numbers.add(Number(m[1] ?? m[4])) if (m[2]) repo = `${m[2]}/${m[3]}` } @@ -18,3 +22,107 @@ export function namedPr(prompt: string): NamedPr | 'multi' | null { const [number] = numbers return number === undefined ? null : { number, ...(repo ? { repo } : {}) } } + +export function namedPrNumber(text: string): number | null { + const n = namedPr(text) + return n && n !== 'multi' ? n.number : null +} + +// Command position only: `gh`/`git` inside a quoted string (a script or test +// being written) is not a command that ran. +const AT_CMD = String.raw`(?:^|[\s;&|(\`])` +const GIT = String.raw`${AT_CMD}git(?:\s+-[Cc]\s+\S+)*\s+` +const PUSH_RE = new RegExp(`${GIT}push\\b([^|;&\\n]*)`, 'g') +const COMMIT_RE = new RegExp(`${GIT}commit\\b`) +const GIT_DIFF_RE = new RegExp(`${GIT}diff\\b`) +const GH_PR_RE = new RegExp(String.raw`${AT_CMD}gh\s+pr\s+([a-z-]+)([^|;&\n]*)`, 'g') +const PR_ARG_VERBS = new Set(['view', 'diff', 'checkout', 'checks', 'merge', 'review', 'edit', 'comment', 'close', 'reopen', 'ready']) +const READ_VERBS = new Set(['view', 'diff', 'checkout', 'review']) +const PR_URL_RE = /github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/ +const API_PR_RE = /\/pulls?\/(\d+)\b/g + +const unquote = (s: string): string => s.replace(/"[^"]*"|'[^']*'/g, ' ') + +export const isPush = (cmd: string): boolean => pushes(cmd).length > 0 +export const isCommit = (cmd: string): boolean => COMMIT_RE.test(cmd) +export const readsCode = (cmd: string): boolean => GIT_DIFF_RE.test(cmd) || ghPrCommands(cmd).some(c => READ_VERBS.has(c.verb)) + +/// Each `git push` in a command line with the branches it names (the refspec +/// destination, `HEAD:fix/x` -> `fix/x`); [] for a bare `git push`. A branch +/// delete is not a push. +export function pushes(cmd: string): string[][] { + const out: string[][] = [] + for (const m of cmd.matchAll(PUSH_RE)) { + const tokens = unquote(m[1]!).trim().split(/\s+/).filter(t => t && !/[<>]/.test(t)) + if (tokens.some(t => t === '--delete' || t === '-d')) continue + const [, ...refspecs] = tokens.filter(t => !t.startsWith('-')) + out.push(refspecs + .map(r => r.replace(/^\+/, '').split(':').pop()!.replace(/^refs\/heads\//, '')) + .filter(b => b && b !== 'HEAD' && !b.startsWith('$'))) + } + return out +} + +export type GhPrCommand = { verb: string; number?: number; repo?: string; args: string } + +/// Every `gh pr ` in a command line, with the PR it names (a number or a +/// PR URL) and its -R/--repo. Quoted text is dropped first so a body's digits +/// never read as a PR number. +export function ghPrCommands(cmd: string): GhPrCommand[] { + const out: GhPrCommand[] = [] + for (const m of cmd.matchAll(GH_PR_RE)) { + const args = unquote(m[2]!) + const url = PR_URL_RE.exec(args) + const num = PR_ARG_VERBS.has(m[1]!) ? /(?:^|\s)#?(\d{1,6})(?=\s|$)/.exec(args) : null + const repo = url ? `${url[1]}/${url[2]}` : /(?:^|\s)(?:-R|--repo)[\s=]+([^\s/$]+\/[^\s/]+)/.exec(args)?.[1] + const number = url ? Number(url[3]) : num ? Number(num[1]) : undefined + out.push({ verb: m[1]!, args, ...(number !== undefined ? { number } : {}), ...(repo ? { repo } : {}) }) + } + return out +} + +/// The `--head` branch of a `gh pr create`, unless it is a shell variable. +export function createHead(args: string): string | undefined { + const head = /(?:^|\s)(?:--head|-H)[\s=]+(\S+)/.exec(args)?.[1] + return head && !head.startsWith('-') && !head.startsWith('$') ? head : undefined +} + +/// PR numbers a command names: `gh pr N`, a PR URL, or an API path. +export function prNumbersIn(cmd: string): Set { + const out = new Set() + for (const c of ghPrCommands(cmd)) if (c.number !== undefined) out.add(c.number) + for (const m of cmd.matchAll(API_PR_RE)) out.add(Number(m[1])) + for (const m of cmd.matchAll(new RegExp(PR_URL_RE.source, 'g'))) out.add(Number(m[3])) + return out +} + +/// PR numbers a command reads by number (gh pr view/diff/checkout/review N). +export function readPrNumbers(cmd: string): number[] { + return ghPrCommands(cmd).filter(c => READ_VERBS.has(c.verb) && c.number !== undefined).map(c => c.number!) +} + +export function reviewVerdict(args: string): 'approve' | 'request-changes' | 'comment' | 'review' { + if (/(?:^|\s)(?:--approve|-a)\b/.test(args)) return 'approve' + if (/(?:^|\s)(?:--request-changes|-r)\b/.test(args)) return 'request-changes' + if (/(?:^|\s)(?:--comment|-c)\b/.test(args)) return 'comment' + return 'review' +} + +export const FOLLOW_UP_GAP_MS = 10 * 60 * 1000 + +/// Pushes more than 10 minutes apart start a new follow-up round. +export function rounds(pushMs: number[]): number { + let n = 0 + let last = -Infinity + for (const ms of [...pushMs].sort((a, b) => a - b)) { + if (ms - last > FOLLOW_UP_GAP_MS) n++ + last = ms + } + return n +} + +export function callCommands(call: { toolSequence?: Array> }): string[] { + const out: string[] = [] + for (const step of call.toolSequence ?? []) for (const t of step) if (t.command) out.push(t.command) + return out +} diff --git a/src/sessions-report.ts b/src/sessions-report.ts index 300db6bd2..9b375a93a 100644 --- a/src/sessions-report.ts +++ b/src/sessions-report.ts @@ -2,7 +2,7 @@ import { behavioralCallCount, behavioralTurnCount } from './behavioral-weight.js import { ESTIMATED_COST_LEGEND, isEstimatedCost, markEstimated } from './format.js' import { modelRowKey } from './models.js' import { maxOf } from './math-utils.js' -import { namedPr, type NamedPr } from './pr-signals.js' +import { callCommands, createHead, ghPrCommands, isCommit, isPush, namedPr, namedPrNumber, prNumbersIn, pushes, readPrNumbers, readsCode, reviewVerdict, rounds, type NamedPr } from './pr-signals.js' import { inferSessionProvider, sessionBillableOutputTokens } from './session-output.js' import { CATEGORY_LABELS } from './types.js' import type { ProjectSummary, SessionSummary, TaskCategory } from './types.js' @@ -428,6 +428,58 @@ export type PrRow = { /// 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 }> + // Local PR signals, read from the cached commands of the turns and folded runs + // attributed to this row (never from GitHub). Only commands are cached, not + // their results, so a failed push or merge still counts. + /// 'explicit' when some attributed command names this PR (gh pr N, a PR + /// URL, an API path), it was opened here, a prompt pasted its URL, or a run's + /// description named it. 'pr-link-only' when only Claude Code's own pr-link + /// entries tie spend to it (a stale link such as an old closed PR). + /// null: no turn-level data (legacy even split). + linkEvidence: 'explicit' | 'pr-link-only' | null + /// Time of the `gh pr create` call paired with the turn that first referenced + /// this PR (its `--head` becomes the PR's branch). null: the open was not seen + /// in this period (not opened here, or before the range). + openedAt: string | null + /// `git push` calls after the open that name the PR's `--head` branch (a bare + /// push in a subagent run counts for the branch it last pushed), plus every + /// push of a subagent run whose description names this PR alone; grouped into + /// rounds by 10-minute gaps. Unnamed pushes in a top-level session never + /// count. null: no tracked open and no push seen. Without a tracked open + /// these are only your follow-ups, never proof of one shot. + followUpRounds: number | null + followUpPushes: number + /// true: opened here, head branch known, no push after the open. false: + /// opened here and pushed again. null: open or head branch not seen, so one + /// shot is unknown. + oneShot: boolean | null + /// Review work attributed here: a folded run that read this PR (gh pr + /// view/diff/checkout/review N, or git diff under a description naming it) and + /// made no commit or push ('reads-pr', label is its agent type), or a turn on + /// this PR that ran `gh pr review ` ('gh-pr-review', label is the + /// verdict). costUSD is the share of this row's cost that run or turn + /// carried. Empty: no review seen. + reviewRuns: PrReviewRun[] + /// Sum of reviewRuns costUSD (already inside `cost`). + reviewCostUSD: number + /// Time of the first `gh pr merge` naming this PR (by number, URL or --repo, + /// matched to one row) from its open on. A merge run through a script or a + /// shell variable is not seen. null: merge not seen here. + mergedAt: string | null + /// mergedAt - openedAt. null when either is unknown. + timeToMergeMs: number | null + /// `cost` split by phase, summing exactly to `cost`: review = reviewCostUSD, + /// fixes = everything else from the first review on, build = the rest. + costSplit: { buildUSD: number; reviewUSD: number; fixesUSD: number } +} + +export type PrReviewRun = { + kind: 'agent' | 'you' + label: string + model: string | null + costUSD: number + at: string + evidence: 'reads-pr' | 'gh-pr-review' } const GITHUB_PR_RE = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/ @@ -454,6 +506,9 @@ export type PrContribution = { export type SessionPrAttribution = { perUrl: Map unattributed: { cost: number; calls: number; savingsUSD: number } + /// The PR set each turn was attributed to, aligned with `session.turns` + /// (null: unattributed). Empty for the legacy even split. + turnPrs: Array } // Minimal structural shape a SessionSummary satisfies, so the state machine is @@ -519,6 +574,17 @@ export type ChildFold = { /// The PR the top child's Agent description names (see `namedPr`). A single named PR in /// the parent's repo outranks the carried-forward PR when the fold resolves. named: NamedPr | 'multi' | null + agentType?: string + /// The one PR number the description names, whatever repo the fold lands in. + mentioned: number | null + /// Over the whole subtree: whether any call pushed, committed, or read code + /// (git diff, gh pr view/diff/checkout/review), the PRs it read by number, and + /// every PR number its commands name. + pushed: boolean + committed: boolean + readsCode: boolean + readPrs: Set + prNumbers: Set } function parseMs(ts: string | undefined): number { @@ -660,11 +726,23 @@ function buildChildFold(child: SessionSummary, index: Map() const categories = new Map() + let pushed = false + let committed = false + let reads = false + const readPrs = new Set() + const prNumbers = new Set() for (const turn of child.turns) { let turnCost = 0 for (const call of turn.assistantCalls) { turnCost += call.costUSD if (call.model) addToMap(models, call.model, call.costUSD) + for (const cmd of callCommands(call)) { + if (isPush(cmd)) pushed = true + if (isCommit(cmd)) committed = true + if (readsCode(cmd)) reads = true + for (const n of readPrNumbers(cmd)) readPrs.add(n) + for (const n of prNumbersIn(cmd)) prNumbers.add(n) + } } if (turn.category) addToMap(categories, turn.category, turnCost) } @@ -675,6 +753,9 @@ function buildChildFold(child: SessionSummary, index: Map = [] for (const turn of session.turns) { if (turn.prRefs?.length) current = turn.prRefs + turnPrs.push(current) const cost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0) const calls = behavioralCallCount(turn.assistantCalls) const savings = turn.assistantCalls.reduce((s, c) => s + (c.savingsUSD ?? 0), 0) @@ -896,7 +984,7 @@ export function attributeSessionPrSpend(session: AttributableSession): SessionPr for (const [m, mc] of modelCostInTurn) addToMap(e.models, m, mc * share) }) } - return { perUrl, unattributed } + return { perUrl, unattributed, turnPrs } } /// PR-attribution totals. `attributedCost` is the sum of the per-PR rows; @@ -923,6 +1011,12 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution { models: Map; categories: Map }>() const attribution = resolveSubagentAttribution(projects) + const signals = new Map() + const sig = (url: string): PrSignals => { + let s = signals.get(url) + if (!s) signals.set(url, s = { spend: [], openedMs: NaN, explicit: false, reviews: [] }) + return s + } let attributedCost = 0 let unattributedCost = 0 let sessions = 0 @@ -984,6 +1078,7 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution { for (const [cat, cc] of rc.fold.categories) categories.set(cat, cc * share) addTo(url, sessionKey, rc.fold.firstTs, rc.fold.lastTs, rc.fold.cost * share, rc.fold.savingsUSD * share, callAlloc[i]!, false, models, categories, rc.fold.foldedSessions) + foldSignals(sig(url), url, rc.fold, share, prs.length === 1) }) } } @@ -993,11 +1088,12 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution { if (!session.prLinks?.length) continue sessions += 1 const sessionKey = rowSessionKey(session) - const { perUrl, unattributed } = attributeSessionPrSpend(session) + const { perUrl, unattributed, turnPrs } = attributeSessionPrSpend(session) for (const [url, c] of perUrl) { attributedCost += c.cost addTo(url, sessionKey, c.firstTs || session.firstTimestamp, c.lastTs || session.lastTimestamp, c.cost, c.savingsUSD, c.calls, c.approx, c.models, c.categories) } + turnSignals(session, turnPrs, sig) unattributedCost += unattributed.cost foldChildren(session) } @@ -1009,6 +1105,12 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution { } } + const scan = scanPrCommands(projects) + const byNumber = new Map() + for (const url of byUrl.keys()) { + const pr = prParts(url) + if (pr) byNumber.set(pr.number, [...(byNumber.get(pr.number) ?? []), url]) + } const rows = [...byUrl.entries()] .map(([url, r]) => { // Collapse raw model names to short display names, summing costs that map @@ -1045,6 +1147,7 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution { approx: r.approx, models, ...(categories.length ? { categories } : {}), + ...rowSignals(url, signals.get(url), r.cost, scan, byNumber), } }) .sort((a, b) => b.cost - a.cost) @@ -1052,6 +1155,173 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution { return { rows, totals: { cost: attributedCost + unattributedCost, sessions, subagentSessions, attributedCost, unattributedCost } } } +type PrSignals = { + /// Each attributed share with its start time; `review` marks review spend. + spend: Array<{ ms: number; cost: number; review: boolean }> + openedMs: number + head?: string + explicit: boolean + reviews: PrReviewRun[] +} + +function prParts(url: string): { repo: string; number: number } | null { + const m = GITHUB_PR_RE.exec(url) + return m ? { repo: `${m[1]}/${m[2]}`, number: Number(m[3]) } : null +} + +function mentionsUrl(text: string, url: string): boolean { + for (let i = text.indexOf(url); i >= 0; i = text.indexOf(url, i + 1)) if (!/\d/.test(text[i + url.length] ?? '')) return true + return false +} + +function topModel(models: Map): string | null { + let best: [string, number] | null = null + for (const [m, c] of models) if (m !== '' && (!best || c > best[1])) best = [m, c] + return best ? modelRowKey(best[0]) : null +} + +// A folded run reviewed this PR when it read it (by number, or read code under +// a description naming it) and neither committed nor pushed. A run split +// across PRs, or whose description names several, reviews none of them. +function foldSignals(s: PrSignals, url: string, fold: ChildFold, share: number, single: boolean): void { + const cost = fold.cost * share + if (cost === 0 && fold.calls === 0) return + const pr = prParts(url) + const readThis = !!pr && single && fold.named !== 'multi' && (fold.readPrs.has(pr.number) || (fold.mentioned === pr.number && fold.readsCode)) + const review = readThis && !fold.committed && !fold.pushed + s.spend.push({ ms: parseMs(fold.firstTs), cost, review }) + if (review) s.reviews.push({ kind: 'agent', label: fold.agentType ?? 'subagent', model: topModel(fold.models), costUSD: cost, at: fold.firstTs, evidence: 'reads-pr' }) + if (pr && (fold.prNumbers.has(pr.number) || fold.mentioned === pr.number)) s.explicit = true +} + +// Pair each `gh pr create` in a turn with a PR first referenced in that turn +// (its pr-link lands there): per repo, creates in call order against the new PR +// numbers ascending, since GitHub numbers grow. A retried create for the same +// head keeps only the last one. +function matchCreates(cmds: Array<{ cmd: string; ms: number }>, fresh: string[], sig: (url: string) => PrSignals): void { + const creates = new Map() + cmds.forEach(c => ghPrCommands(c.cmd).forEach((g, j) => { + if (g.verb !== 'create') return + const head = createHead(g.args) + creates.set(head ?? `${c.ms}:${j}`, { ms: c.ms, ...(g.repo ? { repo: g.repo } : {}), ...(head ? { head } : {}) }) + })) + if (!creates.size) return + const byRepo = new Map>() + for (const c of creates.values()) byRepo.set(c.repo ?? '', [...(byRepo.get(c.repo ?? '') ?? []), c]) + let unclaimed = [...fresh].sort((a, b) => (prParts(a)?.number ?? 0) - (prParts(b)?.number ?? 0)) + for (const repo of [...byRepo.keys()].sort().reverse()) { + const cs = byRepo.get(repo)!.sort((a, b) => a.ms - b.ms) + const us = unclaimed.filter(u => !repo || prParts(u)?.repo === repo) + if (us.length !== cs.length) continue + cs.forEach((c, k) => { + const s = sig(us[k]!) + if (Number.isNaN(s.openedMs) || c.ms < s.openedMs) { s.openedMs = c.ms; s.head = c.head } + s.explicit = true + }) + unclaimed = unclaimed.filter(u => !us.includes(u)) + } +} + +// Walk a parent's turns with the PR set each was attributed to: opens, the +// share of spend each PR carried, and `gh pr review ` posted for it. +function turnSignals(session: SessionSummary, turnPrs: Array, sig: (url: string) => PrSignals): void { + const seen = new Set(session.prRefsAtRangeStart ?? []) + session.turns.forEach((turn, i) => { + const cmds = turn.assistantCalls.flatMap(call => callCommands(call).map(cmd => ({ cmd, ms: parseMs(call.timestamp) }))) + const fresh = (turn.prRefs ?? []).filter(u => !seen.has(u)) + for (const u of fresh) seen.add(u) + if (fresh.length) matchCreates(cmds, fresh, sig) + const prs = turnPrs[i] + if (!prs) return + const cost = turn.assistantCalls.reduce((n, c) => n + c.costUSD, 0) + if (cost === 0 && behavioralCallCount(turn.assistantCalls) === 0) return + const share = cost / prs.length + const at = turnStartTs(turn) + const gh = cmds.flatMap(c => ghPrCommands(c.cmd)) + const named = new Set(cmds.flatMap(c => [...prNumbersIn(c.cmd)])) + const models = new Map() + for (const c of turn.assistantCalls) if (c.model) addToMap(models, c.model, c.costUSD) + for (const url of prs) { + const s = sig(url) + const pr = prParts(url) + const posted = pr ? gh.find(g => g.verb === 'review' && g.number === pr.number && (!g.repo || g.repo === pr.repo)) : undefined + s.spend.push({ ms: parseMs(at), cost: share, review: !!posted }) + if (posted) s.reviews.push({ kind: 'you', label: reviewVerdict(posted.args), model: topModel(models), costUSD: share, at, evidence: 'gh-pr-review' }) + if (!s.explicit && ((pr && named.has(pr.number)) || mentionsUrl(turn.userMessage, url))) s.explicit = true + } + }) +} + +type PrCommandScan = { + pushes: Array<{ ms: number; branches: string[]; mentioned: number | null }> + merges: Array<{ ms: number; number: number; repo?: string }> +} + +// Every push and merge in range, from every session. A bare `git push` in a +// subagent run pushes the branch it last pushed by name; a top-level session +// moves between checkouts too freely to assume that. +function scanPrCommands(projects: ProjectSummary[]): PrCommandScan { + const out: PrCommandScan = { pushes: [], merges: [] } + for (const project of projects) for (const session of project.sessions) { + const side = !!session.isSidechain + const mentioned = side ? namedPrNumber(session.agentDescription ?? '') : null + let upstream: string | undefined + for (const turn of session.turns) for (const call of turn.assistantCalls) { + const ms = parseMs(call.timestamp) + if (Number.isNaN(ms)) continue + for (const cmd of callCommands(call)) { + for (const branches of pushes(cmd)) { + if (side && branches.length) upstream = branches.at(-1) + out.pushes.push({ ms, branches: branches.length ? branches : upstream ? [upstream] : [], mentioned }) + } + for (const g of ghPrCommands(cmd)) { + if (g.verb === 'merge' && g.number !== undefined) out.merges.push({ ms, number: g.number, ...(g.repo ? { repo: g.repo } : {}) }) + } + } + } + } + return out +} + +// ponytail: a bare PR number (a merge, or a run's description) is matched +// against this report's rows only; two rows with that number in different repos +// leave it unmatched. +function rowSignals(url: string, s: PrSignals | undefined, cost: number, scan: PrCommandScan, byNumber: Map): Pick { + const pr = prParts(url) + const isRow = (number: number, repo?: string): boolean => { + const hits = (byNumber.get(number) ?? []).filter(u => !repo || prParts(u)!.repo === repo) + return hits.length === 1 && hits[0] === url + } + const opened = s && !Number.isNaN(s.openedMs) ? s.openedMs : null + // Without the head branch (`--head "$br"`) the open's own pushes cannot be + // told apart, so it reads like a PR opened elsewhere: follow-ups only. + const tracked = opened !== null && !!s?.head + const pushMs = scan.pushes + .filter(p => (s?.head && p.branches.includes(s.head)) || (pr && p.mentioned === pr.number && isRow(pr.number))) + .map(p => p.ms) + .filter(ms => opened === null || ms > opened) + const followUpRounds = tracked || pushMs.length ? rounds(pushMs) : null + const merged = Math.min(Infinity, ...scan.merges.filter(m => isRow(m.number, m.repo) && (opened === null || m.ms >= opened)).map(m => m.ms)) + const mergedMs = Number.isFinite(merged) ? merged : null + const reviewSpend = (s?.spend ?? []).filter(x => x.review) + const firstReview = Math.min(...reviewSpend.map(x => x.ms)) + const reviewUSD = reviewSpend.reduce((n, x) => n + x.cost, 0) + const fixesUSD = (s?.spend ?? []).filter(x => !x.review && x.ms >= firstReview).reduce((n, x) => n + x.cost, 0) + return { + linkEvidence: !s?.spend.length ? null : s.explicit || mergedMs !== null ? 'explicit' : 'pr-link-only', + openedAt: opened === null ? null : new Date(opened).toISOString(), + followUpRounds, + followUpPushes: pushMs.length, + oneShot: tracked ? followUpRounds === 0 : null, + reviewRuns: [...(s?.reviews ?? [])].sort((a, b) => a.at.localeCompare(b.at)), + reviewCostUSD: reviewUSD, + mergedAt: mergedMs === null ? null : new Date(mergedMs).toISOString(), + timeToMergeMs: opened !== null && mergedMs !== null && mergedMs > opened ? mergedMs - opened : null, + costSplit: { buildUSD: cost - reviewUSD - fixesUSD, reviewUSD, fixesUSD }, + } +} + /// Spend attributed to each pull request (thin wrapper over buildPrAttribution). export function aggregateByPr(projects: ProjectSummary[]): PrRow[] { return buildPrAttribution(projects).rows diff --git a/tests/pr-insights.test.ts b/tests/pr-insights.test.ts index ee08dc6f7..745ae4fa4 100644 --- a/tests/pr-insights.test.ts +++ b/tests/pr-insights.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'os' import { parseAllSessions, clearSessionCache } from '../src/parser.js' import { loadPricing } from '../src/models.js' import { buildPrAttribution } from '../src/sessions-report.js' +import { createHead, ghPrCommands, isPush, namedPr, pushes, rounds } from '../src/pr-signals.js' import type { ProjectSummary } from '../src/types.js' // One orchestrator session in the shape Claude Code writes it: pr-link entries, @@ -87,7 +88,7 @@ async function writeTranscripts(): Promise { asst('2026-10-10T07:00:05.000Z', [bash('git status')]), // #1694: pushed and opened here. user('2026-10-10T08:00:00.000Z', 'ship the tooltip fix'), - asst('2026-10-10T08:00:05.000Z', [bash('git push -u origin fix/tooltip && gh pr create --fill')]), + asst('2026-10-10T08:00:05.000Z', [bash('git push -u origin fix/tooltip && gh pr create -R getagentseal/codeburn --base main --head fix/tooltip --title "fix(menubar): tooltip" --body-file /tmp/b.md')]), prLink('2026-10-10T08:00:20.000Z', 1694), user('2026-10-10T08:29:00.000Z', 'get it reviewed'), asst('2026-10-10T08:29:30.000Z', [spawn(c('aR1694').spawnId, c('aR1694').description)]), @@ -96,6 +97,9 @@ async function writeTranscripts(): Promise { asst('2026-10-10T08:59:30.000Z', [spawn(c('aF1694').spawnId, c('aF1694').description)]), spawnResult('2026-10-10T09:10:00.000Z', c('aF1694').spawnId, 'aF1694'), user('2026-10-10T09:30:00.000Z', 'merge it'), + // A push naming the PR's head counts; a bare push from the top-level + // session (it may be in any checkout) does not. + asst('2026-10-10T09:30:01.000Z', [bash('git push origin fix/tooltip'), bash('cd ../other && git push')]), asst('2026-10-10T09:30:05.000Z', [bash('gh pr merge 1694 --squash --admin')]), // A stale pr-link for an old closed PR; nothing here names 24. user('2026-10-10T10:00:00.000Z', 'review the open contributor PRs'), @@ -170,3 +174,72 @@ describe('PR dates and runs come from the attributed work, not the session', () expect(row(1729).runs).toBe(1) }) }) + +describe('local PR signals', () => { + const turnCost = (from: string, to: string) => projects.flatMap(p => p.sessions).find(s => s.sessionId === PARENT)! + .turns.filter(t => t.timestamp >= from && t.timestamp < to).reduce((s, t) => s + t.assistantCalls.reduce((n, c) => n + c.costUSD, 0), 0) + + it('reads open, follow-up rounds, review, merge and the cost split for a PR opened here', () => { + const r = buildPrAttribution(projects).rows.find(x => x.url === PR(1694))! + expect(r.openedAt).toBe('2026-10-10T08:00:05.000Z') + // The push in the create command itself is not a follow-up; the fix run's two + // pushes three minutes apart are one round, the push to fix/tooltip at 09:30 + // a second. + expect(r.followUpPushes).toBe(3) + expect(r.followUpRounds).toBe(2) + expect(r.oneShot).toBe(false) + expect(r.reviewRuns).toEqual([{ kind: 'agent', label: 'general-purpose', model: 'Opus 4.8', costUSD: sessionCost('agent-aR1694'), at: '2026-10-10T08:31:00.000Z', evidence: 'reads-pr' }]) + expect(r.reviewCostUSD).toBeCloseTo(sessionCost('agent-aR1694'), 12) + expect(r.mergedAt).toBe('2026-10-10T09:30:05.000Z') + expect(r.timeToMergeMs).toBe(90 * 60 * 1000) + expect(r.linkEvidence).toBe('explicit') + const { buildUSD, reviewUSD, fixesUSD } = r.costSplit + expect(buildUSD + reviewUSD + fixesUSD).toBeCloseTo(r.cost, 12) + expect(buildUSD).toBeCloseTo(turnCost('2026-10-10T08:00', '2026-10-10T08:30'), 12) + expect(fixesUSD).toBeCloseTo(sessionCost('agent-aF1694') + turnCost('2026-10-10T08:59', '2026-10-10T10:00'), 12) + }) + + it('never calls a PR one shot when its open was not seen, and flags a pr-link-only row', () => { + const rows = buildPrAttribution(projects).rows + const r24 = rows.find(x => x.url === PR(24))! + expect(r24.linkEvidence).toBe('pr-link-only') + expect(r24.openedAt).toBeNull() + expect(r24.oneShot).toBeNull() + // The multi-PR fix run folds here by carry but its push is not this PR's round. + expect(r24.followUpRounds).toBeNull() + expect(r24.reviewRuns).toEqual([]) + expect(r24.mergedAt).toBeNull() + expect(r24.timeToMergeMs).toBeNull() + expect(r24.costSplit).toEqual({ buildUSD: r24.cost, reviewUSD: 0, fixesUSD: 0 }) + const r1729 = rows.find(x => x.url === PR(1729))! + expect(r1729.oneShot).toBeNull() + expect(r1729.followUpRounds).toBeNull() + expect(r1729.linkEvidence).toBe('explicit') + expect(r1729.reviewRuns.map(x => x.evidence)).toEqual(['reads-pr']) + expect(r1729.reviewCostUSD).toBeCloseTo(r1729.cost, 12) + }) +}) + +describe('pr-signals command reading', () => { + it('reads the PR a gh pr command names, ignoring quoted text', () => { + expect(ghPrCommands('cd /x && gh pr review 1483 --request-changes -b "fix the 12 nits" && gh pr merge -R getagentseal/codeburn 1490 --squash')) + .toMatchObject([{ verb: 'review', number: 1483 }, { verb: 'merge', number: 1490, repo: 'getagentseal/codeburn' }]) + expect(ghPrCommands('gh pr merge https://github.com/o/r/pull/7 --admin')).toMatchObject([{ verb: 'merge', number: 7, repo: 'o/r' }]) + expect(ghPrCommands('gh pr list --limit 50')[0]!.number).toBeUndefined() + expect(isPush('git -C /tmp/wt push --force-with-lease')).toBe(true) + expect(isPush('git stash push -m wip')).toBe(false) + expect(pushes('git push -q origin feat/x 2>&1 | tail -2; git push contributor HEAD:refs/heads/fix/y')).toEqual([['feat/x'], ['fix/y']]) + expect(pushes('git push -q origin --delete feat/old')).toEqual([]) + // Text being written (a script, a test) is not a command that ran. + expect(ghPrCommands("asst('x', [bash('gh pr merge 1694 --squash')])")).toEqual([]) + expect(createHead(ghPrCommands('gh pr create -R o/r --head "$br" --title "$t"')[0]!.args)).toBeUndefined() + expect(rounds([0, 5 * 60_000, 9 * 60_000, 30 * 60_000])).toBe(2) + }) + + it('names one PR only from a PR prefix or URL', () => { + expect(namedPr('Review PR 1729 Claude quota dir')).toEqual({ number: 1729 }) + expect(namedPr('Apply review fixes to PRs 1689-1692')).toBe('multi') + expect(namedPr('Review core library worker #22')).toBeNull() + expect(namedPr('Scope-check PR 1478 and PR 1476')).toBe('multi') + }) +}) From b6afaebf5d85e0101f2390ca9e54b4c073191ea1 Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:10:47 -0700 Subject: [PATCH 4/7] feat(session-why): PRs the session opened, their follow-up rounds and review runs The why view now carries prWork: each gh pr create whose output printed a PR URL, the pushes after it on its --head branch (or from a helper whose description names that PR) in 10-minute rounds, and the helpers that read a PR without committing or pushing, with their cost. --- src/session-why.ts | 68 ++++++++++++++++++++++++++++++++++++++- tests/pr-insights.test.ts | 15 ++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/session-why.ts b/src/session-why.ts index 05a67d1e7..38aaf0c62 100644 --- a/src/session-why.ts +++ b/src/session-why.ts @@ -13,6 +13,7 @@ import { parseJsonlLine, type ToolResultMeta, } from './parser.js' +import { callCommands, createHead, ghPrCommands, isCommit, namedPrNumber, pushes, readPrNumbers, readsCode, rounds } from './pr-signals.js' import { estimateTokensFromChars } from './token-estimate.js' import type { JournalEntry, ParsedApiCall } from './types.js' @@ -81,6 +82,18 @@ export type WhyFinding = Base & ( | { kind: 'idle'; timeMs: number; endedBy: 'prompt' | 'tool' | 'message' | 'helper' } | { kind: 'slowCall'; timeMs: number; model: string; outputTokens: number } ) +/// PR work seen in this session and its helpers, from commands and their +/// results. `opened`: each `gh pr create` whose output printed a PR URL, with +/// follow-up pushes after it (naming its --head branch, or from a helper whose +/// description names that PR) in 10-minute rounds; rounds is null when the head +/// branch is unknown. `reviewRuns`: helpers that read a PR (gh pr +/// view/diff/checkout/review, or git diff under a description naming one) and +/// made no commit or push. Failed pushes count: only the command is read. +export type WhyPrWork = { + opened: Array<{ url: string; at: string; head: string | null; followUpRounds: number | null; followUpPushes: number }> + reviewRuns: Array<{ helperId: string; description: string; models: string[]; cost: number; pr: number | null }> + reviewCost: number +} export type SessionWhy = { sessionId: string title: string @@ -99,6 +112,7 @@ export type SessionWhy = { findings: WhyFinding[] rules: typeof WHY_RULES detailsOmitted: boolean + prWork: WhyPrWork } // ── redaction and caps ────────────────────────────────────────────────── @@ -292,7 +306,7 @@ async function readTranscript(filePath: string): Promise { // ── helpers (sub-agent transcripts) ───────────────────────────────────── -type HelperRun = WhyHelper & { toolUseId: string; startTs: number; callList: ParsedApiCall[]; spawnIds: string[] } +type HelperRun = WhyHelper & { toolUseId: string; startTs: number; callList: ParsedApiCall[]; spawnIds: string[]; opens: PrOpen[]; mentioned: number | null } async function jsonlUnder(dir: string, out: string[] = []): Promise { for (const e of await readdir(dir, { withFileTypes: true }).catch(() => [])) { @@ -325,6 +339,8 @@ async function readHelpers(sessionFile: string, spawnLinks: Record ms(c.timestamp)).filter(Number.isFinite)), callList: read.calls, spawnIds: read.spawnIds, + opens: prOpens(read), + mentioned: namedPrNumber(typeof meta.description === 'string' ? meta.description : ''), }) } return helpers @@ -545,7 +561,57 @@ export async function buildSessionWhy(filePath: string): Promise { findings, rules: WHY_RULES, detailsOmitted, + prWork: buildPrWork(read, helpers), + } +} + +// ── PR work ───────────────────────────────────────────────────────────── + +type PrOpen = { url: string; at: number; head: string | null } +const PR_URL = /https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/ + +function prOpens(read: Read): PrOpen[] { + const out: PrOpen[] = [] + for (const m of read.msgs.values()) for (const tu of m.toolUses) { + const creates = tu.name === 'Bash' ? ghPrCommands(str(tu.input['command'])).filter(g => g.verb === 'create') : [] + if (!creates.length) continue + // A loop prints one URL per PR it opened; the --head is only known for one. + const urls = (read.results.get(tu.id)?.text ?? '').split('\n').filter(l => !/already exists/i.test(l)).map(l => PR_URL.exec(l)?.[0]).filter((u): u is string => !!u) + const head = urls.length === 1 && creates.length === 1 ? createHead(creates[0]!.args) ?? null : null + for (const url of new Set(urls)) out.push({ url, at: tu.ts, head }) } + return out +} + +// A bare `git push` in a helper pushes the branch it last pushed by name; the +// session itself moves between checkouts too freely to assume that. +function pushEvents(calls: ParsedApiCall[], inherit: boolean): Array<{ ms: number; branches: string[] }> { + const out: Array<{ ms: number; branches: string[] }> = [] + let upstream: string | undefined + for (const c of calls) for (const cmd of callCommands(c)) for (const branches of pushes(cmd)) { + if (inherit && branches.length) upstream = branches.at(-1) + out.push({ ms: ms(c.timestamp), branches: branches.length ? branches : inherit && upstream ? [upstream] : [] }) + } + return out +} + +function buildPrWork(read: Read, helpers: HelperRun[]): WhyPrWork { + const opens = new Map() + for (const o of [...prOpens(read), ...helpers.flatMap(h => h.opens)]) if (!opens.has(o.url) || o.at < opens.get(o.url)!.at) opens.set(o.url, o) + const all = [...pushEvents(read.calls, false).map(p => ({ ...p, mentioned: null as number | null })), ...helpers.flatMap(h => pushEvents(h.callList, true).map(p => ({ ...p, mentioned: h.mentioned })))] + const opened = [...opens.values()].sort((a, b) => a.at - b.at).map(o => { + const number = Number(PR_URL.exec(o.url)![1]) + const mine = all.filter(p => p.ms > o.at && ((o.head && p.branches.includes(o.head)) || p.mentioned === number)).map(p => p.ms) + return { url: o.url, at: new Date(o.at).toISOString(), head: o.head, followUpRounds: o.head || mine.length ? rounds(mine) : null, followUpPushes: mine.length } + }) + const reviewRuns = helpers.flatMap(h => { + const cmds = h.callList.flatMap(c => callCommands(c)) + if (cmds.some(c => isCommit(c) || pushes(c).length)) return [] + const read = [...new Set(cmds.flatMap(readPrNumbers))] + if (!read.length && !(h.mentioned !== null && cmds.some(readsCode))) return [] + return [{ helperId: h.id, description: h.description, models: h.models, cost: h.cost, pr: h.mentioned ?? (read.length === 1 ? read[0]! : null) }] + }) + return { opened, reviewRuns, reviewCost: reviewRuns.reduce((n, r) => n + r.cost, 0) } } function publicHelper(h: HelperRun | WhyHelper): WhyHelper { diff --git a/tests/pr-insights.test.ts b/tests/pr-insights.test.ts index 745ae4fa4..c787b488e 100644 --- a/tests/pr-insights.test.ts +++ b/tests/pr-insights.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'os' import { parseAllSessions, clearSessionCache } from '../src/parser.js' import { loadPricing } from '../src/models.js' import { buildPrAttribution } from '../src/sessions-report.js' +import { buildSessionWhy } from '../src/session-why.js' import { createHead, ghPrCommands, isPush, namedPr, pushes, rounds } from '../src/pr-signals.js' import type { ProjectSummary } from '../src/types.js' @@ -88,7 +89,8 @@ async function writeTranscripts(): Promise { asst('2026-10-10T07:00:05.000Z', [bash('git status')]), // #1694: pushed and opened here. user('2026-10-10T08:00:00.000Z', 'ship the tooltip fix'), - asst('2026-10-10T08:00:05.000Z', [bash('git push -u origin fix/tooltip && gh pr create -R getagentseal/codeburn --base main --head fix/tooltip --title "fix(menubar): tooltip" --body-file /tmp/b.md')]), + asst('2026-10-10T08:00:05.000Z', [{ ...bash('git push -u origin fix/tooltip && gh pr create -R getagentseal/codeburn --base main --head fix/tooltip --title "fix(menubar): tooltip" --body-file /tmp/b.md'), id: 'toolu_create' }]), + user('2026-10-10T08:00:15.000Z', [{ type: 'tool_result', tool_use_id: 'toolu_create', content: `${PR(1694)}\n` }]), prLink('2026-10-10T08:00:20.000Z', 1694), user('2026-10-10T08:29:00.000Z', 'get it reviewed'), asst('2026-10-10T08:29:30.000Z', [spawn(c('aR1694').spawnId, c('aR1694').description)]), @@ -243,3 +245,14 @@ describe('pr-signals command reading', () => { expect(namedPr('Scope-check PR 1478 and PR 1476')).toBe('multi') }) }) + +describe('session why: PR work', () => { + it('reports the PR this session opened, its follow-up rounds and the review runs', async () => { + const why = await buildSessionWhy(join(configDir, 'projects', 'prins-proj', `${PARENT}.jsonl`)) + expect(why.prWork.opened).toEqual([{ url: PR(1694), at: '2026-10-10T08:00:05.000Z', head: 'fix/tooltip', followUpRounds: 2, followUpPushes: 3 }]) + expect(why.prWork.reviewRuns.map(r => [r.description, r.pr])).toEqual([ + ['Review PR 1694 tooltip', 1694], ['Review PR 1712 Antigravity', 1712], ['Review PR 1729 Claude quota dir', 1729], + ]) + expect(why.prWork.reviewCost).toBeCloseTo(['agent-aR1694', 'agent-aR1712', 'agent-aR1729'].reduce((n, id) => n + sessionCost(id), 0), 12) + }) +}) From e185ae78aabe92b2d413041003191407b60fdb0b Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:16:27 -0700 Subject: [PATCH 5/7] perf(pr): only parse commands that can name a push or a PR --- src/pr-signals.ts | 7 +++++-- src/sessions-report.ts | 11 +++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/pr-signals.ts b/src/pr-signals.ts index 9a7516e1d..89faba584 100644 --- a/src/pr-signals.ts +++ b/src/pr-signals.ts @@ -44,14 +44,15 @@ const API_PR_RE = /\/pulls?\/(\d+)\b/g const unquote = (s: string): string => s.replace(/"[^"]*"|'[^']*'/g, ' ') export const isPush = (cmd: string): boolean => pushes(cmd).length > 0 -export const isCommit = (cmd: string): boolean => COMMIT_RE.test(cmd) -export const readsCode = (cmd: string): boolean => GIT_DIFF_RE.test(cmd) || ghPrCommands(cmd).some(c => READ_VERBS.has(c.verb)) +export const isCommit = (cmd: string): boolean => cmd.includes('commit') && COMMIT_RE.test(cmd) +export const readsCode = (cmd: string): boolean => (cmd.includes('diff') && GIT_DIFF_RE.test(cmd)) || ghPrCommands(cmd).some(c => READ_VERBS.has(c.verb)) /// Each `git push` in a command line with the branches it names (the refspec /// destination, `HEAD:fix/x` -> `fix/x`); [] for a bare `git push`. A branch /// delete is not a push. export function pushes(cmd: string): string[][] { const out: string[][] = [] + if (!cmd.includes('push')) return out for (const m of cmd.matchAll(PUSH_RE)) { const tokens = unquote(m[1]!).trim().split(/\s+/).filter(t => t && !/[<>]/.test(t)) if (tokens.some(t => t === '--delete' || t === '-d')) continue @@ -70,6 +71,7 @@ export type GhPrCommand = { verb: string; number?: number; repo?: string; args: /// never read as a PR number. export function ghPrCommands(cmd: string): GhPrCommand[] { const out: GhPrCommand[] = [] + if (!cmd.includes('gh pr')) return out for (const m of cmd.matchAll(GH_PR_RE)) { const args = unquote(m[2]!) const url = PR_URL_RE.exec(args) @@ -91,6 +93,7 @@ export function createHead(args: string): string | undefined { export function prNumbersIn(cmd: string): Set { const out = new Set() for (const c of ghPrCommands(cmd)) if (c.number !== undefined) out.add(c.number) + if (!cmd.includes('/pull')) return out for (const m of cmd.matchAll(API_PR_RE)) out.add(Number(m[1])) for (const m of cmd.matchAll(new RegExp(PR_URL_RE.source, 'g'))) out.add(Number(m[3])) return out diff --git a/src/sessions-report.ts b/src/sessions-report.ts index 9b375a93a..4ea0e390c 100644 --- a/src/sessions-report.ts +++ b/src/sessions-report.ts @@ -1227,7 +1227,12 @@ function matchCreates(cmds: Array<{ cmd: string; ms: number }>, fresh: string[], function turnSignals(session: SessionSummary, turnPrs: Array, sig: (url: string) => PrSignals): void { const seen = new Set(session.prRefsAtRangeStart ?? []) session.turns.forEach((turn, i) => { - const cmds = turn.assistantCalls.flatMap(call => callCommands(call).map(cmd => ({ cmd, ms: parseMs(call.timestamp) }))) + const cmds = turn.assistantCalls.flatMap(call => { + const list = callCommands(call) + if (!list.length) return [] + const ms = parseMs(call.timestamp) + return list.map(cmd => ({ cmd, ms })) + }) const fresh = (turn.prRefs ?? []).filter(u => !seen.has(u)) for (const u of fresh) seen.add(u) if (fresh.length) matchCreates(cmds, fresh, sig) @@ -1267,9 +1272,11 @@ function scanPrCommands(projects: ProjectSummary[]): PrCommandScan { const mentioned = side ? namedPrNumber(session.agentDescription ?? '') : null let upstream: string | undefined for (const turn of session.turns) for (const call of turn.assistantCalls) { + const cmds = callCommands(call) + if (!cmds.length) continue const ms = parseMs(call.timestamp) if (Number.isNaN(ms)) continue - for (const cmd of callCommands(call)) { + for (const cmd of cmds) { for (const branches of pushes(cmd)) { if (side && branches.length) upstream = branches.at(-1) out.pushes.push({ ms, branches: branches.length ? branches : upstream ? [upstream] : [], mentioned }) From bd5de20b03e685b5a43103a156cab8a059ba29d1 Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:26:31 -0700 Subject: [PATCH 6/7] feat(app): local PR facts on PR cards and PR work in the session view Each PR card gets one line of facts from the CLI's local signals: the review run and its cost, one shot or follow-up rounds, and merge time after open. Unknowns are left out; a pr-link-only row says so. The count shows agent runs instead of sessions. The expanded card splits spend into building, review runs and after first review, and lists the review runs. The session view lists the PRs it opened and the review runs it spawned. --- app/renderer/i18n/catalogs/common.ts | 12 +++ app/renderer/i18n/catalogs/pullRequests.ts | 102 ++++++++++++++++++++ app/renderer/i18n/catalogs/sessionView.ts | 36 +++++++ app/renderer/lib/types.ts | 18 ++++ app/renderer/sections/PullRequests.test.tsx | 83 +++++++++++++++- app/renderer/sections/PullRequests.tsx | 66 ++++++++++++- app/renderer/sections/SessionView.test.tsx | 29 ++++++ app/renderer/sections/SessionView.tsx | 38 +++++++- app/renderer/styles/plain.css | 2 + 9 files changed, 382 insertions(+), 4 deletions(-) diff --git a/app/renderer/i18n/catalogs/common.ts b/app/renderer/i18n/catalogs/common.ts index fdd66f564..83ad41452 100644 --- a/app/renderer/i18n/catalogs/common.ts +++ b/app/renderer/i18n/catalogs/common.ts @@ -51,6 +51,8 @@ export const common: SectionCatalog = { 'common.subject.optimize': 'optimize findings', 'common.asOf': 'as of {time}', 'common.asOfDate': 'as of {date}, {time}', + 'common.count.run.one': '{count} run', + 'common.count.run.other': '{count} runs', }, fr: { 'common.period.today': "Aujourd'hui", @@ -99,6 +101,8 @@ export const common: SectionCatalog = { 'common.subject.optimize': 'les résultats d\'optimisation', 'common.asOf': 'relevé à {time}', 'common.asOfDate': 'relevé le {date} à {time}', + 'common.count.run.one': '{count} exécution', + 'common.count.run.other': '{count} exécutions', }, ja: { 'common.period.today': '今日', @@ -147,6 +151,8 @@ export const common: SectionCatalog = { 'common.subject.optimize': '最適化の分析結果', 'common.asOf': '{time} 時点', 'common.asOfDate': '{date} {time} 時点', + 'common.count.run.one': '{count} 件の実行', + 'common.count.run.other': '{count} 件の実行', }, ko: { 'common.period.today': '오늘', @@ -195,6 +201,8 @@ export const common: SectionCatalog = { 'common.subject.optimize': '최적화 결과', 'common.asOf': '{time} 기준', 'common.asOfDate': '{date} {time} 기준', + 'common.count.run.one': '실행 {count}건', + 'common.count.run.other': '실행 {count}건', }, zhCN: { 'common.period.today': '今天', @@ -243,6 +251,8 @@ export const common: SectionCatalog = { 'common.subject.optimize': '优化发现', 'common.asOf': '截至 {time}', 'common.asOfDate': '截至 {date} {time}', + 'common.count.run.one': '{count} 次运行', + 'common.count.run.other': '{count} 次运行', }, zhTW: { 'common.period.today': '今天', @@ -291,5 +301,7 @@ export const common: SectionCatalog = { 'common.subject.optimize': '最佳化發現', 'common.asOf': '截至 {time}', 'common.asOfDate': '截至 {date} {time}', + 'common.count.run.one': '{count} 次執行', + 'common.count.run.other': '{count} 次執行', }, } diff --git a/app/renderer/i18n/catalogs/pullRequests.ts b/app/renderer/i18n/catalogs/pullRequests.ts index 12511ae2a..b91a3c103 100644 --- a/app/renderer/i18n/catalogs/pullRequests.ts +++ b/app/renderer/i18n/catalogs/pullRequests.ts @@ -33,6 +33,23 @@ export const pullRequests: SectionCatalog = { 'pullRequests.card.noPerTurnDetail': 'No per-turn detail (estimated from a whole-session split).', 'pullRequests.empty.body': 'No sessions in {period} mentioned a pull request URL. Spend is attributed only when a transcript contains a github.com/…/pull/N link.', 'pullRequests.empty.widerHint': ' Lifetime has {count} pull requests. Switch the period control to Life.', + 'pullRequests.facts.reviewedBy': 'Reviewed by {model} run ({amount})', + 'pullRequests.facts.reviewedByYou': 'Reviewed by you ({amount})', + 'pullRequests.facts.reviewRuns.one': '{count} review run ({amount})', + 'pullRequests.facts.reviewRuns.other': '{count} review runs ({amount})', + 'pullRequests.facts.oneShot': 'One shot', + 'pullRequests.facts.rounds.one': '{count} follow-up round', + 'pullRequests.facts.rounds.other': '{count} follow-up rounds', + 'pullRequests.facts.yourFollowUps': 'Your follow-ups: {count}', + 'pullRequests.facts.merged': 'Merged {duration} after open', + 'pullRequests.facts.prLinkOnly': 'Linked by pr-link only', + 'pullRequests.facts.prLinkOnlyTitle': 'Only Claude Code pr-link entries tie this spend to the PR. No command or prompt named it.', + 'pullRequests.split.title': 'By phase', + 'pullRequests.split.aria': '{label} spend by phase', + 'pullRequests.split.build': 'Building', + 'pullRequests.split.review': 'Review runs', + 'pullRequests.split.after': 'After first review', + 'pullRequests.reviews.you': 'Your review ({verdict})', }, fr: { 'pullRequests.loading': 'Analyse des pull requests…', @@ -66,6 +83,23 @@ export const pullRequests: SectionCatalog = { 'pullRequests.card.noPerTurnDetail': "Aucun détail par tour (estimé à partir d'une répartition de session entière).", 'pullRequests.empty.body': "Aucune session dans {period} ne mentionne d'URL de pull request. Les dépenses ne sont attribuées que si une transcription contient un lien github.com/…/pull/N.", 'pullRequests.empty.widerHint': ' Depuis le début compte {count} pull requests. Basculez le sélecteur de période sur Depuis le début.', + 'pullRequests.facts.reviewedBy': 'Revue par une exécution {model} ({amount})', + 'pullRequests.facts.reviewedByYou': 'Revue par vous ({amount})', + 'pullRequests.facts.reviewRuns.one': '{count} exécution de revue ({amount})', + 'pullRequests.facts.reviewRuns.other': '{count} exécutions de revue ({amount})', + 'pullRequests.facts.oneShot': 'Du premier coup', + 'pullRequests.facts.rounds.one': '{count} tour de suivi', + 'pullRequests.facts.rounds.other': '{count} tours de suivi', + 'pullRequests.facts.yourFollowUps': 'Vos suivis : {count}', + 'pullRequests.facts.merged': 'Fusionnée {duration} après ouverture', + 'pullRequests.facts.prLinkOnly': 'Liée uniquement par pr-link', + 'pullRequests.facts.prLinkOnlyTitle': 'Seules les entrées pr-link de Claude Code rattachent cette dépense à la PR. Aucune commande ni aucun prompt ne la nomme.', + 'pullRequests.split.title': 'Par phase', + 'pullRequests.split.aria': 'Dépense de {label} par phase', + 'pullRequests.split.build': 'Construction', + 'pullRequests.split.review': 'Exécutions de revue', + 'pullRequests.split.after': 'Après la première revue', + 'pullRequests.reviews.you': 'Votre revue ({verdict})', }, ja: { 'pullRequests.loading': 'プルリクエストを確認中…', @@ -99,6 +133,23 @@ export const pullRequests: SectionCatalog = { 'pullRequests.card.noPerTurnDetail': 'ターンごとの詳細なし(セッション全体からの均等割り推定)。', 'pullRequests.empty.body': '{period}のセッションでプルリクエストのURLが言及されていません。支出は、トランスクリプトにgithub.com/…/pull/N形式のリンクが含まれる場合のみ割り当てられます。', 'pullRequests.empty.widerHint': ' 全期間には{count}件のプルリクエストがあります。期間コントロールを「全期間」に切り替えてください。', + 'pullRequests.facts.reviewedBy': '{model} の実行がレビュー({amount})', + 'pullRequests.facts.reviewedByYou': 'あなたがレビュー({amount})', + 'pullRequests.facts.reviewRuns.one': 'レビュー実行 {count} 件({amount})', + 'pullRequests.facts.reviewRuns.other': 'レビュー実行 {count} 件({amount})', + 'pullRequests.facts.oneShot': '一発で完了', + 'pullRequests.facts.rounds.one': 'フォローアップ {count} 回', + 'pullRequests.facts.rounds.other': 'フォローアップ {count} 回', + 'pullRequests.facts.yourFollowUps': 'あなたのフォローアップ: {count}', + 'pullRequests.facts.merged': 'オープンから {duration} 後にマージ', + 'pullRequests.facts.prLinkOnly': 'pr-link のみで関連付け', + 'pullRequests.facts.prLinkOnlyTitle': 'この支出を PR に結び付けているのは Claude Code の pr-link エントリだけです。PR を指したコマンドやプロンプトはありません。', + 'pullRequests.split.title': 'フェーズ別', + 'pullRequests.split.aria': '{label} のフェーズ別支出', + 'pullRequests.split.build': '作成', + 'pullRequests.split.review': 'レビュー実行', + 'pullRequests.split.after': '最初のレビュー以降', + 'pullRequests.reviews.you': 'あなたのレビュー({verdict})', }, ko: { 'pullRequests.loading': '풀 리퀘스트 확인 중…', @@ -132,6 +183,23 @@ export const pullRequests: SectionCatalog = { 'pullRequests.card.noPerTurnDetail': '턴 단위 세부 정보 없음(전체 세션 균등 분할로 추정).', 'pullRequests.empty.body': '{period}의 세션 중 풀 리퀘스트 URL을 언급한 세션이 없습니다. 지출은 트랜스크립트에 github.com/…/pull/N 형식의 링크가 있을 때만 귀속됩니다.', 'pullRequests.empty.widerHint': ' 전체 기간에는 풀 리퀘스트 {count}건이 있습니다. 기간 컨트롤을 전체 기간으로 전환하세요.', + 'pullRequests.facts.reviewedBy': '{model} 실행이 리뷰함 ({amount})', + 'pullRequests.facts.reviewedByYou': '직접 리뷰함 ({amount})', + 'pullRequests.facts.reviewRuns.one': '리뷰 실행 {count}건 ({amount})', + 'pullRequests.facts.reviewRuns.other': '리뷰 실행 {count}건 ({amount})', + 'pullRequests.facts.oneShot': '한 번에 완료', + 'pullRequests.facts.rounds.one': '후속 라운드 {count}회', + 'pullRequests.facts.rounds.other': '후속 라운드 {count}회', + 'pullRequests.facts.yourFollowUps': '내 후속 작업: {count}', + 'pullRequests.facts.merged': '연 지 {duration} 후 병합', + 'pullRequests.facts.prLinkOnly': 'pr-link로만 연결됨', + 'pullRequests.facts.prLinkOnlyTitle': '이 지출을 PR에 연결하는 것은 Claude Code의 pr-link 항목뿐입니다. PR을 지정한 명령이나 프롬프트는 없습니다.', + 'pullRequests.split.title': '단계별', + 'pullRequests.split.aria': '{label} 단계별 지출', + 'pullRequests.split.build': '구현', + 'pullRequests.split.review': '리뷰 실행', + 'pullRequests.split.after': '첫 리뷰 이후', + 'pullRequests.reviews.you': '내 리뷰 ({verdict})', }, zhCN: { 'pullRequests.loading': '正在扫描拉取请求…', @@ -165,6 +233,23 @@ export const pullRequests: SectionCatalog = { 'pullRequests.card.noPerTurnDetail': '无逐回合明细(按整个会话平均分摊估算)。', 'pullRequests.empty.body': '{period}内没有会话提及拉取请求 URL。仅当转录记录中包含 github.com/…/pull/N 格式的链接时,支出才会被归属。', 'pullRequests.empty.widerHint': ' 全部时间共有 {count} 个拉取请求。请将期间控件切换到全部时间。', + 'pullRequests.facts.reviewedBy': '由 {model} 运行审查({amount})', + 'pullRequests.facts.reviewedByYou': '由你审查({amount})', + 'pullRequests.facts.reviewRuns.one': '{count} 次审查运行({amount})', + 'pullRequests.facts.reviewRuns.other': '{count} 次审查运行({amount})', + 'pullRequests.facts.oneShot': '一次完成', + 'pullRequests.facts.rounds.one': '{count} 轮后续修改', + 'pullRequests.facts.rounds.other': '{count} 轮后续修改', + 'pullRequests.facts.yourFollowUps': '你的后续修改:{count}', + 'pullRequests.facts.merged': '创建后 {duration} 合并', + 'pullRequests.facts.prLinkOnly': '仅由 pr-link 关联', + 'pullRequests.facts.prLinkOnlyTitle': '只有 Claude Code 的 pr-link 记录把这笔支出关联到该 PR,没有命令或提示提到它。', + 'pullRequests.split.title': '按阶段', + 'pullRequests.split.aria': '{label} 按阶段的支出', + 'pullRequests.split.build': '开发', + 'pullRequests.split.review': '审查运行', + 'pullRequests.split.after': '首次审查之后', + 'pullRequests.reviews.you': '你的审查({verdict})', }, zhTW: { 'pullRequests.loading': '正在掃描拉取請求…', @@ -198,5 +283,22 @@ export const pullRequests: SectionCatalog = { 'pullRequests.card.noPerTurnDetail': '無逐回合明細(以整個工作階段平均分攤估算)。', 'pullRequests.empty.body': '{period}內沒有工作階段提及拉取請求 URL。僅當轉錄記錄中包含 github.com/…/pull/N 格式的連結時,支出才會被歸屬。', 'pullRequests.empty.widerHint': ' 全部時間共有 {count} 個拉取請求。請將期間控制項切換到全部時間。', + 'pullRequests.facts.reviewedBy': '由 {model} 執行審查({amount})', + 'pullRequests.facts.reviewedByYou': '由你審查({amount})', + 'pullRequests.facts.reviewRuns.one': '{count} 次審查執行({amount})', + 'pullRequests.facts.reviewRuns.other': '{count} 次審查執行({amount})', + 'pullRequests.facts.oneShot': '一次完成', + 'pullRequests.facts.rounds.one': '{count} 輪後續修改', + 'pullRequests.facts.rounds.other': '{count} 輪後續修改', + 'pullRequests.facts.yourFollowUps': '你的後續修改:{count}', + 'pullRequests.facts.merged': '建立後 {duration} 合併', + 'pullRequests.facts.prLinkOnly': '僅由 pr-link 關聯', + 'pullRequests.facts.prLinkOnlyTitle': '只有 Claude Code 的 pr-link 紀錄將這筆支出關聯到此 PR,沒有指令或提示提到它。', + 'pullRequests.split.title': '依階段', + 'pullRequests.split.aria': '{label} 依階段的支出', + 'pullRequests.split.build': '開發', + 'pullRequests.split.review': '審查執行', + 'pullRequests.split.after': '首次審查之後', + 'pullRequests.reviews.you': '你的審查({verdict})', }, } diff --git a/app/renderer/i18n/catalogs/sessionView.ts b/app/renderer/i18n/catalogs/sessionView.ts index d87022892..56404a4a7 100644 --- a/app/renderer/i18n/catalogs/sessionView.ts +++ b/app/renderer/i18n/catalogs/sessionView.ts @@ -157,6 +157,12 @@ export const sessionView: SectionCatalog = { 'sessions.why.step.helperFacts.other': '{type} · {models} · {calls} calls · {amount}', 'sessions.why.f.prefix.line.one': 'Writing the {uncached} uncached part to the cache cost {write} (measured). {readCalls} of the {laterCalls} later call read at least {tokens} cached tokens; re-reading that much on it is about {read}.', 'sessions.why.f.prefix.line.other': 'Writing the {uncached} uncached part to the cache cost {write} (measured). {readCalls} of the {laterCalls} later calls read at least {tokens} cached tokens; re-reading that much on each of them is about {read}.', + 'sessions.why.pr.title': 'PR work', + 'sessions.why.pr.reviewTotal': 'review runs {amount}', + 'sessions.why.pr.opened': 'Opened {pr}', + 'sessions.why.pr.noFollowUps': 'No pushes after it was opened', + 'sessions.why.pr.followUps': 'Follow-up rounds: {rounds}, pushes: {pushes}', + 'sessions.why.pr.reviewRun': 'Review run', }, fr: { 'sessions.why.back': 'Sessions', @@ -313,6 +319,12 @@ export const sessionView: SectionCatalog = { 'sessions.why.step.helperFacts.other': '{type} · {models} · {calls} appels · {amount}', 'sessions.why.f.prefix.line.one': 'Écrire la partie hors cache de {uncached} a coûté {write} (mesuré). {readCalls} sur {laterCalls} appel suivant a lu au moins {tokens} jetons en cache ; relire cette quantité coûte environ {read}.', 'sessions.why.f.prefix.line.other': 'Écrire la partie hors cache de {uncached} a coûté {write} (mesuré). {readCalls} des {laterCalls} appels suivants ont lu au moins {tokens} jetons en cache ; relire cette quantité à chacun coûte environ {read}.', + 'sessions.why.pr.title': 'Travail sur les PR', + 'sessions.why.pr.reviewTotal': 'exécutions de revue {amount}', + 'sessions.why.pr.opened': 'Ouverture de {pr}', + 'sessions.why.pr.noFollowUps': 'Aucun push après son ouverture', + 'sessions.why.pr.followUps': 'Tours de suivi : {rounds}, pushes : {pushes}', + 'sessions.why.pr.reviewRun': 'Exécution de revue', }, ja: { 'sessions.why.back': 'セッション', @@ -469,6 +481,12 @@ export const sessionView: SectionCatalog = { 'sessions.why.step.helperFacts.other': '{type} · {models} · {calls} 回の呼び出し · {amount}', 'sessions.why.f.prefix.line.one': '非キャッシュ部分 {uncached} の書き込みに {write}(実測)。以降の {laterCalls} 回中 {readCalls} 回が {tokens} 以上のキャッシュ済みトークンを読み込み、その量を毎回再読み込みすると約 {read} です。', 'sessions.why.f.prefix.line.other': '非キャッシュ部分 {uncached} の書き込みに {write}(実測)。以降の {laterCalls} 回中 {readCalls} 回が {tokens} 以上のキャッシュ済みトークンを読み込み、その量を毎回再読み込みすると約 {read} です。', + 'sessions.why.pr.title': 'PR の作業', + 'sessions.why.pr.reviewTotal': 'レビュー実行 {amount}', + 'sessions.why.pr.opened': '{pr} をオープン', + 'sessions.why.pr.noFollowUps': 'オープン後のプッシュなし', + 'sessions.why.pr.followUps': 'フォローアップ: {rounds} 回、プッシュ: {pushes} 回', + 'sessions.why.pr.reviewRun': 'レビュー実行', }, ko: { 'sessions.why.back': '세션', @@ -625,6 +643,12 @@ export const sessionView: SectionCatalog = { 'sessions.why.step.helperFacts.other': '{type} · {models} · {calls}회 호출 · {amount}', 'sessions.why.f.prefix.line.one': '캐시되지 않은 {uncached} 부분을 캐시에 쓰는 데 {write} (실측). 이후 호출 {laterCalls}회 중 {readCalls}회가 캐시된 토큰을 {tokens} 이상 읽었고, 매번 그만큼 다시 읽는 비용은 약 {read}입니다.', 'sessions.why.f.prefix.line.other': '캐시되지 않은 {uncached} 부분을 캐시에 쓰는 데 {write} (실측). 이후 호출 {laterCalls}회 중 {readCalls}회가 캐시된 토큰을 {tokens} 이상 읽었고, 매번 그만큼 다시 읽는 비용은 약 {read}입니다.', + 'sessions.why.pr.title': 'PR 작업', + 'sessions.why.pr.reviewTotal': '리뷰 실행 {amount}', + 'sessions.why.pr.opened': '{pr} 생성', + 'sessions.why.pr.noFollowUps': '생성 후 푸시 없음', + 'sessions.why.pr.followUps': '후속 라운드: {rounds}회, 푸시: {pushes}회', + 'sessions.why.pr.reviewRun': '리뷰 실행', }, zhCN: { 'sessions.why.back': '会话', @@ -781,6 +805,12 @@ export const sessionView: SectionCatalog = { 'sessions.why.step.helperFacts.other': '{type} · {models} · {calls} 次调用 · {amount}', 'sessions.why.f.prefix.line.one': '将未缓存的 {uncached} 部分写入缓存花费 {write}(实测)。之后 {laterCalls} 次调用中有 {readCalls} 次读取了至少 {tokens} 个缓存 token;每次重读这么多约 {read}。', 'sessions.why.f.prefix.line.other': '将未缓存的 {uncached} 部分写入缓存花费 {write}(实测)。之后 {laterCalls} 次调用中有 {readCalls} 次读取了至少 {tokens} 个缓存 token;每次重读这么多约 {read}。', + 'sessions.why.pr.title': 'PR 工作', + 'sessions.why.pr.reviewTotal': '审查运行 {amount}', + 'sessions.why.pr.opened': '创建了 {pr}', + 'sessions.why.pr.noFollowUps': '创建后没有推送', + 'sessions.why.pr.followUps': '后续轮次:{rounds},推送:{pushes}', + 'sessions.why.pr.reviewRun': '审查运行', }, zhTW: { 'sessions.why.back': '工作階段', @@ -937,5 +967,11 @@ export const sessionView: SectionCatalog = { 'sessions.why.step.helperFacts.other': '{type} · {models} · {calls} 次呼叫 · {amount}', 'sessions.why.f.prefix.line.one': '將未快取的 {uncached} 部分寫入快取花費 {write}(實測)。之後 {laterCalls} 次呼叫中有 {readCalls} 次讀取了至少 {tokens} 個快取 token;每次重讀這麼多約 {read}。', 'sessions.why.f.prefix.line.other': '將未快取的 {uncached} 部分寫入快取花費 {write}(實測)。之後 {laterCalls} 次呼叫中有 {readCalls} 次讀取了至少 {tokens} 個快取 token;每次重讀這麼多約 {read}。', + 'sessions.why.pr.title': 'PR 工作', + 'sessions.why.pr.reviewTotal': '審查執行 {amount}', + 'sessions.why.pr.opened': '建立了 {pr}', + 'sessions.why.pr.noFollowUps': '建立後沒有推送', + 'sessions.why.pr.followUps': '後續輪次:{rounds},推送:{pushes}', + 'sessions.why.pr.reviewRun': '審查執行', }, } diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 9249228e1..04bfde2d6 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -359,6 +359,18 @@ export type MenubarPayload = { approx?: boolean models?: string[] categories?: Array<{ name: string; cost: number }> + // Local PR signals (src/sessions-report.ts PrRow); older CLIs omit them. + runs?: number + linkEvidence?: 'explicit' | 'pr-link-only' | null + openedAt?: string | null + followUpRounds?: number | null + followUpPushes?: number + oneShot?: boolean | null + reviewRuns?: Array<{ kind: 'agent' | 'you'; label: string; model: string | null; costUSD: number; at: string; evidence: 'reads-pr' | 'gh-pr-review' }> + reviewCostUSD?: number + mergedAt?: string | null + timeToMergeMs?: number | null + costSplit?: { buildUSD: number; reviewUSD: number; fixesUSD: number } }> distinctCost: number distinctSessions: number @@ -812,6 +824,12 @@ export type SessionWhy = { findings: WhyFinding[] rules: WhyRules detailsOmitted: boolean + prWork?: WhyPrWork +} +export type WhyPrWork = { + opened: Array<{ url: string; at: string; head: string | null; followUpRounds: number | null; followUpPushes: number }> + reviewRuns: Array<{ helperId: string; description: string; models: string[]; cost: number; pr: number | null }> + reviewCost: number } // ————— src/compare-stats.ts ————— diff --git a/app/renderer/sections/PullRequests.test.tsx b/app/renderer/sections/PullRequests.test.tsx index 186308bc4..ddd1d8705 100644 --- a/app/renderer/sections/PullRequests.test.tsx +++ b/app/renderer/sections/PullRequests.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -370,4 +370,85 @@ describe('PullRequests', () => { expect(await screen.findByText(/No sessions in Aug 20 – 22 mentioned a pull request URL/)).toBeInTheDocument() expect(screen.queryByText(/Last 30 days/)).toBeNull() }) + + describe('local PR facts', () => { + const base = { savingsUSD: 0, sessions: 1, calls: 10, firstStarted: '2026-10-08T10:00:00Z', lastEnded: '2026-10-08T12:00:00Z', models: ['Opus 5.5'], categories: [{ name: 'Coding', cost: 1 }], linkEvidence: 'explicit' as const, openedAt: null, followUpPushes: 0, reviewCostUSD: 0, mergedAt: null, timeToMergeMs: null } + const pr = (n: number) => ({ url: `https://github.com/o/r/pull/${n}`, label: `o/r#${n}` }) + const ROWS: PrPayload['rows'] = [ + { ...base, ...pr(1), cost: 11.16, runs: 3, openedAt: '2026-10-08T10:00:00Z', followUpRounds: 0, oneShot: true, reviewRuns: [], mergedAt: '2026-10-08T10:11:00Z', timeToMergeMs: 660_000, costSplit: { buildUSD: 11.16, reviewUSD: 0, fixesUSD: 0 } }, + { ...base, ...pr(2), cost: 14.11, runs: 4, followUpRounds: 1, followUpPushes: 1, oneShot: null, reviewRuns: [{ kind: 'agent', label: 'general-purpose', model: 'Opus 5.5', costUSD: 1.4, at: '2026-10-10T08:56:00Z', evidence: 'reads-pr' }], reviewCostUSD: 1.4, mergedAt: '2026-10-10T09:34:00Z', costSplit: { buildUSD: 0, reviewUSD: 1.4, fixesUSD: 12.71 } }, + { ...base, ...pr(3), cost: 4.45, runs: 1, linkEvidence: 'pr-link-only', followUpRounds: null, oneShot: null, reviewRuns: [], costSplit: { buildUSD: 4.45, reviewUSD: 0, fixesUSD: 0 } }, + { ...base, ...pr(4), cost: 9, runs: 6, openedAt: '2026-10-08T10:00:00Z', followUpRounds: 2, followUpPushes: 5, oneShot: false, reviewRuns: [ + { kind: 'agent', label: 'general-purpose', model: 'Sonnet 5.5', costUSD: 0.5, at: '2026-10-08T10:30:00Z', evidence: 'reads-pr' }, + { kind: 'you', label: 'approve', model: 'Opus 5.5', costUSD: 0.25, at: '2026-10-08T11:30:00Z', evidence: 'gh-pr-review' }, + ], reviewCostUSD: 0.75, costSplit: { buildUSD: 6, reviewUSD: 0.75, fixesUSD: 2.25 } }, + ] + const payload = { rows: ROWS, distinctCost: 38.72, distinctSessions: 1, attributedCost: 38.72, unattributedCost: 0 } + + async function card(n: number): Promise { + return (await screen.findByRole('link', { name: `o/r#${n}` })).closest('article') as HTMLElement + } + const facts = (el: HTMLElement) => [...el.querySelectorAll('.pr-card-facts span')].map(s => s.textContent) + + it('one shot, merge time and the run count', async () => { + getOverview.mockResolvedValue(makePayload(payload)) + render() + const el = await card(1) + expect(facts(el)).toEqual(['One shot', 'Merged 11m after open']) + expect(el).toHaveTextContent('3 runs') + expect(el).not.toHaveTextContent('1 session') + }) + + it('a PR opened elsewhere: your follow-ups, the review run, no merge time without an open', async () => { + getOverview.mockResolvedValue(makePayload(payload)) + render() + const el = await card(2) + expect(facts(el)).toEqual(['Reviewed by Opus 5.5 run ($1.40)', 'Your follow-ups: 1']) + expect(el).not.toHaveTextContent('One shot') + expect(el).not.toHaveTextContent('Merged') + }) + + it('omits unknowns and says pr-link only, never stale or no review', async () => { + getOverview.mockResolvedValue(makePayload(payload)) + render() + const el = await card(3) + expect(facts(el)).toEqual(['Linked by pr-link only']) + expect(el).toHaveTextContent('1 run') + expect(el.textContent).not.toMatch(/stale|review|follow-up|one shot/i) + }) + + it('several review runs and counted rounds', async () => { + getOverview.mockResolvedValue(makePayload(payload)) + render() + expect(facts(await card(4))).toEqual(['2 review runs ($0.75)', '2 follow-up rounds']) + }) + + it('splits the spend by phase, summing to the cost, and lists the review runs', async () => { + getOverview.mockResolvedValue(makePayload(payload)) + render() + const el = await card(4) + await userEvent.click(rowForLink(within(el).getByRole('link'))) + const region = within(el).getByRole('region', { name: 'o/r#4 spend by phase' }) + const rows = [...region.querySelectorAll('.pr-cat')].map(r => r.textContent) + expect(rows).toEqual(['Building$6.00', 'Review runs$0.75', 'After first review$2.25', 'general-purposeSonnet 5.5$0.50', 'Your review (approve)Opus 5.5$0.25']) + const split = ROWS[3]!.costSplit! + expect(split.buildUSD + split.reviewUSD + split.fixesUSD).toBeCloseTo(ROWS[3]!.cost, 10) + }) + + it('shows no phase split when no review run was seen', async () => { + getOverview.mockResolvedValue(makePayload(payload)) + render() + const el = await card(1) + await userEvent.click(rowForLink(within(el).getByRole('link'))) + expect(within(el).queryByRole('region', { name: 'o/r#1 spend by phase' })).toBeNull() + }) + + it('an older payload without the fields keeps the session count and no facts line', async () => { + getOverview.mockResolvedValue(makePayload(SAMPLE)) + render() + const el = (await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })).closest('article') as HTMLElement + expect(el).toHaveTextContent('3 sessions') + expect(el.querySelector('.pr-card-facts')).toBeNull() + }) + }) }) diff --git a/app/renderer/sections/PullRequests.tsx b/app/renderer/sections/PullRequests.tsx index f8da76509..656734f9c 100644 --- a/app/renderer/sections/PullRequests.tsx +++ b/app/renderer/sections/PullRequests.tsx @@ -7,7 +7,7 @@ import { Panel } from '../components/Panel' import { SectionSkeleton } from '../components/Skeleton' import { StaleBanner } from '../components/StaleBanner' import { type Polled, usePolled } from '../hooks/usePolled' -import { formatCount, formatDayShort, formatUsd } from '../lib/format' +import { formatCount, formatDayShort, formatDuration, formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' import { PERIOD_LABELS } from '../lib/period' import type { CliError, DateRange, MenubarPayload, Period } from '../lib/types' @@ -29,6 +29,22 @@ function spanLabel(firstStarted: string, lastEnded: string): string { return start === end ? start : `${start} - ${end}` } +// Facts read from local commands. An unknown is left out, never shown as a negative. +function prFacts(pr: PrRow): string[] { + const facts: string[] = [] + const reviews = pr.reviewRuns ?? [] + const only = reviews.length === 1 ? reviews[0]! : null + if (only?.kind === 'you') facts.push(t('pullRequests.facts.reviewedByYou', { amount: formatUsd(only.costUSD) })) + else if (only?.model) facts.push(t('pullRequests.facts.reviewedBy', { model: only.model, amount: formatUsd(only.costUSD) })) + else if (reviews.length) facts.push(t(`pullRequests.facts.reviewRuns.${reviews.length === 1 ? 'one' : 'other'}`, { count: reviews.length, amount: formatUsd(pr.reviewCostUSD ?? 0) })) + const rounds = pr.followUpRounds ?? null + if (pr.oneShot === true) facts.push(t('pullRequests.facts.oneShot')) + else if (pr.oneShot === false && rounds !== null) facts.push(t(`pullRequests.facts.rounds.${rounds === 1 ? 'one' : 'other'}`, { count: rounds })) + else if (rounds !== null) facts.push(t('pullRequests.facts.yourFollowUps', { count: rounds })) + if (pr.timeToMergeMs != null) facts.push(t('pullRequests.facts.merged', { duration: formatDuration(pr.timeToMergeMs) })) + return facts +} + function ModelChips({ models }: { models: string[] }) { return (
@@ -218,6 +234,16 @@ function PrRowView({ pr, expanded, onToggle, onInvestigate }: { pr: PrRow; expan const models = pr.models ?? [] const categories = pr.categories ?? [] const catMax = categories.length ? Math.max(...categories.map(cat => cat.cost)) : 0 + const facts = prFacts(pr) + const reviews = pr.reviewRuns ?? [] + const split = pr.costSplit && reviews.length > 0 + ? [ + { key: 'build', cost: Math.max(0, pr.costSplit.buildUSD) }, + { key: 'review', cost: pr.costSplit.reviewUSD }, + { key: 'after', cost: pr.costSplit.fixesUSD }, + ] + : [] + const splitMax = Math.max(0, ...split.map(part => part.cost)) return (
@@ -237,9 +263,15 @@ function PrRowView({ pr, expanded, onToggle, onInvestigate }: { pr: PrRow; expan openPr(event, pr.url)}>{pr.label}
{spanLabel(pr.firstStarted, pr.lastEnded)} - {formatCount(pr.sessions, 'session')} + {pr.runs !== undefined ? formatCount(pr.runs, 'run') : formatCount(pr.sessions, 'session')} {formatCount(pr.calls, 'call')}
+ {(facts.length > 0 || pr.linkEvidence === 'pr-link-only') && ( +
+ {facts.map(fact => {fact})} + {pr.linkEvidence === 'pr-link-only' && {t('pullRequests.facts.prLinkOnly')}} +
+ )}
@@ -291,6 +323,36 @@ function PrRowView({ pr, expanded, onToggle, onInvestigate }: { pr: PrRow; expan ) : (

{t('pullRequests.card.noPerTurnDetail')}

)} + {split.length > 0 && ( +
+
+ {t('pullRequests.split.title')} +
+
+ {split.map(part => ( +
+ {t(`pullRequests.split.${part.key}`)} + + {formatUsd(part.cost)} +
+ ))} +
+
+ {t('pullRequests.split.review')} +
+
+ {reviews.map((run, i) => ( +
+ {run.kind === 'you' ? t('pullRequests.reviews.you', { verdict: run.label }) : run.label} + {run.model ?? ''} + {formatUsd(run.costUSD)} +
+ ))} +
+
+ )}
)} diff --git a/app/renderer/sections/SessionView.test.tsx b/app/renderer/sections/SessionView.test.tsx index 780c8a62a..8cb7fea46 100644 --- a/app/renderer/sections/SessionView.test.tsx +++ b/app/renderer/sections/SessionView.test.tsx @@ -98,6 +98,35 @@ describe('SessionView', () => { expect(container.querySelectorAll('.sv-bar')).toHaveLength(4) }) + it('shows PR work: PRs opened here with follow-ups and review runs with cost', async () => { + getSessionWhy.mockResolvedValue({ ...payload, prWork: { + opened: [ + { url: 'https://github.com/o/r/pull/12', at: '2026-10-02T08:12:00.000Z', head: 'feat/a', followUpRounds: 2, followUpPushes: 3 }, + { url: 'https://github.com/o/r/pull/13', at: '2026-10-02T08:14:00.000Z', head: 'feat/b', followUpRounds: 0, followUpPushes: 0 }, + { url: 'https://github.com/o/r/pull/14', at: '2026-10-02T08:15:00.000Z', head: null, followUpRounds: null, followUpPushes: 0 }, + ], + reviewRuns: [{ helperId: 'h1', description: 'Review PR 12', models: ['Opus 5.5'], cost: 1.4, pr: 12 }], + reviewCost: 1.4, + } }) + const { container } = render( {}} />) + await screen.findByText('PR work') + const block = container.querySelector('.sv-prwork') as HTMLElement + expect(block).toHaveTextContent('review runs $1.40') + expect([...block.querySelectorAll('.sv-fd')].map(r => r.textContent)).toEqual([ + 'Opened o/r#12Follow-up rounds: 2, pushes: 3', + 'Opened o/r#13No pushes after it was opened', + 'Opened o/r#14', + 'Review PR 12Opus 5.5 · #12$1.40', + ]) + }) + + it('has no PR work block when the session did none', async () => { + getSessionWhy.mockResolvedValue({ ...payload, prWork: { opened: [], reviewRuns: [], reviewCost: 0 } }) + render( {}} />) + await screen.findByText('Worth a look') + expect(screen.queryByText('PR work')).toBeNull() + }) + it('says so when the list row covers only part of the session', async () => { getSessionWhy.mockResolvedValue(payload) render( {}} />) diff --git a/app/renderer/sections/SessionView.tsx b/app/renderer/sections/SessionView.tsx index abee93924..8eedb5df9 100644 --- a/app/renderer/sections/SessionView.tsx +++ b/app/renderer/sections/SessionView.tsx @@ -12,7 +12,7 @@ import { formatAxisMoney, niceTicks } from '../lib/chartAxis' import { formatCompact, formatDayLong, formatUsd, formatUsdDifference, shortenProjectPath } from '../lib/format' import type { InvestigationFilters } from '../lib/investigation' import { codeburn, normalizeCliError } from '../lib/ipc' -import type { CliError, SessionDrillRow, SessionWhy, WhyFinding, WhyParts, WhyStep, WhyTokens, WhyTurn } from '../lib/types' +import type { CliError, SessionDrillRow, SessionWhy, WhyFinding, WhyParts, WhyPrWork, WhyStep, WhyTokens, WhyTurn } from '../lib/types' /** Claude Code main sessions are `.jsonl`; `--why` resolves only those. */ const CLAUDE_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i @@ -439,6 +439,40 @@ function TurnDetail({ turn, findings, why, openStep, onStep }: { turn: WhyTurn; ) } +const prLabel = (url: string) => url.replace(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+).*$/, '$1/$2#$3') + +function PrWorkPanel({ work }: { work: WhyPrWork }) { + return ( + 0 ? t('sessions.why.pr.reviewTotal', { amount: formatUsd(work.reviewCost) }) : undefined} className="sv-findings sv-prwork"> + {work.opened.map(pr => ( +
+
+
{t('sessions.why.pr.opened', { pr: prLabel(pr.url) })}
+ {pr.followUpRounds !== null && ( +

{pr.followUpRounds === 0 ? t('sessions.why.pr.noFollowUps') : t('sessions.why.pr.followUps', { rounds: pr.followUpRounds, pushes: pr.followUpPushes })}

+ )} +
+ + +
+ ))} + {work.reviewRuns.map(run => { + const line = [run.models.join(', '), run.pr !== null ? `#${run.pr}` : ''].filter(Boolean).join(' · ') + return ( +
+
+
{run.description || t('sessions.why.pr.reviewRun')}
+ {line &&

{line}

} +
+
{formatUsd(run.cost)}
+ +
+ ) + })} +
+ ) +} + export function SessionView({ row, filters, medianCost, onBack }: { row: SessionDrillRow filters: InvestigationFilters @@ -572,6 +606,8 @@ export function SessionView({ row, filters, medianCost, onBack }: { + {why.prWork && (why.prWork.opened.length > 0 || why.prWork.reviewRuns.length > 0) && } + open(turn)} /> diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 16d6a609b..3b414f8d4 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -863,6 +863,8 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-medium); } .pr-cat-name { overflow: hidden; color: var(--mut); font-size: var(--fs-body); font-weight: var(--fw-medium); text-overflow: ellipsis; white-space: nowrap; } .pr-cat > strong { color: var(--ink); font-family: var(--mono); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; text-align: right; } .pr-cat-empty { margin: 0; color: var(--mut2); font-size: var(--fs-meta); } +.pr-split { margin-top: var(--sp-4); } +.pr-reviews-head { margin-top: 14px; } .pr-footnote { margin: 14px 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; } @media (max-width: 1040px) { From f8e0e5701a127a0493c3aa776cd19c6d13cb8908 Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:17:01 -0700 Subject: [PATCH 7/7] perf(pr): read subagent descriptions only for PR attribution Every report, status and menubar refresh read each subagent's .meta.json (about 2,000 files in 30 days) and the cross-provider correlation scanned every folded run's commands for PR signals it never uses. Sessions now carry the sidecar path; the description is read on demand, memoized by mtime, only for folded runs and runs that push, and correlation skips the command scan. The by-pr footer now counts runs to match the Runs column. --- src/main.ts | 3 ++- src/parser.ts | 20 ++------------------ src/sessions-report.ts | 43 +++++++++++++++++++++++++++++++++--------- src/types.ts | 7 ++++--- 4 files changed, 42 insertions(+), 31 deletions(-) diff --git a/src/main.ts b/src/main.ts index d4fc0480f..3b9cbd3eb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3133,7 +3133,8 @@ program const subagentNote = subagentSessions > 0 ? ` + ${subagentSessions} folded-in subagent run${subagentSessions === 1 ? '' : 's'}` : '' - process.stdout.write(table + `\nRows sum to $${shownAttributed.toFixed(2)} attributed across ${sessions} PR-linked session${sessions === 1 ? '' : 's'}${subagentNote}. $${unattributedCost.toFixed(2)} of that spend was not tied to a specific PR.${approxNote}\n`) + const runs = sessions + subagentSessions + process.stdout.write(table + `\nRows sum to $${shownAttributed.toFixed(2)} attributed across ${runs} run${runs === 1 ? '' : 's'} (${sessions} PR-linked session${sessions === 1 ? '' : 's'}${subagentNote}). $${unattributedCost.toFixed(2)} of that spend was not tied to a specific PR.${approxNote}\n`) return } const rows = aggregateSessions(projects) diff --git a/src/parser.ts b/src/parser.ts index 65422f00f..650731873 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2120,21 +2120,6 @@ export async function readAgentType(filePath: string): Promise() -async function readAgentDescription(filePath: string): Promise { - if (agentDescriptions.has(filePath)) return agentDescriptions.get(filePath) - let description: string | undefined - try { - const d = (JSON.parse(await readFile(filePath.replace(/\.jsonl$/, '.meta.json'), 'utf8')) as { description?: unknown }).description - if (typeof d === 'string' && d.trim()) description = flatString(d.trim().slice(0, 200)) - } catch { /* missing or unreadable meta */ } - agentDescriptions.set(filePath, description) - return description -} - async function scanProjectDirs( dirs: Array<{ path: string; name: string; source?: SessionSourceMetadata }>, seenMsgIds: Set, @@ -2640,14 +2625,12 @@ async function scanProjectDirs( ? normalizeProjectPathKey(cachedFile.canonicalCwd) : `slug:${dirName}` const fileProject: CallProject = { key: projectKey, path: projectPath, name: projectName } - const agentDescription = cachedFile.isSidechain ? await readAgentDescription(filePath) : undefined const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined const decorate = (session: SessionSummary): SessionSummary => { if (cachedFile.workingDirectory && !isCoworkSession(cachedFile.workingDirectory, filePath)) { session.workingDirectory = cachedFile.workingDirectory } session.agentType = cachedFile.agentType - if (agentDescription) session.agentDescription = agentDescription if (everHadBranch) session.everHadBranch = true const observedPrLinks = new Set(classifiedTurns.flatMap(turn => turn.prRefs ?? [])) for (const link of cachedFile.prLinks ?? []) observedPrLinks.add(link) @@ -2664,6 +2647,7 @@ async function scanProjectDirs( if (cachedFile.isSidechain) { session.isSidechain = true if (cachedFile.parentSessionId) session.parentSessionId = cachedFile.parentSessionId + session.agentMetaPath = filePath.replace(/\.jsonl$/, '.meta.json') session.agentId = sessionId.startsWith('agent-') ? sessionId.slice('agent-'.length) : sessionId } // Parent linkage maps (only present on sessions that spawned subagents). @@ -5557,7 +5541,7 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo if (bucket) bucket.push(s) else unlinkedByAgentId.set(s.agentId, [s]) } - for (const resolved of resolveSubagentAttribution(mergeProjectSplits(projects)).values()) { + for (const resolved of resolveSubagentAttribution(mergeProjectSplits(projects), false).values()) { for (const child of resolved) { // A multi-PR spawn set is valid for folding the child's own cost, but is // too broad to identify which PR an independently saved nested review was diff --git a/src/sessions-report.ts b/src/sessions-report.ts index 4ea0e390c..1adcb86be 100644 --- a/src/sessions-report.ts +++ b/src/sessions-report.ts @@ -1,4 +1,6 @@ +import { readFileSync, statSync } from 'fs' import { behavioralCallCount, behavioralTurnCount } from './behavioral-weight.js' +import { flatString } from './content-utils.js' import { ESTIMATED_COST_LEGEND, isEstimatedCost, markEstimated } from './format.js' import { modelRowKey } from './models.js' import { maxOf } from './math-utils.js' @@ -716,13 +718,32 @@ export function buildSubagentIndex(projects: ProjectSummary[]): Map() +function agentDescriptionOf(session: SessionSummary): string { + const path = session.agentMetaPath + if (!path) return '' + try { + const { mtimeMs } = statSync(path) + const hit = agentDescriptions.get(path) + if (hit?.mtimeMs === mtimeMs) return hit.description + const d = (JSON.parse(readFileSync(path, 'utf8')) as { description?: unknown }).description + const description = typeof d === 'string' && d.trim() ? flatString(d.trim().slice(0, 200)) : '' + agentDescriptions.set(path, { mtimeMs, description }) + return description + } catch { + return '' + } +} + // Aggregate a child session and its non-self-linking descendants, depth-first. // `claimed` is ONE set per parent resolution (shared across all direct children), // so a descendant reachable through two paths (duplicate/diamond ids) folds // exactly once and a parent-link cycle terminates. A self-linking descendant is // skipped: it attributes standalone. `spawnAtMs` stays the TOP child's, since the // whole subtree resolves against the top parent. -function buildChildFold(child: SessionSummary, index: Map, claimed: Set, ambiguous: Set): ChildFold { +function buildChildFold(child: SessionSummary, index: Map, claimed: Set, ambiguous: Set, scanCommands: boolean): ChildFold { claimed.add(child.sessionId) const models = new Map() const categories = new Map() @@ -736,7 +757,7 @@ function buildChildFold(child: SessionSummary, index: Map -export function resolveSubagentAttribution(projects: ProjectSummary[]): SubagentAttribution { +/// `scanCommands: false` skips the per-call PR signals (pushed, readPrs, ...) for +/// callers that only need each child's PR set. +export function resolveSubagentAttribution(projects: ProjectSummary[], scanCommands = true): SubagentAttribution { const index = buildSubagentIndex(projects) // A provider+sessionId key is AMBIGUOUS when it is carried by more than one // DISTINCT record (different fingerprint) across ALL candidate sessions and @@ -893,7 +917,7 @@ export function resolveSubagentAttribution(projects: ProjectSummary[]): Subagent const resolved: ResolvedChild[] = [] for (const child of direct) { if (claimed.has(child.sessionId) || selfLinks(child) || ambiguous.has(providerSessionKey(child))) continue - resolved.push(resolveChild(parent, buildChildFold(child, index, claimed, ambiguous))) + resolved.push(resolveChild(parent, buildChildFold(child, index, claimed, ambiguous, scanCommands))) } if (resolved.length) out.set(k, resolved) } @@ -1269,7 +1293,7 @@ function scanPrCommands(projects: ProjectSummary[]): PrCommandScan { const out: PrCommandScan = { pushes: [], merges: [] } for (const project of projects) for (const session of project.sessions) { const side = !!session.isSidechain - const mentioned = side ? namedPrNumber(session.agentDescription ?? '') : null + let mentioned: number | null | undefined let upstream: string | undefined for (const turn of session.turns) for (const call of turn.assistantCalls) { const cmds = callCommands(call) @@ -1278,8 +1302,9 @@ function scanPrCommands(projects: ProjectSummary[]): PrCommandScan { if (Number.isNaN(ms)) continue for (const cmd of cmds) { for (const branches of pushes(cmd)) { + if (side && mentioned === undefined) mentioned = namedPrNumber(agentDescriptionOf(session)) if (side && branches.length) upstream = branches.at(-1) - out.pushes.push({ ms, branches: branches.length ? branches : upstream ? [upstream] : [], mentioned }) + out.pushes.push({ ms, branches: branches.length ? branches : upstream ? [upstream] : [], mentioned: mentioned ?? null }) } for (const g of ghPrCommands(cmd)) { if (g.verb === 'merge' && g.number !== undefined) out.merges.push({ ms, number: g.number, ...(g.repo ? { repo: g.repo } : {}) }) diff --git a/src/types.ts b/src/types.ts index 54c8b878f..986c6138c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -274,9 +274,10 @@ export type SessionSummary = { // (`workflow-subagent`, `Explore`, `general-purpose`, …); undefined for // ordinary sessions. Drives the Claude-scoped agent-type breakdown. agentType?: string - /// Claude Code only: a subagent's Agent `description` from its `.meta.json` - /// ("Review PR 1729 Claude quota dir"). Read at report time, never cached. - agentDescription?: string + /// Claude Code only: a subagent's `.meta.json` sidecar. Its Agent + /// `description` ("Review PR 1729 Claude quota dir") is read only by PR + /// attribution, on demand. + agentMetaPath?: string /// OMP nested-agent file stem. Kept separate from Claude's agentType metadata. agentName?: string /// OMP nested-agent session-header timestamp.