claude-attribution 1.9.4 → 1.9.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-attribution",
3
- "version": "1.9.4",
3
+ "version": "1.9.5",
4
4
  "description": "AI code attribution tracking for Claude Code and GitHub Copilot sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -102,6 +102,7 @@ describe("copilot-session", () => {
102
102
  expect(parsed?.activeMinutes).toBe(3);
103
103
  expect(parsed?.aiMinutes).toBe(2);
104
104
  expect(parsed?.humanMinutes).toBe(1);
105
+ expect(parsed?.signalSourceLabel).toBe("Copilot session logs");
105
106
  expect(parsed?.costMode).toBe("unavailable");
106
107
  } finally {
107
108
  process.env.HOME = originalHome;
@@ -138,4 +139,131 @@ describe("copilot-session", () => {
138
139
  await ctx.cleanup();
139
140
  }
140
141
  });
142
+
143
+ test("scopes Copilot session metrics to the provided start time", async () => {
144
+ const ctx = await createTempContext("copilot-session-since");
145
+ const originalHome = process.env.HOME;
146
+ try {
147
+ process.env.HOME = ctx.home;
148
+ const sessionId = "copilot-session-2";
149
+ await writeCopilotSession(ctx.home, sessionId, [
150
+ {
151
+ type: "session.start",
152
+ timestamp: "2026-04-01T10:00:00.000Z",
153
+ data: {
154
+ context: { cwd: ctx.repo, gitRoot: ctx.repo, branch: "feature/copilot" },
155
+ },
156
+ },
157
+ {
158
+ type: "user.message",
159
+ timestamp: "2026-04-01T10:00:00.000Z",
160
+ data: { content: "Old work" },
161
+ },
162
+ {
163
+ type: "assistant.message",
164
+ timestamp: "2026-04-01T10:01:00.000Z",
165
+ data: { outputTokens: 320 },
166
+ },
167
+ {
168
+ type: "assistant.turn_end",
169
+ timestamp: "2026-04-01T10:01:00.000Z",
170
+ },
171
+ {
172
+ type: "tool.execution_complete",
173
+ timestamp: "2026-04-01T10:01:00.000Z",
174
+ data: { model: "gpt-5.4" },
175
+ },
176
+ {
177
+ type: "user.message",
178
+ timestamp: "2026-04-01T11:00:00.000Z",
179
+ data: { content: "Current work" },
180
+ },
181
+ {
182
+ type: "assistant.message",
183
+ timestamp: "2026-04-01T11:02:00.000Z",
184
+ data: { outputTokens: 120 },
185
+ },
186
+ {
187
+ type: "assistant.turn_end",
188
+ timestamp: "2026-04-01T11:02:00.000Z",
189
+ },
190
+ {
191
+ type: "tool.execution_complete",
192
+ timestamp: "2026-04-01T11:02:00.000Z",
193
+ data: { model: "gpt-5.4" },
194
+ },
195
+ ]);
196
+
197
+ const parsed = await parseCopilotSession(
198
+ sessionId,
199
+ ctx.repo,
200
+ undefined,
201
+ new Date("2026-04-01T10:30:00.000Z"),
202
+ );
203
+
204
+ expect(parsed).not.toBeNull();
205
+ expect(parsed?.humanPromptCount).toBe(1);
206
+ expect(parsed?.activeMinutes).toBe(2);
207
+ expect(parsed?.aiMinutes).toBe(2);
208
+ expect(parsed?.humanMinutes).toBe(0);
209
+ expect(parsed?.totals.totalCalls).toBe(1);
210
+ expect(parsed?.totals.totalOutputTokens).toBe(120);
211
+ } finally {
212
+ process.env.HOME = originalHome;
213
+ await ctx.cleanup();
214
+ }
215
+ });
216
+
217
+ test("keeps a recent prompt as the start anchor when /start is set immediately after it", async () => {
218
+ const ctx = await createTempContext("copilot-session-anchor");
219
+ const originalHome = process.env.HOME;
220
+ try {
221
+ process.env.HOME = ctx.home;
222
+ const sessionId = "copilot-session-3";
223
+ await writeCopilotSession(ctx.home, sessionId, [
224
+ {
225
+ type: "session.start",
226
+ timestamp: "2026-04-01T10:00:00.000Z",
227
+ data: {
228
+ context: { cwd: ctx.repo, gitRoot: ctx.repo, branch: "feature/copilot" },
229
+ },
230
+ },
231
+ {
232
+ type: "user.message",
233
+ timestamp: "2026-04-01T10:00:00.000Z",
234
+ data: { content: "Kick off the fix" },
235
+ },
236
+ {
237
+ type: "assistant.message",
238
+ timestamp: "2026-04-01T10:02:00.000Z",
239
+ data: { outputTokens: 240 },
240
+ },
241
+ {
242
+ type: "assistant.turn_end",
243
+ timestamp: "2026-04-01T10:02:00.000Z",
244
+ },
245
+ {
246
+ type: "tool.execution_complete",
247
+ timestamp: "2026-04-01T10:02:00.000Z",
248
+ data: { model: "gpt-5.4" },
249
+ },
250
+ ]);
251
+
252
+ const parsed = await parseCopilotSession(
253
+ sessionId,
254
+ ctx.repo,
255
+ undefined,
256
+ new Date("2026-04-01T10:00:30.000Z"),
257
+ );
258
+
259
+ expect(parsed).not.toBeNull();
260
+ expect(parsed?.humanPromptCount).toBe(1);
261
+ expect(parsed?.activeMinutes).toBe(2);
262
+ expect(parsed?.aiMinutes).toBe(2);
263
+ expect(parsed?.humanMinutes).toBe(0);
264
+ } finally {
265
+ process.env.HOME = originalHome;
266
+ await ctx.cleanup();
267
+ }
268
+ });
141
269
  });
@@ -98,14 +98,28 @@ describe("attributeLines — basic classification", () => {
98
98
  expect(stats.pctAi).toBe(67);
99
99
  });
100
100
 
101
- test("empty lines always HUMAN regardless of snapshot", () => {
101
+ test("new blank lines in an AI-authored file count as AI", () => {
102
102
  const before: string[] = [];
103
103
  const after = ["", "const x = 1;", ""];
104
104
  const committed = ["", "const x = 1;", ""];
105
- const { attribution } = attributeLines(before, after, committed);
106
- expect(attribution[0]).toBe("HUMAN"); // empty line
105
+ const { attribution, stats } = attributeLines(before, after, committed);
106
+ expect(attribution[0]).toBe("AI"); // new blank line
107
107
  expect(attribution[1]).toBe("AI"); // real content Claude wrote
108
- expect(attribution[2]).toBe("HUMAN"); // empty line
108
+ expect(attribution[2]).toBe("AI"); // new blank line
109
+ expect(stats.ai).toBe(3);
110
+ expect(stats.human).toBe(0);
111
+ expect(stats.pctAi).toBe(100);
112
+ });
113
+
114
+ test("pre-existing blank lines stay HUMAN", () => {
115
+ const before = ["", "const x = 1;"];
116
+ const after = ["", "const x = 1;", "const y = 2;"];
117
+ const committed = ["", "const x = 1;"];
118
+ const { attribution, stats } = attributeLines(before, after, committed);
119
+ expect(attribution[0]).toBe("HUMAN");
120
+ expect(attribution[1]).toBe("HUMAN");
121
+ expect(stats.ai).toBe(0);
122
+ expect(stats.human).toBe(2);
109
123
  });
110
124
  });
111
125
 
@@ -65,14 +65,19 @@ expect(await listNotes(ctx.repo)).toEqual([]);
65
65
  const result = await buildAllAiResult(ctx.repo, sha);
66
66
  expect(result.commit).toBe(sha);
67
67
  expect(result.files).toHaveLength(1);
68
- expect(result.files[0]).toMatchObject({
69
- path: "ai.txt",
70
- ai: 2,
71
- human: 1,
72
- total: 3,
73
- pctAi: 67,
74
- });
75
- expect(result.totals).toMatchObject({ ai: 2, human: 1, total: 3, pctAi: 67 });
68
+ expect(result.files[0]).toMatchObject({
69
+ path: "ai.txt",
70
+ ai: 3,
71
+ human: 0,
72
+ total: 3,
73
+ pctAi: 100,
74
+ });
75
+ expect(result.totals).toMatchObject({
76
+ ai: 3,
77
+ human: 0,
78
+ total: 3,
79
+ pctAi: 100,
80
+ });
76
81
 
77
82
  const meta = await getCommitMeta(ctx.repo, sha);
78
83
  expect(meta.authorName).toBe("Test User");
@@ -11,6 +11,7 @@ import {
11
11
  type MinimapResult,
12
12
  } from "../attribution/minimap.ts";
13
13
  import { readInstalledRepoRegistry } from "../setup/installed-repos.ts";
14
+ import { writeMergedPrArtifacts } from "../export/merge-pr-artifacts.ts";
14
15
  import {
15
16
  CLI_BIN,
16
17
  configureGitIdentity,
@@ -32,7 +33,7 @@ describe("integration", () => {
32
33
  try {
33
34
  await initGitRepo(ctx.repo);
34
35
  const filePath = join(ctx.repo, "src.ts");
35
- await writeFile(filePath, "const value = 1;\n");
36
+ await writeFile(filePath, "const value = 1;");
36
37
  await commitAll(ctx.repo, "initial");
37
38
 
38
39
  const sessionId = "session-commit-1";
@@ -44,7 +45,7 @@ describe("integration", () => {
44
45
  sessionId,
45
46
  );
46
47
  await saveCheckpoint(sessionId, filePath, "before");
47
- await writeFile(filePath, "const value = 2;\nconst nextValue = 3;\n");
48
+ await writeFile(filePath, "const value = 2;\n\nconst nextValue = 3;");
48
49
  await saveCheckpoint(sessionId, filePath, "after");
49
50
 
50
51
  await writeJsonl(join(ctx.repo, ".claude", "logs", "tool-usage.jsonl"), [
@@ -121,6 +122,12 @@ describe("integration", () => {
121
122
  expect(note.sessionMetrics?.agentCounts).toEqual({ "code-review": 1 });
122
123
  expect(note.sessionMetrics?.humanPromptCount).toBe(1);
123
124
  expect(note.sessionMetrics?.activeMinutes).toBe(2);
125
+ expect(note.files.find((file) => file.path === "src.ts")).toMatchObject({
126
+ ai: 3,
127
+ human: 0,
128
+ total: 3,
129
+ pctAi: 100,
130
+ });
124
131
  expect(note.assistantRuntime).toEqual({
125
132
  vendor: "claude",
126
133
  client: "Claude Code",
@@ -207,6 +214,9 @@ describe("integration", () => {
207
214
  expect(metrics.stdout).toContain("**Codebase: ~100% AI**");
208
215
  expect(metrics.stdout).toContain("**This PR:** 1 lines changed");
209
216
  expect(metrics.stdout).toContain("**Session:** 3 prompts · 15m total (10m AI · 5m human)");
217
+ expect(metrics.stdout).toContain(
218
+ "**Session/model source:** Git note snapshot (Copilot-derived)",
219
+ );
210
220
  expect(metrics.stdout).toContain(
211
221
  "**Assistant runtime:** GitHub Copilot CLI (v1.0.14)",
212
222
  );
@@ -313,6 +323,260 @@ describe("integration", () => {
313
323
  "**This PR:** 2 lines changed (2% of codebase) · 100% AI edits · 2 AI-attributed changed lines",
314
324
  );
315
325
  expect(metrics.stdout).not.toContain("**This PR:** 102 lines changed");
326
+ expect(metrics.stdout).toContain(
327
+ "- `README.md` — 100% AI (2 / 2 changed lines)",
328
+ );
329
+ expect(metrics.stdout).not.toContain("- `README.md` — 2% AI (2 lines)");
330
+ } finally {
331
+ await ctx.cleanup();
332
+ }
333
+ });
334
+
335
+ test("metrics preserves AI carry-forward when the branch base is a merge commit without a minimap note", async () => {
336
+ const ctx = await createTempContext("claude-attribution-merge-minimap");
337
+ try {
338
+ await initGitRepo(ctx.repo);
339
+ await runCommand("git", ["branch", "-M", "main"], { cwd: ctx.repo });
340
+
341
+ const remote = join(ctx.root, "origin.git");
342
+ await runCommand("git", ["init", "--bare", remote], { cwd: ctx.root });
343
+ await runCommand("git", ["remote", "add", "origin", remote], {
344
+ cwd: ctx.repo,
345
+ });
346
+
347
+ const filePath = join(ctx.repo, "app.ts");
348
+ await writeFile(filePath, "one\n");
349
+ await commitAll(ctx.repo, "base");
350
+ const baseSha = await currentSha(ctx.repo);
351
+ await writeMinimap(
352
+ {
353
+ commit: baseSha,
354
+ timestamp: "2026-04-01T10:00:00.000Z",
355
+ files: [
356
+ {
357
+ path: "app.ts",
358
+ ai_hashes: "",
359
+ ai: 0,
360
+ human: 2,
361
+ total: 2,
362
+ pctAi: 0,
363
+ },
364
+ ],
365
+ totals: { ai: 0, human: 2, total: 2, pctAi: 0 },
366
+ },
367
+ ctx.repo,
368
+ baseSha,
369
+ );
370
+ await runCommand("git", ["push", "-u", "origin", "main"], { cwd: ctx.repo });
371
+
372
+ await runCommand("git", ["checkout", "-b", "feature/base-ai"], {
373
+ cwd: ctx.repo,
374
+ });
375
+ await writeFile(filePath, "one\ntwo\n");
376
+ await commitAll(ctx.repo, "ai base");
377
+ const aiBaseSha = await currentSha(ctx.repo);
378
+ await writeMinimap(
379
+ {
380
+ commit: aiBaseSha,
381
+ timestamp: "2026-04-01T10:05:00.000Z",
382
+ files: [
383
+ {
384
+ path: "app.ts",
385
+ ai_hashes: hashSetToString(new Set([hashLine("two")])),
386
+ ai: 1,
387
+ human: 2,
388
+ total: 3,
389
+ pctAi: 33,
390
+ },
391
+ ],
392
+ totals: { ai: 1, human: 2, total: 3, pctAi: 33 },
393
+ },
394
+ ctx.repo,
395
+ aiBaseSha,
396
+ );
397
+
398
+ await runCommand("git", ["checkout", "main"], { cwd: ctx.repo });
399
+ await runCommand("git", ["merge", "--no-ff", "feature/base-ai", "-m", "merge ai"], {
400
+ cwd: ctx.repo,
401
+ });
402
+ await runCommand("git", ["push", "origin", "main"], { cwd: ctx.repo });
403
+
404
+ await runCommand("git", ["checkout", "-b", "feature/followup"], {
405
+ cwd: ctx.repo,
406
+ });
407
+ await writeCopilotSession(ctx.home, "merge-session-1", [
408
+ {
409
+ type: "session.start",
410
+ timestamp: "2026-04-01T10:10:00.000Z",
411
+ data: {
412
+ context: {
413
+ cwd: ctx.repo,
414
+ gitRoot: ctx.repo,
415
+ branch: "feature/followup",
416
+ },
417
+ },
418
+ },
419
+ ]);
420
+ await mkdir(join(ctx.repo, ".claude", "attribution-state"), {
421
+ recursive: true,
422
+ });
423
+ await writeFile(
424
+ join(ctx.repo, ".claude", "attribution-state", "current-session"),
425
+ "merge-session-1",
426
+ );
427
+ await saveCheckpoint("merge-session-1", filePath, "before");
428
+ await writeFile(filePath, "one\ntwo\nthree\n");
429
+ await saveCheckpoint("merge-session-1", filePath, "after");
430
+ await runCommand("git", ["add", "app.ts"], { cwd: ctx.repo });
431
+ await runCommand("git", ["commit", "-m", "followup"], { cwd: ctx.repo });
432
+
433
+ await runCommand(CLI_BIN, ["hook", "post-commit"], {
434
+ cwd: ctx.repo,
435
+ env: {
436
+ HOME: ctx.home,
437
+ COPILOT_CLI: "1",
438
+ COPILOT_CLI_BINARY_VERSION: "1.0.15",
439
+ },
440
+ });
441
+
442
+ const metrics = await runCommand(CLI_BIN, ["metrics"], {
443
+ cwd: ctx.repo,
444
+ env: {
445
+ HOME: ctx.home,
446
+ COPILOT_CLI: "1",
447
+ COPILOT_CLI_BINARY_VERSION: "1.0.15",
448
+ },
449
+ });
450
+
451
+ expect(metrics.stdout).toContain("**Codebase: ~50% AI** (2 / 4 lines)");
452
+ expect(metrics.stdout).toContain(
453
+ "**This PR:** 1 lines changed (25% of codebase) · 100% AI edits · 1 AI-attributed changed lines",
454
+ );
455
+ } finally {
456
+ await ctx.cleanup();
457
+ }
458
+ });
459
+
460
+ test("merged PR helper preserves AI minimap on squash merge commits", async () => {
461
+ const ctx = await createTempContext("claude-attribution-squash-merge");
462
+ try {
463
+ await initGitRepo(ctx.repo);
464
+ await runCommand("git", ["branch", "-M", "main"], { cwd: ctx.repo });
465
+
466
+ const remote = join(ctx.root, "origin.git");
467
+ await runCommand("git", ["init", "--bare", remote], { cwd: ctx.root });
468
+ await runCommand("git", ["remote", "add", "origin", remote], {
469
+ cwd: ctx.repo,
470
+ });
471
+
472
+ await writeFile(join(ctx.repo, "app.ts"), "one\n");
473
+ await commitAll(ctx.repo, "base");
474
+ const baseSha = await currentSha(ctx.repo);
475
+ await writeMinimap(
476
+ {
477
+ commit: baseSha,
478
+ timestamp: "2026-04-03T09:00:00.000Z",
479
+ files: [
480
+ {
481
+ path: "app.ts",
482
+ ai_hashes: hashSetToString(
483
+ new Set([hashLine("one"), hashLine("")]),
484
+ ),
485
+ ai: 2,
486
+ human: 0,
487
+ total: 2,
488
+ pctAi: 100,
489
+ },
490
+ ],
491
+ totals: { ai: 2, human: 0, total: 2, pctAi: 100 },
492
+ },
493
+ ctx.repo,
494
+ baseSha,
495
+ );
496
+ await runCommand("git", ["push", "-u", "origin", "main"], { cwd: ctx.repo });
497
+
498
+ await runCommand("git", ["checkout", "-b", "feature/ai-change"], {
499
+ cwd: ctx.repo,
500
+ });
501
+ await writeFile(join(ctx.repo, "app.ts"), "one\ntwo\n");
502
+ await commitAll(ctx.repo, "feature work");
503
+ const headSha = await currentSha(ctx.repo);
504
+
505
+ const featureNote: AttributionResult = {
506
+ commit: headSha,
507
+ session: "session-squash-merge-1",
508
+ branch: "feature/ai-change",
509
+ timestamp: "2026-04-03T09:05:00.000Z",
510
+ files: [
511
+ {
512
+ path: "app.ts",
513
+ ai: 3,
514
+ human: 0,
515
+ mixed: 0,
516
+ total: 3,
517
+ pctAi: 100,
518
+ },
519
+ ],
520
+ totals: { ai: 3, human: 0, mixed: 0, total: 3, pctAi: 100 },
521
+ };
522
+ await writeNote(featureNote, ctx.repo, headSha);
523
+ await writeMinimap(
524
+ {
525
+ commit: headSha,
526
+ timestamp: "2026-04-03T09:05:00.000Z",
527
+ files: [
528
+ {
529
+ path: "app.ts",
530
+ ai_hashes: hashSetToString(
531
+ new Set([hashLine("one"), hashLine("two"), hashLine("")]),
532
+ ),
533
+ ai: 3,
534
+ human: 0,
535
+ total: 3,
536
+ pctAi: 100,
537
+ },
538
+ ],
539
+ totals: { ai: 3, human: 0, total: 3, pctAi: 100 },
540
+ },
541
+ ctx.repo,
542
+ headSha,
543
+ );
544
+
545
+ await runCommand("git", ["checkout", "main"], { cwd: ctx.repo });
546
+ await runCommand("git", ["merge", "--squash", "feature/ai-change"], {
547
+ cwd: ctx.repo,
548
+ });
549
+ await commitAll(ctx.repo, "squash merge feature");
550
+ const mergeSha = await currentSha(ctx.repo);
551
+
552
+ const before = await runCommand(
553
+ "git",
554
+ ["notes", "--ref", "refs/notes/claude-attribution-map", "show", mergeSha],
555
+ { cwd: ctx.repo },
556
+ ).catch(() => null);
557
+ expect(before).toBeNull();
558
+
559
+ await writeMergedPrArtifacts({
560
+ repoRoot: ctx.repo,
561
+ mergeCommitSha: mergeSha,
562
+ prHeadSha: headSha,
563
+ results: [featureNote],
564
+ baseRef: "main",
565
+ });
566
+
567
+ const mergedMinimap = JSON.parse(
568
+ (
569
+ await runCommand(
570
+ "git",
571
+ ["notes", "--ref", "refs/notes/claude-attribution-map", "show", mergeSha],
572
+ { cwd: ctx.repo },
573
+ )
574
+ ).stdout,
575
+ ) as MinimapResult;
576
+ expect(mergedMinimap.totals.pctAi).toBe(100);
577
+ expect(mergedMinimap.files.find((file) => file.path === "app.ts")?.human).toBe(
578
+ 0,
579
+ );
316
580
  } finally {
317
581
  await ctx.cleanup();
318
582
  }
@@ -579,7 +843,7 @@ describe("integration", () => {
579
843
  );
580
844
  await writeFile(
581
845
  join(ctx.repo, "agent.ts"),
582
- "export const generated = true;\n",
846
+ "export const generated = true;",
583
847
  );
584
848
  await commitAll(
585
849
  ctx.repo,
@@ -592,9 +856,9 @@ describe("integration", () => {
592
856
  });
593
857
 
594
858
  expect(metrics.stdout).toContain("## AI Coding Metrics");
595
- expect(metrics.stdout).toContain("**AI contribution: ~50%**");
859
+ expect(metrics.stdout).toContain("**AI contribution: ~100%**");
596
860
  expect(metrics.stdout).toContain("**Assistant runtime:** GitHub Copilot");
597
- expect(metrics.stdout).toContain("`agent.ts` — 50% AI (1 lines)");
861
+ expect(metrics.stdout).toContain("`agent.ts` — 100% AI (1 lines)");
598
862
  expect(metrics.stdout).not.toContain("**Estimated cost:**");
599
863
  } finally {
600
864
  await ctx.cleanup();
@@ -59,7 +59,7 @@ import {
59
59
  import {
60
60
  computeMinimapFile,
61
61
  hashSetFromString,
62
- readMinimap,
62
+ resolveMinimap,
63
63
  writeMinimap,
64
64
  MINIMAP_NOTES_REF,
65
65
  type MinimapFileState,
@@ -140,6 +140,23 @@ async function resolveSessionForCommit(
140
140
  return await resolveCopilotSessionId(repoRoot, branch).catch(() => null);
141
141
  }
142
142
 
143
+ async function readSessionStart(repoRoot: string): Promise<Date | null> {
144
+ const markerPath = join(
145
+ repoRoot,
146
+ ".claude",
147
+ "attribution-state",
148
+ "session-start",
149
+ );
150
+ if (!existsSync(markerPath)) return null;
151
+ try {
152
+ const raw = await readFile(markerPath, "utf8");
153
+ const marker = JSON.parse(raw) as { timestamp?: string };
154
+ return marker.timestamp ? new Date(marker.timestamp) : null;
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
143
160
  function mergeAssistantRuntime(
144
161
  runtime: AssistantRuntimeInfo | undefined,
145
162
  provider: "claude" | "copilot" | undefined,
@@ -263,15 +280,18 @@ async function main() {
263
280
 
264
281
  // Attach session usage metadata (non-fatal if unavailable)
265
282
  if (sessionId) {
283
+ const sessionStart = await readSessionStart(repoRoot);
266
284
  const [tx, toolEntries, agentEntries] = await Promise.all([
267
- parseLocalSession(sessionId, repoRoot).catch(() => null),
285
+ parseLocalSession(sessionId, repoRoot, sessionStart).catch(() => null),
268
286
  readJsonlForSession<ToolLogEntry>(
269
287
  join(repoRoot, ".claude", "logs", "tool-usage.jsonl"),
270
288
  sessionId,
289
+ sessionStart ?? undefined,
271
290
  ).catch(() => []),
272
291
  readJsonlForSession<AgentActivityEntry>(
273
292
  join(repoRoot, ".claude", "logs", "agent-activity.jsonl"),
274
293
  sessionId,
294
+ sessionStart ?? undefined,
275
295
  ).catch(() => []),
276
296
  ]);
277
297
  const mergedRuntime = mergeAssistantRuntime(
@@ -308,9 +328,7 @@ async function main() {
308
328
  try {
309
329
  // Load parent commit's minimap for carry-forward (parentSha fetched at top of main)
310
330
 
311
- const prevMinimap = parentSha
312
- ? await readMinimap(repoRoot, parentSha)
313
- : null;
331
+ const prevMinimap = parentSha ? await resolveMinimap(repoRoot, parentSha) : null;
314
332
 
315
333
  // Build lookup: file path → Set of AI hashes from previous minimap
316
334
  const prevAiByFile = new Map<string, Set<string>>();
@@ -92,7 +92,9 @@ export function hashLine(line: string): string {
92
92
  * - HUMAN: everything else (existed before Claude, or written after Claude
93
93
  * finished without a checkpoint)
94
94
  *
95
- * Empty lines are attributed as HUMAN they carry no signal.
95
+ * Blank lines follow the same hash-based rules as any other line. This means a
96
+ * newly introduced blank line is attributed as AI when it appears in `after`
97
+ * but not `before`, while pre-existing blank lines remain HUMAN.
96
98
  *
97
99
  * **Identical-line limitation**: This algorithm is set-based. If Claude and a
98
100
  * human both write a line with the same trimmed content (e.g., a closing `}`),
@@ -120,9 +122,6 @@ export function attributeLines(
120
122
  const attribution: LineAttribution[] = committedLines.map((line) => {
121
123
  const hash = hashLine(line);
122
124
 
123
- // Blank lines carry no signal
124
- if (line.trim() === "") return "HUMAN";
125
-
126
125
  if (afterHashes.has(hash) && !beforeHashes.has(hash)) {
127
126
  return "AI";
128
127
  }
@@ -323,7 +323,7 @@ function inferAiActorRuntime(meta: CommitMeta): AssistantRuntimeInfo | undefined
323
323
 
324
324
  /**
325
325
  * Build a 100% AI AttributionResult for a commit without running the
326
- * checkpoint-based differ. All non-blank committed lines are marked AI.
326
+ * checkpoint-based differ. All committed lines are marked AI.
327
327
  *
328
328
  * Used by `note-ai-commit` (to write git notes in GHA) and by `collect.ts`
329
329
  * (to synthesize attribution at metrics time for unattributed AI actor commits).
@@ -343,8 +343,8 @@ export async function buildAllAiResult(
343
343
  const content = await committedContentAt(repoRoot, sha, relPath);
344
344
  if (content === null || content.includes("\0")) return null;
345
345
  const lines = content.split("\n");
346
- const ai = lines.filter((l) => l.trim().length > 0).length;
347
- const human = lines.length - ai;
346
+ const ai = lines.length;
347
+ const human = 0;
348
348
  const total = lines.length;
349
349
  return {
350
350
  path: relPath,