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) { diff --git a/src/main.ts b/src/main.ts index 31f228658..3b9cbd3eb 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), @@ -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 da108fec9..650731873 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2647,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). @@ -5540,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/pr-signals.ts b/src/pr-signals.ts new file mode 100644 index 000000000..89faba584 --- /dev/null +++ b/src/pr-signals.ts @@ -0,0 +1,131 @@ +// 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(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 text.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 } : {}) } +} + +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 => 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 + 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[] = [] + 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) + 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) + 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 +} + +/// 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/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/src/sessions-report.ts b/src/sessions-report.ts index 5ffacb9ab..1adcb86be 100644 --- a/src/sessions-report.ts +++ b/src/sessions-report.ts @@ -1,7 +1,10 @@ +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' +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' @@ -409,8 +412,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. @@ -422,6 +430,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+)/ @@ -437,6 +497,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 @@ -445,12 +508,15 @@ 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 // 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 @@ -469,7 +535,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 @@ -507,6 +573,20 @@ 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 + 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 { @@ -638,38 +718,79 @@ 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() + 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) + if (scanCommands) 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) } + const description = agentDescriptionOf(child) const fold: ChildFold = { agentId: child.agentId ?? child.sessionId, cost: child.totalCostUSD, calls: child.apiCalls, savingsUSD: child.totalSavingsUSD, spawnAtMs: parseMs(child.firstTimestamp), firstTs: child.firstTimestamp, lastTs: child.lastTimestamp, models, categories, foldedSessions: 1, + named: namedPr(description), + ...(child.agentType ? { agentType: child.agentType } : {}), + mentioned: namedPrNumber(description), + pushed, committed, readsCode: reads, readPrs, prNumbers, } for (const gc of index.get(providerSessionKey(child)) ?? []) { // Skip a descendant whose id is ambiguous (two conflicting records share it): // fold neither, consistent with the parent-level rule. if (claimed.has(gc.sessionId) || selfLinks(gc) || ambiguous.has(providerSessionKey(gc))) continue - const gcf = buildChildFold(gc, index, claimed, ambiguous) + const gcf = buildChildFold(gc, index, claimed, ambiguous, scanCommands) fold.cost += gcf.cost; fold.calls += gcf.calls; fold.savingsUSD += gcf.savingsUSD fold.foldedSessions += gcf.foldedSessions + fold.pushed ||= gcf.pushed + fold.committed ||= gcf.committed + fold.readsCode ||= gcf.readsCode + for (const n of gcf.readPrs) fold.readPrs.add(n) + for (const n of gcf.prNumbers) fold.prNumbers.add(n) for (const [m, c] of gcf.models) addToMap(fold.models, m, c) for (const [cat, c] of gcf.categories) addToMap(fold.categories, cat, c) if (gcf.firstTs && (!fold.firstTs || gcf.firstTs < fold.firstTs)) fold.firstTs = gcf.firstTs @@ -709,7 +830,7 @@ function resolveChild(parent: SessionSummary, fold: ChildFold): ResolvedChild { const spawnId = parent.agentSpawnLinks?.[fold.agentId] if (spawnId !== undefined && parent.spawnPrSets && Object.prototype.hasOwnProperty.call(parent.spawnPrSets, spawnId)) { const prs = parent.spawnPrSets[spawnId]! - return { fold, prSet: prs.length ? prs : null, unlinked: false } + return { fold, prSet: prs.length ? namedPrSet(prs, fold.named) : null, unlinked: false } } const ms = fold.spawnAtMs if (Number.isNaN(ms)) return { fold, prSet: null, unlinked: true } @@ -734,7 +855,23 @@ function resolveChild(parent: SessionSummary, fold: ChildFold): ResolvedChild { if (tMs <= ms) { if (turn.prRefs?.length) current = turn.prRefs } else break } - return { fold, prSet: current, unlinked: false } + return { fold, prSet: current ? namedPrSet(current, fold.named) : null, unlinked: false } +} + +// A run whose prompt names one PR belongs to that PR, not to whatever PR the +// parent's last pr-link happened to carry (a review of #1729 spawned while the +// parent carried a stale #24). Only re-points spend that already had a PR, and +// only within the carried PR's repo (or the exact URL the prompt gave), so +// attributed totals never change. +function namedPrSet(carried: string[], named: NamedPr | 'multi' | null): string[] { + if (!named || named === 'multi') return carried + const repos = new Set(carried.map(url => { + 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 @@ -744,7 +881,9 @@ function resolveChild(parent: SessionSummary, fold: ChildFold): ResolvedChild { /// (deterministic skip, stays standalone): correctness over coverage. export type SubagentAttribution = 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 @@ -778,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) } @@ -830,12 +969,14 @@ export function attributeSessionPrSpend(session: AttributableSession): SessionPr for (const [m, mc] of legacyModels) addToMap(e.models, m, mc * share) }) } - return { perUrl, unattributed } + return { perUrl, unattributed, turnPrs: [] } } let current: string[] | null = session.prRefsAtRangeStart?.length ? session.prRefsAtRangeStart : null + const turnPrs: Array = [] 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) @@ -854,8 +995,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 @@ -863,7 +1008,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; @@ -886,10 +1031,16 @@ 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) + 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 @@ -904,17 +1055,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) @@ -947,7 +1101,8 @@ 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) + foldSignals(sig(url), url, rc.fold, share, prs.length === 1) }) } } @@ -957,11 +1112,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, 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) } + turnSignals(session, turnPrs, sig) unattributedCost += unattributed.cost foldChildren(session) } @@ -973,6 +1129,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 @@ -1005,9 +1167,11 @@ 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 } : {}), + ...rowSignals(url, signals.get(url), r.cost, scan, byNumber), } }) .sort((a, b) => b.cost - a.cost) @@ -1015,6 +1179,181 @@ 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 => { + 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) + 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 + 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) + if (!cmds.length) continue + const ms = parseMs(call.timestamp) + 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: 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 } : {}) }) + } + } + } + } + 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/src/types.ts b/src/types.ts index dc9ec7032..986c6138c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -274,6 +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 `.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. diff --git a/tests/pr-insights.test.ts b/tests/pr-insights.test.ts new file mode 100644 index 000000000..c787b488e --- /dev/null +++ b/tests/pr-insights.test.ts @@ -0,0 +1,258 @@ +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 { 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' + +// 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 -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)]), + 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'), + // 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'), + 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) + }) +}) + +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) + }) +}) + +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') + }) +}) + +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) + }) +})