claude-attribution 1.2.5 → 1.2.8
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/package.json +1 -1
- package/src/attribution/minimap.ts +160 -0
- package/src/cli.ts +1 -1
- package/src/commands/init.ts +33 -131
- package/src/commands/pr.ts +6 -3
- package/src/hooks/post-tool-use.ts +11 -2
- package/src/metrics/collect.ts +130 -37
- package/src/metrics/transcript.ts +67 -28
- package/src/setup/branch-protection.ts +432 -0
- package/src/setup/install.ts +89 -182
- package/src/setup/templates/pr-metrics-workflow.yml +4 -1
package/src/setup/install.ts
CHANGED
|
@@ -5,21 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* If no path is given, installs into the current working directory.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
readFile,
|
|
10
|
-
writeFile,
|
|
11
|
-
appendFile,
|
|
12
|
-
mkdir,
|
|
13
|
-
mkdtemp,
|
|
14
|
-
unlink,
|
|
15
|
-
rmdir,
|
|
16
|
-
} from "fs/promises";
|
|
8
|
+
import { readFile, writeFile, appendFile, mkdir } from "fs/promises";
|
|
17
9
|
import { existsSync } from "fs";
|
|
18
10
|
import { execFile } from "child_process";
|
|
19
11
|
import { promisify } from "util";
|
|
20
|
-
import { resolve, join } from "path";
|
|
21
|
-
import { tmpdir } from "os";
|
|
22
12
|
import { createInterface } from "readline";
|
|
13
|
+
import { resolve, join } from "path";
|
|
23
14
|
import {
|
|
24
15
|
ATTRIBUTION_ROOT,
|
|
25
16
|
mergeHooks,
|
|
@@ -29,209 +20,122 @@ import {
|
|
|
29
20
|
detectHookManager,
|
|
30
21
|
type HooksConfig,
|
|
31
22
|
} from "./shared.ts";
|
|
23
|
+
import { configureRequiredCheck } from "./branch-protection.ts";
|
|
24
|
+
import {
|
|
25
|
+
listMinimapNotes,
|
|
26
|
+
writeMinimap,
|
|
27
|
+
buildAllAiMinimap,
|
|
28
|
+
buildAiSinceMinimap,
|
|
29
|
+
} from "../attribution/minimap.ts";
|
|
32
30
|
|
|
33
31
|
const execFileAsync = promisify(execFile);
|
|
34
32
|
|
|
35
33
|
const CLI_BIN = resolve(ATTRIBUTION_ROOT, "bin", "claude-attribution");
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
* The exact GitHub Actions job name written into our workflow template.
|
|
39
|
-
* Must match the `name:` field of the `metrics` job in pr-metrics-workflow.yml.
|
|
40
|
-
*/
|
|
41
|
-
const WORKFLOW_CHECK_NAME = "Claude Code Attribution Metrics";
|
|
35
|
+
// ─── Baseline init prompt ────────────────────────────────────────────────────
|
|
42
36
|
|
|
43
|
-
|
|
44
|
-
function remoteUrlToSlug(url: string): string | null {
|
|
45
|
-
const m =
|
|
46
|
-
url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/) ??
|
|
47
|
-
url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
48
|
-
return m?.[1] ?? null;
|
|
49
|
-
}
|
|
37
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
50
38
|
|
|
51
|
-
|
|
52
|
-
async function ghApiGet(path: string): Promise<unknown> {
|
|
53
|
-
try {
|
|
54
|
-
const { stdout } = (await execFileAsync("gh", [
|
|
55
|
-
"api",
|
|
56
|
-
path,
|
|
57
|
-
])) as unknown as {
|
|
58
|
-
stdout: string;
|
|
59
|
-
};
|
|
60
|
-
return JSON.parse(stdout) as unknown;
|
|
61
|
-
} catch {
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Prompt the user for a yes/no answer. Returns false in non-TTY contexts. */
|
|
67
|
-
async function promptYesNo(question: string): Promise<boolean> {
|
|
68
|
-
if (!process.stdin.isTTY) return false;
|
|
39
|
+
async function promptLine(question: string): Promise<string> {
|
|
69
40
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
70
41
|
return new Promise((resolve) => {
|
|
71
42
|
rl.question(question, (answer) => {
|
|
72
43
|
rl.close();
|
|
73
|
-
resolve(answer.trim()
|
|
44
|
+
resolve(answer.trim());
|
|
74
45
|
});
|
|
75
46
|
});
|
|
76
47
|
}
|
|
77
48
|
|
|
78
|
-
function printRequiredCheckNote(branch: string): void {
|
|
79
|
-
console.log(
|
|
80
|
-
`\n ℹ️ To block merges when this workflow fails, add '${WORKFLOW_CHECK_NAME}'`,
|
|
81
|
-
);
|
|
82
|
-
console.log(
|
|
83
|
-
` to required status checks for '${branch}' in Settings → Branches.`,
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
49
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
50
|
+
* After install, ask the developer how to treat existing code in the minimap.
|
|
51
|
+
* Skipped if the minimap was already initialized (flag file or existing notes).
|
|
52
|
+
* Never throws — any failure falls back to a printed note.
|
|
90
53
|
*/
|
|
91
|
-
async function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
body: unknown,
|
|
54
|
+
async function promptBaselineInit(
|
|
55
|
+
repoRoot: string,
|
|
56
|
+
claudeDir: string,
|
|
95
57
|
): Promise<void> {
|
|
96
|
-
const
|
|
97
|
-
const tmpFile = join(tmpDir, "body.json");
|
|
98
|
-
try {
|
|
99
|
-
await writeFile(tmpFile, JSON.stringify(body), { flag: "wx" });
|
|
100
|
-
await execFileAsync("gh", [
|
|
101
|
-
"api",
|
|
102
|
-
`repos/${slug}/branches/${branch}/protection/required_status_checks`,
|
|
103
|
-
"--method",
|
|
104
|
-
"PATCH",
|
|
105
|
-
"--input",
|
|
106
|
-
tmpFile,
|
|
107
|
-
]);
|
|
108
|
-
} finally {
|
|
109
|
-
await unlink(tmpFile).catch(() => {});
|
|
110
|
-
await rmdir(tmpDir).catch(() => {});
|
|
111
|
-
}
|
|
112
|
-
}
|
|
58
|
+
const flagPath = join(claudeDir, "attribution-state", "baseline-initialized");
|
|
113
59
|
|
|
114
|
-
/**
|
|
115
|
-
* After installing the workflow, check branch protection / rulesets and offer
|
|
116
|
-
* to add our workflow job as a required status check.
|
|
117
|
-
*
|
|
118
|
-
* - Classic branch protection: fully automatic (detect → prompt → PATCH)
|
|
119
|
-
* - Rulesets: detect only → informational note (ruleset API requires full PUT)
|
|
120
|
-
* - Any error or non-TTY: fall back to informational note
|
|
121
|
-
*/
|
|
122
|
-
async function maybeAddRequiredCheck(repoRoot: string): Promise<void> {
|
|
123
60
|
try {
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
?.default_branch;
|
|
137
|
-
if (!branch) return;
|
|
138
|
-
|
|
139
|
-
// --- Classic branch protection ---
|
|
140
|
-
const protection = await ghApiGet(
|
|
141
|
-
`repos/${slug}/branches/${branch}/protection`,
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
if (!protection) {
|
|
145
|
-
// No classic protection — check rulesets (detect-only)
|
|
146
|
-
const rulesets = await ghApiGet(`repos/${slug}/rulesets`);
|
|
147
|
-
if (Array.isArray(rulesets) && rulesets.length > 0) {
|
|
148
|
-
// Check if any ruleset already requires our check
|
|
149
|
-
const alreadyRequired = (
|
|
150
|
-
rulesets as Array<{
|
|
151
|
-
rules?: Array<{
|
|
152
|
-
type: string;
|
|
153
|
-
parameters?: {
|
|
154
|
-
required_status_checks?: Array<{ context: string }>;
|
|
155
|
-
};
|
|
156
|
-
}>;
|
|
157
|
-
}>
|
|
158
|
-
).some((rs) =>
|
|
159
|
-
rs.rules?.some(
|
|
160
|
-
(r) =>
|
|
161
|
-
r.type === "required_status_checks" &&
|
|
162
|
-
r.parameters?.required_status_checks?.some(
|
|
163
|
-
(c) => c.context === WORKFLOW_CHECK_NAME,
|
|
164
|
-
),
|
|
165
|
-
),
|
|
166
|
-
);
|
|
167
|
-
if (alreadyRequired) {
|
|
168
|
-
console.log(
|
|
169
|
-
`✓ '${WORKFLOW_CHECK_NAME}' already a required status check`,
|
|
170
|
-
);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
console.log(
|
|
174
|
-
`\n ℹ️ Ruleset branch protection detected on '${branch}'.`,
|
|
175
|
-
);
|
|
176
|
-
console.log(
|
|
177
|
-
` Add '${WORKFLOW_CHECK_NAME}' to required status checks`,
|
|
178
|
-
);
|
|
179
|
-
console.log(
|
|
180
|
-
` in Settings → Rules to block merges on workflow failure.`,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
61
|
+
// Idempotent: skip if already set
|
|
62
|
+
if (existsSync(flagPath)) return;
|
|
63
|
+
const existingNotes = await listMinimapNotes(repoRoot);
|
|
64
|
+
if (existingNotes.length > 0) return;
|
|
65
|
+
|
|
66
|
+
// Verify there are commits to work with
|
|
67
|
+
try {
|
|
68
|
+
await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repoRoot });
|
|
69
|
+
} catch {
|
|
70
|
+
console.log(
|
|
71
|
+
"\n ℹ️ Run 'claude-attribution init --ai' after your first commit to set the attribution baseline.",
|
|
72
|
+
);
|
|
183
73
|
return;
|
|
184
74
|
}
|
|
185
75
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
strict: boolean;
|
|
191
|
-
contexts?: string[];
|
|
192
|
-
checks?: Check[];
|
|
193
|
-
};
|
|
194
|
-
};
|
|
195
|
-
const existingChecks: Check[] =
|
|
196
|
-
prot.required_status_checks?.checks ??
|
|
197
|
-
(prot.required_status_checks?.contexts ?? []).map((c) => ({
|
|
198
|
-
context: c,
|
|
199
|
-
app_id: -1,
|
|
200
|
-
}));
|
|
201
|
-
const strict = prot.required_status_checks?.strict ?? false;
|
|
202
|
-
|
|
203
|
-
if (existingChecks.some((c) => c.context === WORKFLOW_CHECK_NAME)) {
|
|
204
|
-
console.log(`✓ '${WORKFLOW_CHECK_NAME}' already a required status check`);
|
|
76
|
+
if (!process.stdin.isTTY) {
|
|
77
|
+
console.log(
|
|
78
|
+
"\n ℹ️ Run 'claude-attribution init [--ai | --ai-since <YYYY-MM-DD>]' to set the attribution baseline.",
|
|
79
|
+
);
|
|
205
80
|
return;
|
|
206
81
|
}
|
|
207
82
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const yes = await promptYesNo(
|
|
211
|
-
` Add '${WORKFLOW_CHECK_NAME}' as a required status check? [y/N] `,
|
|
83
|
+
console.log(
|
|
84
|
+
"\n Attribution baseline — how should existing code be treated?",
|
|
212
85
|
);
|
|
213
|
-
if (!yes) {
|
|
214
|
-
printRequiredCheckNote(branch);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
await patchRequiredChecks(slug, branch, {
|
|
219
|
-
strict,
|
|
220
|
-
checks: [...existingChecks, { context: WORKFLOW_CHECK_NAME, app_id: -1 }],
|
|
221
|
-
});
|
|
222
86
|
console.log(
|
|
223
|
-
|
|
87
|
+
" [1] AI — all current code was written with Claude Code",
|
|
224
88
|
);
|
|
225
|
-
} catch {
|
|
226
|
-
// Any failure — don't break install, just print the note
|
|
227
89
|
console.log(
|
|
228
|
-
|
|
90
|
+
" [2] Human — assume human-written (AI accumulates from here)",
|
|
229
91
|
);
|
|
230
92
|
console.log(
|
|
231
|
-
|
|
93
|
+
" [3] AI since — mark commits after a date as AI-written",
|
|
232
94
|
);
|
|
233
95
|
console.log(
|
|
234
|
-
|
|
96
|
+
" [4] Skip — decide later (run: claude-attribution init --ai)",
|
|
97
|
+
);
|
|
98
|
+
const raw = await promptLine(" Choice [2]: ");
|
|
99
|
+
const choice = raw === "" ? 2 : parseInt(raw, 10);
|
|
100
|
+
|
|
101
|
+
if (choice === 1) {
|
|
102
|
+
console.log(" Marking all files as AI-written...");
|
|
103
|
+
const result = await buildAllAiMinimap(repoRoot);
|
|
104
|
+
await writeMinimap(result, repoRoot);
|
|
105
|
+
await writeFile(flagPath, "ai");
|
|
106
|
+
const { pctAi, total } = result.totals;
|
|
107
|
+
console.log(
|
|
108
|
+
`✓ Baseline set: AI (${result.files.length} files, ${total} lines, ${pctAi}% AI)`,
|
|
109
|
+
);
|
|
110
|
+
} else if (choice === 2) {
|
|
111
|
+
await writeFile(flagPath, "human");
|
|
112
|
+
console.log(
|
|
113
|
+
"✓ Baseline set: human (AI attribution accumulates from this point)",
|
|
114
|
+
);
|
|
115
|
+
} else if (choice === 3) {
|
|
116
|
+
// Prompt for date with validation loop
|
|
117
|
+
let date = "";
|
|
118
|
+
while (true) {
|
|
119
|
+
date = await promptLine(" Start date (YYYY-MM-DD): ");
|
|
120
|
+
if (DATE_RE.test(date)) break;
|
|
121
|
+
console.log(
|
|
122
|
+
` Invalid format: ${date || "(empty)"}. Use YYYY-MM-DD (e.g. 2026-01-15).`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
console.log(` Marking files changed since ${date} as AI-written...`);
|
|
126
|
+
const result = await buildAiSinceMinimap(repoRoot, date);
|
|
127
|
+
await writeMinimap(result, repoRoot);
|
|
128
|
+
await writeFile(flagPath, `ai-since:${date}`);
|
|
129
|
+
const { pctAi, total } = result.totals;
|
|
130
|
+
console.log(
|
|
131
|
+
`✓ Baseline set: AI since ${date} (${result.files.length} files, ${total} lines, ${pctAi}% AI)`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
// choice === 4 or unrecognized: Skip — write no flag, ask again next install
|
|
135
|
+
} catch {
|
|
136
|
+
console.log("\n ℹ️ Could not set attribution baseline automatically.");
|
|
137
|
+
console.log(
|
|
138
|
+
" Run 'claude-attribution init [--ai | --ai-since <YYYY-MM-DD>]' manually.",
|
|
235
139
|
);
|
|
236
140
|
}
|
|
237
141
|
}
|
|
@@ -387,9 +291,12 @@ async function main() {
|
|
|
387
291
|
);
|
|
388
292
|
|
|
389
293
|
// 5. Check branch protection and offer to add required status check
|
|
390
|
-
await
|
|
294
|
+
await configureRequiredCheck(targetRepo);
|
|
295
|
+
|
|
296
|
+
// 6. Prompt for attribution baseline (skipped on re-runs)
|
|
297
|
+
await promptBaselineInit(targetRepo, claudeDir);
|
|
391
298
|
|
|
392
|
-
//
|
|
299
|
+
// 7. Record installed version for auto-upgrade tracking
|
|
393
300
|
const pkg = JSON.parse(
|
|
394
301
|
await readFile(join(ATTRIBUTION_ROOT, "package.json"), "utf8"),
|
|
395
302
|
) as { version: string };
|
|
@@ -23,7 +23,10 @@ jobs:
|
|
|
23
23
|
git fetch origin refs/notes/claude-attribution-map:refs/notes/claude-attribution-map || true
|
|
24
24
|
|
|
25
25
|
- name: Install claude-attribution
|
|
26
|
-
run:
|
|
26
|
+
run: |
|
|
27
|
+
npm config set prefix "${HOME}/.npm-global"
|
|
28
|
+
npm install -g claude-attribution
|
|
29
|
+
echo "${HOME}/.npm-global/bin" >> "$GITHUB_PATH"
|
|
27
30
|
|
|
28
31
|
- name: Generate metrics
|
|
29
32
|
run: claude-attribution metrics > /tmp/claude-attribution-metrics.md || true
|