claude-attribution 1.2.1 → 1.2.4
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/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 +33 -11
- package/src/setup/shared.ts +213 -17
- package/src/setup/templates/pr-metrics-workflow.yml +1 -1
package/package.json
CHANGED
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`);
|
|
@@ -71,23 +78,34 @@ async function main() {
|
|
|
71
78
|
const hookResult = await installGitHook(targetRepo);
|
|
72
79
|
if (hookResult === "noop") {
|
|
73
80
|
console.log("");
|
|
74
|
-
console.log(
|
|
75
|
-
|
|
81
|
+
console.log(
|
|
82
|
+
" ⚠️ Could not auto-edit lefthook config (unusual structure).",
|
|
83
|
+
);
|
|
84
|
+
console.log(
|
|
85
|
+
" Add this to your lefthook config under post-commit manually:",
|
|
86
|
+
);
|
|
76
87
|
console.log(" commands:");
|
|
77
88
|
console.log(" claude-attribution:");
|
|
78
89
|
console.log(" run: claude-attribution hook post-commit || true");
|
|
79
90
|
console.log("");
|
|
80
|
-
console.log(
|
|
81
|
-
" (skipped automatic hook install — Lefthook manages .git/hooks/)",
|
|
82
|
-
);
|
|
83
91
|
} else if (hookResult === "created") {
|
|
84
92
|
const hookPath =
|
|
85
93
|
manager === "husky" ? ".husky/post-commit" : ".git/hooks/post-commit";
|
|
86
94
|
console.log(`✓ Created ${hookPath}`);
|
|
87
95
|
} else if (hookResult === "appended") {
|
|
88
|
-
const
|
|
89
|
-
manager === "
|
|
90
|
-
|
|
96
|
+
const lefthookConfigFile =
|
|
97
|
+
manager === "lefthook"
|
|
98
|
+
? (["lefthook.yml", "lefthook.yaml", ".lefthook.yml"].find((name) =>
|
|
99
|
+
existsSync(join(targetRepo, name)),
|
|
100
|
+
) ?? "lefthook.yml")
|
|
101
|
+
: undefined;
|
|
102
|
+
const hookFile =
|
|
103
|
+
manager === "husky"
|
|
104
|
+
? ".husky/post-commit"
|
|
105
|
+
: manager === "lefthook"
|
|
106
|
+
? lefthookConfigFile!
|
|
107
|
+
: ".git/hooks/post-commit";
|
|
108
|
+
console.log(`✓ Added post-commit entry to ${hookFile}`);
|
|
91
109
|
} else {
|
|
92
110
|
// unchanged
|
|
93
111
|
console.log("✓ post-commit hook already up to date");
|
|
@@ -147,9 +165,13 @@ async function main() {
|
|
|
147
165
|
console.log("✓ Installed .claude/commands/pr.md (/pr command)");
|
|
148
166
|
|
|
149
167
|
// 4. Install GitHub Actions workflow for automatic PR metrics injection
|
|
150
|
-
await installCiWorkflow(
|
|
168
|
+
const { runsOn, detected } = await installCiWorkflow(
|
|
169
|
+
targetRepo,
|
|
170
|
+
runnerOverride,
|
|
171
|
+
);
|
|
172
|
+
const detectedNote = detected ? " (detected from existing workflows)" : "";
|
|
151
173
|
console.log(
|
|
152
|
-
|
|
174
|
+
`✓ Installed .github/workflows/claude-attribution-pr.yml — runner: ${runsOn}${detectedNote}`,
|
|
153
175
|
);
|
|
154
176
|
|
|
155
177
|
// 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";
|
|
@@ -81,13 +81,111 @@ export type GitHookInstallResult =
|
|
|
81
81
|
| "created" // wrote a new hook file
|
|
82
82
|
| "appended" // appended to an existing hook file
|
|
83
83
|
| "unchanged" // hook already contained our entry; nothing written
|
|
84
|
-
| "noop"; //
|
|
84
|
+
| "noop"; // could not auto-edit (complex YAML structure) — manual config required
|
|
85
|
+
|
|
86
|
+
/** Lefthook config filenames, in priority order. Only YAML files are auto-edited. */
|
|
87
|
+
const LEFTHOOK_CONFIG_FILES = [
|
|
88
|
+
"lefthook.yml",
|
|
89
|
+
"lefthook.yaml",
|
|
90
|
+
".lefthook.yml",
|
|
91
|
+
"lefthook.json", // JSON files: detect-only, never auto-edited
|
|
92
|
+
".lefthook.json",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Attempt to add our post-commit entry to a lefthook YAML config file.
|
|
97
|
+
*
|
|
98
|
+
* Cases handled automatically:
|
|
99
|
+
* 1. No post-commit section → append our block at end of file
|
|
100
|
+
* 2. post-commit + commands section exists → detect indentation, insert entry
|
|
101
|
+
*
|
|
102
|
+
* Falls back to "noop" only when the post-commit section has an unrecognised
|
|
103
|
+
* structure (e.g. uses `scripts:` instead of `commands:`, or non-standard YAML).
|
|
104
|
+
* Returns "unchanged" if already configured.
|
|
105
|
+
*/
|
|
106
|
+
async function tryInstallLefthookYaml(
|
|
107
|
+
configPath: string,
|
|
108
|
+
): Promise<GitHookInstallResult> {
|
|
109
|
+
const content = await readFile(configPath, "utf8");
|
|
110
|
+
|
|
111
|
+
if (content.includes("claude-attribution")) return "unchanged";
|
|
112
|
+
|
|
113
|
+
const RUN_CMD = "claude-attribution hook post-commit || true";
|
|
114
|
+
|
|
115
|
+
// Case 1: no post-commit section — append entire block at the end.
|
|
116
|
+
if (!/^post-commit:/m.test(content)) {
|
|
117
|
+
await writeFile(
|
|
118
|
+
configPath,
|
|
119
|
+
content.trimEnd() +
|
|
120
|
+
"\n\npost-commit:\n commands:\n claude-attribution:\n run: " +
|
|
121
|
+
RUN_CMD +
|
|
122
|
+
"\n",
|
|
123
|
+
);
|
|
124
|
+
return "appended";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Case 2: post-commit section exists — find the commands: block and insert.
|
|
128
|
+
const lines = content.split("\n");
|
|
129
|
+
const postCommitIdx = lines.findIndex((l) => /^post-commit:/.test(l ?? ""));
|
|
130
|
+
if (postCommitIdx === -1) return "noop";
|
|
131
|
+
|
|
132
|
+
// Find the commands: line within the post-commit block.
|
|
133
|
+
let commandsIdx = -1;
|
|
134
|
+
for (let i = postCommitIdx + 1; i < lines.length; i++) {
|
|
135
|
+
const line = lines[i] ?? "";
|
|
136
|
+
if (!line.trim()) continue;
|
|
137
|
+
if (/^\S/.test(line)) break; // hit another top-level key
|
|
138
|
+
if (/^\s+commands:\s*$/.test(line)) {
|
|
139
|
+
commandsIdx = i;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (commandsIdx === -1) return "noop"; // uses scripts: or unusual structure
|
|
144
|
+
|
|
145
|
+
// Detect indentation from the commands: line itself.
|
|
146
|
+
const commandsLine = lines[commandsIdx] ?? "";
|
|
147
|
+
const baseIndent = commandsLine.match(/^(\s+)/)?.[1] ?? " ";
|
|
148
|
+
const cmdIndent = baseIndent + " ";
|
|
149
|
+
const propIndent = cmdIndent + " ";
|
|
150
|
+
|
|
151
|
+
// Find the last indented line of the commands block.
|
|
152
|
+
// Stop when we reach a top-level key OR when indentation returns to
|
|
153
|
+
// baseIndent.length or less (i.e. a sibling key of commands:, such as
|
|
154
|
+
// parallel: or runner:, that would otherwise be treated as a command entry).
|
|
155
|
+
let insertAfterIdx = commandsIdx;
|
|
156
|
+
for (let i = commandsIdx + 1; i < lines.length; i++) {
|
|
157
|
+
const line = lines[i] ?? "";
|
|
158
|
+
if (!line.trim()) continue;
|
|
159
|
+
if (/^\S/.test(line)) break;
|
|
160
|
+
const indentLength = (line.match(/^(\s*)/) ?? ["", ""])[1]?.length ?? 0;
|
|
161
|
+
if (indentLength <= baseIndent.length) break;
|
|
162
|
+
insertAfterIdx = i;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const entry = [
|
|
166
|
+
`${cmdIndent}claude-attribution:`,
|
|
167
|
+
`${propIndent}run: ${RUN_CMD}`,
|
|
168
|
+
];
|
|
169
|
+
const newLines = [
|
|
170
|
+
...lines.slice(0, insertAfterIdx + 1),
|
|
171
|
+
...entry,
|
|
172
|
+
...lines.slice(insertAfterIdx + 1),
|
|
173
|
+
];
|
|
174
|
+
await writeFile(configPath, newLines.join("\n"));
|
|
175
|
+
return "appended";
|
|
176
|
+
}
|
|
85
177
|
|
|
86
178
|
/**
|
|
87
179
|
* Install the post-commit git hook, respecting husky/lefthook if present.
|
|
88
180
|
* Idempotent — safe to call on already-installed repos.
|
|
89
|
-
*
|
|
90
|
-
*
|
|
181
|
+
*
|
|
182
|
+
* Lefthook: auto-edits the YAML config to add our command entry. Falls back
|
|
183
|
+
* to "noop" (with manual instructions) only for unusual config structures.
|
|
184
|
+
*
|
|
185
|
+
* Husky: appends to .husky/post-commit if present; creates it if not.
|
|
186
|
+
*
|
|
187
|
+
* Plain git hooks: surgically appends our entry. Returns "unchanged" if
|
|
188
|
+
* already present — never clobbers hooks written by other tools.
|
|
91
189
|
*/
|
|
92
190
|
export async function installGitHook(
|
|
93
191
|
repoRoot: string,
|
|
@@ -118,11 +216,31 @@ export async function installGitHook(
|
|
|
118
216
|
}
|
|
119
217
|
|
|
120
218
|
if (manager === "lefthook") {
|
|
121
|
-
//
|
|
219
|
+
// Try to auto-edit the YAML config; fall back to noop for JSON or
|
|
220
|
+
// complex structures that are not safe to modify programmatically.
|
|
221
|
+
for (const configFile of LEFTHOOK_CONFIG_FILES) {
|
|
222
|
+
const configPath = join(repoRoot, configFile);
|
|
223
|
+
if (!existsSync(configPath)) continue;
|
|
224
|
+
if (!configFile.endsWith(".yml") && !configFile.endsWith(".yaml")) {
|
|
225
|
+
// JSON config: detect-only, never auto-edit
|
|
226
|
+
try {
|
|
227
|
+
const content = await readFile(configPath, "utf8");
|
|
228
|
+
if (content.includes("claude-attribution")) return "unchanged";
|
|
229
|
+
} catch {
|
|
230
|
+
// skip
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
return await tryInstallLefthookYaml(configPath);
|
|
236
|
+
} catch {
|
|
237
|
+
return "noop";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
122
240
|
return "noop";
|
|
123
241
|
}
|
|
124
242
|
|
|
125
|
-
// Plain git hooks
|
|
243
|
+
// Plain git hooks — surgical append only, never overwrite the whole file.
|
|
126
244
|
const hookDest = join(repoRoot, ".git", "hooks", "post-commit");
|
|
127
245
|
const template = await readFile(
|
|
128
246
|
join(ATTRIBUTION_ROOT, "src", "setup", "templates", "post-commit.sh"),
|
|
@@ -131,15 +249,16 @@ export async function installGitHook(
|
|
|
131
249
|
|
|
132
250
|
if (existsSync(hookDest)) {
|
|
133
251
|
const existing = await readFile(hookDest, "utf8");
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
existing.trimEnd() + "\n\n# claude-attribution\n" + template,
|
|
138
|
-
);
|
|
139
|
-
await chmod(hookDest, 0o755);
|
|
140
|
-
return "appended";
|
|
252
|
+
if (existing.includes("claude-attribution")) {
|
|
253
|
+
// Already present — do not overwrite other hooks in the file.
|
|
254
|
+
return "unchanged";
|
|
141
255
|
}
|
|
142
|
-
|
|
256
|
+
await writeFile(
|
|
257
|
+
hookDest,
|
|
258
|
+
existing.trimEnd() + "\n\n# claude-attribution\n" + template,
|
|
259
|
+
);
|
|
260
|
+
await chmod(hookDest, 0o755);
|
|
261
|
+
return "appended";
|
|
143
262
|
}
|
|
144
263
|
|
|
145
264
|
await writeFile(hookDest, template);
|
|
@@ -169,13 +288,86 @@ export async function installSlashCommands(repoRoot: string): Promise<void> {
|
|
|
169
288
|
}
|
|
170
289
|
}
|
|
171
290
|
|
|
291
|
+
/** GitHub-hosted runner labels — anything else is self-hosted or custom. */
|
|
292
|
+
const GITHUB_HOSTED_RUNNERS = new Set([
|
|
293
|
+
"ubuntu-latest",
|
|
294
|
+
"ubuntu-24.04",
|
|
295
|
+
"ubuntu-22.04",
|
|
296
|
+
"ubuntu-20.04",
|
|
297
|
+
"windows-latest",
|
|
298
|
+
"windows-2025",
|
|
299
|
+
"windows-2022",
|
|
300
|
+
"windows-2019",
|
|
301
|
+
"macos-latest",
|
|
302
|
+
"macos-15",
|
|
303
|
+
"macos-14",
|
|
304
|
+
"macos-13",
|
|
305
|
+
"macos-12",
|
|
306
|
+
]);
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Scan existing workflows in .github/workflows/ (excluding our own) and return
|
|
310
|
+
* the most common non-GitHub-hosted `runs-on` value, or null if none found.
|
|
311
|
+
*/
|
|
312
|
+
async function detectRunsOn(repoRoot: string): Promise<string | null> {
|
|
313
|
+
const workflowsDir = join(repoRoot, ".github", "workflows");
|
|
314
|
+
if (!existsSync(workflowsDir)) return null;
|
|
315
|
+
|
|
316
|
+
let files: string[];
|
|
317
|
+
try {
|
|
318
|
+
files = await readdir(workflowsDir);
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const freq = new Map<string, number>();
|
|
324
|
+
|
|
325
|
+
for (const file of files) {
|
|
326
|
+
if (file === "claude-attribution-pr.yml") continue;
|
|
327
|
+
if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
|
|
328
|
+
try {
|
|
329
|
+
const content = await readFile(join(workflowsDir, file), "utf8");
|
|
330
|
+
for (const match of content.matchAll(/^\s+runs-on:\s+(.+)$/gm)) {
|
|
331
|
+
const raw: string = match[1] ?? "";
|
|
332
|
+
const value = (raw.split("#")[0] ?? raw).trim();
|
|
333
|
+
if (!GITHUB_HOSTED_RUNNERS.has(value)) {
|
|
334
|
+
freq.set(value, (freq.get(value) ?? 0) + 1);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} catch {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (freq.size === 0) return null;
|
|
343
|
+
const sorted = [...freq.entries()].sort((a, b) => b[1] - a[1]);
|
|
344
|
+
return sorted[0]?.[0] ?? null;
|
|
345
|
+
}
|
|
346
|
+
|
|
172
347
|
/**
|
|
173
348
|
* Install (or overwrite) the GitHub Actions PR metrics workflow.
|
|
349
|
+
* Auto-detects the runner from existing workflows; override with runsOn param.
|
|
350
|
+
* Returns the runner value used and whether it was auto-detected.
|
|
174
351
|
*/
|
|
175
|
-
export async function installCiWorkflow(
|
|
352
|
+
export async function installCiWorkflow(
|
|
353
|
+
repoRoot: string,
|
|
354
|
+
runsOn?: string,
|
|
355
|
+
): Promise<{ runsOn: string; detected: boolean }> {
|
|
176
356
|
const workflowsDir = join(repoRoot, ".github", "workflows");
|
|
177
357
|
await mkdir(workflowsDir, { recursive: true });
|
|
178
|
-
|
|
358
|
+
|
|
359
|
+
let detected = false;
|
|
360
|
+
if (!runsOn) {
|
|
361
|
+
const autoDetected = await detectRunsOn(repoRoot);
|
|
362
|
+
if (autoDetected) {
|
|
363
|
+
runsOn = autoDetected;
|
|
364
|
+
detected = true;
|
|
365
|
+
} else {
|
|
366
|
+
runsOn = "ubuntu-latest";
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const template = await readFile(
|
|
179
371
|
join(
|
|
180
372
|
ATTRIBUTION_ROOT,
|
|
181
373
|
"src",
|
|
@@ -185,5 +377,9 @@ export async function installCiWorkflow(repoRoot: string): Promise<void> {
|
|
|
185
377
|
),
|
|
186
378
|
"utf8",
|
|
187
379
|
);
|
|
188
|
-
await writeFile(
|
|
380
|
+
await writeFile(
|
|
381
|
+
join(workflowsDir, "claude-attribution-pr.yml"),
|
|
382
|
+
template.replace("{{RUNS_ON}}", runsOn),
|
|
383
|
+
);
|
|
384
|
+
return { runsOn, detected };
|
|
189
385
|
}
|