claude-attribution 1.8.0 → 1.9.4

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.
@@ -2,10 +2,14 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdir, readFile, writeFile } from "fs/promises";
3
3
  import { join } from "path";
4
4
  import { saveCheckpoint } from "../attribution/checkpoint.ts";
5
- import type { AttributionResult } from "../attribution/differ.ts";
5
+ import { hashLine, type AttributionResult } from "../attribution/differ.ts";
6
6
  import { NOTES_REF, writeNote } from "../attribution/git-notes.ts";
7
7
  import { pushNotesRef } from "../attribution/notes-sync.ts";
8
- import { writeMinimap, type MinimapResult } from "../attribution/minimap.ts";
8
+ import {
9
+ hashSetToString,
10
+ writeMinimap,
11
+ type MinimapResult,
12
+ } from "../attribution/minimap.ts";
9
13
  import { readInstalledRepoRegistry } from "../setup/installed-repos.ts";
10
14
  import {
11
15
  CLI_BIN,
@@ -17,6 +21,7 @@ import {
17
21
  initGitRepo,
18
22
  readJson,
19
23
  runCommand,
24
+ writeCopilotSession,
20
25
  writeJsonl,
21
26
  writeTranscript,
22
27
  } from "./integration-helpers.ts";
@@ -93,8 +98,9 @@ describe("integration", () => {
93
98
  cwd: ctx.repo,
94
99
  env: {
95
100
  HOME: ctx.home,
96
- COPILOT_CLI: "1",
97
- COPILOT_CLI_BINARY_VERSION: "1.0.14",
101
+ COPILOT_CLI: "",
102
+ COPILOT_CLI_BINARY_VERSION: "",
103
+ ANTHROPIC_MODEL: "claude-sonnet-4-6",
98
104
  },
99
105
  });
100
106
 
@@ -116,9 +122,9 @@ describe("integration", () => {
116
122
  expect(note.sessionMetrics?.humanPromptCount).toBe(1);
117
123
  expect(note.sessionMetrics?.activeMinutes).toBe(2);
118
124
  expect(note.assistantRuntime).toEqual({
119
- vendor: "copilot",
120
- client: "GitHub Copilot CLI",
121
- clientVersion: "1.0.14",
125
+ vendor: "claude",
126
+ client: "Claude Code",
127
+ modelFamily: "claude-sonnet-4-6",
122
128
  });
123
129
  } finally {
124
130
  await ctx.cleanup();
@@ -207,7 +213,389 @@ describe("integration", () => {
207
213
  expect(metrics.stdout).toContain("**Skills:** /pr");
208
214
  expect(metrics.stdout).toContain("**Agents:** code-review");
209
215
  expect(metrics.stdout).toContain("**External tools:** WebSearch ×2");
210
- expect(metrics.stdout).toContain("**Estimated cost:**");
216
+ expect(metrics.stdout).toContain("**Estimated cost:** unavailable");
217
+ } finally {
218
+ await ctx.cleanup();
219
+ }
220
+ });
221
+
222
+ test("metrics headline uses diff-scoped PR stats instead of full touched file totals", async () => {
223
+ const ctx = await createTempContext("claude-attribution-pr-diff");
224
+ try {
225
+ await initGitRepo(ctx.repo);
226
+ await runCommand("git", ["branch", "-M", "main"], { cwd: ctx.repo });
227
+
228
+ const remote = join(ctx.root, "origin.git");
229
+ await runCommand("git", ["init", "--bare", remote], { cwd: ctx.root });
230
+ await runCommand("git", ["remote", "add", "origin", remote], {
231
+ cwd: ctx.repo,
232
+ });
233
+
234
+ const baseContent =
235
+ Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join("\n") + "\n";
236
+ await writeFile(join(ctx.repo, "README.md"), baseContent);
237
+ await commitAll(ctx.repo, "initial");
238
+ const baseSha = await currentSha(ctx.repo);
239
+ await runCommand("git", ["push", "-u", "origin", "main"], { cwd: ctx.repo });
240
+
241
+ const baseMinimap: MinimapResult = {
242
+ commit: baseSha,
243
+ timestamp: "2026-03-01T10:00:00.000Z",
244
+ files: [
245
+ {
246
+ path: "README.md",
247
+ ai_hashes: "",
248
+ ai: 0,
249
+ human: 100,
250
+ total: 100,
251
+ pctAi: 0,
252
+ },
253
+ ],
254
+ totals: { ai: 0, human: 100, total: 100, pctAi: 0 },
255
+ };
256
+ await writeMinimap(baseMinimap, ctx.repo, baseSha);
257
+
258
+ await runCommand("git", ["checkout", "-b", "feature/diff-metrics"], {
259
+ cwd: ctx.repo,
260
+ });
261
+ await writeFile(
262
+ join(ctx.repo, "README.md"),
263
+ `${baseContent}AI-added line 1\nAI-added line 2\n`,
264
+ );
265
+ await commitAll(ctx.repo, "feature work");
266
+ const headSha = await currentSha(ctx.repo);
267
+
268
+ const note: AttributionResult = {
269
+ commit: headSha,
270
+ session: "session-pr-diff-1",
271
+ branch: "feature/diff-metrics",
272
+ timestamp: "2026-03-01T10:05:00.000Z",
273
+ files: [
274
+ {
275
+ path: "README.md",
276
+ ai: 2,
277
+ human: 100,
278
+ mixed: 0,
279
+ total: 102,
280
+ pctAi: 2,
281
+ },
282
+ ],
283
+ totals: { ai: 2, human: 100, mixed: 0, total: 102, pctAi: 2 },
284
+ };
285
+ await writeNote(note, ctx.repo, headSha);
286
+
287
+ const aiHashes = hashSetToString(
288
+ new Set([hashLine("AI-added line 1"), hashLine("AI-added line 2")]),
289
+ );
290
+ const headMinimap: MinimapResult = {
291
+ commit: headSha,
292
+ timestamp: "2026-03-01T10:05:00.000Z",
293
+ files: [
294
+ {
295
+ path: "README.md",
296
+ ai_hashes: aiHashes,
297
+ ai: 2,
298
+ human: 100,
299
+ total: 102,
300
+ pctAi: 2,
301
+ },
302
+ ],
303
+ totals: { ai: 2, human: 100, total: 102, pctAi: 2 },
304
+ };
305
+ await writeMinimap(headMinimap, ctx.repo, headSha);
306
+
307
+ const metrics = await runCommand(CLI_BIN, ["metrics"], {
308
+ cwd: ctx.repo,
309
+ env: { HOME: ctx.home },
310
+ });
311
+
312
+ expect(metrics.stdout).toContain(
313
+ "**This PR:** 2 lines changed (2% of codebase) · 100% AI edits · 2 AI-attributed changed lines",
314
+ );
315
+ expect(metrics.stdout).not.toContain("**This PR:** 102 lines changed");
316
+ } finally {
317
+ await ctx.cleanup();
318
+ }
319
+ });
320
+
321
+ test("post-commit falls back to Copilot session-state metadata", async () => {
322
+ const ctx = await createTempContext("copilot-attribution-commit");
323
+ try {
324
+ await initGitRepo(ctx.repo);
325
+ const filePath = join(ctx.repo, "src.ts");
326
+ await writeFile(filePath, "const value = 1;\n");
327
+ await commitAll(ctx.repo, "initial");
328
+
329
+ await writeFile(filePath, "const value = 1;\nconst nextValue = 2;\n");
330
+ await commitAll(ctx.repo, "feature work");
331
+
332
+ const branch = (
333
+ await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
334
+ cwd: ctx.repo,
335
+ })
336
+ ).stdout.trim();
337
+ const sessionId = "copilot-session-commit-1";
338
+ await writeCopilotSession(ctx.home, sessionId, [
339
+ {
340
+ type: "session.start",
341
+ timestamp: "2026-04-01T10:00:00.000Z",
342
+ data: {
343
+ context: {
344
+ cwd: ctx.repo,
345
+ gitRoot: ctx.repo,
346
+ branch,
347
+ },
348
+ },
349
+ },
350
+ {
351
+ type: "user.message",
352
+ timestamp: "2026-04-01T10:00:00.000Z",
353
+ data: { content: "start work" },
354
+ },
355
+ {
356
+ type: "tool.execution_start",
357
+ timestamp: "2026-04-01T10:00:05.000Z",
358
+ data: { toolName: "web_search" },
359
+ },
360
+ {
361
+ type: "tool.execution_complete",
362
+ timestamp: "2026-04-01T10:01:00.000Z",
363
+ data: { model: "gpt-5.4" },
364
+ },
365
+ {
366
+ type: "assistant.message",
367
+ timestamp: "2026-04-01T10:01:00.000Z",
368
+ data: { outputTokens: 240 },
369
+ },
370
+ {
371
+ type: "assistant.turn_end",
372
+ timestamp: "2026-04-01T10:01:00.000Z",
373
+ },
374
+ ]);
375
+
376
+ await runCommand(CLI_BIN, ["hook", "post-commit"], {
377
+ cwd: ctx.repo,
378
+ env: {
379
+ HOME: ctx.home,
380
+ COPILOT_CLI: "1",
381
+ COPILOT_CLI_BINARY_VERSION: "1.0.15",
382
+ },
383
+ });
384
+
385
+ const note = JSON.parse(
386
+ (
387
+ await runCommand(
388
+ "git",
389
+ ["notes", "--ref", "claude-attribution", "show", "HEAD"],
390
+ { cwd: ctx.repo },
391
+ )
392
+ ).stdout,
393
+ ) as AttributionResult;
394
+
395
+ expect(note.session).toBe(sessionId);
396
+ expect(note.totals.ai).toBeGreaterThan(0);
397
+ expect(note.modelUsage).toEqual([
398
+ {
399
+ modelFull: "gpt-5.4",
400
+ modelShort: "Unknown",
401
+ calls: 1,
402
+ inputTokens: 0,
403
+ outputTokens: 240,
404
+ cacheCreationTokens: 0,
405
+ cacheReadTokens: 0,
406
+ },
407
+ ]);
408
+ expect(note.sessionMetrics?.toolCounts).toEqual({ web_search: 1 });
409
+ expect(note.sessionMetrics?.humanPromptCount).toBe(1);
410
+ expect(note.assistantRuntime).toEqual({
411
+ vendor: "copilot",
412
+ client: "GitHub Copilot CLI",
413
+ clientVersion: "1.0.15",
414
+ modelFamily: "gpt-5.4",
415
+ });
416
+ } finally {
417
+ await ctx.cleanup();
418
+ }
419
+ });
420
+
421
+ test("Copilot sessions win over stale Claude session markers and transcripts", async () => {
422
+ const ctx = await createTempContext("copilot-session-precedence");
423
+ try {
424
+ await initGitRepo(ctx.repo);
425
+ const filePath = join(ctx.repo, "src.ts");
426
+ await writeFile(filePath, "const value = 1;\n");
427
+ await commitAll(ctx.repo, "initial");
428
+
429
+ const staleClaudeSession = "claude-session-stale-1";
430
+ await mkdir(join(ctx.repo, ".claude", "attribution-state"), {
431
+ recursive: true,
432
+ });
433
+ await writeFile(
434
+ join(ctx.repo, ".claude", "attribution-state", "current-session"),
435
+ staleClaudeSession,
436
+ );
437
+ await writeJsonl(join(ctx.repo, ".claude", "logs", "tool-usage.jsonl"), [
438
+ {
439
+ timestamp: "2026-03-30T01:09:44.678Z",
440
+ session: staleClaudeSession,
441
+ tool: "Bash",
442
+ },
443
+ ]);
444
+ await writeTranscript(ctx.home, ctx.repo, staleClaudeSession, [
445
+ {
446
+ type: "user",
447
+ timestamp: "2026-03-30T01:00:00.000Z",
448
+ },
449
+ {
450
+ type: "assistant",
451
+ timestamp: "2026-03-30T01:01:00.000Z",
452
+ message: {
453
+ model: "claude-sonnet-4-6",
454
+ usage: {
455
+ input_tokens: 2000,
456
+ output_tokens: 500,
457
+ cache_creation_input_tokens: 100,
458
+ cache_read_input_tokens: 800,
459
+ },
460
+ },
461
+ },
462
+ ]);
463
+
464
+ await writeFile(filePath, "const value = 1;\nconst nextValue = 2;\n");
465
+ await commitAll(ctx.repo, "feature work");
466
+
467
+ const branch = (
468
+ await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
469
+ cwd: ctx.repo,
470
+ })
471
+ ).stdout.trim();
472
+ const copilotSession = "copilot-session-precedence-1";
473
+ await writeCopilotSession(ctx.home, copilotSession, [
474
+ {
475
+ type: "session.start",
476
+ timestamp: "2026-04-01T10:00:00.000Z",
477
+ data: {
478
+ context: {
479
+ cwd: ctx.repo,
480
+ gitRoot: ctx.repo,
481
+ branch,
482
+ },
483
+ },
484
+ },
485
+ {
486
+ type: "user.message",
487
+ timestamp: "2026-04-01T10:00:00.000Z",
488
+ data: { content: "start work" },
489
+ },
490
+ {
491
+ type: "tool.execution_complete",
492
+ timestamp: "2026-04-01T10:01:00.000Z",
493
+ data: { model: "gpt-5.4" },
494
+ },
495
+ {
496
+ type: "assistant.message",
497
+ timestamp: "2026-04-01T10:01:00.000Z",
498
+ data: { outputTokens: 240 },
499
+ },
500
+ {
501
+ type: "assistant.turn_end",
502
+ timestamp: "2026-04-01T10:01:00.000Z",
503
+ },
504
+ ]);
505
+
506
+ await runCommand(CLI_BIN, ["hook", "post-commit"], {
507
+ cwd: ctx.repo,
508
+ env: {
509
+ HOME: ctx.home,
510
+ COPILOT_CLI: "1",
511
+ COPILOT_CLI_BINARY_VERSION: "1.0.15",
512
+ },
513
+ });
514
+
515
+ const note = JSON.parse(
516
+ (
517
+ await runCommand(
518
+ "git",
519
+ ["notes", "--ref", "claude-attribution", "show", "HEAD"],
520
+ { cwd: ctx.repo },
521
+ )
522
+ ).stdout,
523
+ ) as AttributionResult;
524
+
525
+ expect(note.session).toBe(copilotSession);
526
+ expect(note.assistantRuntime).toEqual({
527
+ vendor: "copilot",
528
+ client: "GitHub Copilot CLI",
529
+ clientVersion: "1.0.15",
530
+ modelFamily: "gpt-5.4",
531
+ });
532
+ expect(note.modelUsage).toEqual([
533
+ {
534
+ modelFull: "gpt-5.4",
535
+ modelShort: "Unknown",
536
+ calls: 1,
537
+ inputTokens: 0,
538
+ outputTokens: 240,
539
+ cacheCreationTokens: 0,
540
+ cacheReadTokens: 0,
541
+ },
542
+ ]);
543
+
544
+ const metrics = await runCommand(CLI_BIN, ["metrics"], {
545
+ cwd: ctx.repo,
546
+ env: {
547
+ HOME: ctx.home,
548
+ COPILOT_CLI: "1",
549
+ COPILOT_CLI_BINARY_VERSION: "1.0.15",
550
+ },
551
+ });
552
+
553
+ expect(metrics.stdout).toContain(
554
+ "**Assistant runtime:** GitHub Copilot CLI (v1.0.15 · gpt-5.4)",
555
+ );
556
+ expect(metrics.stdout).toContain("| Model | Calls | Known Tokens |");
557
+ expect(metrics.stdout).toContain("| gpt-5.4 | 1 | 240 |");
558
+ expect(metrics.stdout).toContain("**Estimated cost:** unavailable");
559
+ expect(metrics.stdout).not.toContain("claude-sonnet-4-6");
560
+ expect(metrics.stdout).not.toContain("**Estimated cost:** ~$");
561
+ } finally {
562
+ await ctx.cleanup();
563
+ }
564
+ });
565
+
566
+ test("metrics synthesizes hosted Copilot bot commits without local notes", async () => {
567
+ const ctx = await createTempContext("copilot-hosted-metrics");
568
+ try {
569
+ await initGitRepo(ctx.repo);
570
+ await writeFile(join(ctx.repo, "baseline.ts"), "export const baseline = true;\n");
571
+ await commitAll(ctx.repo, "initial");
572
+ await runCommand("git", ["config", "user.name", "copilot[bot]"], {
573
+ cwd: ctx.repo,
574
+ });
575
+ await runCommand(
576
+ "git",
577
+ ["config", "user.email", "000000+copilot[bot]@users.noreply.github.com"],
578
+ { cwd: ctx.repo },
579
+ );
580
+ await writeFile(
581
+ join(ctx.repo, "agent.ts"),
582
+ "export const generated = true;\n",
583
+ );
584
+ await commitAll(
585
+ ctx.repo,
586
+ "feat: hosted change\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>",
587
+ );
588
+
589
+ const metrics = await runCommand(CLI_BIN, ["metrics"], {
590
+ cwd: ctx.repo,
591
+ env: { HOME: ctx.home },
592
+ });
593
+
594
+ expect(metrics.stdout).toContain("## AI Coding Metrics");
595
+ expect(metrics.stdout).toContain("**AI contribution: ~50%**");
596
+ expect(metrics.stdout).toContain("**Assistant runtime:** GitHub Copilot");
597
+ expect(metrics.stdout).toContain("`agent.ts` — 50% AI (1 lines)");
598
+ expect(metrics.stdout).not.toContain("**Estimated cost:**");
211
599
  } finally {
212
600
  await ctx.cleanup();
213
601
  }
@@ -47,6 +47,23 @@ await ctx.cleanup();
47
47
  }
48
48
  });
49
49
 
50
+ test("all-AI baseline records blank lines as AI-preserving hashes", async () => {
51
+ const ctx = await createTempContext("claude-attribution-minimap-blank-carry");
52
+ try {
53
+ await initGitRepo(ctx.repo);
54
+ await writeFile(join(ctx.repo, "doc.md"), "alpha\n\nbeta\n");
55
+ await runCommand("git", ["add", "."], { cwd: ctx.repo });
56
+ await runCommand("git", ["commit", "-m", "baseline"], { cwd: ctx.repo });
57
+
58
+ const baseline = await buildAllAiMinimap(ctx.repo);
59
+ const prevEntry = baseline.files.find((file) => file.path === "doc.md");
60
+ expect(prevEntry).toMatchObject({ ai: 3, human: 0, total: 3, pctAi: 100 });
61
+ expect(prevEntry?.ai_hashes.length).toBeGreaterThanOrEqual(16);
62
+ } finally {
63
+ await ctx.cleanup();
64
+ }
65
+ });
66
+
50
67
  test("marks only recently changed files as AI in ai-since minimaps", async () => {
51
68
  const ctx = await createTempContext("claude-attribution-minimap-ai-since");
52
69
  try {
@@ -99,7 +99,7 @@ describe("computeMinimapFile", () => {
99
99
  expect(hashSetFromString(result.ai_hashes).has(hash)).toBe(false);
100
100
  });
101
101
 
102
- test("blank line always human (never in ai_hashes)", () => {
102
+ test("blank line stays AI when the blank-line marker is present", () => {
103
103
  const aiHash = hashLine("");
104
104
  const result = computeMinimapFile(
105
105
  "foo.ts",
@@ -107,12 +107,12 @@ describe("computeMinimapFile", () => {
107
107
  new Set([aiHash]),
108
108
  new Set([aiHash]),
109
109
  );
110
- expect(result.ai).toBe(0);
111
- expect(result.human).toBe(1);
112
- expect(result.ai_hashes).toBe("");
110
+ expect(result.ai).toBe(1);
111
+ expect(result.human).toBe(0);
112
+ expect(result.ai_hashes).toBe(aiHash);
113
113
  });
114
114
 
115
- test("whitespace-only line treated as blank (always human)", () => {
115
+ test("whitespace-only line treated as blank and can carry AI attribution", () => {
116
116
  const line = " ";
117
117
  const aiHash = hashLine(line);
118
118
  const result = computeMinimapFile(
@@ -121,8 +121,8 @@ describe("computeMinimapFile", () => {
121
121
  new Set([aiHash]),
122
122
  new Set([aiHash]),
123
123
  );
124
- expect(result.ai).toBe(0);
125
- expect(result.human).toBe(1);
124
+ expect(result.ai).toBe(1);
125
+ expect(result.human).toBe(0);
126
126
  });
127
127
 
128
128
  test("MIXED line — not in currentAiHashes → human even if in prevAiHashSet", () => {
@@ -15,11 +15,22 @@ describe("detectAssistantRuntime", () => {
15
15
  });
16
16
  });
17
17
 
18
- test("falls back to Claude runtime from Anthropic model env", () => {
18
+ test("detects Claude runtime without treating alias defaults as the active model", () => {
19
19
  expect(
20
20
  detectAssistantRuntime({
21
21
  ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-4-6",
22
22
  }),
23
+ ).toEqual({
24
+ vendor: "claude",
25
+ client: "Claude Code",
26
+ });
27
+ });
28
+
29
+ test("uses ANTHROPIC_MODEL when the active model is explicit", () => {
30
+ expect(
31
+ detectAssistantRuntime({
32
+ ANTHROPIC_MODEL: "claude-sonnet-4-6",
33
+ }),
23
34
  ).toEqual({
24
35
  vendor: "claude",
25
36
  client: "Claude Code",
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createTempContext, writeTranscript } from "./integration-helpers.ts";
3
+ import { parseTranscript } from "../metrics/transcript.ts";
4
+
5
+ describe("parseTranscript", () => {
6
+ test("counts real user prompts and ignores tool_result rows", async () => {
7
+ const ctx = await createTempContext("transcript-parser");
8
+ const originalHome = process.env.HOME;
9
+ try {
10
+ process.env.HOME = ctx.home;
11
+ const repo = "/Users/alice.smith/Code/my-repo";
12
+ const sessionId = "session-1";
13
+ await writeTranscript(ctx.home, repo, sessionId, [
14
+ {
15
+ type: "user",
16
+ timestamp: "2026-04-01T10:00:00.000Z",
17
+ message: { role: "user", content: "Add a helper" },
18
+ },
19
+ {
20
+ type: "assistant",
21
+ timestamp: "2026-04-01T10:00:05.000Z",
22
+ message: {
23
+ role: "assistant",
24
+ model: "claude-sonnet-4-6",
25
+ usage: { input_tokens: 10, output_tokens: 5 },
26
+ content: [{ type: "tool_use", name: "WebSearch", input: {} }],
27
+ },
28
+ },
29
+ {
30
+ type: "user",
31
+ timestamp: "2026-04-01T10:00:07.000Z",
32
+ toolUseResult: { tool: "WebSearch" },
33
+ message: {
34
+ role: "user",
35
+ content: [{ type: "tool_result" }],
36
+ },
37
+ },
38
+ {
39
+ type: "assistant",
40
+ timestamp: "2026-04-01T10:00:12.000Z",
41
+ message: {
42
+ role: "assistant",
43
+ model: "claude-sonnet-4-6",
44
+ usage: { input_tokens: 12, output_tokens: 8 },
45
+ content: [{ type: "text" }],
46
+ },
47
+ },
48
+ ]);
49
+
50
+ const result = await parseTranscript(sessionId, repo);
51
+ expect(result).not.toBeNull();
52
+ expect(result?.humanPromptCount).toBe(1);
53
+ expect(result?.toolCounts).toEqual({ WebSearch: 1 });
54
+ expect(result?.totals.totalCalls).toBe(2);
55
+ expect(result?.aiMinutes).toBe(0);
56
+ } finally {
57
+ process.env.HOME = originalHome;
58
+ await ctx.cleanup();
59
+ }
60
+ });
61
+ });