claude-attribution 1.2.8 → 1.3.0

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/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  > ```
12
12
  > From then on, just work normally. After each `git commit` you'll see a one-line attribution summary in your terminal. When you're ready to open a PR, run `/pr` in Claude Code (or `claude-attribution pr "feat: your title"`) — it fills in the metrics automatically, no copy-paste needed.
13
13
  >
14
- > **Using Copilot?** The tool still works for tracking Claude usage alongside Copilot. Copilot line-level attribution isn't supported yet — for Copilot-specific stats, use the GitHub Copilot usage dashboard. Both tools' org-level data flows into the VP Datadog dashboard automatically on every PR merge.
14
+ > **Using Copilot or @claude (claude-code-action)?** Commits made by AI bots (Copilot coding agent, `@claude` via claude-code-action) are automatically detected and attributed as 100% AI in metrics no extra steps needed. See [AI Actor Attribution](#ai-actor-attribution-copilot-bot-claude-gha) for details. For Copilot-specific org-level stats, use the GitHub Copilot usage dashboard. Both tools' data flows into the VP Datadog dashboard automatically on every PR merge.
15
15
  >
16
16
  > **Requirements:** [Bun](https://bun.sh) (preferred) or Node 18+, and `gh` (GitHub CLI) authenticated for the `/pr` command.
17
17
 
@@ -21,6 +21,42 @@ AI code attribution tracking for Claude Code. Measures which lines in a commit w
21
21
 
22
22
  ---
23
23
 
24
+ ## GitHub Actions Requirements
25
+
26
+ Three workflows are installed into repos that use this tool. Each requires specific GitHub Actions to be allowed in your org. If your org enforces an action allowlist, you must approve the third-party action before the workflows will run.
27
+
28
+ ### Workflows and the actions they use
29
+
30
+ | Workflow file | Trigger | GitHub-owned actions | Third-party actions |
31
+ |---|---|---|---|
32
+ | `claude-attribution-pr.yml` | PR opened / pushed | `actions/checkout@v4`, `actions/github-script@v7` | `oven-sh/setup-bun@v2` |
33
+ | `claude-attribution.yml` | PR merged | `actions/checkout@v4` | `oven-sh/setup-bun@v2` |
34
+ | `claude-attribution-gha.yml` | Every push (optional) | `actions/checkout@v4` | `oven-sh/setup-bun@v2` |
35
+
36
+ **GitHub-owned actions** (`actions/*`) are pre-approved in all orgs by default.
37
+
38
+ **`oven-sh/setup-bun@v2`** is a third-party action from the [Bun](https://bun.sh) team. It installs the Bun runtime so the `claude-attribution` CLI can execute without falling back to `npx tsx`. If your org requires explicit action approvals, add `oven-sh/setup-bun` to the allowlist at:
39
+
40
+ > Settings → Actions → General → Allow actions and reusable workflows → add `oven-sh/setup-bun@*`
41
+
42
+ ### Workflow permissions
43
+
44
+ | Workflow | `contents` | `pull-requests` | Why |
45
+ |---|---|---|---|
46
+ | `claude-attribution-pr.yml` | read | write | Reads git history; writes metrics into the PR body |
47
+ | `claude-attribution.yml` | read | — | Reads git notes and pushes Datadog metrics |
48
+ | `claude-attribution-gha.yml` | write | — | Pushes attribution git notes back to origin |
49
+
50
+ ### Required secrets
51
+
52
+ | Secret / Variable | Workflow | Required | Notes |
53
+ |---|---|---|---|
54
+ | `GITHUB_TOKEN` | All | Automatic | Provided by GitHub Actions; no setup needed |
55
+ | `DATADOG_API_KEY` | `claude-attribution.yml` | Yes | Org-level secret; needed to push metrics to Datadog |
56
+ | `DATADOG_SITE` | `claude-attribution.yml` | No | Org-level variable; defaults to `datadoghq.com` |
57
+
58
+ ---
59
+
24
60
  ## For Repo Maintainers: Installing Into a Repo
25
61
 
26
62
  ### Prerequisites
@@ -103,6 +139,8 @@ The installer makes the following changes to the target repo:
103
139
 
104
140
  **`.github/workflows/claude-attribution-pr.yml`** — GitHub Actions workflow that fires on every PR open and push. Injects metrics into the PR body automatically for PRs created outside Claude (Copilot, manual `gh pr create`, GitHub UI). Skips injection if the local `post-bash` hook already injected metrics on `opened`; always updates on `synchronize` (new commits).
105
141
 
142
+ **`.github/workflows/claude-attribution-gha.yml`** *(optional — installed when claude-code-action is detected)* — fires on every push. Writes a 100% AI git note for commits made by known AI actors (e.g. `@claude` via claude-code-action, Copilot coding agent). Silent no-op for human commits. See [AI Actor Attribution](#ai-actor-attribution-copilot-bot-claude-gha).
143
+
106
144
  **`.claude/commands/`** — installs two slash commands:
107
145
  - `/metrics` — generate a PR metrics report
108
146
  - `/start` — mark the start of a new Jira ticket session
@@ -461,6 +499,85 @@ All metrics are tagged `repo:`, `pr:`, `branch:`, `author:`, `tool:` — enablin
461
499
 
462
500
  ---
463
501
 
502
+ ## AI Actor Attribution (Copilot Bot, @claude GHA)
503
+
504
+ The local post-commit hook only runs when code is committed on a developer's machine. Commits made server-side by AI bots — such as `@claude` via [claude-code-action](https://github.com/anthropics/claude-code-action) or the Copilot coding agent — bypass the local hook entirely and would otherwise appear as 100% human in metrics.
505
+
506
+ claude-attribution handles these two ways:
507
+
508
+ ### 1. Auto-detection in metrics (no setup required)
509
+
510
+ When generating PR metrics, branch commits that have no git note are checked for known AI actor signals:
511
+
512
+ | Signal | Example |
513
+ |---|---|
514
+ | Bot author email/name | `github-actions[bot]`, `copilot[bot]` |
515
+ | `Co-authored-by:` trailer | `Co-authored-by: Claude <...>` |
516
+ | `Co-authored-by:` trailer | `Co-authored-by: GitHub Copilot <...>` |
517
+
518
+ If detected, all non-blank committed lines are counted as AI in the metrics output. No git note is written — this is a metrics-time synthesis only.
519
+
520
+ **Not detectable:** Copilot "Commit suggestion" (the button on PR review comments). Those commits are authored as the human who clicked the button — there is no metadata distinguishing them from a human edit. They will count as HUMAN.
521
+
522
+ ### 2. `note-ai-commit` command (writes a permanent git note)
523
+
524
+ For `@claude` via claude-code-action, you can write a permanent git note at CI time so attribution is durable (survives metrics regeneration, appears in Datadog dashboard):
525
+
526
+ ```bash
527
+ # Write a 100% AI note for HEAD (then push the note):
528
+ claude-attribution note-ai-commit --push
529
+
530
+ # Only write the note if the commit looks like an AI actor commit (safe on every push):
531
+ claude-attribution note-ai-commit --if-ai-actor --push
532
+
533
+ # Write a note for a specific SHA:
534
+ claude-attribution note-ai-commit abc1234 --push
535
+ ```
536
+
537
+ #### Setting up for claude-code-action
538
+
539
+ If your repo uses [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action), run `claude-attribution install` — it detects the action and offers to install `claude-attribution-gha.yml`, which records attribution automatically on every AI actor push.
540
+
541
+ To install the workflow manually, add `.github/workflows/claude-attribution-gha.yml`:
542
+
543
+ ```yaml
544
+ name: Claude Attribution — AI Actor Commits
545
+
546
+ on:
547
+ push:
548
+ branches: ["**"]
549
+
550
+ permissions:
551
+ contents: write
552
+
553
+ jobs:
554
+ note-ai-commit:
555
+ name: Record AI actor commit attribution
556
+ runs-on: ubuntu-latest
557
+ steps:
558
+ - uses: actions/checkout@v4
559
+ with:
560
+ fetch-depth: 0
561
+ token: ${{ secrets.GITHUB_TOKEN }}
562
+
563
+ - name: Fetch attribution notes
564
+ run: git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
565
+
566
+ - uses: oven-sh/setup-bun@v2
567
+
568
+ - name: Install claude-attribution
569
+ run: |
570
+ npm install -g --prefix "${HOME}/.npm-global" claude-attribution
571
+ echo "${HOME}/.npm-global/bin" >> "$GITHUB_PATH"
572
+
573
+ - name: Record attribution if AI actor commit
574
+ run: claude-attribution note-ai-commit --if-ai-actor --push
575
+ ```
576
+
577
+ The `--if-ai-actor` flag makes this a silent no-op for human commits — it only fires when the commit author or message matches a known AI actor pattern.
578
+
579
+ ---
580
+
464
581
  ## OTel Traces (optional)
465
582
 
466
583
  claude-attribution can export OpenTelemetry traces to any OTLP-compatible backend (Datadog APM, Jaeger, Tempo, etc.). This is **opt-in** — set one env var to enable it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-attribution",
3
- "version": "1.2.8",
3
+ "version": "1.3.0",
4
4
  "description": "AI code attribution tracking for Claude Code sessions — checkpoint-based line diff approach",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,11 @@
13
13
  */
14
14
  import { execFile } from "child_process";
15
15
  import { promisify } from "util";
16
- import type { AttributionResult } from "./differ.ts";
16
+ import {
17
+ aggregateTotals,
18
+ type AttributionResult,
19
+ type FileAttribution,
20
+ } from "./differ.ts";
17
21
 
18
22
  const execFileAsync = promisify(execFile);
19
23
  const NOTES_REF = "refs/notes/claude-attribution";
@@ -183,3 +187,129 @@ export async function committedContent(
183
187
  return null;
184
188
  }
185
189
  }
190
+
191
+ /**
192
+ * Read the committed content of a file at any commit SHA.
193
+ * Returns null for deleted files or binary files should not be processed.
194
+ */
195
+ export async function committedContentAt(
196
+ repoRoot: string,
197
+ sha: string,
198
+ relPath: string,
199
+ ): Promise<string | null> {
200
+ try {
201
+ return await runRaw("git", ["show", `${sha}:${relPath}`], repoRoot);
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * List relative paths of files changed in any commit (not just HEAD).
209
+ */
210
+ export async function filesInCommitAt(
211
+ repoRoot: string,
212
+ sha: string,
213
+ ): Promise<string[]> {
214
+ const output = await run(
215
+ "git",
216
+ ["diff-tree", "--no-commit-id", "-r", "--name-only", sha],
217
+ repoRoot,
218
+ );
219
+ return output ? output.split("\n").filter(Boolean) : [];
220
+ }
221
+
222
+ export interface CommitMeta {
223
+ authorName: string;
224
+ authorEmail: string;
225
+ /** Full commit message body. */
226
+ message: string;
227
+ /** Committer date in ISO 8601 format. */
228
+ timestamp: string;
229
+ }
230
+
231
+ /**
232
+ * Get author, email, message, and timestamp for any commit SHA.
233
+ */
234
+ export async function getCommitMeta(
235
+ repoRoot: string,
236
+ sha: string,
237
+ ): Promise<CommitMeta> {
238
+ const nameEmail = await run(
239
+ "git",
240
+ ["log", "-1", "--format=%an\n%ae", sha],
241
+ repoRoot,
242
+ );
243
+ const [authorName = "", authorEmail = ""] = nameEmail.split("\n");
244
+ const message = await run("git", ["log", "-1", "--format=%B", sha], repoRoot);
245
+ const timestamp = await run(
246
+ "git",
247
+ ["log", "-1", "--format=%cI", sha],
248
+ repoRoot,
249
+ );
250
+ return { authorName, authorEmail, message, timestamp };
251
+ }
252
+
253
+ /**
254
+ * Return true if the commit was made by a known AI actor:
255
+ * - Bot authors: github-actions[bot], copilot[bot], etc.
256
+ * - Co-authored-by trailers referencing Claude or Copilot.
257
+ *
258
+ * Used to synthesize 100% AI attribution for commits that bypass the local
259
+ * post-commit hook (e.g. @claude via claude-code-action, Copilot coding agent).
260
+ */
261
+ export function isKnownAiActorCommit(meta: CommitMeta): boolean {
262
+ const { authorName, authorEmail, message } = meta;
263
+ if (authorEmail.includes("[bot]") || authorName.includes("[bot]"))
264
+ return true;
265
+ if (/co-authored-by:.*claude/i.test(message)) return true;
266
+ if (/co-authored-by:.*copilot/i.test(message)) return true;
267
+ return false;
268
+ }
269
+
270
+ /**
271
+ * Build a 100% AI AttributionResult for a commit without running the
272
+ * checkpoint-based differ. All non-blank committed lines are marked AI.
273
+ *
274
+ * Used by `note-ai-commit` (to write git notes in GHA) and by `collect.ts`
275
+ * (to synthesize attribution at metrics time for unattributed AI actor commits).
276
+ */
277
+ export async function buildAllAiResult(
278
+ repoRoot: string,
279
+ sha: string,
280
+ ): Promise<AttributionResult> {
281
+ const [changedFiles, meta] = await Promise.all([
282
+ filesInCommitAt(repoRoot, sha),
283
+ getCommitMeta(repoRoot, sha),
284
+ ]);
285
+
286
+ const fileResults: FileAttribution[] = (
287
+ await Promise.all(
288
+ changedFiles.map(async (relPath): Promise<FileAttribution | null> => {
289
+ const content = await committedContentAt(repoRoot, sha, relPath);
290
+ if (content === null || content.includes("\0")) return null;
291
+ const lines = content.split("\n");
292
+ const ai = lines.filter((l) => l.trim().length > 0).length;
293
+ const human = lines.length - ai;
294
+ const total = lines.length;
295
+ return {
296
+ path: relPath,
297
+ ai,
298
+ human,
299
+ mixed: 0,
300
+ total,
301
+ pctAi: total > 0 ? Math.round((ai / total) * 100) : 0,
302
+ };
303
+ }),
304
+ )
305
+ ).filter((f): f is FileAttribution => f !== null);
306
+
307
+ return {
308
+ commit: sha,
309
+ session: null,
310
+ branch: null,
311
+ timestamp: meta.timestamp,
312
+ files: fileResults,
313
+ totals: aggregateTotals(fileResults),
314
+ };
315
+ }
package/src/cli.ts CHANGED
@@ -30,6 +30,9 @@ switch (cmd) {
30
30
  case "pr":
31
31
  await import("./commands/pr.ts");
32
32
  break;
33
+ case "note-ai-commit":
34
+ await import("./commands/note-ai-commit.ts");
35
+ break;
33
36
  case "init":
34
37
  await import("./commands/init.ts");
35
38
  break;
@@ -93,6 +96,7 @@ Commands:
93
96
  metrics [id] Generate PR metrics report
94
97
  pr [title] Create PR with metrics embedded (--draft, --base <branch>)
95
98
  init [--ai | --ai-since <YYYY-MM-DD>] Set attribution baseline in the cumulative minimap
99
+ note-ai-commit [sha] [--push] [--if-ai-actor] Write 100% AI git note for a commit (GHA use)
96
100
  start Mark session start for per-ticket scoping
97
101
  hook <name> Run an internal hook (used by installed git hooks)
98
102
  version Print version
@@ -0,0 +1,69 @@
1
+ /**
2
+ * note-ai-commit — write a 100% AI attribution git note for a commit.
3
+ *
4
+ * Usage: claude-attribution note-ai-commit [sha] [--push] [--if-ai-actor]
5
+ *
6
+ * Used as a step in claude-code-action GHA workflows to record that all
7
+ * lines committed by @claude are AI-authored. Defaults to HEAD.
8
+ *
9
+ * Options:
10
+ * --push Push the note to origin after writing.
11
+ * --if-ai-actor Only write the note if the commit looks like it was made
12
+ * by a known AI actor (bot author or Co-authored-by trailer).
13
+ * Silent no-op otherwise — safe to run on every push.
14
+ *
15
+ * All non-blank lines in the committed files are attributed as AI. Blank
16
+ * lines are always HUMAN per the attribution algorithm in differ.ts.
17
+ */
18
+ import { resolve } from "path";
19
+ import { execFile } from "child_process";
20
+ import { promisify } from "util";
21
+ import {
22
+ writeNote,
23
+ headSha,
24
+ getCommitMeta,
25
+ isKnownAiActorCommit,
26
+ buildAllAiResult,
27
+ } from "../attribution/git-notes.ts";
28
+
29
+ const execFileAsync = promisify(execFile);
30
+ const NOTES_REF = "refs/notes/claude-attribution";
31
+
32
+ async function main() {
33
+ const args = process.argv.slice(2);
34
+ const pushFlag = args.includes("--push");
35
+ const ifAiActorFlag = args.includes("--if-ai-actor");
36
+ const sha = args.find((a) => !a.startsWith("--")) ?? null;
37
+
38
+ const repoRoot = resolve(process.cwd());
39
+ const commitSha = sha ?? (await headSha(repoRoot));
40
+
41
+ if (ifAiActorFlag) {
42
+ const meta = await getCommitMeta(repoRoot, commitSha);
43
+ if (!isKnownAiActorCommit(meta)) return; // silent no-op
44
+ }
45
+
46
+ const result = await buildAllAiResult(repoRoot, commitSha);
47
+ await writeNote(result, repoRoot, commitSha);
48
+
49
+ const { totals } = result;
50
+ console.log(
51
+ `[claude-attribution] ${commitSha.slice(0, 7)} — ${totals.ai} AI / ${totals.human} human / 0 mixed lines (${totals.pctAi}% AI) [ai-actor]`,
52
+ );
53
+
54
+ if (pushFlag) {
55
+ await execFileAsync(
56
+ "git",
57
+ ["push", "origin", `${NOTES_REF}:${NOTES_REF}`],
58
+ {
59
+ cwd: repoRoot,
60
+ },
61
+ );
62
+ console.log("[claude-attribution] pushed attribution note");
63
+ }
64
+ }
65
+
66
+ main().catch((err) => {
67
+ console.error("[claude-attribution] note-ai-commit error:", err);
68
+ process.exit(1);
69
+ });
@@ -15,6 +15,9 @@ import {
15
15
  listNotes,
16
16
  readNote,
17
17
  getBranchCommitShas,
18
+ getCommitMeta,
19
+ isKnownAiActorCommit,
20
+ buildAllAiResult,
18
21
  } from "../attribution/git-notes.ts";
19
22
  import {
20
23
  aggregateTotals,
@@ -173,6 +176,7 @@ async function getBranchAttribution(
173
176
  listNotes(repoRoot),
174
177
  getBranchCommitShas(repoRoot),
175
178
  ]);
179
+ const notedSet = new Set(allShas);
176
180
  const branchSet = new Set(branchShas);
177
181
  const shasToRead =
178
182
  branchShas.length > 0
@@ -189,6 +193,30 @@ async function getBranchAttribution(
189
193
  notes.push(...batchResults);
190
194
  }
191
195
  const results = notes.filter((n): n is AttributionResult => n !== null);
196
+
197
+ // Synthesize attribution for branch commits with no note that look like
198
+ // AI actor commits (e.g. @claude via claude-code-action, Copilot coding agent).
199
+ // These bypass the local post-commit hook, so no note is ever written for them.
200
+ const unnotedBranchShas = branchShas.filter((sha) => !notedSet.has(sha));
201
+ if (unnotedBranchShas.length > 0) {
202
+ const synthetic = (
203
+ await Promise.all(
204
+ unnotedBranchShas.map(
205
+ async (sha): Promise<AttributionResult | null> => {
206
+ try {
207
+ const meta = await getCommitMeta(repoRoot, sha);
208
+ if (!isKnownAiActorCommit(meta)) return null;
209
+ return buildAllAiResult(repoRoot, sha);
210
+ } catch {
211
+ return null;
212
+ }
213
+ },
214
+ ),
215
+ )
216
+ ).filter((r): r is AttributionResult => r !== null);
217
+ results.push(...synthetic);
218
+ }
219
+
192
220
  if (sessionStart) {
193
221
  return results.filter((r) => new Date(r.timestamp) >= sessionStart);
194
222
  }
@@ -333,20 +333,16 @@ export async function configureRequiredCheck(repoRoot: string): Promise<void> {
333
333
  getRulesetStatuses(slug),
334
334
  ]);
335
335
 
336
- // Determine what needs to be added
337
- const classicNeeded = classic !== null && !classic.hasOurCheck;
338
- const rulesetsNeeded = rulesets.filter((rs) => !rs.hasOurCheck);
339
-
340
- // Already fully configured
341
- if (!classicNeeded && rulesetsNeeded.length === 0) {
342
- if (classic?.hasOurCheck || rulesets.some((rs) => rs.hasOurCheck)) {
343
- console.log(
344
- `✓ '${WORKFLOW_CHECK_NAME}' already a required status check`,
345
- );
346
- }
336
+ // Already configured on at least one target — don't prompt again
337
+ if (classic?.hasOurCheck || rulesets.some((rs) => rs.hasOurCheck)) {
338
+ console.log(`✓ '${WORKFLOW_CHECK_NAME}' already a required status check`);
347
339
  return;
348
340
  }
349
341
 
342
+ // Determine what needs to be added
343
+ const classicNeeded = classic !== null;
344
+ const rulesetsNeeded = rulesets;
345
+
350
346
  // Build the list of targets that need our check
351
347
  const targets: Array<
352
348
  | { kind: "classic"; classic: ClassicStatus }
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * If no path is given, installs into the current working directory.
7
7
  */
8
- import { readFile, writeFile, appendFile, mkdir } from "fs/promises";
8
+ import { readFile, writeFile, appendFile, mkdir, readdir } from "fs/promises";
9
9
  import { existsSync } from "fs";
10
10
  import { execFile } from "child_process";
11
11
  import { promisify } from "util";
@@ -140,6 +140,79 @@ async function promptBaselineInit(
140
140
  }
141
141
  }
142
142
 
143
+ /** Return true if any workflow in the repo references claude-code-action. */
144
+ async function detectClaudeCodeAction(repoRoot: string): Promise<boolean> {
145
+ const workflowsDir = join(repoRoot, ".github", "workflows");
146
+ if (!existsSync(workflowsDir)) return false;
147
+ try {
148
+ const files = await readdir(workflowsDir);
149
+ for (const file of files) {
150
+ if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
151
+ if (file.startsWith("claude-attribution")) continue;
152
+ const content = await readFile(join(workflowsDir, file), "utf8");
153
+ if (content.includes("anthropics/claude-code-action")) return true;
154
+ }
155
+ } catch {
156
+ // ignore
157
+ }
158
+ return false;
159
+ }
160
+
161
+ /**
162
+ * Install the AI actor attribution workflow when claude-code-action is present.
163
+ * Always overwrites if already installed (idempotent upgrade). Prompts on first install.
164
+ */
165
+ async function installGhaWorkflow(repoRoot: string): Promise<void> {
166
+ const destPath = join(
167
+ repoRoot,
168
+ ".github",
169
+ "workflows",
170
+ "claude-attribution-gha.yml",
171
+ );
172
+ const templatePath = join(
173
+ ATTRIBUTION_ROOT,
174
+ "src",
175
+ "setup",
176
+ "templates",
177
+ "claude-attribution-gha.yml",
178
+ );
179
+ const template = await readFile(templatePath, "utf8");
180
+
181
+ // Always update if already installed
182
+ if (existsSync(destPath)) {
183
+ await writeFile(destPath, template);
184
+ console.log("✓ Updated .github/workflows/claude-attribution-gha.yml");
185
+ return;
186
+ }
187
+
188
+ // First install — only if claude-code-action is detected
189
+ const hasClaudeAction = await detectClaudeCodeAction(repoRoot);
190
+ if (!hasClaudeAction) return;
191
+
192
+ if (!process.stdin.isTTY) {
193
+ console.log(
194
+ " ℹ️ claude-code-action detected — run install interactively to add AI actor attribution workflow.",
195
+ );
196
+ return;
197
+ }
198
+
199
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
200
+ const yes = await new Promise<boolean>((resolve) => {
201
+ rl.question(
202
+ "\n claude-code-action detected. Install AI actor attribution workflow? [y/N] ",
203
+ (answer) => {
204
+ rl.close();
205
+ resolve(answer.trim().toLowerCase() === "y");
206
+ },
207
+ );
208
+ });
209
+
210
+ if (yes) {
211
+ await writeFile(destPath, template);
212
+ console.log("✓ Installed .github/workflows/claude-attribution-gha.yml");
213
+ }
214
+ }
215
+
143
216
  async function main() {
144
217
  const args = process.argv.slice(2);
145
218
  const runnerFlagIdx = args.findIndex((a: string) => a === "--runner");
@@ -290,6 +363,9 @@ async function main() {
290
363
  `✓ Installed .github/workflows/claude-attribution-pr.yml — runner: ${runsOn}${detectedNote}`,
291
364
  );
292
365
 
366
+ // 4b. Install GHA attribution workflow if claude-code-action is present
367
+ await installGhaWorkflow(targetRepo);
368
+
293
369
  // 5. Check branch protection and offer to add required status check
294
370
  await configureRequiredCheck(targetRepo);
295
371
 
@@ -0,0 +1,38 @@
1
+ name: Claude Attribution — AI Actor Commits
2
+
3
+ # Records attribution for commits made by AI actors that bypass the local
4
+ # post-commit hook (e.g. @claude via claude-code-action, Copilot coding agent).
5
+ #
6
+ # Uses --if-ai-actor so this is a no-op for regular human pushes — it only
7
+ # writes and pushes a git note when the commit author or Co-authored-by trailer
8
+ # identifies the commit as AI-authored.
9
+
10
+ on:
11
+ push:
12
+ branches: ["**"]
13
+
14
+ permissions:
15
+ contents: write
16
+
17
+ jobs:
18
+ note-ai-commit:
19
+ name: Record AI actor commit attribution
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ with:
24
+ fetch-depth: 0
25
+ token: ${{ secrets.GITHUB_TOKEN }}
26
+
27
+ - name: Fetch attribution notes
28
+ run: git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
29
+
30
+ - uses: oven-sh/setup-bun@v2
31
+
32
+ - name: Install claude-attribution
33
+ run: |
34
+ npm install -g --prefix "${HOME}/.npm-global" claude-attribution
35
+ echo "${HOME}/.npm-global/bin" >> "$GITHUB_PATH"
36
+
37
+ - name: Record attribution if AI actor commit
38
+ run: claude-attribution note-ai-commit --if-ai-actor --push
@@ -22,10 +22,11 @@ jobs:
22
22
  git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
23
23
  git fetch origin refs/notes/claude-attribution-map:refs/notes/claude-attribution-map || true
24
24
 
25
+ - uses: oven-sh/setup-bun@v2
26
+
25
27
  - name: Install claude-attribution
26
28
  run: |
27
- npm config set prefix "${HOME}/.npm-global"
28
- npm install -g claude-attribution
29
+ npm install -g --prefix "${HOME}/.npm-global" claude-attribution
29
30
  echo "${HOME}/.npm-global/bin" >> "$GITHUB_PATH"
30
31
 
31
32
  - name: Generate metrics