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/package.json
CHANGED
|
@@ -176,3 +176,163 @@ export async function listMinimapNotes(repoRoot: string): Promise<string[]> {
|
|
|
176
176
|
return [];
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
// ─── Bulk init helpers ────────────────────────────────────────────────────────
|
|
181
|
+
// Exported for use by install.ts without importing init.ts (which auto-executes
|
|
182
|
+
// main() at module level and cannot be statically imported from other modules).
|
|
183
|
+
|
|
184
|
+
const CONCURRENCY = 8;
|
|
185
|
+
|
|
186
|
+
function buildResult(sha: string, files: MinimapFileState[]): MinimapResult {
|
|
187
|
+
let totalAi = 0,
|
|
188
|
+
totalHuman = 0,
|
|
189
|
+
totalLines = 0;
|
|
190
|
+
for (const f of files) {
|
|
191
|
+
totalAi += f.ai;
|
|
192
|
+
totalHuman += f.human;
|
|
193
|
+
totalLines += f.total;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
commit: sha,
|
|
197
|
+
timestamp: new Date().toISOString(),
|
|
198
|
+
files,
|
|
199
|
+
totals: {
|
|
200
|
+
ai: totalAi,
|
|
201
|
+
human: totalHuman,
|
|
202
|
+
total: totalLines,
|
|
203
|
+
pctAi: totalLines > 0 ? Math.round((totalAi / totalLines) * 100) : 0,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Mark all currently tracked files as AI-written at HEAD.
|
|
210
|
+
* Used by `claude-attribution init --ai` and the install baseline prompt.
|
|
211
|
+
*/
|
|
212
|
+
export async function buildAllAiMinimap(
|
|
213
|
+
repoRoot: string,
|
|
214
|
+
): Promise<MinimapResult> {
|
|
215
|
+
const sha = await run("git", ["rev-parse", "HEAD"], repoRoot);
|
|
216
|
+
const lsOutput = await run("git", ["ls-files"], repoRoot);
|
|
217
|
+
const allFiles = lsOutput ? lsOutput.split("\n").filter(Boolean) : [];
|
|
218
|
+
|
|
219
|
+
const minimapFiles: MinimapFileState[] = [];
|
|
220
|
+
for (let i = 0; i < allFiles.length; i += CONCURRENCY) {
|
|
221
|
+
const batch = allFiles.slice(i, i + CONCURRENCY);
|
|
222
|
+
const results = await Promise.all(
|
|
223
|
+
batch.map(async (relPath): Promise<MinimapFileState | null> => {
|
|
224
|
+
try {
|
|
225
|
+
const content = await run(
|
|
226
|
+
"git",
|
|
227
|
+
["show", `HEAD:${relPath}`],
|
|
228
|
+
repoRoot,
|
|
229
|
+
);
|
|
230
|
+
if (content.includes("\0")) return null;
|
|
231
|
+
const lines = content.split("\n");
|
|
232
|
+
const total = lines.length;
|
|
233
|
+
const aiHashes = new Set<string>();
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
if (line.trim() !== "") aiHashes.add(hashLine(line));
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
path: relPath,
|
|
239
|
+
ai_hashes: hashSetToString(aiHashes),
|
|
240
|
+
ai: total,
|
|
241
|
+
human: 0,
|
|
242
|
+
total,
|
|
243
|
+
pctAi: total > 0 ? 100 : 0,
|
|
244
|
+
} satisfies MinimapFileState;
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
minimapFiles.push(
|
|
251
|
+
...results.filter((r): r is MinimapFileState => r !== null),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return buildResult(sha, minimapFiles);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Mark files modified since sinceDate (YYYY-MM-DD) as AI-written at HEAD.
|
|
259
|
+
* Files with no commits since that date are treated as human-written.
|
|
260
|
+
*/
|
|
261
|
+
export async function buildAiSinceMinimap(
|
|
262
|
+
repoRoot: string,
|
|
263
|
+
sinceDate: string,
|
|
264
|
+
): Promise<MinimapResult> {
|
|
265
|
+
const sha = await run("git", ["rev-parse", "HEAD"], repoRoot);
|
|
266
|
+
const lsOutput = await run("git", ["ls-files"], repoRoot);
|
|
267
|
+
const allFiles = lsOutput ? lsOutput.split("\n").filter(Boolean) : [];
|
|
268
|
+
|
|
269
|
+
// Collect all files changed in commits since the given date
|
|
270
|
+
const commitLog = await run(
|
|
271
|
+
"git",
|
|
272
|
+
["log", `--since=${sinceDate}`, "--format=%H"],
|
|
273
|
+
repoRoot,
|
|
274
|
+
);
|
|
275
|
+
const commits = commitLog ? commitLog.split("\n").filter(Boolean) : [];
|
|
276
|
+
const aiFileSet = new Set<string>();
|
|
277
|
+
for (const commitSha of commits) {
|
|
278
|
+
try {
|
|
279
|
+
const changed = await run(
|
|
280
|
+
"git",
|
|
281
|
+
["diff-tree", "--no-commit-id", "-r", "--name-only", commitSha],
|
|
282
|
+
repoRoot,
|
|
283
|
+
);
|
|
284
|
+
for (const f of changed.split("\n").filter(Boolean)) {
|
|
285
|
+
aiFileSet.add(f);
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
// Non-fatal: skip this commit's file list
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const minimapFiles: MinimapFileState[] = [];
|
|
293
|
+
for (let i = 0; i < allFiles.length; i += CONCURRENCY) {
|
|
294
|
+
const batch = allFiles.slice(i, i + CONCURRENCY);
|
|
295
|
+
const results = await Promise.all(
|
|
296
|
+
batch.map(async (relPath): Promise<MinimapFileState | null> => {
|
|
297
|
+
try {
|
|
298
|
+
const content = await run(
|
|
299
|
+
"git",
|
|
300
|
+
["show", `HEAD:${relPath}`],
|
|
301
|
+
repoRoot,
|
|
302
|
+
);
|
|
303
|
+
if (content.includes("\0")) return null;
|
|
304
|
+
const lines = content.split("\n");
|
|
305
|
+
const total = lines.length;
|
|
306
|
+
if (!aiFileSet.has(relPath)) {
|
|
307
|
+
return {
|
|
308
|
+
path: relPath,
|
|
309
|
+
ai_hashes: "",
|
|
310
|
+
ai: 0,
|
|
311
|
+
human: total,
|
|
312
|
+
total,
|
|
313
|
+
pctAi: 0,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const aiHashes = new Set<string>();
|
|
317
|
+
for (const line of lines) {
|
|
318
|
+
if (line.trim() !== "") aiHashes.add(hashLine(line));
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
path: relPath,
|
|
322
|
+
ai_hashes: hashSetToString(aiHashes),
|
|
323
|
+
ai: total,
|
|
324
|
+
human: 0,
|
|
325
|
+
total,
|
|
326
|
+
pctAi: total > 0 ? 100 : 0,
|
|
327
|
+
} satisfies MinimapFileState;
|
|
328
|
+
} catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
minimapFiles.push(
|
|
334
|
+
...results.filter((r): r is MinimapFileState => r !== null),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return buildResult(sha, minimapFiles);
|
|
338
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -92,7 +92,7 @@ Commands:
|
|
|
92
92
|
uninstall [repo] Remove hooks from a repo (default: current directory)
|
|
93
93
|
metrics [id] Generate PR metrics report
|
|
94
94
|
pr [title] Create PR with metrics embedded (--draft, --base <branch>)
|
|
95
|
-
init [--ai
|
|
95
|
+
init [--ai | --ai-since <YYYY-MM-DD>] Set attribution baseline in the cumulative minimap
|
|
96
96
|
start Mark session start for per-ticket scoping
|
|
97
97
|
hook <name> Run an internal hook (used by installed git hooks)
|
|
98
98
|
version Print version
|
package/src/commands/init.ts
CHANGED
|
@@ -1,37 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* claude-attribution init [--ai | --human]
|
|
2
|
+
* claude-attribution init [--ai | --ai-since <YYYY-MM-DD> | --human]
|
|
3
3
|
*
|
|
4
4
|
* Initializes the cumulative attribution minimap for an existing repo.
|
|
5
5
|
*
|
|
6
|
-
* --ai
|
|
7
|
-
*
|
|
8
|
-
* --human
|
|
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.
|
|
6
|
+
* --ai Mark all currently tracked files as AI-written.
|
|
7
|
+
* --ai-since <date> Mark files modified since the given date as AI-written.
|
|
8
|
+
* --human (default) Confirm the human default — no minimap written.
|
|
13
9
|
*/
|
|
14
10
|
import { resolve } from "path";
|
|
15
|
-
import { execFile } from "child_process";
|
|
16
|
-
import { promisify } from "util";
|
|
17
|
-
import { hashLine } from "../attribution/differ.ts";
|
|
18
11
|
import {
|
|
19
|
-
|
|
12
|
+
buildAllAiMinimap,
|
|
13
|
+
buildAiSinceMinimap,
|
|
20
14
|
writeMinimap,
|
|
21
|
-
type MinimapFileState,
|
|
22
|
-
type MinimapResult,
|
|
23
15
|
} from "../attribution/minimap.ts";
|
|
24
16
|
|
|
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
17
|
async function main() {
|
|
36
18
|
const repoRoot = resolve(process.cwd());
|
|
37
19
|
const flag = process.argv[2];
|
|
@@ -49,120 +31,40 @@ async function main() {
|
|
|
49
31
|
return;
|
|
50
32
|
}
|
|
51
33
|
|
|
52
|
-
if (flag
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.",
|
|
34
|
+
if (flag === "--ai") {
|
|
35
|
+
const result = await buildAllAiMinimap(repoRoot);
|
|
36
|
+
await writeMinimap(result, repoRoot);
|
|
37
|
+
const pct = result.totals.pctAi;
|
|
38
|
+
console.log(
|
|
39
|
+
`✓ Marked ${result.files.length} files (${result.totals.total} lines, ${pct}% AI) as AI-written.`,
|
|
65
40
|
);
|
|
66
|
-
|
|
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);
|
|
41
|
+
return;
|
|
75
42
|
}
|
|
76
43
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
const total = lines.length;
|
|
101
|
-
|
|
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>();
|
|
107
|
-
for (const line of lines) {
|
|
108
|
-
if (line.trim() !== "") {
|
|
109
|
-
aiHashes.add(hashLine(line));
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
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;
|
|
121
|
-
} catch {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
}),
|
|
125
|
-
);
|
|
126
|
-
const valid = batchResults.filter((r): r is MinimapFileState => r !== null);
|
|
127
|
-
minimapFiles.push(...valid);
|
|
128
|
-
processed += batch.length;
|
|
129
|
-
if (processed % 100 === 0 || processed === allFiles.length) {
|
|
130
|
-
process.stdout.write(`\r ${processed} / ${allFiles.length} files...`);
|
|
44
|
+
if (flag === "--ai-since") {
|
|
45
|
+
const date = process.argv[3];
|
|
46
|
+
if (!date) {
|
|
47
|
+
console.error("Usage: claude-attribution init --ai-since <YYYY-MM-DD>");
|
|
48
|
+
process.exit(1);
|
|
131
49
|
}
|
|
50
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
51
|
+
console.error(`Invalid date format: ${date}. Expected YYYY-MM-DD.`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
const result = await buildAiSinceMinimap(repoRoot, date);
|
|
55
|
+
await writeMinimap(result, repoRoot);
|
|
56
|
+
const pct = result.totals.pctAi;
|
|
57
|
+
console.log(
|
|
58
|
+
`✓ Marked ${result.files.length} files (${result.totals.total} lines, ${pct}% AI) as AI-written since ${date}.`,
|
|
59
|
+
);
|
|
60
|
+
return;
|
|
132
61
|
}
|
|
133
62
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
totalHuman = 0,
|
|
138
|
-
totalLines = 0;
|
|
139
|
-
for (const f of minimapFiles) {
|
|
140
|
-
totalAi += f.ai;
|
|
141
|
-
totalHuman += f.human;
|
|
142
|
-
totalLines += f.total;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const minimapResult: MinimapResult = {
|
|
146
|
-
commit: sha,
|
|
147
|
-
timestamp: new Date().toISOString(),
|
|
148
|
-
files: minimapFiles,
|
|
149
|
-
totals: {
|
|
150
|
-
ai: totalAi,
|
|
151
|
-
human: totalHuman,
|
|
152
|
-
total: totalLines,
|
|
153
|
-
pctAi: totalLines > 0 ? Math.round((totalAi / totalLines) * 100) : 0,
|
|
154
|
-
},
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
await writeMinimap(minimapResult, repoRoot);
|
|
158
|
-
|
|
159
|
-
const pct = minimapResult.totals.pctAi;
|
|
160
|
-
console.log(
|
|
161
|
-
`✓ Marked ${minimapFiles.length} files (${totalLines} lines, ${pct}% AI) as AI-written.`,
|
|
162
|
-
);
|
|
163
|
-
console.log(
|
|
164
|
-
" Push to share with team: git push origin refs/notes/claude-attribution-map",
|
|
63
|
+
console.error(`Unknown flag: ${flag}`);
|
|
64
|
+
console.error(
|
|
65
|
+
"Usage: claude-attribution init [--ai | --ai-since <YYYY-MM-DD> | --human]",
|
|
165
66
|
);
|
|
67
|
+
process.exit(1);
|
|
166
68
|
}
|
|
167
69
|
|
|
168
70
|
main().catch((err) => {
|
package/src/commands/pr.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { collectMetrics, renderMetrics } from "../metrics/collect.ts";
|
|
|
21
21
|
const execFileAsync = promisify(execFile);
|
|
22
22
|
|
|
23
23
|
const METRICS_PLACEHOLDER = "<!-- claude-attribution metrics -->";
|
|
24
|
+
const METRICS_END = "<!-- /claude-attribution metrics -->";
|
|
24
25
|
|
|
25
26
|
const BUILTIN_TEMPLATE = `## Description
|
|
26
27
|
|
|
@@ -125,12 +126,14 @@ async function main() {
|
|
|
125
126
|
? await readFile(templatePath, "utf8")
|
|
126
127
|
: BUILTIN_TEMPLATE;
|
|
127
128
|
|
|
128
|
-
// Inject metrics
|
|
129
|
+
// Inject metrics wrapped in start/end markers so CI can find and replace on
|
|
130
|
+
// subsequent synchronize events without appending a second block.
|
|
131
|
+
const metricsSection = `${METRICS_PLACEHOLDER}\n${metricsBlock}\n${METRICS_END}`;
|
|
129
132
|
let body: string;
|
|
130
133
|
if (template.includes(METRICS_PLACEHOLDER)) {
|
|
131
|
-
body = template.replace(METRICS_PLACEHOLDER,
|
|
134
|
+
body = template.replace(METRICS_PLACEHOLDER, metricsSection);
|
|
132
135
|
} else {
|
|
133
|
-
body = template.trimEnd() + "\n\n" +
|
|
136
|
+
body = template.trimEnd() + "\n\n" + metricsSection;
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
// Push the current branch to remote. Always push only the branch ref (not
|
|
@@ -45,12 +45,21 @@ async function main() {
|
|
|
45
45
|
const { session_id, tool_name, tool_input } = payload;
|
|
46
46
|
const repoRoot = resolve(process.cwd());
|
|
47
47
|
|
|
48
|
-
// Log every tool call
|
|
49
|
-
|
|
48
|
+
// Log every tool call. For Skill invocations, also capture the skill name
|
|
49
|
+
// so metrics can show "/pr" instead of the generic "Skill ×1".
|
|
50
|
+
const logEntry: {
|
|
51
|
+
timestamp: string;
|
|
52
|
+
session: string;
|
|
53
|
+
tool: string;
|
|
54
|
+
skill?: string;
|
|
55
|
+
} = {
|
|
50
56
|
timestamp: new Date().toISOString(),
|
|
51
57
|
session: session_id,
|
|
52
58
|
tool: tool_name,
|
|
53
59
|
};
|
|
60
|
+
if (tool_name === "Skill" && typeof tool_input["skill"] === "string") {
|
|
61
|
+
logEntry.skill = tool_input["skill"];
|
|
62
|
+
}
|
|
54
63
|
|
|
55
64
|
try {
|
|
56
65
|
const logDir = join(repoRoot, ".claude", "logs");
|