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 CHANGED
@@ -6,13 +6,14 @@
6
6
  > ```bash
7
7
  > npm install -g claude-attribution
8
8
  > claude-attribution install ~/Code/your-repo
9
- > git add .claude/settings.json .github/workflows/claude-attribution-pr.yml .gitignore && git commit -m "chore: install claude-attribution hooks"
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 whether Claude creates it or you run `gh pr create` yourself metrics are injected into the PR body automatically, no command needed.
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 under your organization's Settings → Copilot. Both tools' org-level data flows into the VP Datadog dashboard automatically on every PR merge.
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
- **npm install:**
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
- **Clone install:**
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
- bun ~/Code/claude-attribution/src/setup/install.ts ~/Code/your-repo
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
- The installer makes six changes to the target repo:
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` refspec** — the installer runs `git config --add remote.origin.push refs/notes/claude-attribution:refs/notes/claude-attribution` so that `git push` (without an explicit refspec) automatically includes attribution notes. No pre-push hook is installed — a hook that pushes notes concurrently with the main push causes SSH connection conflicts on GitHub.
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
- ### Committing the settings change
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
- The `.claude/settings.json` and workflow changes should be committed so all developers get the hooks and all PRs get metrics automatically.
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
- # After running the installer:
89
- git add .claude/settings.json .github/workflows/claude-attribution-pr.yml .gitignore
90
- git commit -m "chore: install claude-attribution hooks"
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
- **AI contribution: ~77%** (142 of 184 committed lines) · Active: 8m
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. This is correct.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-attribution",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
4
4
  "description": "AI code attribution tracking for Claude Code sessions — checkpoint-based line diff approach",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- const fileResults = (
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(async (relPath): Promise<FileAttribution | null> => {
61
- const absPath = join(repoRoot, relPath);
62
- const committed = await committedContent(repoRoot, relPath);
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
- // Deleted file — skip attribution
65
- if (committed === null) return null;
85
+ // Deleted file — skip attribution
86
+ if (committed === null) return null;
66
87
 
67
- // Binary file — null bytes indicate binary content; line-splitting produces garbage
68
- if (committed.includes("\0")) return null;
88
+ // Binary file — null bytes indicate binary content; line-splitting produces garbage
89
+ if (committed.includes("\0")) return null;
69
90
 
70
- const committedLines = committed.split("\n");
91
+ const committedLines = committed.split("\n");
92
+ const empty: LineAttribution[] = committedLines.map(() => "HUMAN");
71
93
 
72
- if (!sessionId) {
73
- // No active Claude session — everything is HUMAN
74
- return {
75
- path: relPath,
76
- ai: 0,
77
- human: committedLines.length,
78
- mixed: 0,
79
- total: committedLines.length,
80
- pctAi: 0,
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
- const before = await loadCheckpoint(sessionId, absPath, "before");
85
- const after = await loadCheckpoint(sessionId, absPath, "after");
107
+ const before = await loadCheckpoint(sessionId, absPath, "before");
108
+ const after = await loadCheckpoint(sessionId, absPath, "after");
86
109
 
87
- if (!after) {
88
- // No Claude checkpoint for this file — all HUMAN
89
- return {
90
- path: relPath,
91
- ai: 0,
92
- human: committedLines.length,
93
- mixed: 0,
94
- total: committedLines.length,
95
- pctAi: 0,
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
- const beforeLines = before?.lines ?? [];
100
- const { stats } = attributeLines(
101
- beforeLines,
102
- after.lines,
103
- committedLines,
104
- );
105
- return { ...stats, path: relPath };
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 FileAttribution => r !== null);
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
+ });
@@ -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}`,
@@ -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 (hasAttribution) {
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
@@ -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: git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
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
@@ -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"));