claude-attribution 1.0.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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared utilities for Claude Code hook scripts.
3
+ *
4
+ * Claude Code invokes each hook script as a subprocess and sends the event
5
+ * payload via stdin as a single JSON object. These helpers handle stdin
6
+ * reading and tool-input field extraction consistently across all hook
7
+ * scripts (pre-tool-use, post-tool-use, subagent, stop).
8
+ *
9
+ * Compatible with bun, tsx, and node — no Bun-specific APIs are used.
10
+ */
11
+
12
+ /**
13
+ * Read all of stdin to a string.
14
+ *
15
+ * Uses the async iterator protocol over `process.stdin`, which works correctly
16
+ * across bun, tsx (Node.js), and plain node without runtime-specific APIs like
17
+ * `Bun.stdin.stream()`.
18
+ */
19
+ export async function readStdin(): Promise<string> {
20
+ const chunks: Buffer[] = [];
21
+ for await (const chunk of process.stdin as AsyncIterable<Buffer>) {
22
+ chunks.push(chunk);
23
+ }
24
+ return Buffer.concat(chunks).toString("utf8");
25
+ }
26
+
27
+ /**
28
+ * The set of Claude Code tools that write file content.
29
+ *
30
+ * These are the tools that trigger checkpoint snapshots in pre-tool-use and
31
+ * post-tool-use hooks. Matches the PreToolUse hook matcher pattern:
32
+ * "Edit|Write|MultiEdit|NotebookEdit"
33
+ */
34
+ export const WRITE_TOOLS = new Set([
35
+ "Edit",
36
+ "Write",
37
+ "MultiEdit",
38
+ "NotebookEdit",
39
+ ]);
40
+
41
+ /**
42
+ * Extract the target file path from a Claude Code tool input payload.
43
+ *
44
+ * Different tools use different field names for the file path argument:
45
+ * - Edit, Write, MultiEdit → `file_path`
46
+ * - NotebookEdit → `notebook_path`
47
+ * - Bash → `path` (not a write tool, included for completeness)
48
+ *
49
+ * Returns `undefined` if no path field is found (e.g., for tools that don't
50
+ * operate on files, like WebSearch or AskUserQuestion).
51
+ */
52
+ export function getFilePath(
53
+ toolInput: Record<string, unknown>,
54
+ ): string | undefined {
55
+ return (
56
+ (toolInput["file_path"] as string | undefined) ??
57
+ (toolInput["path"] as string | undefined) ??
58
+ (toolInput["notebook_path"] as string | undefined)
59
+ );
60
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Claude Code metrics calculator.
3
+ *
4
+ * Usage: claude-attribution metrics [session-id]
5
+ *
6
+ * Reads tool logs, agent activity, transcripts, and git attribution notes,
7
+ * then outputs compact markdown suitable for pasting into a PR description.
8
+ *
9
+ * See src/metrics/collect.ts for data collection and rendering logic.
10
+ */
11
+ import { collectMetrics, renderMetrics } from "./collect.ts";
12
+
13
+ async function main() {
14
+ const data = await collectMetrics(process.argv[2]);
15
+ console.log(renderMetrics(data));
16
+ }
17
+
18
+ main().catch((err) => {
19
+ console.error("Error:", err);
20
+ process.exit(1);
21
+ });
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Shared metrics collection and rendering for calculate.ts and pr.ts.
3
+ *
4
+ * Exports:
5
+ * collectMetrics(sessionIdArg?, repoRoot?) — gathers all data sources
6
+ * renderMetrics(data) — compact markdown for PR descriptions
7
+ */
8
+ import { readFile } from "fs/promises";
9
+ import { existsSync } from "fs";
10
+ import { resolve, join, relative } from "path";
11
+ import { parseTranscript, type TranscriptResult } from "./transcript.ts";
12
+ import {
13
+ listNotes,
14
+ readNote,
15
+ getBranchCommitShas,
16
+ } from "../attribution/git-notes.ts";
17
+ import {
18
+ aggregateTotals,
19
+ type AttributionResult,
20
+ type FileAttribution,
21
+ } from "../attribution/differ.ts";
22
+ import { SESSION_ID_RE } from "../attribution/checkpoint.ts";
23
+
24
+ export interface MetricsData {
25
+ repoRoot: string;
26
+ sessionId: string;
27
+ toolCounts: Map<string, number>;
28
+ agentCounts: Map<string, number>;
29
+ transcript: TranscriptResult | null;
30
+ attributions: AttributionResult[];
31
+ lastSeenByFile: Map<string, FileAttribution>;
32
+ allTranscripts: TranscriptResult[];
33
+ }
34
+
35
+ async function readSessionStart(repoRoot: string): Promise<Date | null> {
36
+ const markerPath = join(
37
+ repoRoot,
38
+ ".claude",
39
+ "attribution-state",
40
+ "session-start",
41
+ );
42
+ if (!existsSync(markerPath)) return null;
43
+ try {
44
+ const raw = await readFile(markerPath, "utf8");
45
+ const marker = JSON.parse(raw) as { timestamp?: string };
46
+ return marker.timestamp ? new Date(marker.timestamp) : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ async function readJsonlForSession(
53
+ filePath: string,
54
+ sessionId: string,
55
+ since?: Date,
56
+ ): Promise<unknown[]> {
57
+ if (!existsSync(filePath)) return [];
58
+ const raw = await readFile(filePath, "utf8");
59
+ const results: unknown[] = [];
60
+ for (const line of raw.split("\n")) {
61
+ if (!line.trim()) continue;
62
+ try {
63
+ const entry = JSON.parse(line) as {
64
+ session?: string;
65
+ timestamp?: string;
66
+ };
67
+ if (entry.session !== sessionId) continue;
68
+ if (since && entry.timestamp && new Date(entry.timestamp) < since)
69
+ continue;
70
+ results.push(entry);
71
+ } catch {
72
+ // Skip malformed lines
73
+ }
74
+ }
75
+ return results;
76
+ }
77
+
78
+ async function resolveSessionId(repoRoot: string): Promise<string | null> {
79
+ const toolLog = join(repoRoot, ".claude", "logs", "tool-usage.jsonl");
80
+ if (!existsSync(toolLog)) return null;
81
+ const raw = await readFile(toolLog, "utf8");
82
+ const lines = raw.trim().split("\n").filter(Boolean);
83
+ const last = lines.at(-1);
84
+ if (!last) return null;
85
+ try {
86
+ return (JSON.parse(last) as { session?: string }).session ?? null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ async function getBranchAttribution(
93
+ repoRoot: string,
94
+ sessionStart: Date | null,
95
+ ): Promise<AttributionResult[]> {
96
+ const [allShas, branchShas] = await Promise.all([
97
+ listNotes(repoRoot),
98
+ getBranchCommitShas(repoRoot),
99
+ ]);
100
+ const branchSet = new Set(branchShas);
101
+ const shasToRead =
102
+ branchShas.length > 0
103
+ ? allShas.filter((sha) => branchSet.has(sha))
104
+ : allShas;
105
+
106
+ const CONCURRENCY = 8;
107
+ const notes: Awaited<ReturnType<typeof readNote>>[] = [];
108
+ for (let i = 0; i < shasToRead.length; i += CONCURRENCY) {
109
+ const batch = shasToRead.slice(i, i + CONCURRENCY);
110
+ const batchResults = await Promise.all(
111
+ batch.map((sha) => readNote(repoRoot, sha)),
112
+ );
113
+ notes.push(...batchResults);
114
+ }
115
+ const results = notes.filter((n): n is AttributionResult => n !== null);
116
+ if (sessionStart) {
117
+ return results.filter((r) => new Date(r.timestamp) >= sessionStart);
118
+ }
119
+ return results;
120
+ }
121
+
122
+ export function kFormat(n: number): string {
123
+ return n >= 1000 ? `${Math.floor(n / 1000)}K` : String(n);
124
+ }
125
+
126
+ export async function collectMetrics(
127
+ sessionIdArg?: string,
128
+ repoRoot?: string,
129
+ ): Promise<MetricsData> {
130
+ const root = repoRoot ?? resolve(process.cwd());
131
+ const logDir = join(root, ".claude", "logs");
132
+
133
+ const sessionId = sessionIdArg ?? (await resolveSessionId(root));
134
+ if (!sessionId) {
135
+ console.error(
136
+ "Error: No session ID found. Logs may be empty or not yet generated.",
137
+ );
138
+ console.error("Usage: claude-attribution metrics [session-id]");
139
+ process.exit(1);
140
+ }
141
+
142
+ const sessionStart = await readSessionStart(root);
143
+
144
+ const [toolEntries, agentEntries, transcript, attributions] =
145
+ await Promise.all([
146
+ readJsonlForSession(
147
+ join(logDir, "tool-usage.jsonl"),
148
+ sessionId,
149
+ sessionStart ?? undefined,
150
+ ) as Promise<{ tool?: string }[]>,
151
+ readJsonlForSession(
152
+ join(logDir, "agent-activity.jsonl"),
153
+ sessionId,
154
+ sessionStart ?? undefined,
155
+ ) as Promise<{ subagentType?: string; event?: string }[]>,
156
+ parseTranscript(sessionId, root),
157
+ getBranchAttribution(root, sessionStart),
158
+ ]);
159
+
160
+ // Tool counts
161
+ const toolCounts = new Map<string, number>();
162
+ for (const e of toolEntries) {
163
+ if (e.tool) toolCounts.set(e.tool, (toolCounts.get(e.tool) ?? 0) + 1);
164
+ }
165
+
166
+ // Agent counts (SubagentStart events only)
167
+ const agentCounts = new Map<string, number>();
168
+ for (const e of agentEntries.filter((e) => e.event === "SubagentStart")) {
169
+ const t = e.subagentType ?? "unknown";
170
+ agentCounts.set(t, (agentCounts.get(t) ?? 0) + 1);
171
+ }
172
+
173
+ // Last-wins per file for attribution
174
+ const sorted = [...attributions].sort(
175
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
176
+ );
177
+ const lastSeenByFile = new Map<string, FileAttribution>();
178
+ for (const result of sorted) {
179
+ for (const file of result.files) {
180
+ lastSeenByFile.set(file.path, file);
181
+ }
182
+ }
183
+
184
+ // Multi-session rollup: collect all unique session IDs from branch commits
185
+ const branchSessionIds = new Set(
186
+ attributions
187
+ .map((r) => r.session)
188
+ .filter((s): s is string => s !== null && SESSION_ID_RE.test(s)),
189
+ );
190
+ if (SESSION_ID_RE.test(sessionId)) branchSessionIds.add(sessionId);
191
+ branchSessionIds.delete(sessionId);
192
+
193
+ let allTranscripts: TranscriptResult[] = transcript ? [transcript] : [];
194
+ if (branchSessionIds.size > 0) {
195
+ const otherIds = [...branchSessionIds].filter((id) =>
196
+ SESSION_ID_RE.test(id),
197
+ );
198
+ const otherTranscripts = (
199
+ await Promise.all(otherIds.map((id) => parseTranscript(id, root)))
200
+ ).filter((t): t is TranscriptResult => t !== null);
201
+ allTranscripts = [...allTranscripts, ...otherTranscripts];
202
+ }
203
+
204
+ return {
205
+ repoRoot: root,
206
+ sessionId,
207
+ toolCounts,
208
+ agentCounts,
209
+ transcript,
210
+ attributions,
211
+ lastSeenByFile,
212
+ allTranscripts,
213
+ };
214
+ }
215
+
216
+ export function renderMetrics(data: MetricsData): string {
217
+ const {
218
+ repoRoot,
219
+ toolCounts,
220
+ agentCounts,
221
+ transcript,
222
+ lastSeenByFile,
223
+ allTranscripts,
224
+ } = data;
225
+
226
+ const lines: string[] = [];
227
+ const out = (s = "") => lines.push(s);
228
+
229
+ out("## Claude Code Metrics");
230
+ out();
231
+
232
+ // Headline: AI% + active time (most important stat, shown first)
233
+ const allFileStats = [...lastSeenByFile.values()];
234
+ const hasAttribution = allFileStats.length > 0;
235
+ if (hasAttribution) {
236
+ const { ai, total, pctAi } = aggregateTotals(allFileStats);
237
+ const activePart =
238
+ transcript && transcript.activeMinutes > 0
239
+ ? ` · Active: ${transcript.activeMinutes}m`
240
+ : "";
241
+ out(
242
+ `**AI contribution: ~${pctAi}%** (${ai} of ${total} committed lines)${activePart}`,
243
+ );
244
+ out();
245
+ } else if (transcript && transcript.activeMinutes > 0) {
246
+ out(`**Active session time:** ${transcript.activeMinutes}m`);
247
+ out();
248
+ }
249
+
250
+ // Model usage table
251
+ if (transcript) {
252
+ out("| Model | Calls | Input | Output | Cache |");
253
+ out("|-------|-------|-------|--------|-------|");
254
+ for (const m of transcript.byModel) {
255
+ const cache = m.cacheCreationTokens + m.cacheReadTokens;
256
+ out(
257
+ `| ${m.modelShort} | ${m.calls} | ${kFormat(m.inputTokens)} | ${kFormat(m.outputTokens)} | ${kFormat(cache)} |`,
258
+ );
259
+ }
260
+ const { totals: t } = transcript;
261
+ const totalCache = t.totalCacheCreationTokens + t.totalCacheReadTokens;
262
+ out(
263
+ `| **Total** | ${t.totalCalls} | ${kFormat(t.totalInputTokens)} | ${kFormat(t.totalOutputTokens)} | ${kFormat(totalCache)} |`,
264
+ );
265
+ out();
266
+ out(`**Human prompts (steering effort):** ${transcript.humanPromptCount}`);
267
+ out();
268
+ }
269
+
270
+ // Multi-session rollup (shown when multiple Claude sessions contributed)
271
+ if (allTranscripts.length > 1) {
272
+ out(`### All Sessions on This Branch (${allTranscripts.length} sessions)`);
273
+ out();
274
+ const agg = allTranscripts.reduce(
275
+ (acc, t) => ({
276
+ totalCalls: acc.totalCalls + t.totals.totalCalls,
277
+ totalInputTokens: acc.totalInputTokens + t.totals.totalInputTokens,
278
+ totalOutputTokens: acc.totalOutputTokens + t.totals.totalOutputTokens,
279
+ totalCacheTokens:
280
+ acc.totalCacheTokens +
281
+ t.totals.totalCacheCreationTokens +
282
+ t.totals.totalCacheReadTokens,
283
+ humanPromptCount: acc.humanPromptCount + t.humanPromptCount,
284
+ activeMinutes: acc.activeMinutes + t.activeMinutes,
285
+ }),
286
+ {
287
+ totalCalls: 0,
288
+ totalInputTokens: 0,
289
+ totalOutputTokens: 0,
290
+ totalCacheTokens: 0,
291
+ humanPromptCount: 0,
292
+ activeMinutes: 0,
293
+ },
294
+ );
295
+ out("| API Calls | Input Tokens | Output Tokens | Cache Tokens |");
296
+ out("|-----------|--------------|---------------|--------------|");
297
+ out(
298
+ `| ${agg.totalCalls} | ${kFormat(agg.totalInputTokens)} | ${kFormat(agg.totalOutputTokens)} | ${kFormat(agg.totalCacheTokens)} |`,
299
+ );
300
+ out();
301
+ out(`**Total human prompts:** ${agg.humanPromptCount}`);
302
+ if (agg.activeMinutes > 0) {
303
+ out(`**Total active session time:** ${agg.activeMinutes}m`);
304
+ }
305
+ out();
306
+ }
307
+
308
+ // <details> block — tools, agents, per-file breakdown
309
+ const claudeFiles = [...lastSeenByFile.entries()].filter(
310
+ ([, stats]) => stats.ai > 0 || stats.mixed > 0,
311
+ );
312
+ const hasTools = toolCounts.size > 0;
313
+ const hasAgents = agentCounts.size > 0;
314
+ const hasFiles = claudeFiles.length > 0;
315
+
316
+ const summaryParts: string[] = [];
317
+ if (hasTools) summaryParts.push("Tools");
318
+ if (hasAgents) summaryParts.push("Agents");
319
+ if (hasFiles) summaryParts.push("Files");
320
+ if (summaryParts.length === 0) summaryParts.push("Details");
321
+
322
+ out("<details>");
323
+ out(`<summary>${summaryParts.join(" · ")}</summary>`);
324
+ out();
325
+
326
+ if (hasTools) {
327
+ const toolLine = [...toolCounts.entries()]
328
+ .sort((a, b) => b[1] - a[1])
329
+ .map(([tool, count]) => `${tool} ×${count}`)
330
+ .join(", ");
331
+ out(`**Tools:** ${toolLine}`);
332
+ out();
333
+ } else {
334
+ out("_No tool usage logs found_");
335
+ out();
336
+ }
337
+
338
+ if (hasAgents) {
339
+ const agentLine = [...agentCounts.entries()]
340
+ .sort((a, b) => b[1] - a[1])
341
+ .map(([agent, count]) => `${agent} ×${count}`)
342
+ .join(", ");
343
+ out(`**Agents:** ${agentLine}`);
344
+ out();
345
+ }
346
+
347
+ if (hasFiles) {
348
+ out("#### Files");
349
+ out();
350
+ for (const [path, stats] of claudeFiles.sort()) {
351
+ const filePct =
352
+ stats.total > 0 ? Math.round((stats.ai / stats.total) * 100) : 0;
353
+ const relPath = relative(repoRoot, join(repoRoot, path));
354
+ out(`- \`${relPath}\` — ${filePct}% AI (${stats.ai} lines)`);
355
+ }
356
+ out();
357
+ } else if (!hasAttribution) {
358
+ out("_No attribution data found._");
359
+ out(
360
+ "_Attribution is recorded per-commit by the post-commit hook — ensure claude-attribution is installed._",
361
+ );
362
+ out();
363
+ }
364
+
365
+ out("</details>");
366
+ out();
367
+
368
+ return lines.join("\n");
369
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Mark the start of a new work session for a Jira ticket or feature branch.
3
+ *
4
+ * Usage: <attribution-root>/src/run.sh <attribution-root>/src/metrics/mark-start.ts
5
+ * Or via the /start slash command installed by install.ts.
6
+ *
7
+ * Writes a JSON marker to .claude/attribution-state/session-start:
8
+ * { "timestamp": "<ISO8601>", "sessionId": "<string | null>" }
9
+ *
10
+ * The /metrics command uses this marker to filter tool usage, token counts,
11
+ * and attribution notes to only activity after this timestamp. This prevents
12
+ * metrics from being inflated by work done on previous Jira tickets in the
13
+ * same long-running Claude Code session.
14
+ *
15
+ * Run this when you check out a new feature branch or start a new ticket.
16
+ */
17
+ import { writeFile, mkdir } from "fs/promises";
18
+ import { resolve, join } from "path";
19
+
20
+ async function main() {
21
+ const repoRoot = resolve(process.cwd());
22
+ const stateDir = join(repoRoot, ".claude", "attribution-state");
23
+ await mkdir(stateDir, { recursive: true });
24
+
25
+ const marker = {
26
+ timestamp: new Date().toISOString(),
27
+ sessionId: process.env["CLAUDE_SESSION_ID"] ?? null,
28
+ };
29
+
30
+ await writeFile(join(stateDir, "session-start"), JSON.stringify(marker));
31
+ console.log(`Session start marked at ${marker.timestamp}`);
32
+ console.log(
33
+ "Run /metrics after your PR is ready to generate scoped metrics.",
34
+ );
35
+ }
36
+
37
+ main().catch((err) => {
38
+ console.error("Error:", err);
39
+ process.exit(1);
40
+ });