claude-attribution 1.8.0 → 1.9.0

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.
@@ -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
+ });
@@ -31,11 +31,13 @@ import {
31
31
  attributeLines,
32
32
  aggregateTotals,
33
33
  type AttributionResult,
34
+ type AssistantRuntimeInfo,
34
35
  type FileAttribution,
35
36
  type LineAttribution,
36
37
  hashLine,
37
38
  } from "./differ.ts";
38
- import { parseTranscript } from "../metrics/transcript.ts";
39
+ import { resolveCopilotSessionId } from "../metrics/copilot-session.ts";
40
+ import { parseLocalSession } from "../metrics/local-session.ts";
39
41
  import { detectAssistantRuntime } from "./runtime.ts";
40
42
  import {
41
43
  buildSessionMetrics,
@@ -88,47 +90,66 @@ import {
88
90
  async function resolveSessionForCommit(
89
91
  repoRoot: string,
90
92
  changedFiles: string[],
93
+ branch: string | null,
91
94
  ): Promise<string | null> {
92
95
  const fromCurrentSession = await readCurrentSession(repoRoot);
93
96
  if (fromCurrentSession) return fromCurrentSession;
94
97
 
95
98
  const toolLog = join(repoRoot, ".claude", "logs", "tool-usage.jsonl");
96
- if (!existsSync(toolLog)) return null;
99
+ if (existsSync(toolLog)) {
100
+ try {
101
+ const raw = await readFile(toolLog, "utf8");
102
+ const EIGHT_HOURS = 8 * 60 * 60 * 1000;
103
+ const recentSessions: string[] = [];
104
+ const seen = new Set<string>();
105
+ for (const line of raw.trim().split("\n").filter(Boolean).reverse()) {
106
+ const entry = JSON.parse(line) as { session?: string; timestamp?: string };
107
+ if (!entry.session || !entry.timestamp || seen.has(entry.session)) continue;
108
+ const age = Date.now() - new Date(entry.timestamp).getTime();
109
+ if (age >= EIGHT_HOURS) continue;
110
+ seen.add(entry.session);
111
+ recentSessions.push(entry.session);
112
+ }
113
+ if (recentSessions.length > 0) {
114
+ if (changedFiles.length > 0) {
115
+ const changedAbsPaths = changedFiles.map((relPath) => join(repoRoot, relPath));
116
+ for (const candidate of recentSessions) {
117
+ const hasMatchingCheckpoint = await Promise.all(
118
+ changedAbsPaths.map((absPath) =>
119
+ loadCheckpoint(candidate, absPath, "after")
120
+ .then((checkpoint) => checkpoint !== null)
121
+ .catch(() => false),
122
+ ),
123
+ ).then((matches) => matches.some(Boolean));
124
+ if (hasMatchingCheckpoint) return candidate;
125
+ }
126
+ }
97
127
 
98
- try {
99
- const raw = await readFile(toolLog, "utf8");
100
- const EIGHT_HOURS = 8 * 60 * 60 * 1000;
101
- const recentSessions: string[] = [];
102
- const seen = new Set<string>();
103
- for (const line of raw.trim().split("\n").filter(Boolean).reverse()) {
104
- const entry = JSON.parse(line) as { session?: string; timestamp?: string };
105
- if (!entry.session || !entry.timestamp || seen.has(entry.session)) continue;
106
- const age = Date.now() - new Date(entry.timestamp).getTime();
107
- if (age >= EIGHT_HOURS) continue;
108
- seen.add(entry.session);
109
- recentSessions.push(entry.session);
110
- }
111
- if (recentSessions.length === 0) return null;
112
-
113
- if (changedFiles.length > 0) {
114
- const changedAbsPaths = changedFiles.map((relPath) => join(repoRoot, relPath));
115
- for (const candidate of recentSessions) {
116
- const hasMatchingCheckpoint = await Promise.all(
117
- changedAbsPaths.map((absPath) =>
118
- loadCheckpoint(candidate, absPath, "after")
119
- .then((checkpoint) => checkpoint !== null)
120
- .catch(() => false),
121
- ),
122
- ).then((matches) => matches.some(Boolean));
123
- if (hasMatchingCheckpoint) return candidate;
128
+ return recentSessions[0] ?? null;
124
129
  }
130
+ } catch {
131
+ // Non-fatal
125
132
  }
133
+ }
126
134
 
127
- return recentSessions[0] ?? null;
128
- } catch {
129
- // Non-fatal
135
+ return await resolveCopilotSessionId(repoRoot, branch).catch(() => null);
136
+ }
137
+
138
+ function mergeAssistantRuntime(
139
+ runtime: AssistantRuntimeInfo | undefined,
140
+ provider: "claude" | "copilot" | undefined,
141
+ transcriptModels: { modelFull: string }[],
142
+ ): AssistantRuntimeInfo | undefined {
143
+ if (provider === "copilot") {
144
+ return {
145
+ vendor: "copilot",
146
+ client: runtime?.client ?? "GitHub Copilot CLI",
147
+ clientVersion: runtime?.clientVersion,
148
+ modelFamily: transcriptModels[0]?.modelFull ?? runtime?.modelFamily,
149
+ };
130
150
  }
131
- return null;
151
+
152
+ return runtime;
132
153
  }
133
154
 
134
155
  async function main() {
@@ -143,7 +164,7 @@ async function main() {
143
164
  .catch(() => null as string | null),
144
165
  renamedFilesInCommit(repoRoot),
145
166
  ]);
146
- const sessionId = await resolveSessionForCommit(repoRoot, changedFiles);
167
+ const sessionId = await resolveSessionForCommit(repoRoot, changedFiles, branch);
147
168
 
148
169
  // Process files in parallel — each file attribution is independent.
149
170
  // Return type includes attribution[] so the minimap block can build currentAiHashes.
@@ -227,16 +248,14 @@ async function main() {
227
248
  files: fileResults,
228
249
  totals: aggregateTotals(fileResults),
229
250
  };
230
- const assistantRuntime = detectAssistantRuntime();
231
- if (assistantRuntime) {
232
- result.assistantRuntime = assistantRuntime;
233
- }
234
251
  const notesRefsToSync = [NOTES_REF];
235
252
 
236
- // Attach token usage from the Claude session transcript (non-fatal if unavailable)
253
+ const detectedRuntime = detectAssistantRuntime();
254
+
255
+ // Attach session usage metadata (non-fatal if unavailable)
237
256
  if (sessionId) {
238
257
  const [tx, toolEntries, agentEntries] = await Promise.all([
239
- parseTranscript(sessionId, repoRoot).catch(() => null),
258
+ parseLocalSession(sessionId, repoRoot).catch(() => null),
240
259
  readJsonlForSession<ToolLogEntry>(
241
260
  join(repoRoot, ".claude", "logs", "tool-usage.jsonl"),
242
261
  sessionId,
@@ -246,6 +265,14 @@ async function main() {
246
265
  sessionId,
247
266
  ).catch(() => []),
248
267
  ]);
268
+ const mergedRuntime = mergeAssistantRuntime(
269
+ detectedRuntime ?? undefined,
270
+ tx?.provider,
271
+ tx?.byModel ?? [],
272
+ );
273
+ if (mergedRuntime) {
274
+ result.assistantRuntime = mergedRuntime;
275
+ }
249
276
  if (tx && tx.byModel.length > 0) {
250
277
  result.modelUsage = tx.byModel.map((m) => ({
251
278
  modelFull: m.modelFull,
@@ -261,6 +288,8 @@ async function main() {
261
288
  if (hasSessionMetrics(sessionMetrics)) {
262
289
  result.sessionMetrics = sessionMetrics;
263
290
  }
291
+ } else if (detectedRuntime) {
292
+ result.assistantRuntime = detectedRuntime;
264
293
  }
265
294
 
266
295
  // Write git note
@@ -85,8 +85,9 @@ export function hashLine(line: string): string {
85
85
  * Rules:
86
86
  * - AI: hash present in afterHashes but NOT in beforeHashes
87
87
  * → Claude wrote this line and it survived to the commit unchanged
88
- * - MIXED: hash was in afterHashes originally (positional check) but the
89
- * committed content differs from what Claude wrote at that position
88
+ * - MIXED: committed content sits in a one-for-one replacement span anchored
89
+ * by exact surrounding matches, and the replaced after-snapshot line
90
+ * was AI-authored
90
91
  * → Claude wrote it, human modified it before committing
91
92
  * - HUMAN: everything else (existed before Claude, or written after Claude
92
93
  * finished without a checkpoint)
@@ -100,13 +101,11 @@ export function hashLine(line: string): string {
100
101
  * This is conservative toward AI for identical content — an acceptable trade-off
101
102
  * given that truly identical lines are indistinguishable without positional history.
102
103
  *
103
- * **MIXED detection limitation**: MIXED uses a positional index
104
- * `afterByIndex[i]` is the hash of Claude's i-th line. If a human inserts or
105
- * deletes lines above position `i`, the committed file's positions shift while
106
- * the after-snapshot's positions do not, causing false MIXED classifications.
107
- * MIXED is therefore a "best effort" signal most accurate when human edits are
108
- * small in-place tweaks (e.g., changing a value on a line Claude wrote) rather
109
- * than bulk insertions or deletions that shift line numbers.
104
+ * **MIXED detection limitation**: MIXED is only inferred for replacement spans
105
+ * where we can align the after-snapshot and committed file with exact matching
106
+ * anchors around the changed region. Insertions/deletions without stable
107
+ * anchors are treated as HUMAN to avoid false positives that would reduce the
108
+ * reported AI percentage for otherwise all-Claude changes.
110
109
  */
111
110
  export function attributeLines(
112
111
  beforeLines: string[],
@@ -115,10 +114,10 @@ export function attributeLines(
115
114
  ): { attribution: LineAttribution[]; stats: FileAttribution } {
116
115
  const beforeHashes = new Set(beforeLines.map(hashLine));
117
116
  const afterHashes = new Set(afterLines.map(hashLine));
118
- // Positional index: line index → hash (for MIXED detection)
119
117
  const afterByIndex = afterLines.map(hashLine);
118
+ const committedByIndex = committedLines.map(hashLine);
120
119
 
121
- const attribution: LineAttribution[] = committedLines.map((line, i) => {
120
+ const attribution: LineAttribution[] = committedLines.map((line) => {
122
121
  const hash = hashLine(line);
123
122
 
124
123
  // Blank lines carry no signal
@@ -128,18 +127,75 @@ export function attributeLines(
128
127
  return "AI";
129
128
  }
130
129
 
131
- // MIXED: Claude wrote something at this position but the human changed it
132
- const afterHashAtPos = afterByIndex[i];
133
- if (
134
- afterHashAtPos !== undefined &&
135
- !beforeHashes.has(afterHashAtPos) &&
136
- hash !== afterHashAtPos
130
+ return "HUMAN";
131
+ });
132
+
133
+ function markMixedRange(
134
+ afterStart: number,
135
+ afterEnd: number,
136
+ committedStart: number,
137
+ committedEnd: number,
138
+ ): void {
139
+ while (
140
+ afterStart < afterEnd &&
141
+ committedStart < committedEnd &&
142
+ afterByIndex[afterStart] === committedByIndex[committedStart]
137
143
  ) {
138
- return "MIXED";
144
+ afterStart++;
145
+ committedStart++;
139
146
  }
140
147
 
141
- return "HUMAN";
142
- });
148
+ while (
149
+ afterStart < afterEnd &&
150
+ committedStart < committedEnd &&
151
+ afterByIndex[afterEnd - 1] === committedByIndex[committedEnd - 1]
152
+ ) {
153
+ afterEnd--;
154
+ committedEnd--;
155
+ }
156
+
157
+ if (afterStart >= afterEnd || committedStart >= committedEnd) return;
158
+
159
+ const nextAfterByHash = new Map<string, number>();
160
+ for (let i = afterStart; i < afterEnd; i++) {
161
+ const hash = afterByIndex[i];
162
+ if (hash !== undefined && !nextAfterByHash.has(hash)) {
163
+ nextAfterByHash.set(hash, i);
164
+ }
165
+ }
166
+
167
+ for (let i = committedStart; i < committedEnd; i++) {
168
+ const hash = committedByIndex[i];
169
+ if (hash === undefined) continue;
170
+ const afterMatch = nextAfterByHash.get(hash);
171
+ if (afterMatch === undefined) continue;
172
+
173
+ markMixedRange(afterStart, afterMatch, committedStart, i);
174
+ markMixedRange(afterMatch + 1, afterEnd, i + 1, committedEnd);
175
+ return;
176
+ }
177
+
178
+ if (afterEnd - afterStart !== committedEnd - committedStart) return;
179
+
180
+ for (let offset = 0; offset < committedEnd - committedStart; offset++) {
181
+ const committedIndex = committedStart + offset;
182
+ const afterHash = afterByIndex[afterStart + offset];
183
+ const committedLine = committedLines[committedIndex];
184
+ if (
185
+ afterHash === undefined ||
186
+ committedLine === undefined ||
187
+ committedLine.trim() === "" ||
188
+ attribution[committedIndex] === "AI" ||
189
+ beforeHashes.has(afterHash)
190
+ ) {
191
+ continue;
192
+ }
193
+
194
+ attribution[committedIndex] = "MIXED";
195
+ }
196
+ }
197
+
198
+ markMixedRange(0, afterLines.length, 0, committedLines.length);
143
199
 
144
200
  const ai = attribution.filter((a) => a === "AI").length;
145
201
  const human = attribution.filter((a) => a === "HUMAN").length;
@@ -15,6 +15,7 @@ import { execFile } from "child_process";
15
15
  import { promisify } from "util";
16
16
  import {
17
17
  aggregateTotals,
18
+ type AssistantRuntimeInfo,
18
19
  type AttributionResult,
19
20
  type FileAttribution,
20
21
  } from "./differ.ts";
@@ -292,6 +293,30 @@ export function isKnownAiActorCommit(meta: CommitMeta): boolean {
292
293
  return false;
293
294
  }
294
295
 
296
+ function inferAiActorRuntime(meta: CommitMeta): AssistantRuntimeInfo | undefined {
297
+ const { authorName, authorEmail, message } = meta;
298
+ const combined = `${authorName}\n${authorEmail}\n${message}`.toLowerCase();
299
+ if (combined.includes("copilot")) {
300
+ return {
301
+ vendor: "copilot",
302
+ client: "GitHub Copilot",
303
+ };
304
+ }
305
+ if (combined.includes("claude")) {
306
+ return {
307
+ vendor: "claude",
308
+ client: "Claude Code",
309
+ };
310
+ }
311
+ if (authorEmail.includes("[bot]") || authorName.includes("[bot]")) {
312
+ return {
313
+ vendor: "unknown",
314
+ client: authorName || "AI agent",
315
+ };
316
+ }
317
+ return undefined;
318
+ }
319
+
295
320
  /**
296
321
  * Build a 100% AI AttributionResult for a commit without running the
297
322
  * checkpoint-based differ. All non-blank committed lines are marked AI.
@@ -336,5 +361,6 @@ export async function buildAllAiResult(
336
361
  timestamp: meta.timestamp,
337
362
  files: fileResults,
338
363
  totals: aggregateTotals(fileResults),
364
+ assistantRuntime: inferAiActorRuntime(meta),
339
365
  };
340
366
  }
@@ -17,16 +17,17 @@ export function detectAssistantRuntime(
17
17
  };
18
18
  }
19
19
 
20
- const anthropicModel =
21
- firstNonEmpty(env["ANTHROPIC_MODEL"]) ??
22
- firstNonEmpty(env["ANTHROPIC_DEFAULT_OPUS_MODEL"]) ??
23
- firstNonEmpty(env["ANTHROPIC_DEFAULT_SONNET_MODEL"]) ??
24
- firstNonEmpty(env["ANTHROPIC_DEFAULT_HAIKU_MODEL"]);
25
- if (anthropicModel) {
20
+ const anthropicModel = firstNonEmpty(env["ANTHROPIC_MODEL"]);
21
+ const hasClaudeRuntimeHint =
22
+ anthropicModel !== undefined ||
23
+ firstNonEmpty(env["ANTHROPIC_DEFAULT_OPUS_MODEL"]) !== undefined ||
24
+ firstNonEmpty(env["ANTHROPIC_DEFAULT_SONNET_MODEL"]) !== undefined ||
25
+ firstNonEmpty(env["ANTHROPIC_DEFAULT_HAIKU_MODEL"]) !== undefined;
26
+ if (hasClaudeRuntimeHint) {
26
27
  return {
27
28
  vendor: "claude",
28
29
  client: "Claude Code",
29
- modelFamily: anthropicModel,
30
+ ...(anthropicModel ? { modelFamily: anthropicModel } : {}),
30
31
  };
31
32
  }
32
33
 
@@ -0,0 +1,48 @@
1
+ import { existsSync } from "fs";
2
+ import { readdir } from "fs/promises";
3
+ import { homedir } from "os";
4
+ import { join, resolve } from "path";
5
+
6
+ function legacyProjectKey(repoRoot: string): string {
7
+ return resolve(repoRoot).replace(/\//g, "-");
8
+ }
9
+
10
+ function sanitizeProjectKey(repoRoot: string): string {
11
+ return resolve(repoRoot).replace(/[^A-Za-z0-9-]/g, "-");
12
+ }
13
+
14
+ function canonicalProjectKey(value: string): string {
15
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
16
+ }
17
+
18
+ export function claudeProjectKey(repoRoot: string): string {
19
+ return sanitizeProjectKey(repoRoot);
20
+ }
21
+
22
+ export function claudeProjectsRoot(
23
+ homeDir = process.env["HOME"] ?? homedir(),
24
+ ): string {
25
+ return join(homeDir, ".claude", "projects");
26
+ }
27
+
28
+ export async function resolveClaudeProjectDir(
29
+ repoRoot: string,
30
+ projectsRoot = claudeProjectsRoot(),
31
+ ): Promise<string | null> {
32
+ for (const key of new Set([claudeProjectKey(repoRoot), legacyProjectKey(repoRoot)])) {
33
+ const dir = join(projectsRoot, key);
34
+ if (existsSync(dir)) return dir;
35
+ }
36
+
37
+ if (!existsSync(projectsRoot)) return null;
38
+
39
+ const expected = canonicalProjectKey(resolve(repoRoot));
40
+ for (const entry of await readdir(projectsRoot, { withFileTypes: true })) {
41
+ if (!entry.isDirectory()) continue;
42
+ if (canonicalProjectKey(entry.name) === expected) {
43
+ return join(projectsRoot, entry.name);
44
+ }
45
+ }
46
+
47
+ return null;
48
+ }