claude-attribution 1.2.9 → 1.5.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 +228 -90
- package/package.json +1 -1
- package/src/attribution/commit.ts +16 -0
- package/src/attribution/differ.ts +12 -0
- package/src/attribution/git-notes.ts +131 -1
- package/src/cli.ts +8 -0
- package/src/commands/note-ai-commit.ts +69 -0
- package/src/export/pr-summary.ts +342 -109
- package/src/metrics/collect.ts +41 -0
- package/src/pricing.ts +136 -0
- package/src/setup/branch-protection.ts +7 -11
- package/src/setup/install.ts +148 -2
- package/src/setup/templates/claude-attribution-export.yml +78 -0
- package/src/setup/templates/claude-attribution-gha.yml +38 -0
- package/src/setup/templates/pr-metrics-workflow.yml +2 -0
- package/src/setup/uninstall.ts +19 -1
package/README.md
CHANGED
|
@@ -1,23 +1,70 @@
|
|
|
1
1
|
# claude-attribution
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
3
|
+
AI code attribution for Claude Code. After every `git commit`, a one-line summary appears in your terminal:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
[claude-attribution] a3f1b2c — 142 AI / 38 human / 4 mixed lines (77% AI)
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
When a PR is opened, full metrics — model usage, token counts, tool calls, and attribution percentages — are injected automatically into the PR body. No copy-paste, no manual tracking.
|
|
10
|
+
|
|
11
|
+
**Quick start:**
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g claude-attribution
|
|
15
|
+
claude-attribution install ~/Code/your-repo
|
|
16
|
+
claude-attribution init --ai # if repo was built with Claude Code; use --human otherwise
|
|
17
|
+
git add .claude/settings.json .github/workflows/claude-attribution-pr.yml .gitignore
|
|
18
|
+
git commit -m "chore: install claude-attribution hooks"
|
|
19
|
+
git push
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Using Copilot or `@claude` (claude-code-action)?** Bot commits are auto-detected and attributed as 100% AI — no extra steps needed. See [AI Actor Attribution](#ai-actor-attribution-copilot-bot-claude-gha).
|
|
23
|
+
|
|
24
|
+
**Requirements:** [Bun](https://bun.sh) (preferred) or Node 18+, and `gh` (GitHub CLI) authenticated for the `/pr` command.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
Measures which lines in a commit were written by Claude vs. a human — using checkpoint snapshots and line-level SHA-256 comparison, not gross write-operation counts.
|
|
17
29
|
|
|
18
30
|
---
|
|
19
31
|
|
|
20
|
-
|
|
32
|
+
## GitHub Actions Requirements
|
|
33
|
+
|
|
34
|
+
Up to three workflows are installed into repos that use this tool — one always, two optionally. Each requires specific GitHub Actions to be allowed in your org. If your org enforces an action allowlist, you must approve the third-party action before the workflows will run.
|
|
35
|
+
|
|
36
|
+
### Workflows and the actions they use
|
|
37
|
+
|
|
38
|
+
| Workflow file | Trigger | Install | GitHub-owned actions | Third-party actions |
|
|
39
|
+
|---|---|---|---|---|
|
|
40
|
+
| `claude-attribution-pr.yml` | PR opened / pushed | Always | `actions/checkout@v4`, `actions/github-script@v7` | `oven-sh/setup-bun@v2` |
|
|
41
|
+
| `claude-attribution-export.yml` | PR merged | Optional (prompted) | `actions/checkout@v4` | `oven-sh/setup-bun@v2` |
|
|
42
|
+
| `claude-attribution-gha.yml` | Every push | Optional (claude-code-action detected) | `actions/checkout@v4` | `oven-sh/setup-bun@v2` |
|
|
43
|
+
|
|
44
|
+
**GitHub-owned actions** (`actions/*`) are pre-approved in all orgs by default.
|
|
45
|
+
|
|
46
|
+
**`oven-sh/setup-bun@v2`** is a third-party action from the [Bun](https://bun.sh) team. It installs the Bun runtime so the `claude-attribution` CLI can execute without falling back to `npx tsx`. If your org requires explicit action approvals, add `oven-sh/setup-bun` to the allowlist at:
|
|
47
|
+
|
|
48
|
+
> Settings → Actions → General → Allow actions and reusable workflows → add `oven-sh/setup-bun@*`
|
|
49
|
+
|
|
50
|
+
### Workflow permissions
|
|
51
|
+
|
|
52
|
+
| Workflow | `contents` | `pull-requests` | Why |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| `claude-attribution-pr.yml` | read | write | Reads git history; writes metrics into the PR body |
|
|
55
|
+
| `claude-attribution-export.yml` | read | — | Reads git notes and exports metrics via OTLP/webhook |
|
|
56
|
+
| `claude-attribution-gha.yml` | write | — | Pushes attribution git notes back to origin |
|
|
57
|
+
|
|
58
|
+
### Required secrets
|
|
59
|
+
|
|
60
|
+
| Secret / Variable | Workflow | Required | Notes |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| `GITHUB_TOKEN` | All | Automatic | Provided by GitHub Actions; no setup needed |
|
|
63
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `claude-attribution-export.yml` | One of these | OTLP/HTTP base URL (e.g. `https://otlp.datadoghq.com`, `https://otlp-gateway-<zone>.grafana.net/otlp`, `http://localhost:4318`). Unset = dry-run mode. |
|
|
64
|
+
| `OTEL_EXPORTER_OTLP_HEADERS` | `claude-attribution-export.yml` | Depends on backend | Comma-separated `key=value` auth headers (e.g. `DD-Api-Key=xxx` for Datadog, `x-honeycomb-team=xxx` for Honeycomb) |
|
|
65
|
+
| `DATADOG_API_KEY` | `claude-attribution-export.yml` | Datadog shortcut | Org-level secret; auto-configures the Datadog OTLP endpoint without needing `OTEL_EXPORTER_OTLP_ENDPOINT`. |
|
|
66
|
+
| `DATADOG_SITE` | `claude-attribution-export.yml` | No | Org-level variable; used with `DATADOG_API_KEY`. Defaults to `datadoghq.com`. |
|
|
67
|
+
| `METRICS_WEBHOOK_URL` | `claude-attribution-export.yml` | No | Posts a flat JSON payload to any HTTP endpoint on each PR merge. Can be used alongside OTLP. |
|
|
21
68
|
|
|
22
69
|
---
|
|
23
70
|
|
|
@@ -31,30 +78,16 @@ AI code attribution tracking for Claude Code. Measures which lines in a commit w
|
|
|
31
78
|
|
|
32
79
|
### One-time setup (per developer machine)
|
|
33
80
|
|
|
34
|
-
**Option A — npm (recommended):**
|
|
35
|
-
|
|
36
81
|
```bash
|
|
37
82
|
npm install -g claude-attribution
|
|
38
83
|
```
|
|
39
84
|
|
|
40
|
-
**Option B — clone (if npm isn't available):**
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
git clone git@github.com:DTS-Productivity-Engineering/claude-attribution.git ~/Code/claude-attribution
|
|
44
|
-
cd ~/Code/claude-attribution
|
|
45
|
-
bun install
|
|
46
|
-
```
|
|
47
|
-
|
|
48
85
|
### Install into a repo (per repo, per developer)
|
|
49
86
|
|
|
50
87
|
**Step 1 — Run the installer:**
|
|
51
88
|
|
|
52
89
|
```bash
|
|
53
|
-
# npm install:
|
|
54
90
|
claude-attribution install ~/Code/your-repo
|
|
55
|
-
|
|
56
|
-
# clone install:
|
|
57
|
-
bun ~/Code/claude-attribution/src/setup/install.ts ~/Code/your-repo
|
|
58
91
|
```
|
|
59
92
|
|
|
60
93
|
**Step 2 — Declare your attribution baseline (`init`):**
|
|
@@ -70,6 +103,9 @@ claude-attribution init --human
|
|
|
70
103
|
|
|
71
104
|
# Not sure? Run with no flag — same as --human, prints a confirmation:
|
|
72
105
|
claude-attribution init
|
|
106
|
+
|
|
107
|
+
# Repo has mixed history — mark commits after a specific date as AI-written:
|
|
108
|
+
claude-attribution init --ai-since 2025-01-01
|
|
73
109
|
```
|
|
74
110
|
|
|
75
111
|
> **Why this matters:** Without `init`, the codebase-wide AI% starts at 0% and grows only from new commits. If your repo is all Claude Code, run `init --ai` now or the metrics will be misleading until the entire codebase has been re-committed line by line.
|
|
@@ -103,9 +139,14 @@ The installer makes the following changes to the target repo:
|
|
|
103
139
|
|
|
104
140
|
**`.github/workflows/claude-attribution-pr.yml`** — GitHub Actions workflow that fires on every PR open and push. Injects metrics into the PR body automatically for PRs created outside Claude (Copilot, manual `gh pr create`, GitHub UI). Skips injection if the local `post-bash` hook already injected metrics on `opened`; always updates on `synchronize` (new commits).
|
|
105
141
|
|
|
106
|
-
**`.claude
|
|
142
|
+
**`.github/workflows/claude-attribution-export.yml`** — fires on every PR merge. Exports AI attribution metrics via OTLP/HTTP or a generic webhook. Supports Datadog, Grafana Cloud, Splunk Observability, New Relic, Honeycomb, and any OpenTelemetry Collector. Runs in dry-run mode (prints payload to stdout, exits 0) when no export destination is configured. See [Metrics Export](#metrics-export).
|
|
143
|
+
|
|
144
|
+
**`.github/workflows/claude-attribution-gha.yml`** *(optional — installed when claude-code-action is detected)* — fires on every push. Writes a 100% AI git note for commits made by known AI actors (e.g. `@claude` via claude-code-action, Copilot coding agent). Silent no-op for human commits. See [AI Actor Attribution](#ai-actor-attribution-copilot-bot-claude-gha).
|
|
145
|
+
|
|
146
|
+
**`.claude/commands/`** — installs three slash commands:
|
|
107
147
|
- `/metrics` — generate a PR metrics report
|
|
108
|
-
- `/start` — mark the start of a new
|
|
148
|
+
- `/start` — mark the start of a new ticket session
|
|
149
|
+
- `/pr` — create a PR with metrics embedded
|
|
109
150
|
|
|
110
151
|
**`.gitignore`** — adds `.claude/logs/` so tool usage logs don't end up in version control.
|
|
111
152
|
|
|
@@ -139,16 +180,12 @@ claude-attribution init --ai # only if repo was built 100% with Claude Code
|
|
|
139
180
|
git push origin refs/notes/claude-attribution-map
|
|
140
181
|
```
|
|
141
182
|
|
|
142
|
-
### Re-installing
|
|
183
|
+
### Re-installing
|
|
143
184
|
|
|
144
|
-
If you
|
|
185
|
+
If you reinstall `claude-attribution` globally (e.g. after upgrading), re-run the installer — it updates the absolute paths in `settings.json` and the git hook:
|
|
145
186
|
|
|
146
187
|
```bash
|
|
147
|
-
# npm install:
|
|
148
188
|
claude-attribution install ~/Code/your-repo
|
|
149
|
-
|
|
150
|
-
# clone install:
|
|
151
|
-
bun src/setup/install.ts ~/Code/your-repo
|
|
152
189
|
```
|
|
153
190
|
|
|
154
191
|
### Uninstalling
|
|
@@ -156,14 +193,10 @@ bun src/setup/install.ts ~/Code/your-repo
|
|
|
156
193
|
To remove claude-attribution from a repo:
|
|
157
194
|
|
|
158
195
|
```bash
|
|
159
|
-
# npm install:
|
|
160
196
|
claude-attribution uninstall ~/Code/your-repo
|
|
161
|
-
|
|
162
|
-
# clone install:
|
|
163
|
-
bun ~/Code/claude-attribution/src/setup/uninstall.ts ~/Code/your-repo
|
|
164
197
|
```
|
|
165
198
|
|
|
166
|
-
This removes hooks from `.claude/settings.json`, removes `.git/hooks/post-commit`, removes the slash commands, removes `.github/workflows/claude-attribution-pr.yml`, and removes any legacy `pre-push` hooks (for example `.husky/pre-push` or `.git/hooks/pre-push`) if present. Attribution state (`.claude/attribution-state/`) and logs (`.claude/logs/`) are left in place. The `remote.origin.push` refspec is also removed from git config.
|
|
199
|
+
This removes hooks from `.claude/settings.json`, removes `.git/hooks/post-commit`, removes the slash commands, removes `.github/workflows/claude-attribution-pr.yml`, `.github/workflows/claude-attribution-export.yml`, and `.github/workflows/claude-attribution-gha.yml` (if present), and removes any legacy `pre-push` hooks (for example `.husky/pre-push` or `.git/hooks/pre-push`) if present. Attribution state (`.claude/attribution-state/`) and logs (`.claude/logs/`) are left in place. The `remote.origin.push` refspec is also removed from git config.
|
|
167
200
|
|
|
168
201
|
---
|
|
169
202
|
|
|
@@ -199,33 +232,36 @@ Metrics are injected automatically — no command needed.
|
|
|
199
232
|
|
|
200
233
|
**On every new push to an open PR**: the workflow fires on `synchronize` and updates the attribution percentages to reflect new commits.
|
|
201
234
|
|
|
202
|
-
The metrics block looks like (when the cumulative minimap exists):
|
|
203
|
-
|
|
204
|
-
```markdown
|
|
205
|
-
## Claude Code Metrics
|
|
235
|
+
The metrics block injected into the PR body looks like (when the cumulative minimap exists):
|
|
206
236
|
|
|
207
|
-
|
|
208
|
-
|
|
237
|
+
> ## Claude Code Metrics
|
|
238
|
+
>
|
|
239
|
+
> **Codebase: ~77% AI** (3200 / 4150 lines)
|
|
240
|
+
> **This PR:** 184 lines changed (4% of codebase) · 77% Claude edits · 142 AI lines · Active: 8m
|
|
241
|
+
>
|
|
242
|
+
> | Model | Calls | Input | Output | Cache |
|
|
243
|
+
> |-------|-------|-------|--------|-------|
|
|
244
|
+
> | Sonnet | 45 | 120K | 35K | 10K |
|
|
245
|
+
> | **Total** | 45 | 120K | 35K | 10K |
|
|
246
|
+
>
|
|
247
|
+
> **Human prompts (steering effort):** 12
|
|
248
|
+
>
|
|
249
|
+
> <details><summary>Tools · Agents · Files</summary>
|
|
250
|
+
>
|
|
251
|
+
> **Tools:** Edit ×47, Read ×31, Bash ×12
|
|
252
|
+
>
|
|
253
|
+
> </details>
|
|
209
254
|
|
|
210
|
-
|
|
211
|
-
|-------|-------|-------|--------|-------|
|
|
212
|
-
| Sonnet | 45 | 120K | 35K | 10K |
|
|
213
|
-
| **Total** | 45 | 120K | 35K | 10K |
|
|
255
|
+
Before running `init --ai` (or on a fresh install with no minimap), the headline falls back to the session-only view:
|
|
214
256
|
|
|
215
|
-
**
|
|
257
|
+
> **AI contribution: ~77%** (142 of 184 committed lines) · Active: 8m
|
|
216
258
|
|
|
217
|
-
|
|
218
|
-
<summary>Tools · Agents · Files</summary>
|
|
259
|
+
The block is wrapped in HTML comments for idempotent updates — re-running replaces the existing block rather than appending:
|
|
219
260
|
|
|
220
|
-
**Tools:** Edit ×47, Read ×31, Bash ×12
|
|
221
|
-
...
|
|
222
|
-
</details>
|
|
223
261
|
```
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
```markdown
|
|
228
|
-
**AI contribution: ~77%** (142 of 184 committed lines) · Active: 8m
|
|
262
|
+
<!-- claude-attribution metrics -->
|
|
263
|
+
...metrics content...
|
|
264
|
+
<!-- /claude-attribution metrics -->
|
|
229
265
|
```
|
|
230
266
|
|
|
231
267
|
#### Manual option
|
|
@@ -233,7 +269,7 @@ Before running `init --ai` (or on a fresh install with no minimap), the headline
|
|
|
233
269
|
If you need to create a PR with metrics outside of Claude, use the `/pr` slash command or CLI directly:
|
|
234
270
|
|
|
235
271
|
```bash
|
|
236
|
-
claude-attribution pr "feat:
|
|
272
|
+
claude-attribution pr "feat: PROJ-1234 add user authentication"
|
|
237
273
|
claude-attribution pr "feat: my feature" --draft
|
|
238
274
|
claude-attribution pr "feat: my feature" --base develop
|
|
239
275
|
```
|
|
@@ -246,7 +282,6 @@ To see the metrics output without creating a PR, use `/metrics` or run directly:
|
|
|
246
282
|
|
|
247
283
|
```bash
|
|
248
284
|
claude-attribution metrics
|
|
249
|
-
# or: bun ~/Code/claude-attribution/src/metrics/calculate.ts
|
|
250
285
|
```
|
|
251
286
|
|
|
252
287
|
The output is markdown you paste into your PR description:
|
|
@@ -313,7 +348,7 @@ Example output:
|
|
|
313
348
|
{
|
|
314
349
|
"commit": "a3f1b2c",
|
|
315
350
|
"session": "abc-123-...",
|
|
316
|
-
"branch": "feature/
|
|
351
|
+
"branch": "feature/PROJ-1234",
|
|
317
352
|
"timestamp": "2026-03-26T15:32:00.000Z",
|
|
318
353
|
"files": [
|
|
319
354
|
{ "path": "src/components/Foo.tsx", "ai": 82, "human": 10, "mixed": 2, "total": 94, "pctAi": 87 }
|
|
@@ -428,36 +463,139 @@ To force re-detection of the TypeScript runtime (e.g., after installing Bun): `r
|
|
|
428
463
|
|
|
429
464
|
---
|
|
430
465
|
|
|
431
|
-
##
|
|
466
|
+
## Metrics Export
|
|
432
467
|
|
|
433
|
-
Attribution
|
|
468
|
+
Attribution metrics are exported automatically on every PR merge via GitHub Actions (`.github/workflows/claude-attribution-export.yml`). The workflow uses the OpenTelemetry OTLP/HTTP JSON format and supports any OTel-compliant backend.
|
|
434
469
|
|
|
435
|
-
**Metrics
|
|
470
|
+
**Metrics exported on each merged PR:**
|
|
436
471
|
|
|
437
|
-
| Metric | Description |
|
|
438
|
-
|
|
439
|
-
| `claude_attribution.ai_lines` | Lines written by Claude and committed unchanged |
|
|
440
|
-
| `claude_attribution.human_lines` | Lines written or left unchanged by the developer |
|
|
441
|
-
| `claude_attribution.total_lines` | Total committed lines in the PR |
|
|
442
|
-
| `claude_attribution.pct_ai` | Percentage of lines attributed to Claude (this PR) |
|
|
443
|
-
| `claude_attribution.codebase_pct_ai` | Cumulative codebase-wide AI% at PR merge time (requires minimap) |
|
|
444
|
-
| `claude_attribution.codebase_total_lines` | Total codebase lines tracked in the minimap |
|
|
445
|
-
| `
|
|
446
|
-
| `
|
|
447
|
-
| `
|
|
472
|
+
| Metric | Unit | Description |
|
|
473
|
+
|--------|------|-------------|
|
|
474
|
+
| `claude_attribution.ai_lines` | lines | Lines written by Claude and committed unchanged |
|
|
475
|
+
| `claude_attribution.human_lines` | lines | Lines written or left unchanged by the developer |
|
|
476
|
+
| `claude_attribution.total_lines` | lines | Total committed lines in the PR |
|
|
477
|
+
| `claude_attribution.pct_ai` | % | Percentage of lines attributed to Claude (this PR) |
|
|
478
|
+
| `claude_attribution.codebase_pct_ai` | % | Cumulative codebase-wide AI% at PR merge time (requires minimap) |
|
|
479
|
+
| `claude_attribution.codebase_total_lines` | lines | Total codebase lines tracked in the minimap |
|
|
480
|
+
| `claude_attribution.cost_usd` | $ | Estimated Claude API cost for this PR (requires v1.5.0+ notes) |
|
|
481
|
+
| `claude_attribution.input_tokens` | tokens | Total input tokens consumed by Claude in this PR |
|
|
482
|
+
| `claude_attribution.output_tokens` | tokens | Total output tokens generated by Claude in this PR |
|
|
483
|
+
| `claude_attribution.cache_read_tokens` | tokens | Cache read tokens in this PR |
|
|
484
|
+
| `claude_attribution.cache_creation_tokens` | tokens | Cache creation tokens in this PR |
|
|
448
485
|
|
|
449
|
-
|
|
486
|
+
Token and cost metrics are only emitted when the git notes contain token data (written by v1.5.0+ hooks). All metrics carry attributes `pr`, `branch`, `author`, `tool` — enabling per-PR trend analysis.
|
|
450
487
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
488
|
+
**Supported backends:**
|
|
489
|
+
|
|
490
|
+
| Backend | Configuration |
|
|
491
|
+
|---------|---------------|
|
|
492
|
+
| **Datadog** (shortcut) | Set `DATADOG_API_KEY` secret (and optionally `DATADOG_SITE` org variable, defaults to `datadoghq.com`). Endpoint is auto-configured. |
|
|
493
|
+
| **Datadog** (explicit) | `OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.datadoghq.com`, `OTEL_EXPORTER_OTLP_HEADERS=DD-Api-Key=<key>` |
|
|
494
|
+
| **Grafana Cloud** | `OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-gateway-<zone>.grafana.net/otlp`, `OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic <base64(user:token)>` |
|
|
495
|
+
| **Splunk Observability** | `OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.<realm>.signalfx.com/v2/datapoint/otlp`, `OTEL_EXPORTER_OTLP_HEADERS=X-SF-Token=<token>` |
|
|
496
|
+
| **New Relic** | `OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.nr-data.net`, `OTEL_EXPORTER_OTLP_HEADERS=api-key=<key>` |
|
|
497
|
+
| **Honeycomb** | `OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io`, `OTEL_EXPORTER_OTLP_HEADERS=x-honeycomb-team=<key>` |
|
|
498
|
+
| **OTel Collector** | `OTEL_EXPORTER_OTLP_ENDPOINT=http://your-collector:4318` |
|
|
499
|
+
| **Generic webhook** | `METRICS_WEBHOOK_URL=https://...` — POSTs a flat JSON payload with `pr`, `repo`, `author`, `branch`, `ai_lines`, `human_lines`, `total_lines`, `pct_ai`, and optionally `codebase_pct_ai` / `codebase_total_lines` / `cost_usd` / `input_tokens` / `output_tokens` |
|
|
500
|
+
|
|
501
|
+
When no destination is configured, the workflow runs in **dry-run mode** — it prints the OTLP payload to stdout and exits 0. This makes it safe to install and test before secrets are set.
|
|
502
|
+
|
|
503
|
+
Set secrets at the org level so they apply to all repos that use this tool. Multiple export destinations can be active simultaneously (e.g. both `OTEL_EXPORTER_OTLP_ENDPOINT` and `METRICS_WEBHOOK_URL`).
|
|
504
|
+
|
|
505
|
+
**Model pricing (org variables — update when Anthropic changes pricing):**
|
|
506
|
+
|
|
507
|
+
| Variable | Default | Description |
|
|
508
|
+
|----------|---------|-------------|
|
|
509
|
+
| `CLAUDE_PRICE_OPUS_INPUT` | `15.00` | $ per 1M input tokens (Claude Opus) |
|
|
510
|
+
| `CLAUDE_PRICE_OPUS_OUTPUT` | `75.00` | $ per 1M output tokens (Claude Opus) |
|
|
511
|
+
| `CLAUDE_PRICE_SONNET_INPUT` | `3.00` | $ per 1M input tokens (Claude Sonnet) |
|
|
512
|
+
| `CLAUDE_PRICE_SONNET_OUTPUT` | `15.00` | $ per 1M output tokens (Claude Sonnet) |
|
|
513
|
+
| `CLAUDE_PRICE_HAIKU_INPUT` | `0.80` | $ per 1M input tokens (Claude Haiku) |
|
|
514
|
+
| `CLAUDE_PRICE_HAIKU_OUTPUT` | `4.00` | $ per 1M output tokens (Claude Haiku) |
|
|
515
|
+
| `CLAUDE_PRICE_CACHE_READ_MULT` | `0.1` | Fraction of input price for cache reads |
|
|
516
|
+
| `CLAUDE_PRICE_CACHE_WRITE_MULT` | `1.25` | Fraction of input price for cache writes |
|
|
517
|
+
|
|
518
|
+
Unrecognized model names (new Claude releases) fall back to Opus pricing. Set these as org-level **variables** (not secrets) — they're not sensitive.
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
---
|
|
522
|
+
|
|
523
|
+
## AI Actor Attribution (Copilot Bot, @claude GHA)
|
|
524
|
+
|
|
525
|
+
The local post-commit hook only runs when code is committed on a developer's machine. Commits made server-side by AI bots — such as `@claude` via [claude-code-action](https://github.com/anthropics/claude-code-action) or the Copilot coding agent — bypass the local hook entirely and would otherwise appear as 100% human in metrics.
|
|
526
|
+
|
|
527
|
+
claude-attribution handles these two ways:
|
|
455
528
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
529
|
+
### 1. Auto-detection in metrics (no setup required)
|
|
530
|
+
|
|
531
|
+
When generating PR metrics, branch commits that have no git note are checked for known AI actor signals:
|
|
532
|
+
|
|
533
|
+
| Signal | Example |
|
|
534
|
+
|---|---|
|
|
535
|
+
| Bot author email/name | `github-actions[bot]`, `copilot[bot]` |
|
|
536
|
+
| `Co-authored-by:` trailer | `Co-authored-by: Claude <...>` |
|
|
537
|
+
| `Co-authored-by:` trailer | `Co-authored-by: GitHub Copilot <...>` |
|
|
538
|
+
|
|
539
|
+
If detected, all non-blank committed lines are counted as AI in the metrics output. No git note is written — this is a metrics-time synthesis only.
|
|
540
|
+
|
|
541
|
+
**Not detectable:** Copilot "Commit suggestion" (the button on PR review comments). Those commits are authored as the human who clicked the button — there is no metadata distinguishing them from a human edit. They will count as HUMAN.
|
|
542
|
+
|
|
543
|
+
### 2. `note-ai-commit` command (writes a permanent git note)
|
|
544
|
+
|
|
545
|
+
For `@claude` via claude-code-action, you can write a permanent git note at CI time so attribution is durable (survives metrics regeneration, appears in Datadog dashboard):
|
|
546
|
+
|
|
547
|
+
```bash
|
|
548
|
+
# Write a 100% AI note for HEAD (then push the note):
|
|
549
|
+
claude-attribution note-ai-commit --push
|
|
550
|
+
|
|
551
|
+
# Only write the note if the commit looks like an AI actor commit (safe on every push):
|
|
552
|
+
claude-attribution note-ai-commit --if-ai-actor --push
|
|
553
|
+
|
|
554
|
+
# Write a note for a specific SHA:
|
|
555
|
+
claude-attribution note-ai-commit abc1234 --push
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
#### Setting up for claude-code-action
|
|
559
|
+
|
|
560
|
+
If your repo uses [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action), run `claude-attribution install` — it detects the action and offers to install `claude-attribution-gha.yml`, which records attribution automatically on every AI actor push.
|
|
561
|
+
|
|
562
|
+
To install the workflow manually, add `.github/workflows/claude-attribution-gha.yml`:
|
|
563
|
+
|
|
564
|
+
```yaml
|
|
565
|
+
name: Claude Attribution — AI Actor Commits
|
|
566
|
+
|
|
567
|
+
on:
|
|
568
|
+
push:
|
|
569
|
+
branches: ["**"]
|
|
570
|
+
|
|
571
|
+
permissions:
|
|
572
|
+
contents: write
|
|
573
|
+
|
|
574
|
+
jobs:
|
|
575
|
+
note-ai-commit:
|
|
576
|
+
name: Record AI actor commit attribution
|
|
577
|
+
runs-on: ubuntu-latest # replace with your self-hosted runner label if needed
|
|
578
|
+
steps:
|
|
579
|
+
- uses: actions/checkout@v4
|
|
580
|
+
with:
|
|
581
|
+
fetch-depth: 0
|
|
582
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
583
|
+
|
|
584
|
+
- name: Fetch attribution notes
|
|
585
|
+
run: git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
|
|
586
|
+
|
|
587
|
+
- uses: oven-sh/setup-bun@v2
|
|
588
|
+
|
|
589
|
+
- name: Install claude-attribution
|
|
590
|
+
run: |
|
|
591
|
+
npm install -g --prefix "${HOME}/.npm-global" claude-attribution
|
|
592
|
+
echo "${HOME}/.npm-global/bin" >> "$GITHUB_PATH"
|
|
593
|
+
|
|
594
|
+
- name: Record attribution if AI actor commit
|
|
595
|
+
run: claude-attribution note-ai-commit --if-ai-actor --push
|
|
596
|
+
```
|
|
459
597
|
|
|
460
|
-
|
|
598
|
+
The `--if-ai-actor` flag makes this a silent no-op for human commits — it only fires when the commit author or message matches a known AI actor pattern.
|
|
461
599
|
|
|
462
600
|
---
|
|
463
601
|
|
|
@@ -504,7 +642,7 @@ Each hook invocation is a short-lived process that exits immediately. The trace
|
|
|
504
642
|
|
|
505
643
|
The post-commit hook may not have run. Check:
|
|
506
644
|
1. Is `bun` (or `tsx`) on your PATH in a git hook context? Run `which bun` from your shell, then check if that path is in `.git/hooks/post-commit`.
|
|
507
|
-
2. Did you run `
|
|
645
|
+
2. Did you run `claude-attribution install <repo>` for this specific repo?
|
|
508
646
|
3. Check `.claude/logs/attribution.jsonl` — if it's empty, the hook isn't firing.
|
|
509
647
|
|
|
510
648
|
**Attribution is 0% AI even though Claude wrote everything**
|
package/package.json
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type LineAttribution,
|
|
35
35
|
hashLine,
|
|
36
36
|
} from "./differ.ts";
|
|
37
|
+
import { parseTranscript } from "../metrics/transcript.ts";
|
|
37
38
|
import {
|
|
38
39
|
writeNote,
|
|
39
40
|
headSha,
|
|
@@ -146,6 +147,21 @@ async function main() {
|
|
|
146
147
|
totals: aggregateTotals(fileResults),
|
|
147
148
|
};
|
|
148
149
|
|
|
150
|
+
// Attach token usage from the Claude session transcript (non-fatal if unavailable)
|
|
151
|
+
if (sessionId) {
|
|
152
|
+
const tx = await parseTranscript(sessionId, repoRoot).catch(() => null);
|
|
153
|
+
if (tx && tx.byModel.length > 0) {
|
|
154
|
+
result.modelUsage = tx.byModel.map((m) => ({
|
|
155
|
+
modelFull: m.modelFull,
|
|
156
|
+
modelShort: m.modelShort,
|
|
157
|
+
inputTokens: m.inputTokens,
|
|
158
|
+
outputTokens: m.outputTokens,
|
|
159
|
+
cacheCreationTokens: m.cacheCreationTokens,
|
|
160
|
+
cacheReadTokens: m.cacheReadTokens,
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
149
165
|
// Write git note
|
|
150
166
|
await writeNote(result, repoRoot);
|
|
151
167
|
|
|
@@ -21,6 +21,16 @@ export interface FileAttribution {
|
|
|
21
21
|
pctAi: number;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Token usage captured from the Claude session transcript at commit time. */
|
|
25
|
+
export interface CommitModelUsage {
|
|
26
|
+
modelFull: string;
|
|
27
|
+
modelShort: "Opus" | "Sonnet" | "Haiku" | "Unknown";
|
|
28
|
+
inputTokens: number;
|
|
29
|
+
outputTokens: number;
|
|
30
|
+
cacheCreationTokens: number;
|
|
31
|
+
cacheReadTokens: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
export interface AttributionResult {
|
|
25
35
|
commit: string;
|
|
26
36
|
/** Session ID from .claude/attribution-state/current-session, or null if committed outside a Claude session. */
|
|
@@ -30,6 +40,8 @@ export interface AttributionResult {
|
|
|
30
40
|
timestamp: string;
|
|
31
41
|
files: FileAttribution[];
|
|
32
42
|
totals: Omit<FileAttribution, "path">;
|
|
43
|
+
/** Token usage from the Claude session that produced this commit. Absent for commits outside a Claude session or pre-v1.5.0 notes. */
|
|
44
|
+
modelUsage?: CommitModelUsage[];
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
/**
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { execFile } from "child_process";
|
|
15
15
|
import { promisify } from "util";
|
|
16
|
-
import
|
|
16
|
+
import {
|
|
17
|
+
aggregateTotals,
|
|
18
|
+
type AttributionResult,
|
|
19
|
+
type FileAttribution,
|
|
20
|
+
} from "./differ.ts";
|
|
17
21
|
|
|
18
22
|
const execFileAsync = promisify(execFile);
|
|
19
23
|
const NOTES_REF = "refs/notes/claude-attribution";
|
|
@@ -183,3 +187,129 @@ export async function committedContent(
|
|
|
183
187
|
return null;
|
|
184
188
|
}
|
|
185
189
|
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read the committed content of a file at any commit SHA.
|
|
193
|
+
* Returns null for deleted files or binary files should not be processed.
|
|
194
|
+
*/
|
|
195
|
+
export async function committedContentAt(
|
|
196
|
+
repoRoot: string,
|
|
197
|
+
sha: string,
|
|
198
|
+
relPath: string,
|
|
199
|
+
): Promise<string | null> {
|
|
200
|
+
try {
|
|
201
|
+
return await runRaw("git", ["show", `${sha}:${relPath}`], repoRoot);
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* List relative paths of files changed in any commit (not just HEAD).
|
|
209
|
+
*/
|
|
210
|
+
export async function filesInCommitAt(
|
|
211
|
+
repoRoot: string,
|
|
212
|
+
sha: string,
|
|
213
|
+
): Promise<string[]> {
|
|
214
|
+
const output = await run(
|
|
215
|
+
"git",
|
|
216
|
+
["diff-tree", "--no-commit-id", "-r", "--name-only", sha],
|
|
217
|
+
repoRoot,
|
|
218
|
+
);
|
|
219
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface CommitMeta {
|
|
223
|
+
authorName: string;
|
|
224
|
+
authorEmail: string;
|
|
225
|
+
/** Full commit message body. */
|
|
226
|
+
message: string;
|
|
227
|
+
/** Committer date in ISO 8601 format. */
|
|
228
|
+
timestamp: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get author, email, message, and timestamp for any commit SHA.
|
|
233
|
+
*/
|
|
234
|
+
export async function getCommitMeta(
|
|
235
|
+
repoRoot: string,
|
|
236
|
+
sha: string,
|
|
237
|
+
): Promise<CommitMeta> {
|
|
238
|
+
const nameEmail = await run(
|
|
239
|
+
"git",
|
|
240
|
+
["log", "-1", "--format=%an\n%ae", sha],
|
|
241
|
+
repoRoot,
|
|
242
|
+
);
|
|
243
|
+
const [authorName = "", authorEmail = ""] = nameEmail.split("\n");
|
|
244
|
+
const message = await run("git", ["log", "-1", "--format=%B", sha], repoRoot);
|
|
245
|
+
const timestamp = await run(
|
|
246
|
+
"git",
|
|
247
|
+
["log", "-1", "--format=%cI", sha],
|
|
248
|
+
repoRoot,
|
|
249
|
+
);
|
|
250
|
+
return { authorName, authorEmail, message, timestamp };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Return true if the commit was made by a known AI actor:
|
|
255
|
+
* - Bot authors: github-actions[bot], copilot[bot], etc.
|
|
256
|
+
* - Co-authored-by trailers referencing Claude or Copilot.
|
|
257
|
+
*
|
|
258
|
+
* Used to synthesize 100% AI attribution for commits that bypass the local
|
|
259
|
+
* post-commit hook (e.g. @claude via claude-code-action, Copilot coding agent).
|
|
260
|
+
*/
|
|
261
|
+
export function isKnownAiActorCommit(meta: CommitMeta): boolean {
|
|
262
|
+
const { authorName, authorEmail, message } = meta;
|
|
263
|
+
if (authorEmail.includes("[bot]") || authorName.includes("[bot]"))
|
|
264
|
+
return true;
|
|
265
|
+
if (/co-authored-by:.*claude/i.test(message)) return true;
|
|
266
|
+
if (/co-authored-by:.*copilot/i.test(message)) return true;
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Build a 100% AI AttributionResult for a commit without running the
|
|
272
|
+
* checkpoint-based differ. All non-blank committed lines are marked AI.
|
|
273
|
+
*
|
|
274
|
+
* Used by `note-ai-commit` (to write git notes in GHA) and by `collect.ts`
|
|
275
|
+
* (to synthesize attribution at metrics time for unattributed AI actor commits).
|
|
276
|
+
*/
|
|
277
|
+
export async function buildAllAiResult(
|
|
278
|
+
repoRoot: string,
|
|
279
|
+
sha: string,
|
|
280
|
+
): Promise<AttributionResult> {
|
|
281
|
+
const [changedFiles, meta] = await Promise.all([
|
|
282
|
+
filesInCommitAt(repoRoot, sha),
|
|
283
|
+
getCommitMeta(repoRoot, sha),
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
const fileResults: FileAttribution[] = (
|
|
287
|
+
await Promise.all(
|
|
288
|
+
changedFiles.map(async (relPath): Promise<FileAttribution | null> => {
|
|
289
|
+
const content = await committedContentAt(repoRoot, sha, relPath);
|
|
290
|
+
if (content === null || content.includes("\0")) return null;
|
|
291
|
+
const lines = content.split("\n");
|
|
292
|
+
const ai = lines.filter((l) => l.trim().length > 0).length;
|
|
293
|
+
const human = lines.length - ai;
|
|
294
|
+
const total = lines.length;
|
|
295
|
+
return {
|
|
296
|
+
path: relPath,
|
|
297
|
+
ai,
|
|
298
|
+
human,
|
|
299
|
+
mixed: 0,
|
|
300
|
+
total,
|
|
301
|
+
pctAi: total > 0 ? Math.round((ai / total) * 100) : 0,
|
|
302
|
+
};
|
|
303
|
+
}),
|
|
304
|
+
)
|
|
305
|
+
).filter((f): f is FileAttribution => f !== null);
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
commit: sha,
|
|
309
|
+
session: null,
|
|
310
|
+
branch: null,
|
|
311
|
+
timestamp: meta.timestamp,
|
|
312
|
+
files: fileResults,
|
|
313
|
+
totals: aggregateTotals(fileResults),
|
|
314
|
+
};
|
|
315
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -30,6 +30,12 @@ switch (cmd) {
|
|
|
30
30
|
case "pr":
|
|
31
31
|
await import("./commands/pr.ts");
|
|
32
32
|
break;
|
|
33
|
+
case "note-ai-commit":
|
|
34
|
+
await import("./commands/note-ai-commit.ts");
|
|
35
|
+
break;
|
|
36
|
+
case "pr-summary":
|
|
37
|
+
await import("./export/pr-summary.ts");
|
|
38
|
+
break;
|
|
33
39
|
case "init":
|
|
34
40
|
await import("./commands/init.ts");
|
|
35
41
|
break;
|
|
@@ -93,6 +99,8 @@ Commands:
|
|
|
93
99
|
metrics [id] Generate PR metrics report
|
|
94
100
|
pr [title] Create PR with metrics embedded (--draft, --base <branch>)
|
|
95
101
|
init [--ai | --ai-since <YYYY-MM-DD>] Set attribution baseline in the cumulative minimap
|
|
102
|
+
note-ai-commit [sha] [--push] [--if-ai-actor] Write 100% AI git note for a commit (GHA use)
|
|
103
|
+
pr-summary Export PR attribution metrics via OTLP/webhook (GHA use)
|
|
96
104
|
start Mark session start for per-ticket scoping
|
|
97
105
|
hook <name> Run an internal hook (used by installed git hooks)
|
|
98
106
|
version Print version
|