claude-attribution 1.2.0 → 1.2.2
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 +38 -24
- package/package.json +1 -1
- package/src/attribution/minimap.ts +35 -15
- package/src/commands/init.ts +16 -10
- package/src/commands/pr.ts +1 -1
- package/src/hooks/post-bash.ts +1 -1
- package/src/hooks/pre-tool-use.ts +0 -1
- package/src/metrics/transcript.ts +1 -1
- package/src/setup/install.ts +14 -3
- package/src/setup/shared.ts +81 -4
- package/src/setup/templates/pr-metrics-workflow.yml +1 -1
package/README.md
CHANGED
|
@@ -6,18 +6,14 @@
|
|
|
6
6
|
> ```bash
|
|
7
7
|
> npm install -g claude-attribution
|
|
8
8
|
> claude-attribution install ~/Code/your-repo
|
|
9
|
-
>
|
|
9
|
+
> claude-attribution init --ai # repo built with Claude Code — or --human if human/mixed
|
|
10
|
+
> git add .claude/settings.json .gitignore && git commit -m "chore: install claude-attribution hooks"
|
|
10
11
|
> ```
|
|
11
|
-
> From then on, just work normally. After each `git commit` you'll see a one-line attribution summary in your terminal. When you open a PR
|
|
12
|
+
> From then on, just work normally. After each `git commit` you'll see a one-line attribution summary in your terminal. When you're ready to open a PR, run `/pr` in Claude Code (or `claude-attribution pr "feat: your title"`) — it fills in the metrics automatically, no copy-paste needed.
|
|
12
13
|
>
|
|
13
|
-
> **
|
|
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.
|
|
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.
|
|
17
15
|
>
|
|
18
|
-
> **
|
|
19
|
-
>
|
|
20
|
-
> **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.
|
|
21
17
|
|
|
22
18
|
---
|
|
23
19
|
|
|
@@ -51,17 +47,45 @@ bun install
|
|
|
51
47
|
|
|
52
48
|
### Install into a repo (per repo, per developer)
|
|
53
49
|
|
|
54
|
-
**
|
|
50
|
+
**Step 1 — Run the installer:**
|
|
51
|
+
|
|
55
52
|
```bash
|
|
53
|
+
# npm install:
|
|
56
54
|
claude-attribution install ~/Code/your-repo
|
|
55
|
+
|
|
56
|
+
# clone install:
|
|
57
|
+
bun ~/Code/claude-attribution/src/setup/install.ts ~/Code/your-repo
|
|
57
58
|
```
|
|
58
59
|
|
|
59
|
-
**
|
|
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
|
+
|
|
60
64
|
```bash
|
|
61
|
-
|
|
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
|
|
73
|
+
```
|
|
74
|
+
|
|
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
|
|
62
86
|
```
|
|
63
87
|
|
|
64
|
-
The installer makes
|
|
88
|
+
The installer makes the following changes to the target repo:
|
|
65
89
|
|
|
66
90
|
**`.claude/settings.json`** — merges six Claude Code hooks:
|
|
67
91
|
|
|
@@ -85,17 +109,7 @@ The installer makes six changes to the target repo:
|
|
|
85
109
|
|
|
86
110
|
**`.gitignore`** — adds `.claude/logs/` so tool usage logs don't end up in version control.
|
|
87
111
|
|
|
88
|
-
###
|
|
89
|
-
|
|
90
|
-
The `.claude/settings.json` and workflow changes should be committed so all developers get the hooks and all PRs get metrics automatically.
|
|
91
|
-
|
|
92
|
-
```bash
|
|
93
|
-
# After running the installer:
|
|
94
|
-
git add .claude/settings.json .github/workflows/claude-attribution-pr.yml .gitignore
|
|
95
|
-
git commit -m "chore: install claude-attribution hooks"
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
### Backfilling the attribution minimap
|
|
112
|
+
### Attribution minimap — detailed options
|
|
99
113
|
|
|
100
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`.
|
|
101
115
|
|
package/package.json
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { execFile } from "child_process";
|
|
20
20
|
import { promisify } from "util";
|
|
21
|
+
import { writeFile, unlink, mkdtemp, rmdir } from "fs/promises";
|
|
22
|
+
import { tmpdir } from "os";
|
|
23
|
+
import { join } from "path";
|
|
21
24
|
import { hashLine } from "./differ.ts";
|
|
22
25
|
|
|
23
26
|
const execFileAsync = promisify(execFile);
|
|
@@ -107,21 +110,38 @@ export async function writeMinimap(
|
|
|
107
110
|
repoRoot: string,
|
|
108
111
|
commitSha = "HEAD",
|
|
109
112
|
): Promise<void> {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
}
|
|
125
145
|
}
|
|
126
146
|
|
|
127
147
|
export async function readMinimap(
|
package/src/commands/init.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { execFile } from "child_process";
|
|
|
16
16
|
import { promisify } from "util";
|
|
17
17
|
import { hashLine } from "../attribution/differ.ts";
|
|
18
18
|
import {
|
|
19
|
-
|
|
19
|
+
hashSetToString,
|
|
20
20
|
writeMinimap,
|
|
21
21
|
type MinimapFileState,
|
|
22
22
|
type MinimapResult,
|
|
@@ -97,21 +97,27 @@ async function main() {
|
|
|
97
97
|
if (content.includes("\0")) return null;
|
|
98
98
|
|
|
99
99
|
const lines = content.split("\n");
|
|
100
|
+
const total = lines.length;
|
|
100
101
|
|
|
101
|
-
//
|
|
102
|
-
|
|
102
|
+
// init --ai is an explicit declaration: every line (including blank)
|
|
103
|
+
// is AI-written. Build ai_hashes from all non-blank lines so the
|
|
104
|
+
// carry-forward algorithm in future commits works correctly; count
|
|
105
|
+
// blank lines as AI in the totals.
|
|
106
|
+
const aiHashes = new Set<string>();
|
|
103
107
|
for (const line of lines) {
|
|
104
108
|
if (line.trim() !== "") {
|
|
105
|
-
|
|
109
|
+
aiHashes.add(hashLine(line));
|
|
106
110
|
}
|
|
107
111
|
}
|
|
108
112
|
|
|
109
|
-
return
|
|
110
|
-
relPath,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
return {
|
|
114
|
+
path: relPath,
|
|
115
|
+
ai_hashes: hashSetToString(aiHashes),
|
|
116
|
+
ai: total,
|
|
117
|
+
human: 0,
|
|
118
|
+
total,
|
|
119
|
+
pctAi: total > 0 ? 100 : 0,
|
|
120
|
+
} satisfies MinimapFileState;
|
|
115
121
|
} catch {
|
|
116
122
|
return null;
|
|
117
123
|
}
|
package/src/commands/pr.ts
CHANGED
package/src/hooks/post-bash.ts
CHANGED
|
@@ -44,7 +44,7 @@ function extractPrUrl(response: unknown): string | null {
|
|
|
44
44
|
|
|
45
45
|
function prNumberFromUrl(url: string): number | null {
|
|
46
46
|
const match = url.match(/\/pull\/(\d+)$/);
|
|
47
|
-
return match ? parseInt(match[1], 10) : null;
|
|
47
|
+
return match?.[1] ? parseInt(match[1], 10) : null;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
async function main() {
|
|
@@ -120,7 +120,7 @@ function computeActiveMinutes(allTimestamps: number[]): number {
|
|
|
120
120
|
let totalMs = 0;
|
|
121
121
|
const IDLE_THRESHOLD_MS = 900_000; // 15 minutes
|
|
122
122
|
for (let i = 1; i < sorted.length; i++) {
|
|
123
|
-
const gap = sorted[i] - sorted[i - 1];
|
|
123
|
+
const gap = (sorted[i] ?? 0) - (sorted[i - 1] ?? 0);
|
|
124
124
|
if (gap < IDLE_THRESHOLD_MS) totalMs += gap;
|
|
125
125
|
}
|
|
126
126
|
return Math.round(totalMs / 60_000);
|
package/src/setup/install.ts
CHANGED
|
@@ -25,7 +25,14 @@ const execFileAsync = promisify(execFile);
|
|
|
25
25
|
const CLI_BIN = resolve(ATTRIBUTION_ROOT, "bin", "claude-attribution");
|
|
26
26
|
|
|
27
27
|
async function main() {
|
|
28
|
-
const
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
const runnerFlagIdx = args.findIndex((a: string) => a === "--runner");
|
|
30
|
+
const runnerOverride =
|
|
31
|
+
runnerFlagIdx !== -1 ? args[runnerFlagIdx + 1] : undefined;
|
|
32
|
+
const positional = args.filter(
|
|
33
|
+
(a: string, i: number) => a !== "--runner" && i !== runnerFlagIdx + 1,
|
|
34
|
+
);
|
|
35
|
+
const targetRepo = resolve(positional[0] ?? process.cwd());
|
|
29
36
|
|
|
30
37
|
if (!existsSync(join(targetRepo, ".git"))) {
|
|
31
38
|
console.error(`Error: ${targetRepo} is not a git repository`);
|
|
@@ -147,9 +154,13 @@ async function main() {
|
|
|
147
154
|
console.log("✓ Installed .claude/commands/pr.md (/pr command)");
|
|
148
155
|
|
|
149
156
|
// 4. Install GitHub Actions workflow for automatic PR metrics injection
|
|
150
|
-
await installCiWorkflow(
|
|
157
|
+
const { runsOn, detected } = await installCiWorkflow(
|
|
158
|
+
targetRepo,
|
|
159
|
+
runnerOverride,
|
|
160
|
+
);
|
|
161
|
+
const detectedNote = detected ? " (detected from existing workflows)" : "";
|
|
151
162
|
console.log(
|
|
152
|
-
|
|
163
|
+
`✓ Installed .github/workflows/claude-attribution-pr.yml — runner: ${runsOn}${detectedNote}`,
|
|
153
164
|
);
|
|
154
165
|
|
|
155
166
|
// 5. Record installed version for auto-upgrade tracking
|
package/src/setup/shared.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* No top-level side effects. No console.log — logging is the caller's
|
|
5
5
|
* responsibility.
|
|
6
6
|
*/
|
|
7
|
-
import { readFile, writeFile, chmod, mkdir } from "fs/promises";
|
|
7
|
+
import { readFile, writeFile, chmod, mkdir, readdir } from "fs/promises";
|
|
8
8
|
import { existsSync } from "fs";
|
|
9
9
|
import { resolve, join, dirname } from "path";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
@@ -169,13 +169,86 @@ export async function installSlashCommands(repoRoot: string): Promise<void> {
|
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/** GitHub-hosted runner labels — anything else is self-hosted or custom. */
|
|
173
|
+
const GITHUB_HOSTED_RUNNERS = new Set([
|
|
174
|
+
"ubuntu-latest",
|
|
175
|
+
"ubuntu-24.04",
|
|
176
|
+
"ubuntu-22.04",
|
|
177
|
+
"ubuntu-20.04",
|
|
178
|
+
"windows-latest",
|
|
179
|
+
"windows-2025",
|
|
180
|
+
"windows-2022",
|
|
181
|
+
"windows-2019",
|
|
182
|
+
"macos-latest",
|
|
183
|
+
"macos-15",
|
|
184
|
+
"macos-14",
|
|
185
|
+
"macos-13",
|
|
186
|
+
"macos-12",
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Scan existing workflows in .github/workflows/ (excluding our own) and return
|
|
191
|
+
* the most common non-GitHub-hosted `runs-on` value, or null if none found.
|
|
192
|
+
*/
|
|
193
|
+
async function detectRunsOn(repoRoot: string): Promise<string | null> {
|
|
194
|
+
const workflowsDir = join(repoRoot, ".github", "workflows");
|
|
195
|
+
if (!existsSync(workflowsDir)) return null;
|
|
196
|
+
|
|
197
|
+
let files: string[];
|
|
198
|
+
try {
|
|
199
|
+
files = await readdir(workflowsDir);
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const freq = new Map<string, number>();
|
|
205
|
+
|
|
206
|
+
for (const file of files) {
|
|
207
|
+
if (file === "claude-attribution-pr.yml") continue;
|
|
208
|
+
if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
|
|
209
|
+
try {
|
|
210
|
+
const content = await readFile(join(workflowsDir, file), "utf8");
|
|
211
|
+
for (const match of content.matchAll(/^\s+runs-on:\s+(.+)$/gm)) {
|
|
212
|
+
const raw: string = match[1] ?? "";
|
|
213
|
+
const value = (raw.split("#")[0] ?? raw).trim();
|
|
214
|
+
if (!GITHUB_HOSTED_RUNNERS.has(value)) {
|
|
215
|
+
freq.set(value, (freq.get(value) ?? 0) + 1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (freq.size === 0) return null;
|
|
224
|
+
const sorted = [...freq.entries()].sort((a, b) => b[1] - a[1]);
|
|
225
|
+
return sorted[0]?.[0] ?? null;
|
|
226
|
+
}
|
|
227
|
+
|
|
172
228
|
/**
|
|
173
229
|
* Install (or overwrite) the GitHub Actions PR metrics workflow.
|
|
230
|
+
* Auto-detects the runner from existing workflows; override with runsOn param.
|
|
231
|
+
* Returns the runner value used and whether it was auto-detected.
|
|
174
232
|
*/
|
|
175
|
-
export async function installCiWorkflow(
|
|
233
|
+
export async function installCiWorkflow(
|
|
234
|
+
repoRoot: string,
|
|
235
|
+
runsOn?: string,
|
|
236
|
+
): Promise<{ runsOn: string; detected: boolean }> {
|
|
176
237
|
const workflowsDir = join(repoRoot, ".github", "workflows");
|
|
177
238
|
await mkdir(workflowsDir, { recursive: true });
|
|
178
|
-
|
|
239
|
+
|
|
240
|
+
let detected = false;
|
|
241
|
+
if (!runsOn) {
|
|
242
|
+
const autoDetected = await detectRunsOn(repoRoot);
|
|
243
|
+
if (autoDetected) {
|
|
244
|
+
runsOn = autoDetected;
|
|
245
|
+
detected = true;
|
|
246
|
+
} else {
|
|
247
|
+
runsOn = "ubuntu-latest";
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const template = await readFile(
|
|
179
252
|
join(
|
|
180
253
|
ATTRIBUTION_ROOT,
|
|
181
254
|
"src",
|
|
@@ -185,5 +258,9 @@ export async function installCiWorkflow(repoRoot: string): Promise<void> {
|
|
|
185
258
|
),
|
|
186
259
|
"utf8",
|
|
187
260
|
);
|
|
188
|
-
await writeFile(
|
|
261
|
+
await writeFile(
|
|
262
|
+
join(workflowsDir, "claude-attribution-pr.yml"),
|
|
263
|
+
template.replace("{{RUNS_ON}}", runsOn),
|
|
264
|
+
);
|
|
265
|
+
return { runsOn, detected };
|
|
189
266
|
}
|