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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-attribution",
3
- "version": "1.2.1",
3
+ "version": "1.2.4",
4
4
  "description": "AI code attribution tracking for Claude Code sessions — checkpoint-based line diff approach",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- computeMinimapFile,
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
- // Build currentAiHashes from every non-blank line in the file
102
- const currentAiHashes = new Set<string>();
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
- currentAiHashes.add(hashLine(line));
109
+ aiHashes.add(hashLine(line));
106
110
  }
107
111
  }
108
112
 
109
- return computeMinimapFile(
110
- relPath,
111
- lines,
112
- currentAiHashes,
113
- new Set<string>(),
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
  }
@@ -88,7 +88,7 @@ async function main() {
88
88
  draft = true;
89
89
  } else if (args[i] === "--base" && i + 1 < args.length) {
90
90
  base = args[++i];
91
- } else if (!args[i].startsWith("--")) {
91
+ } else if (!(args[i] ?? "").startsWith("--")) {
92
92
  title = args[i];
93
93
  }
94
94
  }
@@ -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() {
@@ -20,7 +20,6 @@ import { readStdin, WRITE_TOOLS, getFilePath } from "../lib/hooks.ts";
20
20
  import { maybeAutoUpgrade } from "../lib/auto-upgrade.ts";
21
21
  import {
22
22
  otelEndpoint,
23
- otelHeaders,
24
23
  readOtelContext,
25
24
  writeOtelContext,
26
25
  makeTraceId,
@@ -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);
@@ -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 targetRepo = resolve(process.argv[2] ?? process.cwd());
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(" ⚠️ Lefthook detected. Add this to lefthook.yml manually:");
75
- console.log(" post-commit:");
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 hookPath =
89
- manager === "husky" ? ".husky/post-commit" : ".git/hooks/post-commit";
90
- console.log(`✓ Appended to existing ${hookPath}`);
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(targetRepo);
168
+ const { runsOn, detected } = await installCiWorkflow(
169
+ targetRepo,
170
+ runnerOverride,
171
+ );
172
+ const detectedNote = detected ? " (detected from existing workflows)" : "";
151
173
  console.log(
152
- "✓ Installed .github/workflows/claude-attribution-pr.yml (auto-injects metrics on PR open)",
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
@@ -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"; // Lefthook detected — manual config required, nothing written
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
- * Note: for Lefthook repos this is a no-op (manual config required).
90
- * Returns a GitHookInstallResult describing what was done.
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
- // Cannot safely auto-edit YAML caller should inform the user
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 (!existing.includes("claude-attribution")) {
135
- await writeFile(
136
- hookDest,
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
- // Already ours — replace with latest template
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(repoRoot: string): Promise<void> {
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
- const content = await readFile(
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(join(workflowsDir, "claude-attribution-pr.yml"), content);
380
+ await writeFile(
381
+ join(workflowsDir, "claude-attribution-pr.yml"),
382
+ template.replace("{{RUNS_ON}}", runsOn),
383
+ );
384
+ return { runsOn, detected };
189
385
  }
@@ -11,7 +11,7 @@ permissions:
11
11
  jobs:
12
12
  metrics:
13
13
  name: Claude Code Attribution Metrics
14
- runs-on: ubuntu-latest
14
+ runs-on: {{RUNS_ON}}
15
15
  steps:
16
16
  - uses: actions/checkout@v4
17
17
  with: