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,245 @@
1
+ /**
2
+ * Parse Claude Code transcript files for token and model usage.
3
+ *
4
+ * Claude Code writes JSONL transcript files to:
5
+ * ~/.claude/projects/<project-key>/<session-id>.jsonl
6
+ *
7
+ * where <project-key> is the absolute repo path with '/' replaced by '-'.
8
+ * Each line is a JSON object with `type: "human" | "assistant" | ...`.
9
+ * Assistant messages include `message.model` and `message.usage` (token counts).
10
+ *
11
+ * Subagent transcripts are stored in:
12
+ * ~/.claude/projects/<project-key>/<session-id>/subagents/<agent-id>.jsonl
13
+ *
14
+ * This module reads both main and subagent transcripts, merges them by model,
15
+ * and returns aggregated token/model usage + human prompt count.
16
+ */
17
+ import { readFile, readdir } from "fs/promises";
18
+ import { existsSync } from "fs";
19
+ import { join, resolve } from "path";
20
+ import { homedir } from "os";
21
+
22
+ export interface ModelUsage {
23
+ modelShort: "Opus" | "Sonnet" | "Haiku" | "Unknown";
24
+ modelFull: string;
25
+ calls: number;
26
+ inputTokens: number;
27
+ outputTokens: number;
28
+ cacheCreationTokens: number;
29
+ cacheReadTokens: number;
30
+ }
31
+
32
+ export interface TranscriptResult {
33
+ sessionId: string;
34
+ byModel: ModelUsage[];
35
+ totals: {
36
+ totalCalls: number;
37
+ totalInputTokens: number;
38
+ totalOutputTokens: number;
39
+ totalCacheCreationTokens: number;
40
+ totalCacheReadTokens: number;
41
+ };
42
+ humanPromptCount: number;
43
+ /** Active session time in minutes (idle gaps >15 min are excluded). */
44
+ activeMinutes: number;
45
+ }
46
+
47
+ interface TranscriptEntry {
48
+ type?: string;
49
+ timestamp?: string;
50
+ message?: {
51
+ model?: string;
52
+ usage?: {
53
+ input_tokens?: number;
54
+ output_tokens?: number;
55
+ cache_creation_input_tokens?: number;
56
+ cache_read_input_tokens?: number;
57
+ };
58
+ };
59
+ }
60
+
61
+ function modelShort(full: string): ModelUsage["modelShort"] {
62
+ if (/opus/i.test(full)) return "Opus";
63
+ if (/sonnet/i.test(full)) return "Sonnet";
64
+ if (/haiku/i.test(full)) return "Haiku";
65
+ return "Unknown";
66
+ }
67
+
68
+ /**
69
+ * Derive the Claude Code projects directory key from an absolute repo path.
70
+ *
71
+ * Claude Code stores transcripts at `~/.claude/projects/<key>/` where <key>
72
+ * is the absolute path with every '/' replaced by '-'. For example:
73
+ * /Users/alice/Code/my-repo → -Users-alice-Code-my-repo
74
+ *
75
+ * This key is used to locate the transcript JSONL file for a given session.
76
+ */
77
+ function projectKey(repoRoot: string): string {
78
+ // Claude Code replaces '/' with '-' in the path
79
+ return repoRoot.replace(/\//g, "-");
80
+ }
81
+
82
+ async function parseTranscriptFile(filePath: string): Promise<{
83
+ entries: TranscriptEntry[];
84
+ humanCount: number;
85
+ timestamps: number[];
86
+ }> {
87
+ const raw = await readFile(filePath, "utf8");
88
+ const entries: TranscriptEntry[] = [];
89
+ let humanCount = 0;
90
+ const timestamps: number[] = [];
91
+
92
+ for (const line of raw.split("\n")) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed) continue;
95
+ try {
96
+ const entry = JSON.parse(trimmed) as TranscriptEntry;
97
+ entries.push(entry);
98
+ if (entry.type === "human") humanCount++;
99
+ if (entry.timestamp) {
100
+ const ms = new Date(entry.timestamp).getTime();
101
+ if (!isNaN(ms)) timestamps.push(ms);
102
+ }
103
+ } catch {
104
+ // Skip malformed lines
105
+ }
106
+ }
107
+
108
+ return { entries, humanCount, timestamps };
109
+ }
110
+
111
+ /**
112
+ * Compute active session time in minutes from a sorted list of timestamps.
113
+ *
114
+ * Sums consecutive gaps only when the gap is under 15 minutes (900_000ms).
115
+ * Gaps of 15+ minutes are treated as idle (away from keyboard, blocked on CI,
116
+ * etc.) and excluded so they don't inflate the active time metric.
117
+ */
118
+ function computeActiveMinutes(allTimestamps: number[]): number {
119
+ const sorted = [...allTimestamps].sort((a, b) => a - b);
120
+ let totalMs = 0;
121
+ const IDLE_THRESHOLD_MS = 900_000; // 15 minutes
122
+ for (let i = 1; i < sorted.length; i++) {
123
+ const gap = sorted[i] - sorted[i - 1];
124
+ if (gap < IDLE_THRESHOLD_MS) totalMs += gap;
125
+ }
126
+ return Math.round(totalMs / 60_000);
127
+ }
128
+
129
+ function aggregateEntries(entries: TranscriptEntry[]): Map<string, ModelUsage> {
130
+ const byModel = new Map<string, ModelUsage>();
131
+
132
+ for (const entry of entries) {
133
+ if (entry.type !== "assistant") continue;
134
+ const model = entry.message?.model;
135
+ const usage = entry.message?.usage;
136
+ if (!model || !usage) continue;
137
+
138
+ const existing = byModel.get(model) ?? {
139
+ modelShort: modelShort(model),
140
+ modelFull: model,
141
+ calls: 0,
142
+ inputTokens: 0,
143
+ outputTokens: 0,
144
+ cacheCreationTokens: 0,
145
+ cacheReadTokens: 0,
146
+ };
147
+
148
+ existing.calls++;
149
+ existing.inputTokens += usage.input_tokens ?? 0;
150
+ existing.outputTokens += usage.output_tokens ?? 0;
151
+ existing.cacheCreationTokens += usage.cache_creation_input_tokens ?? 0;
152
+ existing.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
153
+
154
+ byModel.set(model, existing);
155
+ }
156
+
157
+ return byModel;
158
+ }
159
+
160
+ /**
161
+ * Parse transcript data for a session, merging main and subagent token usage.
162
+ *
163
+ * Reads the main session transcript and all subagent transcripts from the
164
+ * ~/.claude/projects/<key>/ directory structure. Aggregates token counts by model
165
+ * (Opus / Sonnet / Haiku / Unknown) and counts human prompt turns.
166
+ *
167
+ * Human prompt count is used as a proxy for "steering effort" in the /metrics output
168
+ * — it represents how many times the developer had to direct Claude.
169
+ *
170
+ * Returns null if the session transcript file doesn't exist (session not found).
171
+ */
172
+ export async function parseTranscript(
173
+ sessionId: string,
174
+ repoRoot?: string,
175
+ ): Promise<TranscriptResult | null> {
176
+ const root = repoRoot ?? resolve(process.cwd());
177
+ const key = projectKey(root);
178
+ const transcriptDir = join(homedir(), ".claude", "projects", key);
179
+ const mainFile = join(transcriptDir, `${sessionId}.jsonl`);
180
+
181
+ if (!existsSync(mainFile)) return null;
182
+
183
+ const {
184
+ entries: mainEntries,
185
+ humanCount,
186
+ timestamps: mainTimestamps,
187
+ } = await parseTranscriptFile(mainFile);
188
+ const combined = aggregateEntries(mainEntries);
189
+ const allTimestamps: number[] = [...mainTimestamps];
190
+
191
+ // Merge subagent transcripts
192
+ const subagentDir = join(transcriptDir, sessionId, "subagents");
193
+ if (existsSync(subagentDir)) {
194
+ for (const file of (await readdir(subagentDir)).filter((f) =>
195
+ f.endsWith(".jsonl"),
196
+ )) {
197
+ const { entries, timestamps } = await parseTranscriptFile(
198
+ join(subagentDir, file),
199
+ );
200
+ allTimestamps.push(...timestamps);
201
+ for (const [model, usage] of aggregateEntries(entries)) {
202
+ const existing = combined.get(model);
203
+ if (!existing) {
204
+ combined.set(model, usage);
205
+ } else {
206
+ existing.calls += usage.calls;
207
+ existing.inputTokens += usage.inputTokens;
208
+ existing.outputTokens += usage.outputTokens;
209
+ existing.cacheCreationTokens += usage.cacheCreationTokens;
210
+ existing.cacheReadTokens += usage.cacheReadTokens;
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ const byModel = [...combined.values()].sort((a, b) =>
217
+ a.modelShort.localeCompare(b.modelShort),
218
+ );
219
+
220
+ const totals = byModel.reduce(
221
+ (acc, m) => ({
222
+ totalCalls: acc.totalCalls + m.calls,
223
+ totalInputTokens: acc.totalInputTokens + m.inputTokens,
224
+ totalOutputTokens: acc.totalOutputTokens + m.outputTokens,
225
+ totalCacheCreationTokens:
226
+ acc.totalCacheCreationTokens + m.cacheCreationTokens,
227
+ totalCacheReadTokens: acc.totalCacheReadTokens + m.cacheReadTokens,
228
+ }),
229
+ {
230
+ totalCalls: 0,
231
+ totalInputTokens: 0,
232
+ totalOutputTokens: 0,
233
+ totalCacheCreationTokens: 0,
234
+ totalCacheReadTokens: 0,
235
+ },
236
+ );
237
+
238
+ return {
239
+ sessionId,
240
+ byModel,
241
+ totals,
242
+ humanPromptCount: humanCount,
243
+ activeMinutes: computeActiveMinutes(allTimestamps),
244
+ };
245
+ }
package/src/run.sh ADDED
@@ -0,0 +1,25 @@
1
+ #!/bin/sh
2
+ # TypeScript runtime detector: try bun, then tsx, then npx tsx (no install required).
3
+ #
4
+ # Caches the detected runtime to /tmp/claude-attribution-runtime so that the
5
+ # `command -v` probe only runs once per machine reboot instead of on every hook
6
+ # invocation (hooks run on every Claude Code tool call).
7
+ #
8
+ # To force re-detection (e.g., after installing bun): rm /tmp/claude-attribution-runtime
9
+ CACHE="/tmp/claude-attribution-runtime"
10
+ if [ ! -f "$CACHE" ]; then
11
+ if command -v bun >/dev/null 2>&1; then
12
+ echo "bun" > "$CACHE"
13
+ elif command -v tsx >/dev/null 2>&1; then
14
+ echo "tsx" > "$CACHE"
15
+ else
16
+ echo "npx_tsx" > "$CACHE"
17
+ fi
18
+ fi
19
+ RUNTIME=$(cat "$CACHE")
20
+ case "$RUNTIME" in
21
+ bun) exec bun "$@" ;;
22
+ tsx) exec tsx "$@" ;;
23
+ npx_tsx) exec npx --yes tsx "$@" ;;
24
+ *) exec bun "$@" ;;
25
+ esac
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Installer — run once per repo to add claude-attribution hooks.
3
+ *
4
+ * Usage: bun src/setup/install.ts [/path/to/target-repo]
5
+ *
6
+ * If no path is given, installs into the current working directory.
7
+ */
8
+ import {
9
+ readFile,
10
+ writeFile,
11
+ appendFile,
12
+ copyFile,
13
+ chmod,
14
+ mkdir,
15
+ } from "fs/promises";
16
+ import { existsSync } from "fs";
17
+ import { execFile } from "child_process";
18
+ import { promisify } from "util";
19
+ import { resolve, join, dirname } from "path";
20
+ import { fileURLToPath } from "url";
21
+
22
+ const execFileAsync = promisify(execFile);
23
+
24
+ const ATTRIBUTION_ROOT = resolve(
25
+ dirname(fileURLToPath(import.meta.url)),
26
+ "..",
27
+ "..",
28
+ );
29
+ const CLI_BIN = resolve(ATTRIBUTION_ROOT, "bin", "claude-attribution");
30
+
31
+ interface HookEntry {
32
+ matcher: string;
33
+ hooks: Array<{ type: string; command: string }>;
34
+ }
35
+
36
+ type HooksConfig = Record<string, HookEntry[]>;
37
+
38
+ interface ClaudeSettings {
39
+ hooks?: HooksConfig;
40
+ [key: string]: unknown;
41
+ }
42
+
43
+ async function mergeHooks(
44
+ settingsPath: string,
45
+ newHooks: HooksConfig,
46
+ ): Promise<void> {
47
+ let settings: ClaudeSettings = {};
48
+ if (existsSync(settingsPath)) {
49
+ const raw = await readFile(settingsPath, "utf8");
50
+ settings = JSON.parse(raw) as ClaudeSettings;
51
+ }
52
+
53
+ const existing = settings.hooks ?? {};
54
+
55
+ for (const [event, entries] of Object.entries(newHooks)) {
56
+ const current = existing[event] ?? [];
57
+ // Remove any existing claude-attribution entries, then push all new ones
58
+ const filtered = current.filter(
59
+ (e) => !e.hooks.some((h) => h.command.includes("claude-attribution")),
60
+ );
61
+ filtered.push(...entries);
62
+ existing[event] = filtered;
63
+ }
64
+
65
+ settings.hooks = existing;
66
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
67
+ }
68
+
69
+ /**
70
+ * Detect whether the repo uses Husky or Lefthook to manage git hooks.
71
+ * These tools own .git/hooks/ themselves — we must not write there directly.
72
+ * Instead we register via their config files.
73
+ */
74
+ function detectHookManager(repoRoot: string): "husky" | "lefthook" | "none" {
75
+ if (existsSync(join(repoRoot, ".husky"))) return "husky";
76
+ if (
77
+ existsSync(join(repoRoot, "lefthook.yml")) ||
78
+ existsSync(join(repoRoot, "lefthook.yaml")) ||
79
+ existsSync(join(repoRoot, "lefthook.json")) ||
80
+ existsSync(join(repoRoot, ".lefthook.yml")) ||
81
+ existsSync(join(repoRoot, ".lefthook.json"))
82
+ )
83
+ return "lefthook";
84
+ return "none";
85
+ }
86
+
87
+ async function installPrePushHook(repoRoot: string): Promise<void> {
88
+ const manager = detectHookManager(repoRoot);
89
+ const runLine = `"${CLI_BIN}" hook pre-push || true`;
90
+
91
+ if (manager === "husky") {
92
+ const huskyHook = join(repoRoot, ".husky", "pre-push");
93
+ if (existsSync(huskyHook)) {
94
+ const existing = await readFile(huskyHook, "utf8");
95
+ if (!existing.includes("claude-attribution")) {
96
+ await writeFile(
97
+ huskyHook,
98
+ existing.trimEnd() + "\n\n# claude-attribution\n" + runLine + "\n",
99
+ );
100
+ console.log(" (appended to existing .husky/pre-push)");
101
+ }
102
+ } else {
103
+ await writeFile(
104
+ huskyHook,
105
+ `#!/bin/sh\n# claude-attribution\n${runLine}\n`,
106
+ );
107
+ await chmod(huskyHook, 0o755);
108
+ }
109
+ return;
110
+ }
111
+
112
+ if (manager === "lefthook") {
113
+ console.log("");
114
+ console.log(" ⚠️ Lefthook detected. Add this to lefthook.yml manually:");
115
+ console.log(" pre-push:");
116
+ console.log(" commands:");
117
+ console.log(" claude-attribution:");
118
+ console.log(` run: ${runLine}`);
119
+ console.log("");
120
+ return;
121
+ }
122
+
123
+ // Plain git hooks
124
+ const hookDest = join(repoRoot, ".git", "hooks", "pre-push");
125
+ const rawTemplate = await readFile(
126
+ join(ATTRIBUTION_ROOT, "src", "setup", "templates", "pre-push.sh"),
127
+ "utf8",
128
+ );
129
+ const template = rawTemplate.replace(/\{\{CLI_BIN\}\}/g, () => CLI_BIN);
130
+
131
+ if (existsSync(hookDest)) {
132
+ const existing = await readFile(hookDest, "utf8");
133
+ if (!existing.includes("claude-attribution")) {
134
+ await writeFile(
135
+ hookDest,
136
+ existing.trimEnd() + "\n\n# claude-attribution\n" + template,
137
+ );
138
+ await chmod(hookDest, 0o755);
139
+ return;
140
+ }
141
+ // Already ours — replace
142
+ }
143
+
144
+ await writeFile(hookDest, template);
145
+ await chmod(hookDest, 0o755);
146
+ }
147
+
148
+ async function installGitHook(repoRoot: string): Promise<void> {
149
+ const manager = detectHookManager(repoRoot);
150
+ const runLine = `"${CLI_BIN}" hook post-commit || true`;
151
+
152
+ if (manager === "husky") {
153
+ // Husky: add post-commit file to .husky/ directory
154
+ const huskyHook = join(repoRoot, ".husky", "post-commit");
155
+ if (existsSync(huskyHook)) {
156
+ const existing = await readFile(huskyHook, "utf8");
157
+ if (!existing.includes("claude-attribution")) {
158
+ await writeFile(
159
+ huskyHook,
160
+ existing.trimEnd() + "\n\n# claude-attribution\n" + runLine + "\n",
161
+ );
162
+ console.log(" (appended to existing .husky/post-commit)");
163
+ }
164
+ } else {
165
+ await writeFile(
166
+ huskyHook,
167
+ `#!/bin/sh\n# claude-attribution\n${runLine}\n`,
168
+ );
169
+ await chmod(huskyHook, 0o755);
170
+ }
171
+ return;
172
+ }
173
+
174
+ if (manager === "lefthook") {
175
+ // Lefthook: instruct the user — auto-editing YAML is fragile
176
+ console.log("");
177
+ console.log(" ⚠️ Lefthook detected. Add this to lefthook.yml manually:");
178
+ console.log(" post-commit:");
179
+ console.log(" commands:");
180
+ console.log(" claude-attribution:");
181
+ console.log(` run: ${runLine}`);
182
+ console.log("");
183
+ return;
184
+ }
185
+
186
+ // Plain git hooks
187
+ const hookDest = join(repoRoot, ".git", "hooks", "post-commit");
188
+ const template = await readFile(
189
+ join(ATTRIBUTION_ROOT, "src", "setup", "templates", "post-commit.sh"),
190
+ "utf8",
191
+ );
192
+ const newContent = template.replace(/\{\{CLI_BIN\}\}/g, () => CLI_BIN);
193
+
194
+ if (existsSync(hookDest)) {
195
+ const existing = await readFile(hookDest, "utf8");
196
+ if (!existing.includes("claude-attribution")) {
197
+ await writeFile(
198
+ hookDest,
199
+ existing.trimEnd() + "\n\n# claude-attribution\n" + newContent,
200
+ );
201
+ await chmod(hookDest, 0o755);
202
+ return;
203
+ }
204
+ // Already ours — replace
205
+ }
206
+
207
+ await writeFile(hookDest, newContent);
208
+ await chmod(hookDest, 0o755);
209
+ }
210
+
211
+ async function main() {
212
+ const targetRepo = resolve(process.argv[2] ?? process.cwd());
213
+
214
+ if (!existsSync(join(targetRepo, ".git"))) {
215
+ console.error(`Error: ${targetRepo} is not a git repository`);
216
+ process.exit(1);
217
+ }
218
+
219
+ console.log(`Installing claude-attribution into: ${targetRepo}`);
220
+ console.log(`CLI binary: ${CLI_BIN}`);
221
+
222
+ // 1. Merge hooks into .claude/settings.json
223
+ const claudeDir = join(targetRepo, ".claude");
224
+ await mkdir(claudeDir, { recursive: true });
225
+ await mkdir(join(claudeDir, "attribution-state"), { recursive: true });
226
+ await mkdir(join(claudeDir, "logs", "sessions"), { recursive: true });
227
+
228
+ // Ensure .claude/logs/ is gitignored — tool usage logs contain session data
229
+ // that shouldn't land in version control.
230
+ const gitignorePath = join(targetRepo, ".gitignore");
231
+ const existingGitignore = existsSync(gitignorePath)
232
+ ? await readFile(gitignorePath, "utf8")
233
+ : "";
234
+ if (!existingGitignore.includes(".claude/logs")) {
235
+ const prefix = existingGitignore.endsWith("\n") ? "" : "\n";
236
+ await appendFile(
237
+ gitignorePath,
238
+ `${prefix}# claude-attribution tool usage logs\n.claude/logs/\n`,
239
+ );
240
+ console.log("✓ Added .claude/logs/ to .gitignore");
241
+ }
242
+
243
+ const settingsPath = join(claudeDir, "settings.json");
244
+ const hooksTemplate = await readFile(
245
+ join(ATTRIBUTION_ROOT, "src", "setup", "templates", "hooks.json"),
246
+ "utf8",
247
+ );
248
+ const hooksConfig = JSON.parse(
249
+ hooksTemplate.replace(/\{\{CLI_BIN\}\}/g, () => CLI_BIN),
250
+ ) as HooksConfig;
251
+
252
+ await mergeHooks(settingsPath, hooksConfig);
253
+ console.log("✓ Merged hooks into .claude/settings.json");
254
+
255
+ // 2. Install post-commit and pre-push git hooks
256
+ await installGitHook(targetRepo);
257
+ console.log("✓ Installed .git/hooks/post-commit");
258
+ await installPrePushHook(targetRepo);
259
+ console.log(
260
+ "✓ Installed .git/hooks/pre-push (pushes attribution notes to origin)",
261
+ );
262
+
263
+ // Ensure git push also pushes notes by default
264
+ try {
265
+ await execFileAsync(
266
+ "git",
267
+ [
268
+ "config",
269
+ "--add",
270
+ "remote.origin.push",
271
+ "refs/notes/claude-attribution:refs/notes/claude-attribution",
272
+ ],
273
+ { cwd: targetRepo },
274
+ );
275
+ console.log(
276
+ "✓ Configured remote.origin.push for attribution notes refspec",
277
+ );
278
+ } catch {
279
+ console.log(
280
+ " (skipped git config remote.origin.push — no origin remote or git unavailable)",
281
+ );
282
+ }
283
+
284
+ // 3. Install /metrics slash command
285
+ const commandsDir = join(claudeDir, "commands");
286
+ await mkdir(commandsDir, { recursive: true });
287
+ const metricsTemplate = await readFile(
288
+ join(ATTRIBUTION_ROOT, "src", "setup", "templates", "metrics-command.md"),
289
+ "utf8",
290
+ );
291
+ const metricsContent = metricsTemplate.replace(
292
+ /\{\{CLI_BIN\}\}/g,
293
+ () => CLI_BIN,
294
+ );
295
+ await writeFile(join(commandsDir, "metrics.md"), metricsContent);
296
+ console.log("✓ Installed .claude/commands/metrics.md (/metrics command)");
297
+
298
+ const startTemplate = await readFile(
299
+ join(ATTRIBUTION_ROOT, "src", "setup", "templates", "start-command.md"),
300
+ "utf8",
301
+ );
302
+ const startContent = startTemplate.replace(/\{\{CLI_BIN\}\}/g, () => CLI_BIN);
303
+ await writeFile(join(commandsDir, "start.md"), startContent);
304
+ console.log("✓ Installed .claude/commands/start.md (/start command)");
305
+
306
+ const prTemplate = await readFile(
307
+ join(ATTRIBUTION_ROOT, "src", "setup", "templates", "pr-command.md"),
308
+ "utf8",
309
+ );
310
+ const prContent = prTemplate.replace(/\{\{CLI_BIN\}\}/g, () => CLI_BIN);
311
+ await writeFile(join(commandsDir, "pr.md"), prContent);
312
+ console.log("✓ Installed .claude/commands/pr.md (/pr command)");
313
+
314
+ console.log("\nDone! claude-attribution is active for this repo.");
315
+ console.log("Use /pr in Claude Code to create a PR with metrics embedded.");
316
+ }
317
+
318
+ main().catch((err) => {
319
+ console.error("Installation failed:", err);
320
+ process.exit(1);
321
+ });
@@ -0,0 +1,57 @@
1
+ {
2
+ "PreToolUse": [
3
+ {
4
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "\"{{CLI_BIN}}\" hook pre-tool-use"
9
+ }
10
+ ]
11
+ }
12
+ ],
13
+ "PostToolUse": [
14
+ {
15
+ "matcher": ".*",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "\"{{CLI_BIN}}\" hook post-tool-use"
20
+ }
21
+ ]
22
+ }
23
+ ],
24
+ "SubagentStart": [
25
+ {
26
+ "matcher": ".*",
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "\"{{CLI_BIN}}\" hook subagent"
31
+ }
32
+ ]
33
+ }
34
+ ],
35
+ "SubagentStop": [
36
+ {
37
+ "matcher": ".*",
38
+ "hooks": [
39
+ {
40
+ "type": "command",
41
+ "command": "\"{{CLI_BIN}}\" hook subagent"
42
+ }
43
+ ]
44
+ }
45
+ ],
46
+ "Stop": [
47
+ {
48
+ "matcher": ".*",
49
+ "hooks": [
50
+ {
51
+ "type": "command",
52
+ "command": "\"{{CLI_BIN}}\" hook stop"
53
+ }
54
+ ]
55
+ }
56
+ ]
57
+ }
@@ -0,0 +1,27 @@
1
+ # Generate Claude Code Metrics
2
+
3
+ Generate session metrics for PR documentation.
4
+
5
+ ## Instructions
6
+
7
+ Run the metrics calculator:
8
+
9
+ ```bash
10
+ "{{CLI_BIN}}" metrics
11
+ ```
12
+
13
+ This outputs PR-ready markdown covering:
14
+ - **Tools Used** — all tool invocations by count
15
+ - **Agents/Skills** — subagent invocations
16
+ - **Model Usage** — API calls and tokens by model
17
+ - **Human prompts** — count of human turns (steering effort proxy)
18
+ - **Code Attribution** — AI / human / mixed line counts from git notes (accurate, not gross write counts)
19
+ - **Files Modified** — per-file AI %
20
+
21
+ To run with a specific session ID:
22
+
23
+ ```bash
24
+ "{{CLI_BIN}}" metrics <session-id>
25
+ ```
26
+
27
+ Session IDs are in `.claude/logs/tool-usage.jsonl`.
@@ -0,0 +1,4 @@
1
+ #!/bin/sh
2
+ # Auto-installed by claude-attribution
3
+ # Runs attribution analysis after each git commit
4
+ "{{CLI_BIN}}" hook post-commit || true