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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-attribution",
3
- "version": "1.2.5",
3
+ "version": "1.2.8",
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
@@ -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
- const logEntry = {
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");