claude-attribution 1.2.7 → 1.2.9

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.7",
3
+ "version": "1.2.9",
4
4
  "description": "AI code attribution tracking for Claude Code sessions — checkpoint-based line diff approach",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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] Declare current codebase as AI-written in the cumulative minimap
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
@@ -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 Mark all currently tracked files as AI-written. Use for repos that
7
- * were built entirely with Claude Code from the start.
8
- * --human (default) Confirm the default: no minimap note written; all lines
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
- hashSetToString,
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 !== "--ai") {
53
- console.error(`Unknown flag: ${flag}`);
54
- console.error("Usage: claude-attribution init [--ai | --human]");
55
- process.exit(1);
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
- process.exit(1);
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
- console.log(
78
- `Marking ${allFiles.length} files as AI-written on ${sha.slice(0, 7)}...`,
79
- );
80
-
81
- const minimapFiles: MinimapFileState[] = [];
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
- process.stdout.write("\n");
135
-
136
- let totalAi = 0,
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) => {
@@ -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 at placeholder or append
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, metricsBlock);
134
+ body = template.replace(METRICS_PLACEHOLDER, metricsSection);
132
135
  } else {
133
- body = template.trimEnd() + "\n\n" + metricsBlock;
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
@@ -9,6 +9,7 @@ import { readFile, writeFile, appendFile, mkdir } from "fs/promises";
9
9
  import { existsSync } from "fs";
10
10
  import { execFile } from "child_process";
11
11
  import { promisify } from "util";
12
+ import { createInterface } from "readline";
12
13
  import { resolve, join } from "path";
13
14
  import {
14
15
  ATTRIBUTION_ROOT,
@@ -20,11 +21,125 @@ import {
20
21
  type HooksConfig,
21
22
  } from "./shared.ts";
22
23
  import { configureRequiredCheck } from "./branch-protection.ts";
24
+ import {
25
+ listMinimapNotes,
26
+ writeMinimap,
27
+ buildAllAiMinimap,
28
+ buildAiSinceMinimap,
29
+ } from "../attribution/minimap.ts";
23
30
 
24
31
  const execFileAsync = promisify(execFile);
25
32
 
26
33
  const CLI_BIN = resolve(ATTRIBUTION_ROOT, "bin", "claude-attribution");
27
34
 
35
+ // ─── Baseline init prompt ────────────────────────────────────────────────────
36
+
37
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
38
+
39
+ async function promptLine(question: string): Promise<string> {
40
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
41
+ return new Promise((resolve) => {
42
+ rl.question(question, (answer) => {
43
+ rl.close();
44
+ resolve(answer.trim());
45
+ });
46
+ });
47
+ }
48
+
49
+ /**
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.
53
+ */
54
+ async function promptBaselineInit(
55
+ repoRoot: string,
56
+ claudeDir: string,
57
+ ): Promise<void> {
58
+ const flagPath = join(claudeDir, "attribution-state", "baseline-initialized");
59
+
60
+ try {
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
+ );
73
+ return;
74
+ }
75
+
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
+ );
80
+ return;
81
+ }
82
+
83
+ console.log(
84
+ "\n Attribution baseline — how should existing code be treated?",
85
+ );
86
+ console.log(
87
+ " [1] AI — all current code was written with Claude Code",
88
+ );
89
+ console.log(
90
+ " [2] Human — assume human-written (AI accumulates from here)",
91
+ );
92
+ console.log(
93
+ " [3] AI since — mark commits after a date as AI-written",
94
+ );
95
+ console.log(
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.",
139
+ );
140
+ }
141
+ }
142
+
28
143
  async function main() {
29
144
  const args = process.argv.slice(2);
30
145
  const runnerFlagIdx = args.findIndex((a: string) => a === "--runner");
@@ -178,7 +293,10 @@ async function main() {
178
293
  // 5. Check branch protection and offer to add required status check
179
294
  await configureRequiredCheck(targetRepo);
180
295
 
181
- // 6. Record installed version for auto-upgrade tracking
296
+ // 6. Prompt for attribution baseline (skipped on re-runs)
297
+ await promptBaselineInit(targetRepo, claudeDir);
298
+
299
+ // 7. Record installed version for auto-upgrade tracking
182
300
  const pkg = JSON.parse(
183
301
  await readFile(join(ATTRIBUTION_ROOT, "package.json"), "utf8"),
184
302
  ) as { version: string };
@@ -23,7 +23,9 @@ 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: npm install -g claude-attribution
26
+ run: |
27
+ npm install -g --prefix "${HOME}/.npm-global" claude-attribution
28
+ echo "${HOME}/.npm-global/bin" >> "$GITHUB_PATH"
27
29
 
28
30
  - name: Generate metrics
29
31
  run: claude-attribution metrics > /tmp/claude-attribution-metrics.md || true