claude-attribution 1.1.3 → 1.2.1
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 +92 -19
- package/package.json +1 -1
- package/src/__tests__/minimap.test.ts +225 -0
- package/src/attribution/commit.ts +220 -43
- package/src/attribution/minimap.ts +178 -0
- package/src/cli.ts +4 -0
- package/src/commands/init.ts +165 -0
- package/src/export/pr-summary.ts +18 -0
- package/src/metrics/collect.ts +59 -2
- package/src/setup/install.ts +21 -0
- package/src/setup/templates/pr-metrics-workflow.yml +3 -1
- package/src/setup/uninstall.ts +9 -0
package/README.md
CHANGED
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
> ```bash
|
|
7
7
|
> npm install -g claude-attribution
|
|
8
8
|
> claude-attribution install ~/Code/your-repo
|
|
9
|
-
>
|
|
9
|
+
> claude-attribution init --ai # repo built with Claude Code — or --human if human/mixed
|
|
10
|
+
> git add .claude/settings.json .gitignore && git commit -m "chore: install claude-attribution hooks"
|
|
10
11
|
> ```
|
|
11
|
-
> From then on, just work normally. After each `git commit` you'll see a one-line attribution summary in your terminal. When you open a PR
|
|
12
|
+
> From then on, just work normally. After each `git commit` you'll see a one-line attribution summary in your terminal. When you're ready to open a PR, run `/pr` in Claude Code (or `claude-attribution pr "feat: your title"`) — it fills in the metrics automatically, no copy-paste needed.
|
|
12
13
|
>
|
|
13
|
-
> **Using Copilot?** The tool still works for tracking Claude usage alongside Copilot. Copilot line-level attribution isn't supported yet — for Copilot-specific stats, use the GitHub Copilot usage dashboard
|
|
14
|
+
> **Using Copilot?** The tool still works for tracking Claude usage alongside Copilot. Copilot line-level attribution isn't supported yet — for Copilot-specific stats, use the GitHub Copilot usage dashboard. Both tools' org-level data flows into the VP Datadog dashboard automatically on every PR merge.
|
|
14
15
|
>
|
|
15
|
-
> **Requirements:** [Bun](https://bun.sh) (preferred) or Node 18+, and `gh` (GitHub CLI) authenticated.
|
|
16
|
+
> **Requirements:** [Bun](https://bun.sh) (preferred) or Node 18+, and `gh` (GitHub CLI) authenticated for the `/pr` command.
|
|
16
17
|
|
|
17
18
|
---
|
|
18
19
|
|
|
@@ -46,17 +47,45 @@ bun install
|
|
|
46
47
|
|
|
47
48
|
### Install into a repo (per repo, per developer)
|
|
48
49
|
|
|
49
|
-
**
|
|
50
|
+
**Step 1 — Run the installer:**
|
|
51
|
+
|
|
50
52
|
```bash
|
|
53
|
+
# npm install:
|
|
51
54
|
claude-attribution install ~/Code/your-repo
|
|
55
|
+
|
|
56
|
+
# clone install:
|
|
57
|
+
bun ~/Code/claude-attribution/src/setup/install.ts ~/Code/your-repo
|
|
52
58
|
```
|
|
53
59
|
|
|
54
|
-
**
|
|
60
|
+
**Step 2 — Declare your attribution baseline (`init`):**
|
|
61
|
+
|
|
62
|
+
This step tells the tool whether the codebase was written by Claude or by humans before this install. It only needs to be run once.
|
|
63
|
+
|
|
55
64
|
```bash
|
|
56
|
-
|
|
65
|
+
# Repo was built entirely with Claude Code — mark all files as AI-written:
|
|
66
|
+
claude-attribution init --ai
|
|
67
|
+
|
|
68
|
+
# Repo is human-written, or a mix — confirm the default (no note written):
|
|
69
|
+
claude-attribution init --human
|
|
70
|
+
|
|
71
|
+
# Not sure? Run with no flag — same as --human, prints a confirmation:
|
|
72
|
+
claude-attribution init
|
|
57
73
|
```
|
|
58
74
|
|
|
59
|
-
|
|
75
|
+
> **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.
|
|
76
|
+
|
|
77
|
+
**Step 3 — Commit and push:**
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
git add .claude/settings.json .github/workflows/claude-attribution-pr.yml .gitignore
|
|
81
|
+
git commit -m "chore: install claude-attribution hooks"
|
|
82
|
+
git push
|
|
83
|
+
|
|
84
|
+
# If you ran init --ai, also push the minimap notes:
|
|
85
|
+
git push origin refs/notes/claude-attribution-map
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The installer makes the following changes to the target repo:
|
|
60
89
|
|
|
61
90
|
**`.claude/settings.json`** — merges six Claude Code hooks:
|
|
62
91
|
|
|
@@ -70,7 +99,7 @@ The installer makes six changes to the target repo:
|
|
|
70
99
|
|
|
71
100
|
**`.git/hooks/post-commit`** — runs attribution after every commit. If the repo already has a `post-commit` hook from Husky or another tool, the call is appended rather than replacing it. For Lefthook repos, the installer prints the config snippet to add manually.
|
|
72
101
|
|
|
73
|
-
**`remote.origin.push`
|
|
102
|
+
**`remote.origin.push` refspecs** — the installer adds two refspecs so that `git push` (without an explicit refspec) automatically includes both notes refs: `refs/notes/claude-attribution` (per-commit attribution) and `refs/notes/claude-attribution-map` (cumulative minimap). No pre-push hook is installed — a hook that pushes notes concurrently with the main push causes SSH connection conflicts on GitHub.
|
|
74
103
|
|
|
75
104
|
**`.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).
|
|
76
105
|
|
|
@@ -80,14 +109,34 @@ The installer makes six changes to the target repo:
|
|
|
80
109
|
|
|
81
110
|
**`.gitignore`** — adds `.claude/logs/` so tool usage logs don't end up in version control.
|
|
82
111
|
|
|
83
|
-
###
|
|
112
|
+
### Attribution minimap — detailed options
|
|
113
|
+
|
|
114
|
+
The attribution minimap tracks cumulative AI% across the entire codebase, carrying attribution forward across sessions and developers. For new commits it is updated automatically. For the history that predates the install, you declare the baseline once using `claude-attribution init`.
|
|
84
115
|
|
|
85
|
-
|
|
116
|
+
There are three options depending on the history of your repo:
|
|
86
117
|
|
|
118
|
+
**Option 1 — Repo was built entirely with Claude Code (`--ai`):**
|
|
87
119
|
```bash
|
|
88
|
-
|
|
89
|
-
git
|
|
90
|
-
|
|
120
|
+
claude-attribution init --ai
|
|
121
|
+
git push origin refs/notes/claude-attribution-map
|
|
122
|
+
```
|
|
123
|
+
Marks every currently tracked file as AI-written at HEAD. After this, PR metrics will show:
|
|
124
|
+
```
|
|
125
|
+
Codebase: ~100% AI (4150 / 4150 lines)
|
|
126
|
+
This PR: 184 lines changed (4% of codebase) · 77% Claude edits · 142 AI lines
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Option 2 — Repo is human-written, or a mix (`--human` / no flag):**
|
|
130
|
+
```bash
|
|
131
|
+
claude-attribution init # or: claude-attribution init --human
|
|
132
|
+
```
|
|
133
|
+
Prints a confirmation that the default human baseline is already in effect — no note is written. Attribution accumulates naturally as Claude writes new code going forward.
|
|
134
|
+
|
|
135
|
+
**Option 3 — Already had claude-attribution installed before v1.2.0:**
|
|
136
|
+
The minimap feature didn't exist before v1.2.0 — per-session notes are intact but the codebase-wide signal was missing. Run `init --ai` now if the repo is all Claude Code, or do nothing (human default) if it's a mix:
|
|
137
|
+
```bash
|
|
138
|
+
claude-attribution init --ai # only if repo was built 100% with Claude Code
|
|
139
|
+
git push origin refs/notes/claude-attribution-map
|
|
91
140
|
```
|
|
92
141
|
|
|
93
142
|
### Re-installing after moving this directory
|
|
@@ -150,12 +199,13 @@ Metrics are injected automatically — no command needed.
|
|
|
150
199
|
|
|
151
200
|
**On every new push to an open PR**: the workflow fires on `synchronize` and updates the attribution percentages to reflect new commits.
|
|
152
201
|
|
|
153
|
-
The metrics block looks like:
|
|
202
|
+
The metrics block looks like (when the cumulative minimap exists):
|
|
154
203
|
|
|
155
204
|
```markdown
|
|
156
205
|
## Claude Code Metrics
|
|
157
206
|
|
|
158
|
-
**
|
|
207
|
+
**Codebase: ~77% AI** (3200 / 4150 lines)
|
|
208
|
+
**This PR:** 184 lines changed (4% of codebase) · 77% Claude edits · 142 AI lines · Active: 8m
|
|
159
209
|
|
|
160
210
|
| Model | Calls | Input | Output | Cache |
|
|
161
211
|
|-------|-------|-------|--------|-------|
|
|
@@ -172,6 +222,12 @@ The metrics block looks like:
|
|
|
172
222
|
</details>
|
|
173
223
|
```
|
|
174
224
|
|
|
225
|
+
Before running `init --ai` (or on a fresh install with no minimap), the headline falls back to the session-only view:
|
|
226
|
+
|
|
227
|
+
```markdown
|
|
228
|
+
**AI contribution: ~77%** (142 of 184 committed lines) · Active: 8m
|
|
229
|
+
```
|
|
230
|
+
|
|
175
231
|
#### Manual option
|
|
176
232
|
|
|
177
233
|
If you need to create a PR with metrics outside of Claude, use the `/pr` slash command or CLI directly:
|
|
@@ -238,11 +294,17 @@ Session IDs are shown in `.claude/logs/tool-usage.jsonl`.
|
|
|
238
294
|
Attribution results are stored as git notes and queryable directly:
|
|
239
295
|
|
|
240
296
|
```bash
|
|
241
|
-
# View attribution for the last commit
|
|
297
|
+
# View per-commit attribution for the last commit
|
|
242
298
|
git notes --ref=claude-attribution show HEAD
|
|
243
299
|
|
|
244
300
|
# List all attributed commits in the repo
|
|
245
301
|
git notes --ref=claude-attribution list
|
|
302
|
+
|
|
303
|
+
# View the cumulative codebase minimap (all files, AI% totals)
|
|
304
|
+
git notes --ref=refs/notes/claude-attribution-map show HEAD | jq .totals
|
|
305
|
+
|
|
306
|
+
# Check codebase AI% quickly
|
|
307
|
+
git notes --ref=refs/notes/claude-attribution-map show HEAD | jq .totals.pctAi
|
|
246
308
|
```
|
|
247
309
|
|
|
248
310
|
Example output:
|
|
@@ -336,7 +398,7 @@ Running `/start` scopes both tool/token metrics AND attribution data to commits
|
|
|
336
398
|
|
|
337
399
|
- **MIXED detection is positional (best-effort)** — MIXED is detected by checking whether Claude's i-th line was changed in the committed file. If a human inserts or deletes lines above position `i`, the commit's line positions shift while the after-snapshot's positions don't, causing false MIXED classifications. MIXED is most accurate when human edits are small in-place tweaks (e.g., changing a value on a line Claude wrote) rather than bulk insertions or deletions.
|
|
338
400
|
|
|
339
|
-
- **Sessions without checkpoints** — commits made outside an active Claude session (no `current-session` file, or checkpoints already cleaned up) are attributed 100% HUMAN.
|
|
401
|
+
- **Sessions without checkpoints** — commits made outside an active Claude session (no `current-session` file, or checkpoints already cleaned up) are attributed 100% HUMAN for that commit's per-session stats. However, the cumulative minimap carries AI attribution forward for untouched lines from previous sessions — so the codebase-wide AI% is not lost when another developer commits without hooks installed.
|
|
340
402
|
|
|
341
403
|
- **`git commit --amend`** — when a commit is amended, the original SHA is replaced but the old git note (pointing to the now-orphaned SHA) remains in the notes object store. `/metrics` reads notes across the entire branch, so an amended commit's lines may appear twice. Avoid amending published commits; if you do, run `/metrics` knowing totals may be slightly inflated for that commit's files.
|
|
342
404
|
|
|
@@ -377,7 +439,9 @@ Attribution data is pushed to Datadog automatically on every PR merge via GitHub
|
|
|
377
439
|
| `claude_attribution.ai_lines` | Lines written by Claude and committed unchanged |
|
|
378
440
|
| `claude_attribution.human_lines` | Lines written or left unchanged by the developer |
|
|
379
441
|
| `claude_attribution.total_lines` | Total committed lines in the PR |
|
|
380
|
-
| `claude_attribution.pct_ai` | Percentage of lines attributed to Claude |
|
|
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 |
|
|
381
445
|
| `github_copilot.acceptance_rate` | Org-level Copilot suggestion acceptance rate |
|
|
382
446
|
| `github_copilot.lines_accepted` | Copilot lines accepted org-wide |
|
|
383
447
|
| `github_copilot.lines_suggested` | Copilot lines suggested org-wide |
|
|
@@ -447,6 +511,15 @@ The post-commit hook may not have run. Check:
|
|
|
447
511
|
|
|
448
512
|
Most likely the commit happened in a different terminal session from where Claude is running. The `current-session` file in `.claude/attribution-state/` needs to match the session that created the checkpoints. Start Claude, make your changes, and commit without switching sessions.
|
|
449
513
|
|
|
514
|
+
**Codebase AI% shows 0% (or very low) even though the repo is all Claude Code**
|
|
515
|
+
|
|
516
|
+
The cumulative minimap hasn't been initialized yet. Run once to backfill:
|
|
517
|
+
```bash
|
|
518
|
+
claude-attribution init --ai
|
|
519
|
+
git push origin refs/notes/claude-attribution-map
|
|
520
|
+
```
|
|
521
|
+
See [Backfilling the attribution minimap](#backfilling-the-attribution-minimap) for details.
|
|
522
|
+
|
|
450
523
|
**The hook is slowing down commits**
|
|
451
524
|
|
|
452
525
|
The post-commit hook runs attribution after the commit is already recorded — it can't block the commit. If you see a pause, it's likely runtime startup time on a cold start (~100ms for Bun, ~300ms for npx tsx). Subsequent runs use the cached runtime from `/tmp/claude-attribution-runtime` and are faster.
|
package/package.json
CHANGED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
hashSetFromString,
|
|
4
|
+
hashSetToString,
|
|
5
|
+
computeMinimapFile,
|
|
6
|
+
} from "../attribution/minimap.ts";
|
|
7
|
+
import { hashLine } from "../attribution/differ.ts";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// hashSetFromString / hashSetToString round-trip
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
describe("hashSetFromString", () => {
|
|
14
|
+
test("empty string returns empty set", () => {
|
|
15
|
+
expect(hashSetFromString("")).toEqual(new Set());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("parses single 16-char hash", () => {
|
|
19
|
+
const hash = "abcd1234abcd1234";
|
|
20
|
+
expect(hashSetFromString(hash)).toEqual(new Set([hash]));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("parses two concatenated hashes", () => {
|
|
24
|
+
const h1 = "aaaa1111aaaa1111";
|
|
25
|
+
const h2 = "bbbb2222bbbb2222";
|
|
26
|
+
expect(hashSetFromString(h1 + h2)).toEqual(new Set([h1, h2]));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("ignores trailing partial hash (< 16 chars)", () => {
|
|
30
|
+
const full = "aaaa1111aaaa1111";
|
|
31
|
+
expect(hashSetFromString(full + "tooshort")).toEqual(new Set([full]));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("hashSetToString", () => {
|
|
36
|
+
test("empty set returns empty string", () => {
|
|
37
|
+
expect(hashSetToString(new Set())).toBe("");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("single hash round-trips", () => {
|
|
41
|
+
const hash = "abcd1234abcd1234";
|
|
42
|
+
expect(hashSetToString(new Set([hash]))).toBe(hash);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("hashSetFromString / hashSetToString round-trip", () => {
|
|
47
|
+
test("multiple hashes survive encode-decode", () => {
|
|
48
|
+
const hashes = new Set([
|
|
49
|
+
"aaaa1111aaaa1111",
|
|
50
|
+
"bbbb2222bbbb2222",
|
|
51
|
+
"cccc3333cccc3333",
|
|
52
|
+
]);
|
|
53
|
+
const encoded = hashSetToString(hashes);
|
|
54
|
+
expect(hashSetFromString(encoded)).toEqual(hashes);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// computeMinimapFile
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
describe("computeMinimapFile", () => {
|
|
63
|
+
test("new AI line (in currentAiHashes) → ai=1, in ai_hashes", () => {
|
|
64
|
+
const line = "const x = 1;";
|
|
65
|
+
const hash = hashLine(line);
|
|
66
|
+
const result = computeMinimapFile(
|
|
67
|
+
"foo.ts",
|
|
68
|
+
[line],
|
|
69
|
+
new Set([hash]),
|
|
70
|
+
new Set(),
|
|
71
|
+
);
|
|
72
|
+
expect(result.ai).toBe(1);
|
|
73
|
+
expect(result.human).toBe(0);
|
|
74
|
+
expect(result.total).toBe(1);
|
|
75
|
+
expect(result.pctAi).toBe(100);
|
|
76
|
+
expect(hashSetFromString(result.ai_hashes).has(hash)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("carry-forward AI line (in prevAiHashSet only) → ai=1", () => {
|
|
80
|
+
const line = "const y = 2;";
|
|
81
|
+
const hash = hashLine(line);
|
|
82
|
+
const result = computeMinimapFile(
|
|
83
|
+
"foo.ts",
|
|
84
|
+
[line],
|
|
85
|
+
new Set(), // not in current session
|
|
86
|
+
new Set([hash]), // but in prev minimap
|
|
87
|
+
);
|
|
88
|
+
expect(result.ai).toBe(1);
|
|
89
|
+
expect(result.human).toBe(0);
|
|
90
|
+
expect(hashSetFromString(result.ai_hashes).has(hash)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("human line (in neither set) → human=1, not in ai_hashes", () => {
|
|
94
|
+
const line = "const z = 3;";
|
|
95
|
+
const hash = hashLine(line);
|
|
96
|
+
const result = computeMinimapFile("foo.ts", [line], new Set(), new Set());
|
|
97
|
+
expect(result.ai).toBe(0);
|
|
98
|
+
expect(result.human).toBe(1);
|
|
99
|
+
expect(hashSetFromString(result.ai_hashes).has(hash)).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("blank line always human (never in ai_hashes)", () => {
|
|
103
|
+
const aiHash = hashLine("");
|
|
104
|
+
const result = computeMinimapFile(
|
|
105
|
+
"foo.ts",
|
|
106
|
+
[""],
|
|
107
|
+
new Set([aiHash]),
|
|
108
|
+
new Set([aiHash]),
|
|
109
|
+
);
|
|
110
|
+
expect(result.ai).toBe(0);
|
|
111
|
+
expect(result.human).toBe(1);
|
|
112
|
+
expect(result.ai_hashes).toBe("");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("whitespace-only line treated as blank (always human)", () => {
|
|
116
|
+
const line = " ";
|
|
117
|
+
const aiHash = hashLine(line);
|
|
118
|
+
const result = computeMinimapFile(
|
|
119
|
+
"foo.ts",
|
|
120
|
+
[line],
|
|
121
|
+
new Set([aiHash]),
|
|
122
|
+
new Set([aiHash]),
|
|
123
|
+
);
|
|
124
|
+
expect(result.ai).toBe(0);
|
|
125
|
+
expect(result.human).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("MIXED line — not in currentAiHashes → human even if in prevAiHashSet", () => {
|
|
129
|
+
// A MIXED line means Claude wrote it then a human modified it.
|
|
130
|
+
// The commit.ts attributeLines() result for MIXED lines only marks "AI"
|
|
131
|
+
// labeled lines in sessionAiByPath — MIXED lines are excluded.
|
|
132
|
+
// So if a hash is in prevAiHashSet but NOT in currentAiHashes,
|
|
133
|
+
// AND it's the committed version (i.e., human changed it), it stays human.
|
|
134
|
+
// We simulate this: prevAiHashSet has a hash, currentAiHashes is empty.
|
|
135
|
+
// But the committed line is DIFFERENT (human modified), so its hash is
|
|
136
|
+
// NOT in either set.
|
|
137
|
+
const prevLine = "const x = aiVersion;";
|
|
138
|
+
const prevHash = hashLine(prevLine);
|
|
139
|
+
const committedLine = "const x = humanVersion;";
|
|
140
|
+
const result = computeMinimapFile(
|
|
141
|
+
"foo.ts",
|
|
142
|
+
[committedLine],
|
|
143
|
+
new Set(), // current session: no AI label for this line
|
|
144
|
+
new Set([prevHash]), // prev minimap had the old AI hash
|
|
145
|
+
);
|
|
146
|
+
// committedLine hash is not in either set → human
|
|
147
|
+
expect(result.ai).toBe(0);
|
|
148
|
+
expect(result.human).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("all-AI file", () => {
|
|
152
|
+
const lines = ["line one", "line two", "line three"];
|
|
153
|
+
const aiHashes = new Set(lines.map(hashLine));
|
|
154
|
+
const result = computeMinimapFile("foo.ts", lines, aiHashes, new Set());
|
|
155
|
+
expect(result.ai).toBe(3);
|
|
156
|
+
expect(result.human).toBe(0);
|
|
157
|
+
expect(result.pctAi).toBe(100);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("all-human file", () => {
|
|
161
|
+
const lines = ["line one", "line two", "line three"];
|
|
162
|
+
const result = computeMinimapFile("foo.ts", lines, new Set(), new Set());
|
|
163
|
+
expect(result.ai).toBe(0);
|
|
164
|
+
expect(result.human).toBe(3);
|
|
165
|
+
expect(result.pctAi).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("removed AI line (hash not in committedLines) → absent from result", () => {
|
|
169
|
+
// The old AI line was deleted; committedLines no longer contain it.
|
|
170
|
+
const oldAiLine = "const removed = true;";
|
|
171
|
+
const oldAiHash = hashLine(oldAiLine);
|
|
172
|
+
const committedLines = ["const kept = 1;"];
|
|
173
|
+
const result = computeMinimapFile(
|
|
174
|
+
"foo.ts",
|
|
175
|
+
committedLines,
|
|
176
|
+
new Set(), // session had no AI lines this commit
|
|
177
|
+
new Set([oldAiHash]), // prev minimap had the old line as AI
|
|
178
|
+
);
|
|
179
|
+
// Old hash not in committedLines → not counted as AI
|
|
180
|
+
expect(result.ai).toBe(0);
|
|
181
|
+
expect(result.human).toBe(1);
|
|
182
|
+
expect(hashSetFromString(result.ai_hashes).has(oldAiHash)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("duplicate content lines — set deduplication + correct counts", () => {
|
|
186
|
+
// Two lines with identical content → one hash in the set, but both counted
|
|
187
|
+
const line = "return null;";
|
|
188
|
+
const hash = hashLine(line);
|
|
189
|
+
const result = computeMinimapFile(
|
|
190
|
+
"foo.ts",
|
|
191
|
+
[line, line],
|
|
192
|
+
new Set([hash]),
|
|
193
|
+
new Set(),
|
|
194
|
+
);
|
|
195
|
+
// Both lines match the AI hash → ai=2
|
|
196
|
+
expect(result.ai).toBe(2);
|
|
197
|
+
expect(result.human).toBe(0);
|
|
198
|
+
expect(result.total).toBe(2);
|
|
199
|
+
// ai_hashes only stores the hash once (it's a set)
|
|
200
|
+
expect(hashSetFromString(result.ai_hashes).size).toBe(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("pctAi rounds correctly", () => {
|
|
204
|
+
// 1 AI out of 3 total → 33%
|
|
205
|
+
const aiLine = "ai line";
|
|
206
|
+
const humanLine1 = "human line one";
|
|
207
|
+
const humanLine2 = "human line two";
|
|
208
|
+
const result = computeMinimapFile(
|
|
209
|
+
"foo.ts",
|
|
210
|
+
[aiLine, humanLine1, humanLine2],
|
|
211
|
+
new Set([hashLine(aiLine)]),
|
|
212
|
+
new Set(),
|
|
213
|
+
);
|
|
214
|
+
expect(result.pctAi).toBe(33);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("empty file → all zeros, no crash", () => {
|
|
218
|
+
const result = computeMinimapFile("empty.ts", [], new Set(), new Set());
|
|
219
|
+
expect(result.ai).toBe(0);
|
|
220
|
+
expect(result.human).toBe(0);
|
|
221
|
+
expect(result.total).toBe(0);
|
|
222
|
+
expect(result.pctAi).toBe(0);
|
|
223
|
+
expect(result.ai_hashes).toBe("");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -21,12 +21,18 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { resolve, join } from "path";
|
|
23
23
|
import { mkdir, appendFile } from "fs/promises";
|
|
24
|
+
import { execFile } from "child_process";
|
|
25
|
+
import { promisify } from "util";
|
|
26
|
+
|
|
27
|
+
const execFileAsync = promisify(execFile);
|
|
24
28
|
import { loadCheckpoint, readCurrentSession } from "./checkpoint.ts";
|
|
25
29
|
import {
|
|
26
30
|
attributeLines,
|
|
27
31
|
aggregateTotals,
|
|
28
32
|
type AttributionResult,
|
|
29
33
|
type FileAttribution,
|
|
34
|
+
type LineAttribution,
|
|
35
|
+
hashLine,
|
|
30
36
|
} from "./differ.ts";
|
|
31
37
|
import {
|
|
32
38
|
writeNote,
|
|
@@ -35,6 +41,14 @@ import {
|
|
|
35
41
|
committedContent,
|
|
36
42
|
currentBranch,
|
|
37
43
|
} from "./git-notes.ts";
|
|
44
|
+
import {
|
|
45
|
+
computeMinimapFile,
|
|
46
|
+
hashSetFromString,
|
|
47
|
+
readMinimap,
|
|
48
|
+
writeMinimap,
|
|
49
|
+
type MinimapFileState,
|
|
50
|
+
type MinimapResult,
|
|
51
|
+
} from "./minimap.ts";
|
|
38
52
|
import {
|
|
39
53
|
otelEndpoint,
|
|
40
54
|
otelHeaders,
|
|
@@ -54,58 +68,74 @@ async function main() {
|
|
|
54
68
|
filesInCommit(repoRoot),
|
|
55
69
|
]);
|
|
56
70
|
|
|
57
|
-
// Process files in parallel — each file attribution is independent
|
|
58
|
-
|
|
71
|
+
// Process files in parallel — each file attribution is independent.
|
|
72
|
+
// Return type includes attribution[] so the minimap block can build currentAiHashes.
|
|
73
|
+
type FileAttributionWithLines = FileAttribution & {
|
|
74
|
+
attribution: LineAttribution[];
|
|
75
|
+
committedLines: string[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const filesWithAttribution = (
|
|
59
79
|
await Promise.all(
|
|
60
|
-
changedFiles.map(
|
|
61
|
-
|
|
62
|
-
|
|
80
|
+
changedFiles.map(
|
|
81
|
+
async (relPath): Promise<FileAttributionWithLines | null> => {
|
|
82
|
+
const absPath = join(repoRoot, relPath);
|
|
83
|
+
const committed = await committedContent(repoRoot, relPath);
|
|
63
84
|
|
|
64
|
-
|
|
65
|
-
|
|
85
|
+
// Deleted file — skip attribution
|
|
86
|
+
if (committed === null) return null;
|
|
66
87
|
|
|
67
|
-
|
|
68
|
-
|
|
88
|
+
// Binary file — null bytes indicate binary content; line-splitting produces garbage
|
|
89
|
+
if (committed.includes("\0")) return null;
|
|
69
90
|
|
|
70
|
-
|
|
91
|
+
const committedLines = committed.split("\n");
|
|
92
|
+
const empty: LineAttribution[] = committedLines.map(() => "HUMAN");
|
|
71
93
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
if (!sessionId) {
|
|
95
|
+
return {
|
|
96
|
+
path: relPath,
|
|
97
|
+
ai: 0,
|
|
98
|
+
human: committedLines.length,
|
|
99
|
+
mixed: 0,
|
|
100
|
+
total: committedLines.length,
|
|
101
|
+
pctAi: 0,
|
|
102
|
+
attribution: empty,
|
|
103
|
+
committedLines,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
83
106
|
|
|
84
|
-
|
|
85
|
-
|
|
107
|
+
const before = await loadCheckpoint(sessionId, absPath, "before");
|
|
108
|
+
const after = await loadCheckpoint(sessionId, absPath, "after");
|
|
86
109
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
110
|
+
if (!after) {
|
|
111
|
+
return {
|
|
112
|
+
path: relPath,
|
|
113
|
+
ai: 0,
|
|
114
|
+
human: committedLines.length,
|
|
115
|
+
mixed: 0,
|
|
116
|
+
total: committedLines.length,
|
|
117
|
+
pctAi: 0,
|
|
118
|
+
attribution: empty,
|
|
119
|
+
committedLines,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
98
122
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
123
|
+
const beforeLines = before?.lines ?? [];
|
|
124
|
+
const { stats, attribution } = attributeLines(
|
|
125
|
+
beforeLines,
|
|
126
|
+
after.lines,
|
|
127
|
+
committedLines,
|
|
128
|
+
);
|
|
129
|
+
return { ...stats, path: relPath, attribution, committedLines };
|
|
130
|
+
},
|
|
131
|
+
),
|
|
107
132
|
)
|
|
108
|
-
).filter((r): r is
|
|
133
|
+
).filter((r): r is FileAttributionWithLines => r !== null);
|
|
134
|
+
|
|
135
|
+
// Strip attribution/committedLines before building the AttributionResult
|
|
136
|
+
const fileResults: FileAttribution[] = filesWithAttribution.map(
|
|
137
|
+
({ attribution: _a, committedLines: _c, ...f }) => f,
|
|
138
|
+
);
|
|
109
139
|
|
|
110
140
|
const result: AttributionResult = {
|
|
111
141
|
commit: sha,
|
|
@@ -119,6 +149,153 @@ async function main() {
|
|
|
119
149
|
// Write git note
|
|
120
150
|
await writeNote(result, repoRoot);
|
|
121
151
|
|
|
152
|
+
// Update cumulative minimap — non-fatal, never blocks commits
|
|
153
|
+
try {
|
|
154
|
+
// Load parent commit's minimap for carry-forward
|
|
155
|
+
const parentSha = await execFileAsync("git", ["rev-parse", "HEAD^1"], {
|
|
156
|
+
cwd: repoRoot,
|
|
157
|
+
})
|
|
158
|
+
.then((r: { stdout: string }) => r.stdout.trim())
|
|
159
|
+
.catch(() => null);
|
|
160
|
+
|
|
161
|
+
const prevMinimap = parentSha
|
|
162
|
+
? await readMinimap(repoRoot, parentSha)
|
|
163
|
+
: null;
|
|
164
|
+
|
|
165
|
+
// Build lookup: file path → Set of AI hashes from previous minimap
|
|
166
|
+
const prevAiByFile = new Map<string, Set<string>>();
|
|
167
|
+
if (prevMinimap) {
|
|
168
|
+
for (const f of prevMinimap.files) {
|
|
169
|
+
prevAiByFile.set(f.path, hashSetFromString(f.ai_hashes));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Build lookup: file path → Set of AI hashes from this session
|
|
174
|
+
const sessionAiByPath = new Map<string, Set<string>>();
|
|
175
|
+
for (const f of filesWithAttribution) {
|
|
176
|
+
const aiHashes = new Set<string>();
|
|
177
|
+
for (let i = 0; i < f.committedLines.length; i++) {
|
|
178
|
+
if (f.attribution[i] === "AI") {
|
|
179
|
+
aiHashes.add(hashLine(f.committedLines[i] ?? ""));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
sessionAiByPath.set(f.path, aiHashes);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Get all tracked files in HEAD
|
|
186
|
+
const lsResult = (await execFileAsync("git", ["ls-files"], {
|
|
187
|
+
cwd: repoRoot,
|
|
188
|
+
})) as unknown as { stdout: string };
|
|
189
|
+
const allFiles = lsResult.stdout.trim().split("\n").filter(Boolean);
|
|
190
|
+
|
|
191
|
+
const changedFileSet = new Set(changedFiles);
|
|
192
|
+
|
|
193
|
+
// Build minimap state for every tracked file
|
|
194
|
+
const CONCURRENCY = 8;
|
|
195
|
+
const minimapFiles: MinimapFileState[] = [];
|
|
196
|
+
|
|
197
|
+
for (let i = 0; i < allFiles.length; i += CONCURRENCY) {
|
|
198
|
+
const batch = allFiles.slice(i, i + CONCURRENCY);
|
|
199
|
+
const batchResults = await Promise.all(
|
|
200
|
+
batch.map(async (relPath): Promise<MinimapFileState> => {
|
|
201
|
+
const prevAiSet = prevAiByFile.get(relPath) ?? new Set<string>();
|
|
202
|
+
|
|
203
|
+
if (changedFileSet.has(relPath)) {
|
|
204
|
+
// File changed this commit — recompute using session data
|
|
205
|
+
const fileWithAttr = filesWithAttribution.find(
|
|
206
|
+
(f) => f.path === relPath,
|
|
207
|
+
);
|
|
208
|
+
if (!fileWithAttr) {
|
|
209
|
+
// Changed but no attribution (deleted/binary) — carry forward or all-human
|
|
210
|
+
const prev = prevMinimap?.files.find((f) => f.path === relPath);
|
|
211
|
+
return prev
|
|
212
|
+
? { ...prev }
|
|
213
|
+
: {
|
|
214
|
+
path: relPath,
|
|
215
|
+
ai_hashes: "",
|
|
216
|
+
ai: 0,
|
|
217
|
+
human: 0,
|
|
218
|
+
total: 0,
|
|
219
|
+
pctAi: 0,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const currentAiSet =
|
|
223
|
+
sessionAiByPath.get(relPath) ?? new Set<string>();
|
|
224
|
+
return computeMinimapFile(
|
|
225
|
+
relPath,
|
|
226
|
+
fileWithAttr.committedLines,
|
|
227
|
+
currentAiSet,
|
|
228
|
+
prevAiSet,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// File unchanged this commit
|
|
233
|
+
const prevEntry = prevMinimap?.files.find((f) => f.path === relPath);
|
|
234
|
+
if (prevEntry) {
|
|
235
|
+
// Carry forward existing minimap entry unchanged
|
|
236
|
+
return { ...prevEntry };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// File exists in repo but has no prior minimap entry (pre-install file)
|
|
240
|
+
// Baseline as all-Human
|
|
241
|
+
const committed = await committedContent(repoRoot, relPath).catch(
|
|
242
|
+
() => null,
|
|
243
|
+
);
|
|
244
|
+
if (!committed || committed.includes("\0")) {
|
|
245
|
+
return {
|
|
246
|
+
path: relPath,
|
|
247
|
+
ai_hashes: "",
|
|
248
|
+
ai: 0,
|
|
249
|
+
human: 0,
|
|
250
|
+
total: 0,
|
|
251
|
+
pctAi: 0,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const lines = committed.split("\n");
|
|
255
|
+
return {
|
|
256
|
+
path: relPath,
|
|
257
|
+
ai_hashes: "",
|
|
258
|
+
ai: 0,
|
|
259
|
+
human: lines.length,
|
|
260
|
+
total: lines.length,
|
|
261
|
+
pctAi: 0,
|
|
262
|
+
};
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
minimapFiles.push(...batchResults);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Compute totals
|
|
269
|
+
let mAi = 0,
|
|
270
|
+
mHuman = 0,
|
|
271
|
+
mTotal = 0;
|
|
272
|
+
for (const f of minimapFiles) {
|
|
273
|
+
mAi += f.ai;
|
|
274
|
+
mHuman += f.human;
|
|
275
|
+
mTotal += f.total;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const minimapResult: MinimapResult = {
|
|
279
|
+
commit: sha,
|
|
280
|
+
timestamp: new Date().toISOString(),
|
|
281
|
+
files: minimapFiles,
|
|
282
|
+
totals: {
|
|
283
|
+
ai: mAi,
|
|
284
|
+
human: mHuman,
|
|
285
|
+
total: mTotal,
|
|
286
|
+
pctAi: mTotal > 0 ? Math.round((mAi / mTotal) * 100) : 0,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
await writeMinimap(minimapResult, repoRoot);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
// Never block the commit
|
|
293
|
+
console.error(
|
|
294
|
+
"[claude-attribution] minimap update failed (non-fatal):",
|
|
295
|
+
err,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
122
299
|
// Append to local log
|
|
123
300
|
const logDir = join(repoRoot, ".claude", "logs");
|
|
124
301
|
await mkdir(logDir, { recursive: true });
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cumulative AI attribution minimap.
|
|
3
|
+
*
|
|
4
|
+
* Stores a persistent, per-file line-hash attribution map in a separate git
|
|
5
|
+
* notes ref (`refs/notes/claude-attribution-map`). Updated on every commit by
|
|
6
|
+
* commit.ts. Carries AI attribution forward across sessions and developers.
|
|
7
|
+
*
|
|
8
|
+
* Design:
|
|
9
|
+
* - Only `ai_hashes` is stored; Human = any committed line NOT in ai_hashes.
|
|
10
|
+
* - ai_hashes is a concatenated string of 16-char hex hashes (no separator).
|
|
11
|
+
* - Blank lines are always Human and never appear in ai_hashes.
|
|
12
|
+
* - Full state (all tracked files) is stored on every commit for simple reads.
|
|
13
|
+
*
|
|
14
|
+
* Carry-forward algorithm (per committed line hash):
|
|
15
|
+
* 1. In currentAiHashes (this session's attributeLines() AI lines) → AI
|
|
16
|
+
* 2. In prevAiHashSet (parent commit's minimap for this file) → AI (carry forward)
|
|
17
|
+
* 3. Otherwise → Human
|
|
18
|
+
*/
|
|
19
|
+
import { execFile } from "child_process";
|
|
20
|
+
import { promisify } from "util";
|
|
21
|
+
import { writeFile, unlink, mkdtemp, rmdir } from "fs/promises";
|
|
22
|
+
import { tmpdir } from "os";
|
|
23
|
+
import { join } from "path";
|
|
24
|
+
import { hashLine } from "./differ.ts";
|
|
25
|
+
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
|
|
28
|
+
export const MINIMAP_NOTES_REF = "refs/notes/claude-attribution-map";
|
|
29
|
+
|
|
30
|
+
export interface MinimapFileState {
|
|
31
|
+
path: string;
|
|
32
|
+
/** Concatenated 16-char hex hashes of AI-attributed lines (no separator). */
|
|
33
|
+
ai_hashes: string;
|
|
34
|
+
ai: number;
|
|
35
|
+
human: number;
|
|
36
|
+
total: number;
|
|
37
|
+
pctAi: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MinimapResult {
|
|
41
|
+
commit: string;
|
|
42
|
+
timestamp: string;
|
|
43
|
+
files: MinimapFileState[];
|
|
44
|
+
totals: { ai: number; human: number; total: number; pctAi: number };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function run(cmd: string, args: string[], cwd?: string): Promise<string> {
|
|
48
|
+
const { stdout } = await execFileAsync(cmd, args, { cwd });
|
|
49
|
+
return stdout.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Parse a concatenated 16-char hex hash string into a Set. */
|
|
53
|
+
export function hashSetFromString(s: string): Set<string> {
|
|
54
|
+
const result = new Set<string>();
|
|
55
|
+
for (let i = 0; i + 16 <= s.length; i += 16) {
|
|
56
|
+
result.add(s.slice(i, i + 16));
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Serialize a Set of 16-char hashes into a concatenated string. */
|
|
62
|
+
export function hashSetToString(hashes: Set<string>): string {
|
|
63
|
+
return [...hashes].join("");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compute the minimap state for a single file given:
|
|
68
|
+
* - committedLines: the lines of the file as committed
|
|
69
|
+
* - currentAiHashes: hashes of lines Claude wrote in the current session
|
|
70
|
+
* - prevAiHashSet: hashes of AI lines from the parent commit's minimap
|
|
71
|
+
*/
|
|
72
|
+
export function computeMinimapFile(
|
|
73
|
+
path: string,
|
|
74
|
+
committedLines: string[],
|
|
75
|
+
currentAiHashes: Set<string>,
|
|
76
|
+
prevAiHashSet: Set<string>,
|
|
77
|
+
): MinimapFileState {
|
|
78
|
+
const newAiHashes = new Set<string>();
|
|
79
|
+
let ai = 0;
|
|
80
|
+
let human = 0;
|
|
81
|
+
|
|
82
|
+
for (const line of committedLines) {
|
|
83
|
+
if (line.trim() === "") {
|
|
84
|
+
// Blank lines carry no attribution signal — always Human
|
|
85
|
+
human++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const hash = hashLine(line);
|
|
89
|
+
if (currentAiHashes.has(hash) || prevAiHashSet.has(hash)) {
|
|
90
|
+
newAiHashes.add(hash);
|
|
91
|
+
ai++;
|
|
92
|
+
} else {
|
|
93
|
+
human++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const total = committedLines.length;
|
|
98
|
+
return {
|
|
99
|
+
path,
|
|
100
|
+
ai_hashes: hashSetToString(newAiHashes),
|
|
101
|
+
ai,
|
|
102
|
+
human,
|
|
103
|
+
total,
|
|
104
|
+
pctAi: total > 0 ? Math.round((ai / total) * 100) : 0,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function writeMinimap(
|
|
109
|
+
result: MinimapResult,
|
|
110
|
+
repoRoot: string,
|
|
111
|
+
commitSha = "HEAD",
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
// Write JSON to a temp file and use -F to avoid E2BIG on large repos.
|
|
114
|
+
// Passing the full minimap as a -m argument fails when the JSON exceeds
|
|
115
|
+
// the OS argument size limit (~500KB on macOS).
|
|
116
|
+
//
|
|
117
|
+
// Use mkdtemp to create a unique, isolated temp directory rather than a
|
|
118
|
+
// predictable filename in the shared OS temp dir — prevents collisions
|
|
119
|
+
// under concurrent runs and symlink/race attacks.
|
|
120
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "claude-attribution-minimap-"));
|
|
121
|
+
const tmpFile = join(tmpDir, "minimap.json");
|
|
122
|
+
try {
|
|
123
|
+
await writeFile(tmpFile, JSON.stringify(result, null, 2), {
|
|
124
|
+
encoding: "utf8",
|
|
125
|
+
flag: "wx",
|
|
126
|
+
});
|
|
127
|
+
await run(
|
|
128
|
+
"git",
|
|
129
|
+
[
|
|
130
|
+
"notes",
|
|
131
|
+
"--ref",
|
|
132
|
+
MINIMAP_NOTES_REF,
|
|
133
|
+
"add",
|
|
134
|
+
"--force",
|
|
135
|
+
"-F",
|
|
136
|
+
tmpFile,
|
|
137
|
+
commitSha,
|
|
138
|
+
],
|
|
139
|
+
repoRoot,
|
|
140
|
+
);
|
|
141
|
+
} finally {
|
|
142
|
+
await unlink(tmpFile).catch(() => {});
|
|
143
|
+
await rmdir(tmpDir).catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function readMinimap(
|
|
148
|
+
repoRoot: string,
|
|
149
|
+
commitSha = "HEAD",
|
|
150
|
+
): Promise<MinimapResult | null> {
|
|
151
|
+
try {
|
|
152
|
+
const output = await run(
|
|
153
|
+
"git",
|
|
154
|
+
["notes", "--ref", MINIMAP_NOTES_REF, "show", commitSha],
|
|
155
|
+
repoRoot,
|
|
156
|
+
);
|
|
157
|
+
return JSON.parse(output) as MinimapResult;
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function listMinimapNotes(repoRoot: string): Promise<string[]> {
|
|
164
|
+
try {
|
|
165
|
+
const output = await run(
|
|
166
|
+
"git",
|
|
167
|
+
["notes", "--ref", MINIMAP_NOTES_REF, "list"],
|
|
168
|
+
repoRoot,
|
|
169
|
+
);
|
|
170
|
+
if (!output) return [];
|
|
171
|
+
return output
|
|
172
|
+
.split("\n")
|
|
173
|
+
.map((line) => line.split(" ")[1])
|
|
174
|
+
.filter((sha): sha is string => !!sha && sha.length > 0);
|
|
175
|
+
} catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -30,6 +30,9 @@ switch (cmd) {
|
|
|
30
30
|
case "pr":
|
|
31
31
|
await import("./commands/pr.ts");
|
|
32
32
|
break;
|
|
33
|
+
case "init":
|
|
34
|
+
await import("./commands/init.ts");
|
|
35
|
+
break;
|
|
33
36
|
case "hook": {
|
|
34
37
|
switch (rest[0]) {
|
|
35
38
|
case "pre-tool-use":
|
|
@@ -89,6 +92,7 @@ Commands:
|
|
|
89
92
|
uninstall [repo] Remove hooks from a repo (default: current directory)
|
|
90
93
|
metrics [id] Generate PR metrics report
|
|
91
94
|
pr [title] Create PR with metrics embedded (--draft, --base <branch>)
|
|
95
|
+
init [--ai] Declare current codebase as AI-written in the cumulative minimap
|
|
92
96
|
start Mark session start for per-ticket scoping
|
|
93
97
|
hook <name> Run an internal hook (used by installed git hooks)
|
|
94
98
|
version Print version
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-attribution init [--ai | --human]
|
|
3
|
+
*
|
|
4
|
+
* Initializes the cumulative attribution minimap for an existing repo.
|
|
5
|
+
*
|
|
6
|
+
* --ai Mark all currently tracked files as AI-written. Use for repos that
|
|
7
|
+
* were built entirely with Claude Code from the start.
|
|
8
|
+
* --human (default) Confirm the default: no minimap note written; all lines
|
|
9
|
+
* are assumed human until Claude writes them.
|
|
10
|
+
*
|
|
11
|
+
* After running init --ai, the next `claude-attribution metrics` or PR will show
|
|
12
|
+
* the true codebase AI% instead of only the current session's delta.
|
|
13
|
+
*/
|
|
14
|
+
import { resolve } from "path";
|
|
15
|
+
import { execFile } from "child_process";
|
|
16
|
+
import { promisify } from "util";
|
|
17
|
+
import { hashLine } from "../attribution/differ.ts";
|
|
18
|
+
import {
|
|
19
|
+
computeMinimapFile,
|
|
20
|
+
writeMinimap,
|
|
21
|
+
type MinimapFileState,
|
|
22
|
+
type MinimapResult,
|
|
23
|
+
} from "../attribution/minimap.ts";
|
|
24
|
+
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
const CONCURRENCY = 8;
|
|
27
|
+
|
|
28
|
+
async function runGit(args: string[], cwd: string): Promise<string> {
|
|
29
|
+
const result = (await execFileAsync("git", args, { cwd })) as unknown as {
|
|
30
|
+
stdout: string;
|
|
31
|
+
};
|
|
32
|
+
return result.stdout.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function main() {
|
|
36
|
+
const repoRoot = resolve(process.cwd());
|
|
37
|
+
const flag = process.argv[2];
|
|
38
|
+
|
|
39
|
+
if (!flag || flag === "--human") {
|
|
40
|
+
console.log(
|
|
41
|
+
'Baseline is already "human" by default. No minimap note written.',
|
|
42
|
+
);
|
|
43
|
+
console.log(
|
|
44
|
+
"Attribution accumulates automatically as Claude Code writes and commits code.",
|
|
45
|
+
);
|
|
46
|
+
console.log(
|
|
47
|
+
"\nIf this repo was built entirely with Claude Code, run: claude-attribution init --ai",
|
|
48
|
+
);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (flag !== "--ai") {
|
|
53
|
+
console.error(`Unknown flag: ${flag}`);
|
|
54
|
+
console.error("Usage: claude-attribution init [--ai | --human]");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --ai: mark entire current codebase as AI-written
|
|
59
|
+
let sha = "";
|
|
60
|
+
try {
|
|
61
|
+
sha = await runGit(["rev-parse", "HEAD"], repoRoot);
|
|
62
|
+
} catch {
|
|
63
|
+
console.error(
|
|
64
|
+
"Error: no commits found. Commit your files first, then run init --ai.",
|
|
65
|
+
);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lsOutput = await runGit(["ls-files"], repoRoot);
|
|
70
|
+
const allFiles = lsOutput ? lsOutput.split("\n").filter(Boolean) : [];
|
|
71
|
+
|
|
72
|
+
if (allFiles.length === 0) {
|
|
73
|
+
console.error("Error: no tracked files found.");
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log(
|
|
78
|
+
`Marking ${allFiles.length} files as AI-written on ${sha.slice(0, 7)}...`,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const minimapFiles: MinimapFileState[] = [];
|
|
82
|
+
let processed = 0;
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < allFiles.length; i += CONCURRENCY) {
|
|
85
|
+
const batch = allFiles.slice(i, i + CONCURRENCY);
|
|
86
|
+
const batchResults = await Promise.all(
|
|
87
|
+
batch.map(async (relPath): Promise<MinimapFileState | null> => {
|
|
88
|
+
try {
|
|
89
|
+
const result = (await execFileAsync(
|
|
90
|
+
"git",
|
|
91
|
+
["show", `HEAD:${relPath}`],
|
|
92
|
+
{ cwd: repoRoot },
|
|
93
|
+
)) as unknown as { stdout: string };
|
|
94
|
+
const content = result.stdout;
|
|
95
|
+
|
|
96
|
+
// Skip binary files
|
|
97
|
+
if (content.includes("\0")) return null;
|
|
98
|
+
|
|
99
|
+
const lines = content.split("\n");
|
|
100
|
+
|
|
101
|
+
// Build currentAiHashes from every non-blank line in the file
|
|
102
|
+
const currentAiHashes = new Set<string>();
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
if (line.trim() !== "") {
|
|
105
|
+
currentAiHashes.add(hashLine(line));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return computeMinimapFile(
|
|
110
|
+
relPath,
|
|
111
|
+
lines,
|
|
112
|
+
currentAiHashes,
|
|
113
|
+
new Set<string>(),
|
|
114
|
+
);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
const valid = batchResults.filter((r): r is MinimapFileState => r !== null);
|
|
121
|
+
minimapFiles.push(...valid);
|
|
122
|
+
processed += batch.length;
|
|
123
|
+
if (processed % 100 === 0 || processed === allFiles.length) {
|
|
124
|
+
process.stdout.write(`\r ${processed} / ${allFiles.length} files...`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
process.stdout.write("\n");
|
|
129
|
+
|
|
130
|
+
let totalAi = 0,
|
|
131
|
+
totalHuman = 0,
|
|
132
|
+
totalLines = 0;
|
|
133
|
+
for (const f of minimapFiles) {
|
|
134
|
+
totalAi += f.ai;
|
|
135
|
+
totalHuman += f.human;
|
|
136
|
+
totalLines += f.total;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const minimapResult: MinimapResult = {
|
|
140
|
+
commit: sha,
|
|
141
|
+
timestamp: new Date().toISOString(),
|
|
142
|
+
files: minimapFiles,
|
|
143
|
+
totals: {
|
|
144
|
+
ai: totalAi,
|
|
145
|
+
human: totalHuman,
|
|
146
|
+
total: totalLines,
|
|
147
|
+
pctAi: totalLines > 0 ? Math.round((totalAi / totalLines) * 100) : 0,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
await writeMinimap(minimapResult, repoRoot);
|
|
152
|
+
|
|
153
|
+
const pct = minimapResult.totals.pctAi;
|
|
154
|
+
console.log(
|
|
155
|
+
`✓ Marked ${minimapFiles.length} files (${totalLines} lines, ${pct}% AI) as AI-written.`,
|
|
156
|
+
);
|
|
157
|
+
console.log(
|
|
158
|
+
" Push to share with team: git push origin refs/notes/claude-attribution-map",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
main().catch((err) => {
|
|
163
|
+
console.error("Error:", err);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
});
|
package/src/export/pr-summary.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type AttributionResult,
|
|
31
31
|
type FileAttribution,
|
|
32
32
|
} from "../attribution/differ.ts";
|
|
33
|
+
import { readMinimap } from "../attribution/minimap.ts";
|
|
33
34
|
|
|
34
35
|
interface MetricPoint {
|
|
35
36
|
timestamp: number;
|
|
@@ -175,6 +176,23 @@ async function main() {
|
|
|
175
176
|
},
|
|
176
177
|
];
|
|
177
178
|
|
|
179
|
+
// Codebase-wide minimap gauges (optional — only pushed if minimap exists)
|
|
180
|
+
const headMinimap = await readMinimap(repoRoot, "HEAD").catch(() => null);
|
|
181
|
+
if (headMinimap) {
|
|
182
|
+
series.push({
|
|
183
|
+
metric: "claude_attribution.codebase_pct_ai",
|
|
184
|
+
type: 3,
|
|
185
|
+
points: [{ timestamp, value: headMinimap.totals.pctAi }],
|
|
186
|
+
tags,
|
|
187
|
+
});
|
|
188
|
+
series.push({
|
|
189
|
+
metric: "claude_attribution.codebase_total_lines",
|
|
190
|
+
type: 3,
|
|
191
|
+
points: [{ timestamp, value: headMinimap.totals.total }],
|
|
192
|
+
tags,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
178
196
|
const event: DatadogEventPayload = {
|
|
179
197
|
title: `PR #${prNumber} merged — ${pctAi}% AI (claude-code)`,
|
|
180
198
|
text: `repo: ${repo}\nbranch: ${branch}\nai: ${ai} / human: ${human} / total: ${total}`,
|
package/src/metrics/collect.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type FileAttribution,
|
|
23
23
|
} from "../attribution/differ.ts";
|
|
24
24
|
import { SESSION_ID_RE } from "../attribution/checkpoint.ts";
|
|
25
|
+
import { readMinimap, listMinimapNotes } from "../attribution/minimap.ts";
|
|
25
26
|
|
|
26
27
|
const execFileAsync = promisify(execFile);
|
|
27
28
|
|
|
@@ -34,6 +35,12 @@ export interface MetricsData {
|
|
|
34
35
|
attributions: AttributionResult[];
|
|
35
36
|
lastSeenByFile: Map<string, FileAttribution>;
|
|
36
37
|
allTranscripts: TranscriptResult[];
|
|
38
|
+
minimapTotals: {
|
|
39
|
+
ai: number;
|
|
40
|
+
human: number;
|
|
41
|
+
total: number;
|
|
42
|
+
pctAi: number;
|
|
43
|
+
} | null;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
async function readSessionStart(repoRoot: string): Promise<Date | null> {
|
|
@@ -151,6 +158,29 @@ export function kFormat(n: number): string {
|
|
|
151
158
|
return n >= 1000 ? `${Math.floor(n / 1000)}K` : String(n);
|
|
152
159
|
}
|
|
153
160
|
|
|
161
|
+
async function getMinimapTotals(
|
|
162
|
+
repoRoot: string,
|
|
163
|
+
): Promise<{ ai: number; human: number; total: number; pctAi: number } | null> {
|
|
164
|
+
// Try HEAD first (written by post-commit hook)
|
|
165
|
+
const head = await readMinimap(repoRoot, "HEAD");
|
|
166
|
+
if (head) return head.totals;
|
|
167
|
+
|
|
168
|
+
// Fall back: find the most recent minimap note on the current branch
|
|
169
|
+
const [allNotes, branchShas] = await Promise.all([
|
|
170
|
+
listMinimapNotes(repoRoot),
|
|
171
|
+
getBranchCommitShas(repoRoot),
|
|
172
|
+
]);
|
|
173
|
+
const branchSet = new Set(branchShas);
|
|
174
|
+
const candidates = allNotes.filter((sha) => branchSet.has(sha));
|
|
175
|
+
const scanList = candidates.length > 0 ? candidates : allNotes;
|
|
176
|
+
|
|
177
|
+
for (const sha of scanList) {
|
|
178
|
+
const note = await readMinimap(repoRoot, sha);
|
|
179
|
+
if (note) return note.totals;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
154
184
|
export async function collectMetrics(
|
|
155
185
|
sessionIdArg?: string,
|
|
156
186
|
repoRoot?: string,
|
|
@@ -165,7 +195,7 @@ export async function collectMetrics(
|
|
|
165
195
|
const sessionStart =
|
|
166
196
|
(await readSessionStart(root)) ?? (await getBranchStartTime(root));
|
|
167
197
|
|
|
168
|
-
const [toolEntries, agentEntries, transcript, attributions] =
|
|
198
|
+
const [toolEntries, agentEntries, transcript, attributions, minimapTotals] =
|
|
169
199
|
await Promise.all([
|
|
170
200
|
readJsonlForSession(
|
|
171
201
|
join(logDir, "tool-usage.jsonl"),
|
|
@@ -179,6 +209,7 @@ export async function collectMetrics(
|
|
|
179
209
|
) as Promise<{ subagentType?: string; event?: string }[]>,
|
|
180
210
|
parseTranscript(sessionId, root),
|
|
181
211
|
getBranchAttribution(root, sessionStart),
|
|
212
|
+
getMinimapTotals(root),
|
|
182
213
|
]);
|
|
183
214
|
|
|
184
215
|
// Tool counts
|
|
@@ -234,6 +265,7 @@ export async function collectMetrics(
|
|
|
234
265
|
attributions,
|
|
235
266
|
lastSeenByFile,
|
|
236
267
|
allTranscripts,
|
|
268
|
+
minimapTotals,
|
|
237
269
|
};
|
|
238
270
|
}
|
|
239
271
|
|
|
@@ -245,6 +277,7 @@ export function renderMetrics(data: MetricsData): string {
|
|
|
245
277
|
transcript,
|
|
246
278
|
lastSeenByFile,
|
|
247
279
|
allTranscripts,
|
|
280
|
+
minimapTotals,
|
|
248
281
|
} = data;
|
|
249
282
|
|
|
250
283
|
const lines: string[] = [];
|
|
@@ -256,7 +289,31 @@ export function renderMetrics(data: MetricsData): string {
|
|
|
256
289
|
// Headline: AI% + active time (most important stat, shown first)
|
|
257
290
|
const allFileStats = [...lastSeenByFile.values()];
|
|
258
291
|
const hasAttribution = allFileStats.length > 0;
|
|
259
|
-
if (
|
|
292
|
+
if (minimapTotals && minimapTotals.total > 0) {
|
|
293
|
+
// Two-signal headline: codebase-wide + this PR
|
|
294
|
+
out(
|
|
295
|
+
`**Codebase: ~${minimapTotals.pctAi}% AI** (${minimapTotals.ai} / ${minimapTotals.total} lines)`,
|
|
296
|
+
);
|
|
297
|
+
if (hasAttribution) {
|
|
298
|
+
const {
|
|
299
|
+
ai: prAi,
|
|
300
|
+
total: prTotal,
|
|
301
|
+
pctAi: prPctAi,
|
|
302
|
+
} = aggregateTotals(allFileStats);
|
|
303
|
+
const codebasePct =
|
|
304
|
+
minimapTotals.total > 0
|
|
305
|
+
? Math.round((prTotal / minimapTotals.total) * 100)
|
|
306
|
+
: 0;
|
|
307
|
+
const activePart =
|
|
308
|
+
transcript && transcript.activeMinutes > 0
|
|
309
|
+
? ` · Active: ${transcript.activeMinutes}m`
|
|
310
|
+
: "";
|
|
311
|
+
out(
|
|
312
|
+
`**This PR:** ${prTotal} lines changed (${codebasePct}% of codebase) · ${prPctAi}% Claude edits · ${prAi} AI lines${activePart}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
out();
|
|
316
|
+
} else if (hasAttribution) {
|
|
260
317
|
const { ai, total, pctAi } = aggregateTotals(allFileStats);
|
|
261
318
|
const activePart =
|
|
262
319
|
transcript && transcript.activeMinutes > 0
|
package/src/setup/install.ts
CHANGED
|
@@ -119,6 +119,27 @@ async function main() {
|
|
|
119
119
|
);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Configure git push to also push minimap notes (same idempotent pattern)
|
|
123
|
+
const minimapRefspec =
|
|
124
|
+
"refs/notes/claude-attribution-map:refs/notes/claude-attribution-map";
|
|
125
|
+
try {
|
|
126
|
+
await execFileAsync(
|
|
127
|
+
"git",
|
|
128
|
+
["config", "--unset-all", "remote.origin.push", minimapRefspec],
|
|
129
|
+
{ cwd: targetRepo },
|
|
130
|
+
).catch(() => {});
|
|
131
|
+
await execFileAsync(
|
|
132
|
+
"git",
|
|
133
|
+
["config", "--add", "remote.origin.push", minimapRefspec],
|
|
134
|
+
{ cwd: targetRepo },
|
|
135
|
+
);
|
|
136
|
+
console.log("✓ Configured remote.origin.push to include minimap notes");
|
|
137
|
+
} catch {
|
|
138
|
+
console.log(
|
|
139
|
+
" (skipped git config remote.origin.push — no origin remote or git unavailable)",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
122
143
|
// 3. Install slash commands
|
|
123
144
|
await installSlashCommands(targetRepo);
|
|
124
145
|
console.log("✓ Installed .claude/commands/metrics.md (/metrics command)");
|
|
@@ -18,7 +18,9 @@ jobs:
|
|
|
18
18
|
fetch-depth: 0
|
|
19
19
|
|
|
20
20
|
- name: Fetch attribution notes
|
|
21
|
-
run:
|
|
21
|
+
run: |
|
|
22
|
+
git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
|
|
23
|
+
git fetch origin refs/notes/claude-attribution-map:refs/notes/claude-attribution-map || true
|
|
22
24
|
|
|
23
25
|
- name: Install claude-attribution
|
|
24
26
|
run: npm install -g claude-attribution
|
package/src/setup/uninstall.ts
CHANGED
|
@@ -149,6 +149,15 @@ async function main() {
|
|
|
149
149
|
// Ignore — refspec may not be present or git may be unavailable
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// 4b. Remove remote.origin.push refspec for minimap notes (best-effort)
|
|
153
|
+
const minimapRefspec =
|
|
154
|
+
"refs/notes/claude-attribution-map:refs/notes/claude-attribution-map";
|
|
155
|
+
await execFileAsync(
|
|
156
|
+
"git",
|
|
157
|
+
["config", "--unset", "remote.origin.push", minimapRefspec],
|
|
158
|
+
{ cwd: targetRepo },
|
|
159
|
+
).catch(() => {});
|
|
160
|
+
|
|
152
161
|
// 5. Remove slash commands
|
|
153
162
|
const commandsDir = join(targetRepo, ".claude", "commands");
|
|
154
163
|
const metricsRemoved = await removeFile(join(commandsDir, "metrics.md"));
|