claude-attribution 1.9.4 → 1.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,10 +22,14 @@ import {
22
22
 
23
23
  const execFileAsync = promisify(execFile);
24
24
  export const NOTES_REF = "refs/notes/claude-attribution";
25
+ const GIT_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024;
25
26
 
26
27
  /** Run a shell command and return trimmed stdout. Throws on non-zero exit. */
27
28
  async function run(cmd: string, args: string[], cwd?: string): Promise<string> {
28
- const { stdout } = await execFileAsync(cmd, args, { cwd });
29
+ const { stdout } = await execFileAsync(cmd, args, {
30
+ cwd,
31
+ maxBuffer: GIT_OUTPUT_MAX_BUFFER,
32
+ });
29
33
  return stdout.trim();
30
34
  }
31
35
 
@@ -35,7 +39,10 @@ async function runRaw(
35
39
  args: string[],
36
40
  cwd?: string,
37
41
  ): Promise<string> {
38
- const { stdout } = await execFileAsync(cmd, args, { cwd });
42
+ const { stdout } = await execFileAsync(cmd, args, {
43
+ cwd,
44
+ maxBuffer: GIT_OUTPUT_MAX_BUFFER,
45
+ });
39
46
  return stdout;
40
47
  }
41
48
 
@@ -323,7 +330,7 @@ function inferAiActorRuntime(meta: CommitMeta): AssistantRuntimeInfo | undefined
323
330
 
324
331
  /**
325
332
  * Build a 100% AI AttributionResult for a commit without running the
326
- * checkpoint-based differ. All non-blank committed lines are marked AI.
333
+ * checkpoint-based differ. All committed lines are marked AI.
327
334
  *
328
335
  * Used by `note-ai-commit` (to write git notes in GHA) and by `collect.ts`
329
336
  * (to synthesize attribution at metrics time for unattributed AI actor commits).
@@ -343,8 +350,8 @@ export async function buildAllAiResult(
343
350
  const content = await committedContentAt(repoRoot, sha, relPath);
344
351
  if (content === null || content.includes("\0")) return null;
345
352
  const lines = content.split("\n");
346
- const ai = lines.filter((l) => l.trim().length > 0).length;
347
- const human = lines.length - ai;
353
+ const ai = lines.length;
354
+ const human = 0;
348
355
  const total = lines.length;
349
356
  return {
350
357
  path: relPath,
@@ -23,9 +23,11 @@ import { writeFile, unlink, mkdtemp, rmdir } from "fs/promises";
23
23
  import { tmpdir } from "os";
24
24
  import { join } from "path";
25
25
  import { hashLine } from "./differ.ts";
26
+ import { committedContentAt } from "./git-notes.ts";
26
27
 
27
28
  const execFileAsync = promisify(execFile);
28
29
  const BLANK_LINE_HASH = hashLine("");
30
+ const GIT_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024;
29
31
 
30
32
  export const MINIMAP_NOTES_REF = "refs/notes/claude-attribution-map";
31
33
 
@@ -47,7 +49,10 @@ export interface MinimapResult {
47
49
  }
48
50
 
49
51
  async function run(cmd: string, args: string[], cwd?: string): Promise<string> {
50
- const { stdout } = await execFileAsync(cmd, args, { cwd });
52
+ const { stdout } = await execFileAsync(cmd, args, {
53
+ cwd,
54
+ maxBuffer: GIT_OUTPUT_MAX_BUFFER,
55
+ });
51
56
  return stdout.trim();
52
57
  }
53
58
 
@@ -169,6 +174,102 @@ export async function readMinimap(
169
174
  }
170
175
  }
171
176
 
177
+ async function commitParents(
178
+ repoRoot: string,
179
+ commitSha: string,
180
+ ): Promise<string[]> {
181
+ const output = await run(
182
+ "git",
183
+ ["rev-list", "--parents", "-n", "1", commitSha],
184
+ repoRoot,
185
+ ).catch(() => "");
186
+ const parts = output.split(" ").filter(Boolean);
187
+ return parts.slice(1);
188
+ }
189
+
190
+ async function trackedFilesAt(
191
+ repoRoot: string,
192
+ commitSha: string,
193
+ ): Promise<string[]> {
194
+ const output = await run(
195
+ "git",
196
+ ["ls-tree", "-r", "--name-only", commitSha],
197
+ repoRoot,
198
+ ).catch(() => "");
199
+ return output ? output.split("\n").filter(Boolean) : [];
200
+ }
201
+
202
+ export async function resolveMinimap(
203
+ repoRoot: string,
204
+ commitSha = "HEAD",
205
+ cache = new Map<string, Promise<MinimapResult | null>>(),
206
+ ): Promise<MinimapResult | null> {
207
+ const cached = cache.get(commitSha);
208
+ if (cached) return cached;
209
+
210
+ const pending = (async (): Promise<MinimapResult | null> => {
211
+ const direct = await readMinimap(repoRoot, commitSha);
212
+ if (direct) return direct;
213
+
214
+ const parents = await commitParents(repoRoot, commitSha);
215
+ if (parents.length === 0) return null;
216
+
217
+ const parentMinimaps = (
218
+ await Promise.all(parents.map((parent) => resolveMinimap(repoRoot, parent, cache)))
219
+ ).filter((result): result is MinimapResult => result !== null);
220
+ if (parentMinimaps.length === 0) return null;
221
+
222
+ const inheritedAiByPath = new Map<string, Set<string>>();
223
+ for (const parent of parentMinimaps) {
224
+ for (const file of parent.files) {
225
+ let combined = inheritedAiByPath.get(file.path);
226
+ if (!combined) {
227
+ combined = new Set<string>();
228
+ inheritedAiByPath.set(file.path, combined);
229
+ }
230
+ for (const hash of hashSetFromString(file.ai_hashes)) {
231
+ combined.add(hash);
232
+ }
233
+ }
234
+ }
235
+
236
+ const allFiles = await trackedFilesAt(repoRoot, commitSha);
237
+ const minimapFiles: MinimapFileState[] = [];
238
+ for (let i = 0; i < allFiles.length; i += CONCURRENCY) {
239
+ const batch = allFiles.slice(i, i + CONCURRENCY);
240
+ const results = await Promise.all(
241
+ batch.map(async (relPath): Promise<MinimapFileState> => {
242
+ const committed = await committedContentAt(repoRoot, commitSha, relPath).catch(
243
+ () => null,
244
+ );
245
+ if (!committed || committed.includes("\0")) {
246
+ return {
247
+ path: relPath,
248
+ ai_hashes: "",
249
+ ai: 0,
250
+ human: 0,
251
+ total: 0,
252
+ pctAi: 0,
253
+ };
254
+ }
255
+ return computeMinimapFile(
256
+ relPath,
257
+ committed.split("\n"),
258
+ new Set<string>(),
259
+ inheritedAiByPath.get(relPath) ?? new Set<string>(),
260
+ );
261
+ }),
262
+ );
263
+ minimapFiles.push(...results);
264
+ }
265
+
266
+ return buildResult(commitSha, minimapFiles);
267
+ })();
268
+
269
+ cache.set(commitSha, pending);
270
+ return pending;
271
+ }
272
+
172
273
  export async function listMinimapNotes(repoRoot: string): Promise<string[]> {
173
274
  try {
174
275
  const output = await run(
@@ -12,8 +12,8 @@
12
12
  * by a known AI actor (bot author or Co-authored-by trailer).
13
13
  * Silent no-op otherwise — safe to run on every push.
14
14
  *
15
- * All non-blank lines in the committed files are attributed as AI. Blank
16
- * lines are always HUMAN per the attribution algorithm in differ.ts.
15
+ * All committed lines in the changed files are attributed as AI, including
16
+ * blank lines.
17
17
  */
18
18
  import { resolve } from "path";
19
19
  import {
@@ -0,0 +1,271 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import {
4
+ aggregateTotals,
5
+ type AssistantRuntimeInfo,
6
+ type AttributionResult,
7
+ type CommitModelUsage,
8
+ type CommitSessionMetrics,
9
+ } from "../attribution/differ.ts";
10
+ import {
11
+ NOTES_REF,
12
+ committedContentAt,
13
+ renamedFilesInCommit,
14
+ writeNote,
15
+ } from "../attribution/git-notes.ts";
16
+ import {
17
+ MINIMAP_NOTES_REF,
18
+ computeMinimapFile,
19
+ hashSetFromString,
20
+ resolveMinimap,
21
+ type MinimapResult,
22
+ type MinimapFileState,
23
+ writeMinimap,
24
+ } from "../attribution/minimap.ts";
25
+ import { pushNotesRefs } from "../attribution/notes-sync.ts";
26
+
27
+ const execFileAsync = promisify(execFile);
28
+
29
+ interface WriteMergedPrArtifactsOptions {
30
+ repoRoot: string;
31
+ mergeCommitSha: string;
32
+ prHeadSha: string;
33
+ results: AttributionResult[];
34
+ baseRef?: string | null;
35
+ push?: boolean;
36
+ }
37
+
38
+ async function runGit(repoRoot: string, args: string[]): Promise<string> {
39
+ const { stdout } = await execFileAsync("git", args, { cwd: repoRoot });
40
+ return stdout.trim();
41
+ }
42
+
43
+ async function trackedFilesAt(repoRoot: string, commitSha: string): Promise<string[]> {
44
+ const output = await runGit(repoRoot, ["ls-tree", "-r", "--name-only", commitSha]).catch(
45
+ () => "",
46
+ );
47
+ return output ? output.split("\n").filter(Boolean) : [];
48
+ }
49
+
50
+ async function changedFilesInCommit(
51
+ repoRoot: string,
52
+ commitSha: string,
53
+ ): Promise<Set<string>> {
54
+ const output = await runGit(repoRoot, [
55
+ "diff-tree",
56
+ "--no-commit-id",
57
+ "-r",
58
+ "--name-only",
59
+ commitSha,
60
+ ]).catch(() => "");
61
+ return new Set(output.split("\n").filter(Boolean));
62
+ }
63
+
64
+ async function firstParent(repoRoot: string, commitSha: string): Promise<string | null> {
65
+ const output = await runGit(repoRoot, ["rev-list", "--parents", "-n", "1", commitSha]).catch(
66
+ () => "",
67
+ );
68
+ const [, parent] = output.split(" ");
69
+ return parent ?? null;
70
+ }
71
+
72
+ function aggregateModelUsage(results: AttributionResult[]): CommitModelUsage[] {
73
+ const byModel = new Map<string, CommitModelUsage>();
74
+ for (const result of results) {
75
+ for (const model of result.modelUsage ?? []) {
76
+ const key = `${model.modelFull}|${model.modelShort}`;
77
+ const existing = byModel.get(key);
78
+ if (existing) {
79
+ existing.calls += model.calls;
80
+ existing.inputTokens += model.inputTokens;
81
+ existing.outputTokens += model.outputTokens;
82
+ existing.cacheCreationTokens += model.cacheCreationTokens;
83
+ existing.cacheReadTokens += model.cacheReadTokens;
84
+ } else {
85
+ byModel.set(key, { ...model });
86
+ }
87
+ }
88
+ }
89
+ return [...byModel.values()].sort((a, b) =>
90
+ a.modelFull.localeCompare(b.modelFull),
91
+ );
92
+ }
93
+
94
+ function aggregateSessionMetrics(
95
+ results: AttributionResult[],
96
+ ): CommitSessionMetrics | undefined {
97
+ const toolCounts: Record<string, number> = {};
98
+ const agentCounts: Record<string, number> = {};
99
+ const skillNames = new Set<string>();
100
+ let humanPromptCount = 0;
101
+ let activeMinutes = 0;
102
+ let aiMinutes = 0;
103
+ let humanMinutes = 0;
104
+ let hasAny = false;
105
+
106
+ for (const result of results) {
107
+ const metrics = result.sessionMetrics;
108
+ if (!metrics) continue;
109
+ hasAny = true;
110
+ for (const [tool, count] of Object.entries(metrics.toolCounts ?? {})) {
111
+ toolCounts[tool] = (toolCounts[tool] ?? 0) + count;
112
+ }
113
+ for (const [agent, count] of Object.entries(metrics.agentCounts ?? {})) {
114
+ agentCounts[agent] = (agentCounts[agent] ?? 0) + count;
115
+ }
116
+ for (const skill of metrics.skillNames ?? []) {
117
+ skillNames.add(skill);
118
+ }
119
+ humanPromptCount += metrics.humanPromptCount ?? 0;
120
+ activeMinutes += metrics.activeMinutes ?? 0;
121
+ aiMinutes += metrics.aiMinutes ?? 0;
122
+ humanMinutes += metrics.humanMinutes ?? 0;
123
+ }
124
+
125
+ if (!hasAny) return undefined;
126
+ return {
127
+ toolCounts: Object.keys(toolCounts).length > 0 ? toolCounts : undefined,
128
+ agentCounts: Object.keys(agentCounts).length > 0 ? agentCounts : undefined,
129
+ skillNames: skillNames.size > 0 ? [...skillNames].sort() : undefined,
130
+ humanPromptCount,
131
+ activeMinutes,
132
+ aiMinutes,
133
+ humanMinutes,
134
+ };
135
+ }
136
+
137
+ function latestAssistantRuntime(
138
+ results: AttributionResult[],
139
+ ): AssistantRuntimeInfo | undefined {
140
+ for (let i = results.length - 1; i >= 0; i--) {
141
+ const runtime = results[i]?.assistantRuntime;
142
+ if (runtime) return runtime;
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ function buildMinimapResult(
148
+ commitSha: string,
149
+ files: MinimapFileState[],
150
+ ): MinimapResult {
151
+ const totals = files.reduce(
152
+ (acc, file) => ({
153
+ ai: acc.ai + file.ai,
154
+ human: acc.human + file.human,
155
+ total: acc.total + file.total,
156
+ }),
157
+ { ai: 0, human: 0, total: 0 },
158
+ );
159
+ return {
160
+ commit: commitSha,
161
+ timestamp: new Date().toISOString(),
162
+ files,
163
+ totals: {
164
+ ...totals,
165
+ pctAi: totals.total > 0 ? Math.round((totals.ai / totals.total) * 100) : 0,
166
+ },
167
+ };
168
+ }
169
+
170
+ export async function writeMergedPrArtifacts({
171
+ repoRoot,
172
+ mergeCommitSha,
173
+ prHeadSha,
174
+ results,
175
+ baseRef,
176
+ push = false,
177
+ }: WriteMergedPrArtifactsOptions): Promise<{
178
+ note: AttributionResult;
179
+ minimap: MinimapResult | null;
180
+ }> {
181
+ const sorted = [...results].sort(
182
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
183
+ );
184
+ const lastSeenByFile = new Map<string, AttributionResult["files"][number]>();
185
+ for (const result of sorted) {
186
+ for (const file of result.files) {
187
+ lastSeenByFile.set(file.path, file);
188
+ }
189
+ }
190
+
191
+ const files = [...lastSeenByFile.values()].sort((a, b) =>
192
+ a.path.localeCompare(b.path),
193
+ );
194
+ const note: AttributionResult = {
195
+ commit: mergeCommitSha,
196
+ session: sorted.at(-1)?.session ?? null,
197
+ branch: baseRef ?? sorted.at(-1)?.branch ?? null,
198
+ timestamp: new Date().toISOString(),
199
+ files,
200
+ totals: aggregateTotals(files),
201
+ };
202
+
203
+ const modelUsage = aggregateModelUsage(sorted);
204
+ if (modelUsage.length > 0) note.modelUsage = modelUsage;
205
+ const sessionMetrics = aggregateSessionMetrics(sorted);
206
+ if (sessionMetrics) note.sessionMetrics = sessionMetrics;
207
+ const assistantRuntime = latestAssistantRuntime(sorted);
208
+ if (assistantRuntime) note.assistantRuntime = assistantRuntime;
209
+
210
+ await writeNote(note, repoRoot, mergeCommitSha);
211
+
212
+ const parentSha = await firstParent(repoRoot, mergeCommitSha);
213
+ const [
214
+ baseMinimap,
215
+ prHeadMinimap,
216
+ renamedBasePathByCurrentPath,
217
+ trackedFiles,
218
+ changedFiles,
219
+ ] = await Promise.all([
220
+ parentSha ? resolveMinimap(repoRoot, parentSha) : Promise.resolve(null),
221
+ resolveMinimap(repoRoot, prHeadSha).catch(() => null),
222
+ renamedFilesInCommit(repoRoot, mergeCommitSha),
223
+ trackedFilesAt(repoRoot, mergeCommitSha),
224
+ changedFilesInCommit(repoRoot, mergeCommitSha),
225
+ ]);
226
+
227
+ let minimap: MinimapResult | null = null;
228
+ if (parentSha && baseMinimap && prHeadMinimap) {
229
+ const baseAiByPath = new Map(
230
+ baseMinimap.files.map((file) => [file.path, hashSetFromString(file.ai_hashes)]),
231
+ );
232
+ const prHeadAiByPath = new Map(
233
+ prHeadMinimap.files.map((file) => [file.path, hashSetFromString(file.ai_hashes)]),
234
+ );
235
+
236
+ const minimapFiles = await Promise.all(
237
+ trackedFiles.map(async (path): Promise<MinimapFileState> => {
238
+ const committed = await committedContentAt(repoRoot, mergeCommitSha, path).catch(
239
+ () => null,
240
+ );
241
+ if (!committed || committed.includes("\0")) {
242
+ return {
243
+ path,
244
+ ai_hashes: "",
245
+ ai: 0,
246
+ human: 0,
247
+ total: 0,
248
+ pctAi: 0,
249
+ };
250
+ }
251
+ const basePath = renamedBasePathByCurrentPath.get(path) ?? path;
252
+ return computeMinimapFile(
253
+ path,
254
+ committed.split("\n"),
255
+ changedFiles.has(path)
256
+ ? (prHeadAiByPath.get(path) ?? new Set<string>())
257
+ : new Set<string>(),
258
+ baseAiByPath.get(basePath) ?? new Set<string>(),
259
+ );
260
+ }),
261
+ );
262
+ minimap = buildMinimapResult(mergeCommitSha, minimapFiles);
263
+ await writeMinimap(minimap, repoRoot, mergeCommitSha);
264
+ }
265
+
266
+ if (push) {
267
+ await pushNotesRefs(repoRoot, [NOTES_REF, MINIMAP_NOTES_REF]);
268
+ }
269
+
270
+ return { note, minimap };
271
+ }
@@ -53,6 +53,7 @@ import {
53
53
  } from "../attribution/differ.ts";
54
54
  import { readMinimap } from "../attribution/minimap.ts";
55
55
  import { calculateCostUsd } from "../pricing.ts";
56
+ import { writeMergedPrArtifacts } from "./merge-pr-artifacts.ts";
56
57
 
57
58
  // ---------------------------------------------------------------------------
58
59
  // OTLP/HTTP JSON types (subset needed for gauge metrics)
@@ -180,6 +181,8 @@ async function main() {
180
181
  const repo = process.env.GITHUB_REPOSITORY ?? "";
181
182
  const author = process.env.PR_AUTHOR ?? "";
182
183
  const baseRef = process.env.GITHUB_BASE_REF ?? "main";
184
+ const prHeadSha = process.env.PR_HEAD_SHA ?? "";
185
+ const mergeCommitSha = process.env.MERGE_COMMIT_SHA ?? "";
183
186
  const webhookUrl = process.env.METRICS_WEBHOOK_URL ?? "";
184
187
 
185
188
  // Resolve OTLP endpoint + headers — explicit vars take precedence over Datadog shortcut
@@ -271,7 +274,26 @@ async function main() {
271
274
  // Use branch from the most recent note; fall back to env var
272
275
  const branch = results[results.length - 1]?.branch ?? baseRef;
273
276
 
274
- const headMinimap = await readMinimap(repoRoot, "HEAD").catch(() => null);
277
+ const mergedArtifacts =
278
+ mergeCommitSha && prHeadSha
279
+ ? await writeMergedPrArtifacts({
280
+ repoRoot,
281
+ mergeCommitSha,
282
+ prHeadSha,
283
+ results,
284
+ baseRef,
285
+ push: true,
286
+ }).catch((error) => {
287
+ console.warn(
288
+ `[pr-summary] Warning: failed to write merge commit notes for ${mergeCommitSha}:`,
289
+ error,
290
+ );
291
+ return null;
292
+ })
293
+ : null;
294
+
295
+ const headMinimap =
296
+ mergedArtifacts?.minimap ?? (await readMinimap(repoRoot, "HEAD").catch(() => null));
275
297
 
276
298
  // OTLP data point attributes (shared across all metrics)
277
299
  const timeUnixNano = String(Date.now() * 1_000_000);
@@ -37,6 +37,7 @@ import { SESSION_ID_RE } from "../attribution/checkpoint.ts";
37
37
  import {
38
38
  hashSetFromString,
39
39
  readMinimap,
40
+ resolveMinimap,
40
41
  listMinimapNotes,
41
42
  } from "../attribution/minimap.ts";
42
43
  import { detectAssistantRuntime } from "../attribution/runtime.ts";
@@ -72,6 +73,12 @@ export interface MetricsData {
72
73
  ai: number;
73
74
  total: number;
74
75
  pctAi: number;
76
+ files: Array<{
77
+ path: string;
78
+ ai: number;
79
+ total: number;
80
+ pctAi: number;
81
+ }>;
75
82
  } | null;
76
83
  }
77
84
 
@@ -154,14 +161,19 @@ function parseChangedLineNumbers(diff: string): {
154
161
 
155
162
  async function getBranchDiffStats(
156
163
  repoRoot: string,
157
- ): Promise<{ ai: number; total: number; pctAi: number } | null> {
164
+ ): Promise<{
165
+ ai: number;
166
+ total: number;
167
+ pctAi: number;
168
+ files: Array<{ path: string; ai: number; total: number; pctAi: number }>;
169
+ } | null> {
158
170
  const baseSha = await getBranchBaseSha(repoRoot);
159
171
  if (!baseSha) return null;
160
172
 
161
173
  const [headMinimap, baseMinimap, changedFilesOutput, renameStatusOutput] =
162
174
  await Promise.all([
163
- readMinimap(repoRoot, "HEAD"),
164
- readMinimap(repoRoot, baseSha).catch(() => null),
175
+ resolveMinimap(repoRoot, "HEAD"),
176
+ resolveMinimap(repoRoot, baseSha).catch(() => null),
165
177
  execFileAsync(
166
178
  "git",
167
179
  ["diff", "--name-only", "--find-renames", `${baseSha}..HEAD`],
@@ -197,7 +209,7 @@ async function getBranchDiffStats(
197
209
 
198
210
  const changedFiles = changedFilesOutput.split("\n").filter(Boolean);
199
211
  if (changedFiles.length === 0) {
200
- return { ai: 0, total: 0, pctAi: 0 };
212
+ return { ai: 0, total: 0, pctAi: 0, files: [] };
201
213
  }
202
214
 
203
215
  const fileStats = await Promise.all(
@@ -238,18 +250,20 @@ async function getBranchDiffStats(
238
250
  ]);
239
251
 
240
252
  const numstatLine = numstatResult.split("\n").find(Boolean);
241
- if (!numstatLine) return { ai: 0, total: 0 };
253
+ if (!numstatLine) return { path, ai: 0, total: 0, pctAi: 0 };
242
254
 
243
255
  const [additionsRaw, deletionsRaw] = numstatLine.split("\t");
244
- if (!additionsRaw || !deletionsRaw) return { ai: 0, total: 0 };
256
+ if (!additionsRaw || !deletionsRaw) {
257
+ return { path, ai: 0, total: 0, pctAi: 0 };
258
+ }
245
259
  if (additionsRaw === "-" || deletionsRaw === "-") {
246
- return { ai: 0, total: 0 };
260
+ return { path, ai: 0, total: 0, pctAi: 0 };
247
261
  }
248
262
 
249
263
  const additions = parseInt(additionsRaw, 10);
250
264
  const deletions = parseInt(deletionsRaw, 10);
251
265
  const total = additions + deletions;
252
- if (total === 0) return { ai: 0, total: 0 };
266
+ if (total === 0) return { path, ai: 0, total: 0, pctAi: 0 };
253
267
 
254
268
  const { added, removed } = parseChangedLineNumbers(diffResult);
255
269
  const headLines = headContent?.split("\n") ?? [];
@@ -271,7 +285,12 @@ async function getBranchDiffStats(
271
285
  }
272
286
  }
273
287
 
274
- return { ai, total };
288
+ return {
289
+ path,
290
+ ai,
291
+ total,
292
+ pctAi: total > 0 ? Math.round((ai / total) * 100) : 0,
293
+ };
275
294
  }),
276
295
  );
277
296
 
@@ -286,6 +305,7 @@ async function getBranchDiffStats(
286
305
  ai: totals.ai,
287
306
  total: totals.total,
288
307
  pctAi: totals.total > 0 ? Math.round((totals.ai / totals.total) * 100) : 0,
308
+ files: fileStats.filter((file) => file.total > 0),
289
309
  };
290
310
  }
291
311
 
@@ -472,6 +492,11 @@ function transcriptFromAttribution(
472
492
  agentCounts: sessionMetrics?.agentCounts ?? {},
473
493
  provider:
474
494
  result.assistantRuntime?.vendor === "copilot" ? "copilot" : "claude",
495
+ signalSource: "git-note",
496
+ signalSourceLabel:
497
+ result.assistantRuntime?.vendor === "copilot"
498
+ ? "Git note snapshot (Copilot-derived)"
499
+ : "Git note snapshot (Claude-derived)",
475
500
  costMode:
476
501
  result.assistantRuntime?.vendor === "copilot" ? "unavailable" : "estimated",
477
502
  costDescription:
@@ -488,8 +513,8 @@ export function kFormat(n: number): string {
488
513
  async function getMinimapTotals(
489
514
  repoRoot: string,
490
515
  ): Promise<{ ai: number; human: number; total: number; pctAi: number } | null> {
491
- // Try HEAD first (written by post-commit hook)
492
- const head = await readMinimap(repoRoot, "HEAD");
516
+ // Try HEAD first (written by post-commit hook), then synthesize from ancestors.
517
+ const head = await resolveMinimap(repoRoot, "HEAD");
493
518
  if (head) return head.totals;
494
519
 
495
520
  // Fall back: find the most recent minimap note on the current branch
@@ -567,7 +592,7 @@ export async function collectMetrics(
567
592
  id,
568
593
  sessionStart ?? undefined,
569
594
  ).catch(() => []),
570
- parseLocalSession(id, root).catch(() => null),
595
+ parseLocalSession(id, root, sessionStart).catch(() => null),
571
596
  ]);
572
597
  return {
573
598
  sessionId: id,
@@ -785,6 +810,10 @@ export function renderMetrics(data: MetricsData): string {
785
810
  out(`**Session:** ${sessionLine}`);
786
811
  out();
787
812
  }
813
+ if (transcript.signalSourceLabel) {
814
+ out(`**Session/model source:** ${transcript.signalSourceLabel}`);
815
+ out();
816
+ }
788
817
  }
789
818
 
790
819
  if (runtimeSummaries.length > 0) {
@@ -871,13 +900,14 @@ export function renderMetrics(data: MetricsData): string {
871
900
  }
872
901
 
873
902
  // <details> block — skills, agents, notable tools, per-file breakdown
903
+ const prFiles = (prDiffStats?.files ?? []).filter((file) => file.total > 0);
874
904
  const claudeFiles = [...lastSeenByFile.entries()].filter(
875
905
  ([, stats]) => stats.ai > 0 || stats.mixed > 0,
876
906
  );
877
907
  const hasSkills = skillNames.length > 0;
878
908
  const hasAgents = agentCounts.size > 0;
879
909
  const hasNotableTools = toolCounts.size > 0;
880
- const hasFiles = claudeFiles.length > 0;
910
+ const hasFiles = prFiles.length > 0 || claudeFiles.length > 0;
881
911
 
882
912
  const summaryParts: string[] = [];
883
913
  if (hasSkills) summaryParts.push("Skills");
@@ -923,11 +953,20 @@ export function renderMetrics(data: MetricsData): string {
923
953
  if (hasFiles) {
924
954
  out("#### Files");
925
955
  out();
926
- for (const [path, stats] of claudeFiles.sort()) {
927
- const filePct =
928
- stats.total > 0 ? Math.round((stats.ai / stats.total) * 100) : 0;
929
- const relPath = relative(repoRoot, join(repoRoot, path));
930
- out(`- \`${relPath}\` — ${filePct}% AI (${stats.ai} lines)`);
956
+ if (prFiles.length > 0) {
957
+ for (const stats of [...prFiles].sort((a, b) => a.path.localeCompare(b.path))) {
958
+ const relPath = relative(repoRoot, join(repoRoot, stats.path));
959
+ out(
960
+ `- \`${relPath}\` — ${stats.pctAi}% AI (${stats.ai} / ${stats.total} changed lines)`,
961
+ );
962
+ }
963
+ } else {
964
+ for (const [path, stats] of claudeFiles.sort()) {
965
+ const filePct =
966
+ stats.total > 0 ? Math.round((stats.ai / stats.total) * 100) : 0;
967
+ const relPath = relative(repoRoot, join(repoRoot, path));
968
+ out(`- \`${relPath}\` — ${filePct}% AI (${stats.ai} lines)`);
969
+ }
931
970
  }
932
971
  out();
933
972
  } else if (!hasAttribution) {