claude-attribution 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,11 @@
10
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
12
  >
13
+ > **Already using Claude Code before installing this?** Run `claude-attribution init` once to declare the baseline — this enables the codebase-wide AI% signal in PR metrics:
14
+ > - **All Claude Code:** `claude-attribution init --ai && git push origin refs/notes/claude-attribution-map`
15
+ > - **All human (or mixed):** `claude-attribution init` (or `--human`) — prints a confirmation, no note written; human is the default
16
+ > - **Not sure?** Default to `--human`. Attribution accumulates accurately from new commits forward.
17
+ >
13
18
  > **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
19
  >
15
20
  > **Requirements:** [Bun](https://bun.sh) (preferred) or Node 18+, and `gh` (GitHub CLI) authenticated.
@@ -70,7 +75,7 @@ The installer makes six changes to the target repo:
70
75
 
71
76
  **`.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
77
 
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.
78
+ **`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
79
 
75
80
  **`.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
81
 
@@ -90,6 +95,36 @@ git add .claude/settings.json .github/workflows/claude-attribution-pr.yml .gitig
90
95
  git commit -m "chore: install claude-attribution hooks"
91
96
  ```
92
97
 
98
+ ### Backfilling the attribution minimap
99
+
100
+ 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`.
101
+
102
+ There are three options depending on the history of your repo:
103
+
104
+ **Option 1 — Repo was built entirely with Claude Code (`--ai`):**
105
+ ```bash
106
+ claude-attribution init --ai
107
+ git push origin refs/notes/claude-attribution-map
108
+ ```
109
+ Marks every currently tracked file as AI-written at HEAD. After this, PR metrics will show:
110
+ ```
111
+ Codebase: ~100% AI (4150 / 4150 lines)
112
+ This PR: 184 lines changed (4% of codebase) · 77% Claude edits · 142 AI lines
113
+ ```
114
+
115
+ **Option 2 — Repo is human-written, or a mix (`--human` / no flag):**
116
+ ```bash
117
+ claude-attribution init # or: claude-attribution init --human
118
+ ```
119
+ 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.
120
+
121
+ **Option 3 — Already had claude-attribution installed before v1.2.0:**
122
+ 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:
123
+ ```bash
124
+ claude-attribution init --ai # only if repo was built 100% with Claude Code
125
+ git push origin refs/notes/claude-attribution-map
126
+ ```
127
+
93
128
  ### Re-installing after moving this directory
94
129
 
95
130
  If you move `~/Code/claude-attribution` to a different path, re-run the installer — it updates the absolute paths in `settings.json` and the git hook:
@@ -150,12 +185,13 @@ Metrics are injected automatically — no command needed.
150
185
 
151
186
  **On every new push to an open PR**: the workflow fires on `synchronize` and updates the attribution percentages to reflect new commits.
152
187
 
153
- The metrics block looks like:
188
+ The metrics block looks like (when the cumulative minimap exists):
154
189
 
155
190
  ```markdown
156
191
  ## Claude Code Metrics
157
192
 
158
- **AI contribution: ~77%** (142 of 184 committed lines) · Active: 8m
193
+ **Codebase: ~77% AI** (3200 / 4150 lines)
194
+ **This PR:** 184 lines changed (4% of codebase) · 77% Claude edits · 142 AI lines · Active: 8m
159
195
 
160
196
  | Model | Calls | Input | Output | Cache |
161
197
  |-------|-------|-------|--------|-------|
@@ -172,6 +208,12 @@ The metrics block looks like:
172
208
  </details>
173
209
  ```
174
210
 
211
+ Before running `init --ai` (or on a fresh install with no minimap), the headline falls back to the session-only view:
212
+
213
+ ```markdown
214
+ **AI contribution: ~77%** (142 of 184 committed lines) · Active: 8m
215
+ ```
216
+
175
217
  #### Manual option
176
218
 
177
219
  If you need to create a PR with metrics outside of Claude, use the `/pr` slash command or CLI directly:
@@ -238,11 +280,17 @@ Session IDs are shown in `.claude/logs/tool-usage.jsonl`.
238
280
  Attribution results are stored as git notes and queryable directly:
239
281
 
240
282
  ```bash
241
- # View attribution for the last commit
283
+ # View per-commit attribution for the last commit
242
284
  git notes --ref=claude-attribution show HEAD
243
285
 
244
286
  # List all attributed commits in the repo
245
287
  git notes --ref=claude-attribution list
288
+
289
+ # View the cumulative codebase minimap (all files, AI% totals)
290
+ git notes --ref=refs/notes/claude-attribution-map show HEAD | jq .totals
291
+
292
+ # Check codebase AI% quickly
293
+ git notes --ref=refs/notes/claude-attribution-map show HEAD | jq .totals.pctAi
246
294
  ```
247
295
 
248
296
  Example output:
@@ -336,7 +384,7 @@ Running `/start` scopes both tool/token metrics AND attribution data to commits
336
384
 
337
385
  - **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
386
 
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.
387
+ - **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
388
 
341
389
  - **`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
390
 
@@ -377,7 +425,9 @@ Attribution data is pushed to Datadog automatically on every PR merge via GitHub
377
425
  | `claude_attribution.ai_lines` | Lines written by Claude and committed unchanged |
378
426
  | `claude_attribution.human_lines` | Lines written or left unchanged by the developer |
379
427
  | `claude_attribution.total_lines` | Total committed lines in the PR |
380
- | `claude_attribution.pct_ai` | Percentage of lines attributed to Claude |
428
+ | `claude_attribution.pct_ai` | Percentage of lines attributed to Claude (this PR) |
429
+ | `claude_attribution.codebase_pct_ai` | Cumulative codebase-wide AI% at PR merge time (requires minimap) |
430
+ | `claude_attribution.codebase_total_lines` | Total codebase lines tracked in the minimap |
381
431
  | `github_copilot.acceptance_rate` | Org-level Copilot suggestion acceptance rate |
382
432
  | `github_copilot.lines_accepted` | Copilot lines accepted org-wide |
383
433
  | `github_copilot.lines_suggested` | Copilot lines suggested org-wide |
@@ -447,6 +497,15 @@ The post-commit hook may not have run. Check:
447
497
 
448
498
  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
499
 
500
+ **Codebase AI% shows 0% (or very low) even though the repo is all Claude Code**
501
+
502
+ The cumulative minimap hasn't been initialized yet. Run once to backfill:
503
+ ```bash
504
+ claude-attribution init --ai
505
+ git push origin refs/notes/claude-attribution-map
506
+ ```
507
+ See [Backfilling the attribution minimap](#backfilling-the-attribution-minimap) for details.
508
+
450
509
  **The hook is slowing down commits**
451
510
 
452
511
  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.2",
3
+ "version": "1.2.0",
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,158 @@
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 { hashLine } from "./differ.ts";
22
+
23
+ const execFileAsync = promisify(execFile);
24
+
25
+ export const MINIMAP_NOTES_REF = "refs/notes/claude-attribution-map";
26
+
27
+ export interface MinimapFileState {
28
+ path: string;
29
+ /** Concatenated 16-char hex hashes of AI-attributed lines (no separator). */
30
+ ai_hashes: string;
31
+ ai: number;
32
+ human: number;
33
+ total: number;
34
+ pctAi: number;
35
+ }
36
+
37
+ export interface MinimapResult {
38
+ commit: string;
39
+ timestamp: string;
40
+ files: MinimapFileState[];
41
+ totals: { ai: number; human: number; total: number; pctAi: number };
42
+ }
43
+
44
+ async function run(cmd: string, args: string[], cwd?: string): Promise<string> {
45
+ const { stdout } = await execFileAsync(cmd, args, { cwd });
46
+ return stdout.trim();
47
+ }
48
+
49
+ /** Parse a concatenated 16-char hex hash string into a Set. */
50
+ export function hashSetFromString(s: string): Set<string> {
51
+ const result = new Set<string>();
52
+ for (let i = 0; i + 16 <= s.length; i += 16) {
53
+ result.add(s.slice(i, i + 16));
54
+ }
55
+ return result;
56
+ }
57
+
58
+ /** Serialize a Set of 16-char hashes into a concatenated string. */
59
+ export function hashSetToString(hashes: Set<string>): string {
60
+ return [...hashes].join("");
61
+ }
62
+
63
+ /**
64
+ * Compute the minimap state for a single file given:
65
+ * - committedLines: the lines of the file as committed
66
+ * - currentAiHashes: hashes of lines Claude wrote in the current session
67
+ * - prevAiHashSet: hashes of AI lines from the parent commit's minimap
68
+ */
69
+ export function computeMinimapFile(
70
+ path: string,
71
+ committedLines: string[],
72
+ currentAiHashes: Set<string>,
73
+ prevAiHashSet: Set<string>,
74
+ ): MinimapFileState {
75
+ const newAiHashes = new Set<string>();
76
+ let ai = 0;
77
+ let human = 0;
78
+
79
+ for (const line of committedLines) {
80
+ if (line.trim() === "") {
81
+ // Blank lines carry no attribution signal — always Human
82
+ human++;
83
+ continue;
84
+ }
85
+ const hash = hashLine(line);
86
+ if (currentAiHashes.has(hash) || prevAiHashSet.has(hash)) {
87
+ newAiHashes.add(hash);
88
+ ai++;
89
+ } else {
90
+ human++;
91
+ }
92
+ }
93
+
94
+ const total = committedLines.length;
95
+ return {
96
+ path,
97
+ ai_hashes: hashSetToString(newAiHashes),
98
+ ai,
99
+ human,
100
+ total,
101
+ pctAi: total > 0 ? Math.round((ai / total) * 100) : 0,
102
+ };
103
+ }
104
+
105
+ export async function writeMinimap(
106
+ result: MinimapResult,
107
+ repoRoot: string,
108
+ commitSha = "HEAD",
109
+ ): Promise<void> {
110
+ const json = JSON.stringify(result, null, 2);
111
+ await run(
112
+ "git",
113
+ [
114
+ "notes",
115
+ "--ref",
116
+ MINIMAP_NOTES_REF,
117
+ "add",
118
+ "--force",
119
+ "-m",
120
+ json,
121
+ commitSha,
122
+ ],
123
+ repoRoot,
124
+ );
125
+ }
126
+
127
+ export async function readMinimap(
128
+ repoRoot: string,
129
+ commitSha = "HEAD",
130
+ ): Promise<MinimapResult | null> {
131
+ try {
132
+ const output = await run(
133
+ "git",
134
+ ["notes", "--ref", MINIMAP_NOTES_REF, "show", commitSha],
135
+ repoRoot,
136
+ );
137
+ return JSON.parse(output) as MinimapResult;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ export async function listMinimapNotes(repoRoot: string): Promise<string[]> {
144
+ try {
145
+ const output = await run(
146
+ "git",
147
+ ["notes", "--ref", MINIMAP_NOTES_REF, "list"],
148
+ repoRoot,
149
+ );
150
+ if (!output) return [];
151
+ return output
152
+ .split("\n")
153
+ .map((line) => line.split(" ")[1])
154
+ .filter((sha): sha is string => !!sha && sha.length > 0);
155
+ } catch {
156
+ return [];
157
+ }
158
+ }
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":
@@ -77,6 +80,7 @@ switch (cmd) {
77
80
  process.exit(0);
78
81
  break;
79
82
  }
83
+ case undefined:
80
84
  case "help":
81
85
  case "--help":
82
86
  case "-h":
@@ -88,6 +92,7 @@ Commands:
88
92
  uninstall [repo] Remove hooks from a repo (default: current directory)
89
93
  metrics [id] Generate PR metrics report
90
94
  pr [title] Create PR with metrics embedded (--draft, --base <branch>)
95
+ init [--ai] Declare current codebase as AI-written in the cumulative minimap
91
96
  start Mark session start for per-ticket scoping
92
97
  hook <name> Run an internal hook (used by installed git hooks)
93
98
  version Print version
@@ -97,7 +102,7 @@ Commands:
97
102
  break;
98
103
  default:
99
104
  console.error(
100
- `Unknown command: ${cmd ?? "(none)"}. Run "claude-attribution help" for usage.`,
105
+ `Unknown command: ${cmd}. Run "claude-attribution help" for usage.`,
101
106
  );
102
107
  process.exit(1);
103
108
  }
@@ -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
+ });
@@ -133,8 +133,8 @@ async function main() {
133
133
  body = template.trimEnd() + "\n\n" + metricsBlock;
134
134
  }
135
135
 
136
- // Push branch to remote. If the branch already has a remote tracking ref,
137
- // push any new commits (no-op if up-to-date). Otherwise set tracking with -u.
136
+ // Push the current branch to remote. Always push only the branch ref (not
137
+ // notes or other configured refspecs). Set tracking with -u if not yet set.
138
138
  try {
139
139
  const hasUpstream = await execFileAsync(
140
140
  "git",
@@ -144,11 +144,12 @@ async function main() {
144
144
  () => true,
145
145
  () => false,
146
146
  );
147
+ const branch = await getCurrentBranch(repoRoot);
147
148
  if (hasUpstream) {
148
- // Branch already pushed — push any new commits, ignore "already up-to-date"
149
- await execFileAsync("git", ["push"], { cwd: repoRoot }).catch(() => {});
149
+ // Branch already has upstream — push new commits only (no-op if up-to-date)
150
+ await execFileAsync("git", ["push", "origin", branch], { cwd: repoRoot });
150
151
  } else {
151
- await execFileAsync("git", ["push", "-u", "origin", "HEAD"], {
152
+ await execFileAsync("git", ["push", "-u", "origin", branch], {
152
153
  cwd: repoRoot,
153
154
  });
154
155
  }
@@ -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"));