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.
- package/README.md +431 -0
- package/bin/claude-attribution +9 -0
- package/package.json +26 -0
- package/src/__tests__/differ.test.ts +250 -0
- package/src/attribution/checkpoint.ts +148 -0
- package/src/attribution/commit.ts +163 -0
- package/src/attribution/differ.ts +154 -0
- package/src/attribution/git-notes.ts +185 -0
- package/src/attribution/otel.ts +233 -0
- package/src/cli.ts +109 -0
- package/src/commands/pr.ts +164 -0
- package/src/export/pr-summary.ts +204 -0
- package/src/hooks/post-tool-use.ts +105 -0
- package/src/hooks/pre-tool-use.ts +95 -0
- package/src/hooks/stop.ts +33 -0
- package/src/hooks/subagent.ts +72 -0
- package/src/lib/hooks.ts +60 -0
- package/src/metrics/calculate.ts +21 -0
- package/src/metrics/collect.ts +369 -0
- package/src/metrics/mark-start.ts +40 -0
- package/src/metrics/transcript.ts +245 -0
- package/src/run.sh +25 -0
- package/src/setup/install.ts +321 -0
- package/src/setup/templates/hooks.json +57 -0
- package/src/setup/templates/metrics-command.md +27 -0
- package/src/setup/templates/post-commit.sh +4 -0
- package/src/setup/templates/pr-command.md +33 -0
- package/src/setup/templates/pre-push.sh +4 -0
- package/src/setup/templates/start-command.md +25 -0
- package/src/setup/uninstall.ts +175 -0
|
@@ -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
|
+
}
|