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,154 @@
1
+ /**
2
+ * Line attribution algorithm for claude-attribution.
3
+ *
4
+ * Core logic: compare Claude's "after" file snapshot against the "before" snapshot
5
+ * and the committed file content using 16-character SHA-256 line hashes to classify
6
+ * each committed line as AI, HUMAN, or MIXED.
7
+ *
8
+ * This module is the only place where attribution decisions are made. All other
9
+ * modules (commit.ts, calculate.ts) call into here and consume the results.
10
+ */
11
+ import { createHash } from "crypto";
12
+
13
+ export type LineAttribution = "AI" | "HUMAN" | "MIXED";
14
+
15
+ export interface FileAttribution {
16
+ path: string;
17
+ ai: number;
18
+ human: number;
19
+ mixed: number;
20
+ total: number;
21
+ pctAi: number;
22
+ }
23
+
24
+ export interface AttributionResult {
25
+ commit: string;
26
+ /** Session ID from .claude/attribution-state/current-session, or null if committed outside a Claude session. */
27
+ session: string | null;
28
+ /** Git branch name at commit time, or null on detached HEAD (CI checkouts, rebases). */
29
+ branch: string | null;
30
+ timestamp: string;
31
+ files: FileAttribution[];
32
+ totals: Omit<FileAttribution, "path">;
33
+ }
34
+
35
+ /**
36
+ * Compute a 16-character SHA-256 hex prefix of the trimmed line content.
37
+ *
38
+ * Lines are trimmed before hashing so that equivalent code is recognized
39
+ * regardless of indentation differences. The 16-character prefix provides
40
+ * 2^64 collision resistance — sufficient for any realistic file size.
41
+ *
42
+ * This approach mirrors Docusign's internal `aidev-track` tool's ContentHasher.
43
+ */
44
+ export function hashLine(line: string): string {
45
+ return createHash("sha256").update(line.trim()).digest("hex").slice(0, 16);
46
+ }
47
+
48
+ /**
49
+ * Attribute each line in `committedLines` as AI / HUMAN / MIXED.
50
+ *
51
+ * Rules:
52
+ * - AI: hash present in afterHashes but NOT in beforeHashes
53
+ * → Claude wrote this line and it survived to the commit unchanged
54
+ * - MIXED: hash was in afterHashes originally (positional check) but the
55
+ * committed content differs from what Claude wrote at that position
56
+ * → Claude wrote it, human modified it before committing
57
+ * - HUMAN: everything else (existed before Claude, or written after Claude
58
+ * finished without a checkpoint)
59
+ *
60
+ * Empty lines are attributed as HUMAN — they carry no signal.
61
+ *
62
+ * **Identical-line limitation**: This algorithm is set-based. If Claude and a
63
+ * human both write a line with the same trimmed content (e.g., a closing `}`),
64
+ * they produce the same hash. Lines that appear in `afterLines` but not
65
+ * `beforeLines` are attributed as AI regardless of who actually wrote them.
66
+ * This is conservative toward AI for identical content — an acceptable trade-off
67
+ * given that truly identical lines are indistinguishable without positional history.
68
+ *
69
+ * **MIXED detection limitation**: MIXED uses a positional index —
70
+ * `afterByIndex[i]` is the hash of Claude's i-th line. If a human inserts or
71
+ * deletes lines above position `i`, the committed file's positions shift while
72
+ * the after-snapshot's positions do not, causing false MIXED classifications.
73
+ * MIXED is therefore a "best effort" signal most accurate when human edits are
74
+ * small in-place tweaks (e.g., changing a value on a line Claude wrote) rather
75
+ * than bulk insertions or deletions that shift line numbers.
76
+ */
77
+ export function attributeLines(
78
+ beforeLines: string[],
79
+ afterLines: string[],
80
+ committedLines: string[],
81
+ ): { attribution: LineAttribution[]; stats: FileAttribution } {
82
+ const beforeHashes = new Set(beforeLines.map(hashLine));
83
+ const afterHashes = new Set(afterLines.map(hashLine));
84
+ // Positional index: line index → hash (for MIXED detection)
85
+ const afterByIndex = afterLines.map(hashLine);
86
+
87
+ const attribution: LineAttribution[] = committedLines.map((line, i) => {
88
+ const hash = hashLine(line);
89
+
90
+ // Blank lines carry no signal
91
+ if (line.trim() === "") return "HUMAN";
92
+
93
+ if (afterHashes.has(hash) && !beforeHashes.has(hash)) {
94
+ return "AI";
95
+ }
96
+
97
+ // MIXED: Claude wrote something at this position but the human changed it
98
+ const afterHashAtPos = afterByIndex[i];
99
+ if (
100
+ afterHashAtPos !== undefined &&
101
+ !beforeHashes.has(afterHashAtPos) &&
102
+ hash !== afterHashAtPos
103
+ ) {
104
+ return "MIXED";
105
+ }
106
+
107
+ return "HUMAN";
108
+ });
109
+
110
+ const ai = attribution.filter((a) => a === "AI").length;
111
+ const human = attribution.filter((a) => a === "HUMAN").length;
112
+ const mixed = attribution.filter((a) => a === "MIXED").length;
113
+ const total = committedLines.length;
114
+
115
+ return {
116
+ attribution,
117
+ stats: {
118
+ path: "",
119
+ ai,
120
+ human,
121
+ mixed,
122
+ total,
123
+ pctAi: total > 0 ? Math.round((ai / total) * 100) : 0,
124
+ },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Aggregate per-file attribution stats into a single totals object.
130
+ *
131
+ * Used by commit.ts to compute the commit-level summary, and by calculate.ts
132
+ * to compute the PR-level summary from the last-seen stats per file.
133
+ */
134
+ export function aggregateTotals(
135
+ files: FileAttribution[],
136
+ ): Omit<FileAttribution, "path"> {
137
+ let ai = 0,
138
+ human = 0,
139
+ mixed = 0,
140
+ total = 0;
141
+ for (const f of files) {
142
+ ai += f.ai;
143
+ human += f.human;
144
+ mixed += f.mixed;
145
+ total += f.total;
146
+ }
147
+ return {
148
+ ai,
149
+ human,
150
+ mixed,
151
+ total,
152
+ pctAi: total > 0 ? Math.round((ai / total) * 100) : 0,
153
+ };
154
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Git notes interface for claude-attribution.
3
+ *
4
+ * Attribution results are stored as JSON blobs attached to commits via
5
+ * `git notes --ref=refs/notes/claude-attribution`. This keeps attribution
6
+ * data in the git object store without modifying commit history.
7
+ *
8
+ * Notes are local by default and must be explicitly pushed/fetched:
9
+ * git push origin refs/notes/claude-attribution
10
+ * git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution
11
+ *
12
+ * The Faros API pipeline (ADMPLAT-9609) reads from this ref for org-wide dashboards.
13
+ */
14
+ import { execFile } from "child_process";
15
+ import { promisify } from "util";
16
+ import type { AttributionResult } from "./differ.ts";
17
+
18
+ const execFileAsync = promisify(execFile);
19
+ const NOTES_REF = "refs/notes/claude-attribution";
20
+
21
+ /** Run a shell command and return trimmed stdout. Throws on non-zero exit. */
22
+ async function run(cmd: string, args: string[], cwd?: string): Promise<string> {
23
+ const { stdout } = await execFileAsync(cmd, args, { cwd });
24
+ return stdout.trim();
25
+ }
26
+
27
+ /** Run a shell command and return raw (untrimmed) stdout. Used for file content. */
28
+ async function runRaw(
29
+ cmd: string,
30
+ args: string[],
31
+ cwd?: string,
32
+ ): Promise<string> {
33
+ const { stdout } = await execFileAsync(cmd, args, { cwd });
34
+ return stdout;
35
+ }
36
+
37
+ /**
38
+ * Write an AttributionResult as a git note on a commit.
39
+ *
40
+ * Runs: `git notes --ref=<NOTES_REF> add --force -m <json> <commitSha>`
41
+ *
42
+ * Uses `--force` to overwrite any existing note (e.g., from a retry after a
43
+ * failed hook run). Notes are stored in the git object store under NOTES_REF.
44
+ */
45
+ export async function writeNote(
46
+ result: AttributionResult,
47
+ repoRoot: string,
48
+ commitSha = "HEAD",
49
+ ): Promise<void> {
50
+ const json = JSON.stringify(result, null, 2);
51
+ // git notes add overwrites if note already exists with --force
52
+ await run(
53
+ "git",
54
+ ["notes", "--ref", NOTES_REF, "add", "--force", "-m", json, commitSha],
55
+ repoRoot,
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Read the attribution note for a given commit.
61
+ *
62
+ * Runs: `git notes --ref=<NOTES_REF> show <commitSha>`
63
+ * Returns null if no note exists (commit was not attributed, or made before installation).
64
+ */
65
+ export async function readNote(
66
+ repoRoot: string,
67
+ commitSha = "HEAD",
68
+ ): Promise<AttributionResult | null> {
69
+ try {
70
+ const output = await run(
71
+ "git",
72
+ ["notes", "--ref", NOTES_REF, "show", commitSha],
73
+ repoRoot,
74
+ );
75
+ return JSON.parse(output) as AttributionResult;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * List all commit SHAs that have attribution notes in the entire repository.
83
+ *
84
+ * Runs: `git notes --ref=<NOTES_REF> list`
85
+ * Each output line is: "<note-blob-sha> <commit-sha>"
86
+ *
87
+ * NOTE: This returns notes from ALL branches and commits, not just the current
88
+ * branch. Call `getBranchCommitShas()` and intersect to scope to the current branch.
89
+ */
90
+ export async function listNotes(repoRoot: string): Promise<string[]> {
91
+ try {
92
+ const output = await run(
93
+ "git",
94
+ ["notes", "--ref", NOTES_REF, "list"],
95
+ repoRoot,
96
+ );
97
+ if (!output) return [];
98
+ // Each line: "<note-sha> <commit-sha>"
99
+ return output
100
+ .split("\n")
101
+ .map((line) => line.split(" ")[1])
102
+ .filter((sha): sha is string => sha !== undefined && sha.length > 0);
103
+ } catch {
104
+ return [];
105
+ }
106
+ }
107
+
108
+ /** Get the current HEAD commit SHA. Runs: `git rev-parse HEAD` */
109
+ export async function headSha(repoRoot: string): Promise<string> {
110
+ return run("git", ["rev-parse", "HEAD"], repoRoot);
111
+ }
112
+
113
+ /**
114
+ * Get the current branch name. Returns null on detached HEAD
115
+ * (e.g., CI checkouts, interactive rebases).
116
+ *
117
+ * Runs: `git symbolic-ref --short HEAD`
118
+ */
119
+ export async function currentBranch(repoRoot: string): Promise<string | null> {
120
+ try {
121
+ return await run("git", ["symbolic-ref", "--short", "HEAD"], repoRoot);
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * List relative paths of files changed in the HEAD commit.
129
+ *
130
+ * Uses `git diff-tree --no-commit-id -r --name-only HEAD`, which works correctly
131
+ * for regular commits, the initial commit (no parent), and merge commits (compares
132
+ * against all parents, not just HEAD~1).
133
+ *
134
+ * Binary files are included in this list; commit.ts skips them by detecting null
135
+ * bytes in the committed content.
136
+ */
137
+ export async function filesInCommit(repoRoot: string): Promise<string[]> {
138
+ const output = await run(
139
+ "git",
140
+ ["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"],
141
+ repoRoot,
142
+ );
143
+ return output ? output.split("\n").filter(Boolean) : [];
144
+ }
145
+
146
+ /**
147
+ * Return the commit SHAs that are on the current branch but not yet in the
148
+ * remote default branch (origin/main or origin/master).
149
+ *
150
+ * Used by calculate.ts to scope `/metrics` output to the current PR's commits
151
+ * rather than every annotated commit across the entire repository.
152
+ *
153
+ * Runs: `git merge-base HEAD origin/main` to find the fork point, then
154
+ * `git log --format=%H <base>..HEAD` to list commits since.
155
+ * Falls back to all commits reachable from HEAD if no remote ref is found.
156
+ */
157
+ export async function getBranchCommitShas(repoRoot: string): Promise<string[]> {
158
+ const base = await run("git", ["merge-base", "HEAD", "origin/HEAD"], repoRoot)
159
+ .catch(() => run("git", ["merge-base", "HEAD", "origin/main"], repoRoot))
160
+ .catch(() => run("git", ["merge-base", "HEAD", "origin/master"], repoRoot))
161
+ .catch(() => null);
162
+ const range = base ? `${(base as string).trim()}..HEAD` : "HEAD";
163
+ const out = await run("git", ["log", "--format=%H", range], repoRoot).catch(
164
+ () => "",
165
+ );
166
+ return out ? out.split("\n").filter(Boolean) : [];
167
+ }
168
+
169
+ /**
170
+ * Read the committed content of a file at HEAD as a raw string.
171
+ *
172
+ * Runs: `git show HEAD:<relPath>`
173
+ * Returns null for deleted files (git show exits non-zero for files not in the tree).
174
+ * Uses `runRaw` (untrimmed) to preserve trailing newlines in file content.
175
+ */
176
+ export async function committedContent(
177
+ repoRoot: string,
178
+ relPath: string,
179
+ ): Promise<string | null> {
180
+ try {
181
+ return await runRaw("git", ["show", `HEAD:${relPath}`], repoRoot);
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * OTel trace exporter — hand-crafted OTLP/HTTP over fetch, no SDK dependency.
3
+ *
4
+ * Emits two span types:
5
+ * - "claude-session" (root) — created at commit time, covers the full coding session.
6
+ * Attributes: session.id, git.branch, git.commit, attribution.*
7
+ * - "tool_call/{toolName}" (child) — created per tool call in post-tool-use.
8
+ * Attributes: tool.name, file.path (if applicable), session.id
9
+ *
10
+ * Context is persisted across short-lived hook processes to:
11
+ * .claude/attribution-state/otel-context.json
12
+ *
13
+ * All OTel behavior is gated on OTEL_EXPORTER_OTLP_ENDPOINT being set.
14
+ * All errors are silently swallowed — never block hooks or commits.
15
+ *
16
+ * Env vars:
17
+ * OTEL_EXPORTER_OTLP_ENDPOINT — e.g. http://localhost:4318 or https://otlp.datadoghq.com
18
+ * OTEL_EXPORTER_OTLP_HEADERS — e.g. DD-Api-Key=<key>,Another-Header=value
19
+ * OTEL_SERVICE_NAME — defaults to "claude-code"
20
+ */
21
+ import { readFile, writeFile, unlink } from "fs/promises";
22
+ import { existsSync, mkdirSync } from "fs";
23
+ import { join } from "path";
24
+ import { randomBytes } from "crypto";
25
+
26
+ export interface OtelContext {
27
+ traceId: string; // 32 hex chars (128-bit)
28
+ rootSpanId: string; // 16 hex chars (64-bit)
29
+ sessionId: string;
30
+ startTime: string; // ISO8601 — set at first pre-tool-use invocation
31
+ lastToolCallStart: string | null; // ISO8601 — updated each pre-tool-use
32
+ }
33
+
34
+ function contextPath(repoRoot: string): string {
35
+ return join(repoRoot, ".claude", "attribution-state", "otel-context.json");
36
+ }
37
+
38
+ export async function readOtelContext(
39
+ repoRoot: string,
40
+ ): Promise<OtelContext | null> {
41
+ const p = contextPath(repoRoot);
42
+ if (!existsSync(p)) return null;
43
+ try {
44
+ const raw = await readFile(p, "utf8");
45
+ return JSON.parse(raw) as OtelContext;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export async function writeOtelContext(
52
+ repoRoot: string,
53
+ ctx: OtelContext,
54
+ ): Promise<void> {
55
+ const dir = join(repoRoot, ".claude", "attribution-state");
56
+ mkdirSync(dir, { recursive: true });
57
+ await writeFile(contextPath(repoRoot), JSON.stringify(ctx, null, 2) + "\n");
58
+ }
59
+
60
+ export async function clearOtelContext(repoRoot: string): Promise<void> {
61
+ try {
62
+ await unlink(contextPath(repoRoot));
63
+ } catch {
64
+ // Already gone — fine
65
+ }
66
+ }
67
+
68
+ /** Returns the OTLP endpoint, or null if OTel is disabled. */
69
+ export function otelEndpoint(): string | null {
70
+ return process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? null;
71
+ }
72
+
73
+ /** Parses OTEL_EXPORTER_OTLP_HEADERS into a headers map. */
74
+ export function otelHeaders(): Record<string, string> {
75
+ const raw = process.env.OTEL_EXPORTER_OTLP_HEADERS ?? "";
76
+ if (!raw.trim()) return {};
77
+ const result: Record<string, string> = {};
78
+ for (const pair of raw.split(",")) {
79
+ const idx = pair.indexOf("=");
80
+ if (idx === -1) continue;
81
+ const key = pair.slice(0, idx).trim();
82
+ const value = pair.slice(idx + 1).trim();
83
+ if (key) result[key] = value;
84
+ }
85
+ return result;
86
+ }
87
+
88
+ export function makeSpanId(): string {
89
+ return randomBytes(8).toString("hex");
90
+ }
91
+
92
+ export function makeTraceId(): string {
93
+ return randomBytes(16).toString("hex");
94
+ }
95
+
96
+ /** Convert ISO8601 string to nanoseconds string for OTLP *TimeUnixNano fields. */
97
+ export function toNano(iso: string): string {
98
+ return (BigInt(new Date(iso).getTime()) * 1_000_000n).toString();
99
+ }
100
+
101
+ const SERVICE_NAME = process.env.OTEL_SERVICE_NAME ?? "claude-code";
102
+
103
+ function buildResourceSpans(spans: object[]): object {
104
+ return {
105
+ resourceSpans: [
106
+ {
107
+ resource: {
108
+ attributes: [
109
+ {
110
+ key: "service.name",
111
+ value: { stringValue: SERVICE_NAME },
112
+ },
113
+ ],
114
+ },
115
+ scopeSpans: [
116
+ {
117
+ scope: { name: "claude-attribution" },
118
+ spans,
119
+ },
120
+ ],
121
+ },
122
+ ],
123
+ };
124
+ }
125
+
126
+ function strAttr(key: string, value: string): object {
127
+ return { key, value: { stringValue: value } };
128
+ }
129
+
130
+ function intAttr(key: string, value: number): object {
131
+ return { key, value: { intValue: value } };
132
+ }
133
+
134
+ /**
135
+ * Build an OTLP child span for a single tool call.
136
+ *
137
+ * The span is parented to the root session span (rootSpanId).
138
+ * start = ctx.lastToolCallStart, end = endTime.
139
+ */
140
+ export function buildToolCallSpan(
141
+ ctx: OtelContext,
142
+ toolName: string,
143
+ filePath: string | null,
144
+ endTime: string,
145
+ ): object {
146
+ const spanId = makeSpanId();
147
+ const attrs: object[] = [
148
+ strAttr("tool.name", toolName),
149
+ strAttr("session.id", ctx.sessionId),
150
+ ];
151
+ if (filePath) attrs.push(strAttr("file.path", filePath));
152
+
153
+ return {
154
+ traceId: ctx.traceId,
155
+ spanId,
156
+ parentSpanId: ctx.rootSpanId,
157
+ name: `tool_call/${toolName}`,
158
+ kind: 1, // SPAN_KIND_INTERNAL
159
+ startTimeUnixNano: toNano(ctx.lastToolCallStart ?? ctx.startTime),
160
+ endTimeUnixNano: toNano(endTime),
161
+ attributes: attrs,
162
+ status: { code: 1 }, // STATUS_CODE_OK
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Build an OTLP root span for a Claude coding session.
168
+ *
169
+ * Emitted once at commit time. Covers startTime → endTime (commit time).
170
+ * Carries attribution totals so APM traces correlate to code authorship.
171
+ */
172
+ export function buildSessionSpan(
173
+ ctx: OtelContext,
174
+ commitSha: string,
175
+ branch: string | null,
176
+ totals: {
177
+ ai: number;
178
+ human: number;
179
+ mixed: number;
180
+ total: number;
181
+ pctAi: number;
182
+ },
183
+ endTime: string,
184
+ ): object {
185
+ const attrs: object[] = [
186
+ strAttr("session.id", ctx.sessionId),
187
+ strAttr("git.commit", commitSha),
188
+ intAttr("attribution.ai_lines", totals.ai),
189
+ intAttr("attribution.human_lines", totals.human),
190
+ intAttr("attribution.mixed_lines", totals.mixed),
191
+ intAttr("attribution.total_lines", totals.total),
192
+ intAttr("attribution.pct_ai", totals.pctAi),
193
+ ];
194
+ if (branch) attrs.push(strAttr("git.branch", branch));
195
+
196
+ return {
197
+ traceId: ctx.traceId,
198
+ spanId: ctx.rootSpanId,
199
+ name: "claude-session",
200
+ kind: 1, // SPAN_KIND_INTERNAL
201
+ startTimeUnixNano: toNano(ctx.startTime),
202
+ endTimeUnixNano: toNano(endTime),
203
+ attributes: attrs,
204
+ status: { code: 1 }, // STATUS_CODE_OK
205
+ };
206
+ }
207
+
208
+ /**
209
+ * POST spans to the OTLP/HTTP endpoint.
210
+ *
211
+ * Uses the v1/traces path per the OTLP spec.
212
+ * Silent no-op on any error — never block hooks or commits.
213
+ */
214
+ export async function exportOtlpSpans(
215
+ spans: object[],
216
+ endpoint: string,
217
+ headers: Record<string, string>,
218
+ ): Promise<void> {
219
+ if (spans.length === 0) return;
220
+ try {
221
+ const url = endpoint.replace(/\/$/, "") + "/v1/traces";
222
+ await fetch(url, {
223
+ method: "POST",
224
+ headers: {
225
+ "Content-Type": "application/json",
226
+ ...headers,
227
+ },
228
+ body: JSON.stringify(buildResourceSpans(spans)),
229
+ });
230
+ } catch {
231
+ // Silent — never block caller
232
+ }
233
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * CLI entry point for claude-attribution.
3
+ *
4
+ * Routes subcommands to existing modules via dynamic import(), which triggers
5
+ * auto-execution because each module calls main().catch(...) at module level.
6
+ * argv is shifted before delegating so sub-modules see their args at process.argv[2].
7
+ */
8
+ import { resolve, dirname } from "path";
9
+ import { fileURLToPath } from "url";
10
+ import { readFile } from "fs/promises";
11
+ import { execFile } from "child_process";
12
+ import { promisify } from "util";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+ const [, , cmd, ...rest] = process.argv;
16
+
17
+ // Shift argv so sub-modules see their expected args at process.argv[2]
18
+ process.argv.splice(2, Infinity, ...rest);
19
+
20
+ switch (cmd) {
21
+ case "install":
22
+ await import("./setup/install.ts");
23
+ break;
24
+ case "uninstall":
25
+ await import("./setup/uninstall.ts");
26
+ break;
27
+ case "metrics":
28
+ await import("./metrics/calculate.ts");
29
+ break;
30
+ case "start":
31
+ await import("./metrics/mark-start.ts");
32
+ break;
33
+ case "pr":
34
+ await import("./commands/pr.ts");
35
+ break;
36
+ case "hook": {
37
+ switch (rest[0]) {
38
+ case "pre-tool-use":
39
+ await import("./hooks/pre-tool-use.ts");
40
+ break;
41
+ case "post-tool-use":
42
+ await import("./hooks/post-tool-use.ts");
43
+ break;
44
+ case "subagent":
45
+ await import("./hooks/subagent.ts");
46
+ break;
47
+ case "stop":
48
+ await import("./hooks/stop.ts");
49
+ break;
50
+ case "post-commit":
51
+ await import("./attribution/commit.ts");
52
+ break;
53
+ case "pre-push":
54
+ try {
55
+ await execFileAsync("git", [
56
+ "push",
57
+ "origin",
58
+ "refs/notes/claude-attribution",
59
+ ]);
60
+ } catch {
61
+ // Ignore — notes push failure must not block git push
62
+ }
63
+ process.exit(0);
64
+ break;
65
+ default:
66
+ console.error(`Unknown hook: ${rest[0]}`);
67
+ process.exit(1);
68
+ }
69
+ break;
70
+ }
71
+ case "version":
72
+ case "--version":
73
+ case "-v": {
74
+ const pkgPath = resolve(
75
+ dirname(fileURLToPath(import.meta.url)),
76
+ "..",
77
+ "package.json",
78
+ );
79
+ const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as {
80
+ version: string;
81
+ };
82
+ console.log(pkg.version);
83
+ process.exit(0);
84
+ break;
85
+ }
86
+ case "help":
87
+ case "--help":
88
+ case "-h":
89
+ console.log(
90
+ `claude-attribution <command> [args]
91
+
92
+ Commands:
93
+ install [repo] Install hooks into a repo (default: current directory)
94
+ uninstall [repo] Remove hooks from a repo (default: current directory)
95
+ metrics [id] Generate PR metrics report
96
+ pr [title] Create PR with metrics embedded (--draft, --base <branch>)
97
+ start Mark session start for per-ticket scoping
98
+ hook <name> Run an internal hook (used by installed git hooks)
99
+ version Print version
100
+ help Print this help`,
101
+ );
102
+ process.exit(0);
103
+ break;
104
+ default:
105
+ console.error(
106
+ `Unknown command: ${cmd ?? "(none)"}. Run "claude-attribution help" for usage.`,
107
+ );
108
+ process.exit(1);
109
+ }