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 +5 -0
- package/package.json +1 -1
- package/src/attribution/commit.ts +59 -18
- package/src/hooks/post-bash.ts +1 -1
- package/src/metrics/collect.ts +65 -7
- package/src/metrics/transcript.ts +40 -3
- package/src/setup/install.ts +13 -3
- package/src/setup/templates/claude-attribution-gha.yml +5 -0
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
|
@@ -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
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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)
|
package/src/hooks/post-bash.ts
CHANGED
|
@@ -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", [
|
package/src/metrics/collect.ts
CHANGED
|
@@ -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 (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
}
|
package/src/setup/install.ts
CHANGED
|
@@ -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
|
|
321
|
+
`${prefix}# claude-attribution ephemeral state (do not commit)\n${gitignoreEntries.join("\n")}\n`,
|
|
312
322
|
);
|
|
313
|
-
console.log(
|
|
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
|
|
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
|