claude-attribution 1.5.0 → 1.6.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.
package/README.md CHANGED
@@ -584,6 +584,11 @@ jobs:
584
584
  - name: Fetch attribution notes
585
585
  run: git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
586
586
 
587
+ - name: Configure git identity for notes
588
+ run: |
589
+ git config user.email "claude-attribution[bot]@users.noreply.github.com"
590
+ git config user.name "claude-attribution[bot]"
591
+
587
592
  - uses: oven-sh/setup-bun@v2
588
593
 
589
594
  - name: Install claude-attribution
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-attribution",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "AI code attribution tracking for Claude Code sessions — checkpoint-based line diff approach",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,8 @@
20
20
  * All errors are caught and logged; the process always exits 0.
21
21
  */
22
22
  import { resolve, join } from "path";
23
- import { mkdir, appendFile } from "fs/promises";
23
+ import { mkdir, appendFile, readFile } from "fs/promises";
24
+ import { existsSync } from "fs";
24
25
  import { execFile } from "child_process";
25
26
  import { promisify } from "util";
26
27
 
@@ -40,6 +41,7 @@ import {
40
41
  headSha,
41
42
  filesInCommit,
42
43
  committedContent,
44
+ committedContentAt,
43
45
  currentBranch,
44
46
  } from "./git-notes.ts";
45
47
  import {
@@ -59,14 +61,53 @@ import {
59
61
  clearOtelContext,
60
62
  } from "./otel.ts";
61
63
 
64
+ /**
65
+ * Resolve the active Claude session ID for this commit.
66
+ *
67
+ * Primary: read from .claude/attribution-state/current-session (written by pre-tool-use hook
68
+ * on the first Write/Edit in a session).
69
+ *
70
+ * Fallback: if no current-session file exists (e.g. claude --continue where only Bash was
71
+ * used to run git commit, so no Write/Edit hook fired), read the most recent entry from
72
+ * tool-usage.jsonl. If that entry is within 8 hours, return its session ID — this covers
73
+ * the "install, exit, --continue, commit" workflow where the editing and committing happen
74
+ * in the same logical session but different invocations.
75
+ */
76
+ async function resolveSessionForCommit(
77
+ repoRoot: string,
78
+ ): Promise<string | null> {
79
+ const fromCurrentSession = await readCurrentSession(repoRoot);
80
+ if (fromCurrentSession) return fromCurrentSession;
81
+
82
+ const toolLog = join(repoRoot, ".claude", "logs", "tool-usage.jsonl");
83
+ if (!existsSync(toolLog)) return null;
84
+
85
+ try {
86
+ const raw = await readFile(toolLog, "utf8");
87
+ const last = raw.trim().split("\n").filter(Boolean).at(-1);
88
+ if (!last) return null;
89
+ const entry = JSON.parse(last) as { session?: string; timestamp?: string };
90
+ if (!entry.session || !entry.timestamp) return null;
91
+ const age = Date.now() - new Date(entry.timestamp).getTime();
92
+ const EIGHT_HOURS = 8 * 60 * 60 * 1000;
93
+ if (age < EIGHT_HOURS) return entry.session;
94
+ } catch {
95
+ // Non-fatal
96
+ }
97
+ return null;
98
+ }
99
+
62
100
  async function main() {
63
101
  const repoRoot = resolve(process.cwd());
64
- const sessionId = await readCurrentSession(repoRoot);
102
+ const sessionId = await resolveSessionForCommit(repoRoot);
65
103
 
66
- const [sha, branch, changedFiles] = await Promise.all([
104
+ const [sha, branch, changedFiles, parentSha] = await Promise.all([
67
105
  headSha(repoRoot),
68
106
  currentBranch(repoRoot),
69
107
  filesInCommit(repoRoot),
108
+ execFileAsync("git", ["rev-parse", "HEAD^1"], { cwd: repoRoot })
109
+ .then((r: { stdout: string }) => r.stdout.trim())
110
+ .catch(() => null as string | null),
70
111
  ]);
71
112
 
72
113
  // Process files in parallel — each file attribution is independent.
@@ -109,16 +150,21 @@ async function main() {
109
150
  const after = await loadCheckpoint(sessionId, absPath, "after");
110
151
 
111
152
  if (!after) {
112
- return {
113
- path: relPath,
114
- ai: 0,
115
- human: committedLines.length,
116
- mixed: 0,
117
- total: committedLines.length,
118
- pctAi: 0,
119
- attribution: empty,
153
+ // Git-diff fallback: no after checkpoint means Claude didn't write this
154
+ // file in the current session via Write/Edit hooks. Fall back to treating
155
+ // lines new since the parent commit as AI-authored — covers the case where
156
+ // attribution was installed after the code was written and committed via
157
+ // claude --continue (no Write/Edit in the commit session).
158
+ const parentContent = parentSha
159
+ ? await committedContentAt(repoRoot, parentSha, relPath)
160
+ : null;
161
+ const parentLines = parentContent?.split("\n") ?? [];
162
+ const { stats, attribution } = attributeLines(
163
+ parentLines,
120
164
  committedLines,
121
- };
165
+ committedLines,
166
+ );
167
+ return { ...stats, path: relPath, attribution, committedLines };
122
168
  }
123
169
 
124
170
  const beforeLines = before?.lines ?? [];
@@ -167,12 +213,7 @@ async function main() {
167
213
 
168
214
  // Update cumulative minimap — non-fatal, never blocks commits
169
215
  try {
170
- // Load parent commit's minimap for carry-forward
171
- const parentSha = await execFileAsync("git", ["rev-parse", "HEAD^1"], {
172
- cwd: repoRoot,
173
- })
174
- .then((r: { stdout: string }) => r.stdout.trim())
175
- .catch(() => null);
216
+ // Load parent commit's minimap for carry-forward (parentSha fetched at top of main)
176
217
 
177
218
  const prevMinimap = parentSha
178
219
  ? await readMinimap(repoRoot, parentSha)
@@ -102,7 +102,7 @@ async function main() {
102
102
  newBody = body ? `${body}\n\n${block}` : block;
103
103
  }
104
104
 
105
- const tmpFile = `/tmp/claude-attribution-pr-body-${Date.now()}.md`;
105
+ const tmpFile = `/tmp/claude-attribution-pr-body-${Date.now()}-${process.pid}.md`;
106
106
  await writeFile(tmpFile, newBody);
107
107
  try {
108
108
  await execFileAsync("gh", [
@@ -5,10 +5,11 @@
5
5
  * collectMetrics(sessionIdArg?, repoRoot?) — gathers all data sources
6
6
  * renderMetrics(data) — compact markdown for PR descriptions
7
7
  */
8
- import { readFile } from "fs/promises";
8
+ import { readFile, readdir, stat } from "fs/promises";
9
9
  import { calculateCostUsd } from "../pricing.ts";
10
10
  import { existsSync } from "fs";
11
11
  import { resolve, join, relative } from "path";
12
+ import { homedir } from "os";
12
13
  import { execFile } from "child_process";
13
14
  import { promisify } from "util";
14
15
  import { parseTranscript, type TranscriptResult } from "./transcript.ts";
@@ -155,15 +156,52 @@ async function readJsonlForSession(
155
156
  return results;
156
157
  }
157
158
 
159
+ /**
160
+ * Resolve the most recent Claude session ID for this repo.
161
+ *
162
+ * Strategy (in priority order):
163
+ * 1. Last entry in .claude/logs/tool-usage.jsonl (hooks were active — most precise)
164
+ * 2. Most recently modified session file in ~/.claude/projects/<repo-key>/
165
+ * (Claude Code always writes transcripts, even without attribution hooks)
166
+ *
167
+ * Strategy 2 ensures that /metrics and /pr have full transcript data (cost, model
168
+ * table, tool counts) even when attribution was installed after the editing session.
169
+ */
158
170
  async function resolveSessionId(repoRoot: string): Promise<string | null> {
171
+ // Strategy 1: tool-usage.jsonl
159
172
  const toolLog = join(repoRoot, ".claude", "logs", "tool-usage.jsonl");
160
- if (!existsSync(toolLog)) return null;
161
- const raw = await readFile(toolLog, "utf8");
162
- const lines = raw.trim().split("\n").filter(Boolean);
163
- const last = lines.at(-1);
164
- if (!last) return null;
173
+ if (existsSync(toolLog)) {
174
+ try {
175
+ const raw = await readFile(toolLog, "utf8");
176
+ const last = raw.trim().split("\n").filter(Boolean).at(-1);
177
+ if (last) {
178
+ const id = (JSON.parse(last) as { session?: string }).session ?? null;
179
+ if (id && SESSION_ID_RE.test(id)) return id;
180
+ }
181
+ } catch {
182
+ // Fall through to strategy 2
183
+ }
184
+ }
185
+
186
+ // Strategy 2: most recently modified top-level session JSONL in ~/.claude/projects/<key>/
187
+ const projectKey = repoRoot.replace(/\//g, "-");
188
+ const transcriptDir = join(homedir(), ".claude", "projects", projectKey);
189
+ if (!existsSync(transcriptDir)) return null;
165
190
  try {
166
- return (JSON.parse(last) as { session?: string }).session ?? null;
191
+ const files = await readdir(transcriptDir);
192
+ const sessionFiles = files.filter(
193
+ (f) => f.endsWith(".jsonl") && SESSION_ID_RE.test(f.slice(0, -6)),
194
+ );
195
+ if (sessionFiles.length === 0) return null;
196
+ // Pick the most recently modified
197
+ const withMtimes = await Promise.all(
198
+ sessionFiles.map(async (f) => ({
199
+ id: f.slice(0, -6),
200
+ mtime: (await stat(join(transcriptDir, f))).mtimeMs,
201
+ })),
202
+ );
203
+ withMtimes.sort((a, b) => b.mtime - a.mtime);
204
+ return withMtimes[0]?.id ?? null;
167
205
  } catch {
168
206
  return null;
169
207
  }
@@ -296,6 +334,26 @@ export async function collectMetrics(
296
334
  toolCounts.set(e.tool, (toolCounts.get(e.tool) ?? 0) + 1);
297
335
  }
298
336
 
337
+ // Fallback: if tool-usage.jsonl had no entries for this session (e.g. attribution was
338
+ // installed after the editing session, or --continue with only Bash calls), read tool
339
+ // counts from the session transcript instead. The transcript is written by Claude Code
340
+ // itself regardless of hook installation, so it always has the full tool call history.
341
+ if (
342
+ toolCounts.size === 0 &&
343
+ skillNames.length === 0 &&
344
+ transcript?.toolCounts
345
+ ) {
346
+ for (const [tool, count] of Object.entries(transcript.toolCounts)) {
347
+ if (tool === "Skill") continue;
348
+ if (!BORING_TOOLS.has(tool)) {
349
+ toolCounts.set(tool, (toolCounts.get(tool) ?? 0) + count);
350
+ }
351
+ }
352
+ if (transcript.skillNames) {
353
+ skillNames.push(...transcript.skillNames);
354
+ }
355
+ }
356
+
299
357
  // Agent counts (SubagentStart events only)
300
358
  const agentCounts = new Map<string, number>();
301
359
  for (const e of agentEntries.filter((e) => e.event === "SubagentStart")) {
@@ -46,6 +46,10 @@ export interface TranscriptResult {
46
46
  aiMinutes: number;
47
47
  /** Minutes the human was active between Claude responses (>30s gaps, <15m). */
48
48
  humanMinutes: number;
49
+ /** Per-tool invocation counts extracted from tool_use content blocks. */
50
+ toolCounts: Record<string, number>;
51
+ /** Skill names invoked (from Skill tool_use blocks with input.skill). */
52
+ skillNames: string[];
49
53
  }
50
54
 
51
55
  interface TranscriptEntry {
@@ -59,6 +63,11 @@ interface TranscriptEntry {
59
63
  cache_creation_input_tokens?: number;
60
64
  cache_read_input_tokens?: number;
61
65
  };
66
+ content?: Array<{
67
+ type?: string;
68
+ name?: string;
69
+ input?: Record<string, unknown>;
70
+ }>;
62
71
  };
63
72
  }
64
73
 
@@ -92,11 +101,15 @@ async function parseTranscriptFile(filePath: string): Promise<{
92
101
  entries: TranscriptEntry[];
93
102
  humanCount: number;
94
103
  timedMessages: TimedMessage[];
104
+ toolCounts: Map<string, number>;
105
+ skillNames: string[];
95
106
  }> {
96
107
  const raw = await readFile(filePath, "utf8");
97
108
  const entries: TranscriptEntry[] = [];
98
109
  let humanCount = 0;
99
110
  const timedMessages: TimedMessage[] = [];
111
+ const toolCounts = new Map<string, number>();
112
+ const skillNames: string[] = [];
100
113
 
101
114
  for (const line of raw.split("\n")) {
102
115
  const trimmed = line.trim();
@@ -109,12 +122,24 @@ async function parseTranscriptFile(filePath: string): Promise<{
109
122
  const ms = new Date(entry.timestamp).getTime();
110
123
  if (!isNaN(ms)) timedMessages.push({ type: entry.type, ts: ms });
111
124
  }
125
+ if (entry.type === "assistant") {
126
+ for (const block of entry.message?.content ?? []) {
127
+ if (block.type !== "tool_use" || !block.name) continue;
128
+ toolCounts.set(block.name, (toolCounts.get(block.name) ?? 0) + 1);
129
+ if (
130
+ block.name === "Skill" &&
131
+ typeof block.input?.skill === "string"
132
+ ) {
133
+ skillNames.push(block.input.skill);
134
+ }
135
+ }
136
+ }
112
137
  } catch {
113
138
  // Skip malformed lines
114
139
  }
115
140
  }
116
141
 
117
- return { entries, humanCount, timedMessages };
142
+ return { entries, humanCount, timedMessages, toolCounts, skillNames };
118
143
  }
119
144
 
120
145
  /**
@@ -222,16 +247,22 @@ export async function parseTranscript(
222
247
  entries: mainEntries,
223
248
  humanCount,
224
249
  timedMessages,
250
+ toolCounts,
251
+ skillNames,
225
252
  } = await parseTranscriptFile(mainFile);
226
253
  const combined = aggregateEntries(mainEntries);
227
254
 
228
- // Merge subagent transcripts (token counts only — exclude from time breakdown)
255
+ // Merge subagent transcripts (token counts + tool counts — exclude from time breakdown)
229
256
  const subagentDir = join(transcriptDir, sessionId, "subagents");
230
257
  if (existsSync(subagentDir)) {
231
258
  for (const file of (await readdir(subagentDir)).filter((f) =>
232
259
  f.endsWith(".jsonl"),
233
260
  )) {
234
- const { entries } = await parseTranscriptFile(join(subagentDir, file));
261
+ const {
262
+ entries,
263
+ toolCounts: subToolCounts,
264
+ skillNames: subSkillNames,
265
+ } = await parseTranscriptFile(join(subagentDir, file));
235
266
  for (const [model, usage] of aggregateEntries(entries)) {
236
267
  const existing = combined.get(model);
237
268
  if (!existing) {
@@ -244,6 +275,10 @@ export async function parseTranscript(
244
275
  existing.cacheReadTokens += usage.cacheReadTokens;
245
276
  }
246
277
  }
278
+ for (const [tool, count] of subToolCounts) {
279
+ toolCounts.set(tool, (toolCounts.get(tool) ?? 0) + count);
280
+ }
281
+ skillNames.push(...subSkillNames);
247
282
  }
248
283
  }
249
284
 
@@ -280,5 +315,7 @@ export async function parseTranscript(
280
315
  activeMinutes: totalMinutes,
281
316
  aiMinutes,
282
317
  humanMinutes,
318
+ toolCounts: Object.fromEntries(toolCounts),
319
+ skillNames,
283
320
  };
284
321
  }
@@ -304,13 +304,23 @@ async function main() {
304
304
  const existingGitignore = existsSync(gitignorePath)
305
305
  ? await readFile(gitignorePath, "utf8")
306
306
  : "";
307
+ const gitignoreEntries: string[] = [];
307
308
  if (!existingGitignore.includes(".claude/logs")) {
309
+ gitignoreEntries.push(".claude/logs/");
310
+ }
311
+ if (!existingGitignore.includes("attribution-state/current-session")) {
312
+ gitignoreEntries.push(".claude/attribution-state/current-session");
313
+ }
314
+ if (!existingGitignore.includes("attribution-state/otel-context")) {
315
+ gitignoreEntries.push(".claude/attribution-state/otel-context.json");
316
+ }
317
+ if (gitignoreEntries.length > 0) {
308
318
  const prefix = existingGitignore.endsWith("\n") ? "" : "\n";
309
319
  await appendFile(
310
320
  gitignorePath,
311
- `${prefix}# claude-attribution tool usage logs\n.claude/logs/\n`,
321
+ `${prefix}# claude-attribution ephemeral state (do not commit)\n${gitignoreEntries.join("\n")}\n`,
312
322
  );
313
- console.log("✓ Added .claude/logs/ to .gitignore");
323
+ console.log(`✓ Added ${gitignoreEntries.length} entries to .gitignore`);
314
324
  }
315
325
 
316
326
  const settingsPath = join(claudeDir, "settings.json");
@@ -454,7 +464,7 @@ async function main() {
454
464
  ? ", .github/workflows/claude-attribution-export.yml"
455
465
  : "";
456
466
  console.log(
457
- `Commit .claude/settings.json, .github/workflows/claude-attribution-pr.yml${exportNote} to share with the team.`,
467
+ `Commit these files to share with the team:\n .claude/settings.json\n .claude/commands/\n .claude/attribution-state/baseline-initialized (if set)\n .claude/attribution-state/installed-version\n .github/workflows/claude-attribution-pr.yml${exportNote}`,
458
468
  );
459
469
  }
460
470
 
@@ -27,6 +27,11 @@ jobs:
27
27
  - name: Fetch attribution notes
28
28
  run: git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
29
29
 
30
+ - name: Configure git identity for notes
31
+ run: |
32
+ git config user.email "claude-attribution[bot]@users.noreply.github.com"
33
+ git config user.name "claude-attribution[bot]"
34
+
30
35
  - uses: oven-sh/setup-bun@v2
31
36
 
32
37
  - name: Install claude-attribution