claude-attribution 1.0.2 → 1.1.1

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
@@ -6,13 +6,13 @@
6
6
  > ```bash
7
7
  > npm install -g claude-attribution
8
8
  > claude-attribution install ~/Code/your-repo
9
- > git add .claude/settings.json .gitignore && git commit -m "chore: install claude-attribution hooks"
9
+ > git add .claude/settings.json .github/workflows/claude-attribution-pr.yml .gitignore && git commit -m "chore: install claude-attribution hooks"
10
10
  > ```
11
- > 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.
11
+ > From then on, just work normally. After each `git commit` you'll see a one-line attribution summary in your terminal. When you open a PR whether Claude creates it or you run `gh pr create` yourselfmetrics are injected into the PR body automatically, no command needed.
12
12
  >
13
- > **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](https://github.com/organizations/DTS-Productivity-Engineering/settings/copilot/seat_management). Both tools' org-level data flows into the VP Datadog dashboard automatically on every PR merge.
13
+ > **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 under your organization's Settings → Copilot. Both tools' org-level data flows into the VP Datadog dashboard automatically on every PR merge.
14
14
  >
15
- > **Requirements:** [Bun](https://bun.sh) (preferred) or Node 18+, and `gh` (GitHub CLI) authenticated for the `/pr` command.
15
+ > **Requirements:** [Bun](https://bun.sh) (preferred) or Node 18+, and `gh` (GitHub CLI) authenticated.
16
16
 
17
17
  ---
18
18
 
@@ -56,20 +56,23 @@ claude-attribution install ~/Code/your-repo
56
56
  bun ~/Code/claude-attribution/src/setup/install.ts ~/Code/your-repo
57
57
  ```
58
58
 
59
- The installer makes four changes to the target repo:
59
+ The installer makes six changes to the target repo:
60
60
 
61
- **`.claude/settings.json`** — merges five Claude Code hooks:
61
+ **`.claude/settings.json`** — merges six Claude Code hooks:
62
62
 
63
63
  | Event | Hook | What it does |
64
64
  |-------|------|--------------|
65
65
  | PreToolUse (Edit/Write/MultiEdit/NotebookEdit) | `pre-tool-use.ts` | Snapshot file content before Claude touches it |
66
66
  | PostToolUse (all tools) | `post-tool-use.ts` | Snapshot file after Claude writes + log tool call |
67
+ | PostToolUse (Bash) | `post-bash.ts` | Detect `gh pr create` and inject metrics into the new PR |
67
68
  | SubagentStart / SubagentStop | `subagent.ts` | Log subagent activity |
68
69
  | Stop | `stop.ts` | No-op; registered for future use |
69
70
 
70
71
  **`.git/hooks/post-commit`** — runs attribution after every commit. If the repo already has a `post-commit` hook from Husky or another tool, the call is appended rather than replacing it. For Lefthook repos, the installer prints the config snippet to add manually.
71
72
 
72
- **`.git/hooks/pre-push`** — pushes `refs/notes/claude-attribution` to origin whenever you push, so GitHub Actions can read the notes on PR merge. The installer also runs `git config --add remote.origin.push refs/notes/claude-attribution:refs/notes/claude-attribution` so `git push origin` includes notes automatically.
73
+ **`remote.origin.push` refspec** the installer runs `git config --add remote.origin.push refs/notes/claude-attribution:refs/notes/claude-attribution` so that `git push` (without an explicit refspec) automatically includes attribution notes. No pre-push hook is installed — a hook that pushes notes concurrently with the main push causes SSH connection conflicts on GitHub.
74
+
75
+ **`.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).
73
76
 
74
77
  **`.claude/commands/`** — installs two slash commands:
75
78
  - `/metrics` — generate a PR metrics report
@@ -79,11 +82,11 @@ The installer makes four changes to the target repo:
79
82
 
80
83
  ### Committing the settings change
81
84
 
82
- The `.claude/settings.json` change should be committed to the repo so all developers get the hooks automatically. The `.git/hooks/post-commit` hook is local only (`.git/` is not committed) — each developer runs the installer once.
85
+ The `.claude/settings.json` and workflow changes should be committed so all developers get the hooks and all PRs get metrics automatically.
83
86
 
84
87
  ```bash
85
88
  # After running the installer:
86
- git add .claude/settings.json .gitignore
89
+ git add .claude/settings.json .github/workflows/claude-attribution-pr.yml .gitignore
87
90
  git commit -m "chore: install claude-attribution hooks"
88
91
  ```
89
92
 
@@ -111,7 +114,7 @@ claude-attribution uninstall ~/Code/your-repo
111
114
  bun ~/Code/claude-attribution/src/setup/uninstall.ts ~/Code/your-repo
112
115
  ```
113
116
 
114
- This removes hooks from `.claude/settings.json`, removes `.git/hooks/post-commit` and `.git/hooks/pre-push`, and removes the `/metrics` and `/start` slash commands. Attribution state (`.claude/attribution-state/`) and logs (`.claude/logs/`) are left in place.
117
+ This removes hooks from `.claude/settings.json`, removes `.git/hooks/post-commit`, removes the slash commands, removes `.github/workflows/claude-attribution-pr.yml`, and removes any legacy `pre-push` hooks (for example `.husky/pre-push` or `.git/hooks/pre-push`) if present. Attribution state (`.claude/attribution-state/`) and logs (`.claude/logs/`) are left in place. The `remote.origin.push` refspec is also removed from git config.
115
118
 
116
119
  ---
117
120
 
@@ -137,24 +140,49 @@ When you check out a new branch for a new ticket, run `/start` in Claude Code:
137
140
 
138
141
  This writes a timestamp to `.claude/attribution-state/session-start`. The `/metrics` command uses this to scope tool counts, token usage, and attribution data to only the activity after this marker — so a long-running Claude Code session doesn't inflate metrics across multiple tickets.
139
142
 
140
- ### Creating a PR with metrics
143
+ ### Getting metrics into your PR
141
144
 
142
- When you're ready to create a PR, use the `/pr` slash command in Claude Code. It collects metrics and creates the PR in one step — no copy-paste needed:
145
+ Metrics are injected automatically — no command needed.
143
146
 
147
+ **When Claude creates the PR** (asks Claude to open a PR, uses `/pr`): the `post-bash` hook fires immediately after `gh pr create` succeeds, injects full local metrics (token usage, tool counts, attribution) into the PR body before Claude continues.
148
+
149
+ **When you create the PR yourself** (`gh pr create`, GitHub UI, Copilot): the `claude-attribution-pr.yml` GitHub Actions workflow fires on `opened` and injects attribution-only metrics (token counts aren't available in CI since logs are local).
150
+
151
+ **On every new push to an open PR**: the workflow fires on `synchronize` and updates the attribution percentages to reflect new commits.
152
+
153
+ The metrics block looks like:
154
+
155
+ ```markdown
156
+ ## Claude Code Metrics
157
+
158
+ **AI contribution: ~77%** (142 of 184 committed lines) · Active: 8m
159
+
160
+ | Model | Calls | Input | Output | Cache |
161
+ |-------|-------|-------|--------|-------|
162
+ | Sonnet | 45 | 120K | 35K | 10K |
163
+ | **Total** | 45 | 120K | 35K | 10K |
164
+
165
+ **Human prompts (steering effort):** 12
166
+
167
+ <details>
168
+ <summary>Tools · Agents · Files</summary>
169
+
170
+ **Tools:** Edit ×47, Read ×31, Bash ×12
171
+ ...
172
+ </details>
144
173
  ```
145
- /pr "feat: COMM-1234 add user authentication"
146
- ```
147
174
 
148
- Or run directly:
175
+ #### Manual option
176
+
177
+ If you need to create a PR with metrics outside of Claude, use the `/pr` slash command or CLI directly:
149
178
 
150
179
  ```bash
151
180
  claude-attribution pr "feat: COMM-1234 add user authentication"
152
- claude-attribution pr "feat: my feature" --draft # Open as draft
153
- claude-attribution pr "feat: my feature" --base develop # Different base branch
154
- claude-attribution pr # Title from branch name
181
+ claude-attribution pr "feat: my feature" --draft
182
+ claude-attribution pr "feat: my feature" --base develop
155
183
  ```
156
184
 
157
- This reads `.github/PULL_REQUEST_TEMPLATE.md` if it exists, injects the metrics block at a `<!-- claude-attribution metrics -->` placeholder (or appends if missing), and creates the PR via `gh`. Requires `gh` to be installed and authenticated (`gh auth status`).
185
+ Requires `gh` to be installed and authenticated (`gh auth status`).
158
186
 
159
187
  ### Viewing metrics without creating a PR
160
188
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-attribution",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "AI code attribution tracking for Claude Code sessions — checkpoint-based line diff approach",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -8,10 +8,7 @@
8
8
  import { resolve, dirname } from "path";
9
9
  import { fileURLToPath } from "url";
10
10
  import { readFile } from "fs/promises";
11
- import { execFile } from "child_process";
12
- import { promisify } from "util";
13
11
 
14
- const execFileAsync = promisify(execFile);
15
12
  const [, , cmd, ...rest] = process.argv;
16
13
 
17
14
  // Shift argv so sub-modules see their expected args at process.argv[2]
@@ -47,43 +44,18 @@ switch (cmd) {
47
44
  case "stop":
48
45
  await import("./hooks/stop.ts");
49
46
  break;
47
+ case "post-bash":
48
+ await import("./hooks/post-bash.ts");
49
+ break;
50
50
  case "post-commit":
51
51
  await import("./attribution/commit.ts");
52
52
  break;
53
- case "pre-push": {
54
- // Git passes pushed refs on stdin: "<local-ref> <local-sha> <remote-ref> <remote-sha>\n..."
55
- // Skip notes push for tag-only and notes pushes to avoid SSH connection conflicts.
56
- const stdin = await new Promise<string>((resolve) => {
57
- let data = "";
58
- process.stdin.setEncoding("utf8");
59
- process.stdin.on("data", (chunk: string) => (data += chunk));
60
- process.stdin.on("end", () => resolve(data));
61
- });
62
- const isBranchPush = stdin
63
- .trim()
64
- .split("\n")
65
- .filter(Boolean)
66
- .some((line) => {
67
- const remoteRef = line.split(" ")[2] ?? "";
68
- return (
69
- !remoteRef.startsWith("refs/tags/") &&
70
- !remoteRef.startsWith("refs/notes/")
71
- );
72
- });
73
- if (isBranchPush) {
74
- try {
75
- await execFileAsync("git", [
76
- "push",
77
- "origin",
78
- "refs/notes/claude-attribution",
79
- ]);
80
- } catch {
81
- // Ignore — notes push failure must not block git push
82
- }
83
- }
53
+ case "pre-push":
54
+ // No-op notes are pushed via the remote.origin.push refspec configured
55
+ // at install time, avoiding concurrent SSH connection conflicts.
56
+ // Kept for backwards compatibility with existing hook installations.
84
57
  process.exit(0);
85
58
  break;
86
- }
87
59
  default:
88
60
  console.error(`Unknown hook: ${rest[0]}`);
89
61
  process.exit(1);
@@ -133,6 +133,17 @@ async function main() {
133
133
  body = template.trimEnd() + "\n\n" + metricsBlock;
134
134
  }
135
135
 
136
+ // Push branch to remote if not already pushed
137
+ try {
138
+ await execFileAsync("git", ["push", "-u", "origin", "HEAD"], {
139
+ cwd: repoRoot,
140
+ });
141
+ } catch (err) {
142
+ const msg = err instanceof Error ? err.message : String(err);
143
+ console.error(`Error pushing branch: ${msg}`);
144
+ process.exit(1);
145
+ }
146
+
136
147
  // Write body to a temp file (avoids shell quoting issues)
137
148
  const tmpDir = await mkdtemp(join(tmpdir(), "claude-attribution-pr-"));
138
149
  const bodyFile = join(tmpDir, "pr-body.md");
@@ -0,0 +1,125 @@
1
+ /**
2
+ * PostToolUse hook for Bash tool — detects `gh pr create` and injects metrics.
3
+ *
4
+ * Registered in .claude/settings.json for matcher: "Bash"
5
+ * Fires after every Bash tool call; exits immediately if it isn't a PR creation.
6
+ *
7
+ * When Claude runs `gh pr create`, this hook:
8
+ * 1. Parses the PR number from the gh output URL
9
+ * 2. Collects metrics (attribution + tool counts + transcript)
10
+ * 3. Injects the rendered metrics block into the PR body via `gh pr edit`
11
+ *
12
+ * This provides full local metrics (tokens, tools, attribution) automatically,
13
+ * without requiring the developer to run any command. Falls back gracefully if
14
+ * gh isn't installed, the URL can't be parsed, or metrics are empty.
15
+ */
16
+ import { execFile } from "child_process";
17
+ import { promisify } from "util";
18
+ import { resolve } from "path";
19
+ import { writeFile, unlink } from "fs/promises";
20
+ import { readStdin } from "../lib/hooks.ts";
21
+ import { collectMetrics, renderMetrics } from "../metrics/collect.ts";
22
+
23
+ const execFileAsync = promisify(execFile);
24
+
25
+ interface HookPayload {
26
+ session_id: string;
27
+ tool_name: string;
28
+ tool_input: { command?: string };
29
+ tool_response?: unknown;
30
+ }
31
+
32
+ function extractPrUrl(response: unknown): string | null {
33
+ const text =
34
+ typeof response === "string"
35
+ ? response
36
+ : typeof response === "object" &&
37
+ response !== null &&
38
+ "output" in response
39
+ ? String((response as { output: unknown }).output)
40
+ : "";
41
+ const match = text.match(/https:\/\/github\.com\/[^\s]+\/pull\/\d+/);
42
+ return match ? match[0] : null;
43
+ }
44
+
45
+ function prNumberFromUrl(url: string): number | null {
46
+ const match = url.match(/\/pull\/(\d+)$/);
47
+ return match ? parseInt(match[1], 10) : null;
48
+ }
49
+
50
+ async function main() {
51
+ const raw = await readStdin();
52
+ if (!raw.trim()) process.exit(0);
53
+
54
+ let payload: HookPayload;
55
+ try {
56
+ payload = JSON.parse(raw) as HookPayload;
57
+ } catch {
58
+ process.exit(0);
59
+ }
60
+
61
+ if (payload.tool_name !== "Bash") process.exit(0);
62
+
63
+ const command = payload.tool_input?.command ?? "";
64
+ if (!command.includes("gh pr create")) process.exit(0);
65
+
66
+ const prUrl = extractPrUrl(payload.tool_response);
67
+ if (!prUrl) process.exit(0);
68
+
69
+ const prNumber = prNumberFromUrl(prUrl);
70
+ if (!prNumber) process.exit(0);
71
+
72
+ try {
73
+ const repoRoot = resolve(process.cwd());
74
+ const metricsData = await collectMetrics(
75
+ payload.session_id || undefined,
76
+ repoRoot,
77
+ );
78
+ const metricsBlock = renderMetrics(metricsData);
79
+ if (!metricsBlock.trim()) process.exit(0);
80
+
81
+ const { stdout: prJson } = await execFileAsync("gh", [
82
+ "pr",
83
+ "view",
84
+ String(prNumber),
85
+ "--json",
86
+ "body",
87
+ ]);
88
+ const { body = "" } = JSON.parse(prJson) as { body?: string };
89
+
90
+ const start = "<!-- claude-attribution metrics -->";
91
+ const end = "<!-- /claude-attribution metrics -->";
92
+ const block = `${start}\n${metricsBlock}\n${end}`;
93
+
94
+ let newBody: string;
95
+ if (body.includes(start) && body.includes(end)) {
96
+ const startIdx = body.indexOf(start);
97
+ const endIdx = body.indexOf(end) + end.length;
98
+ newBody = body.slice(0, startIdx) + block + body.slice(endIdx);
99
+ } else if (body.includes(start)) {
100
+ newBody = body.slice(0, body.indexOf(start)) + block;
101
+ } else {
102
+ newBody = body ? `${body}\n\n${block}` : block;
103
+ }
104
+
105
+ const tmpFile = `/tmp/claude-attribution-pr-body-${Date.now()}.md`;
106
+ await writeFile(tmpFile, newBody);
107
+ try {
108
+ await execFileAsync("gh", [
109
+ "pr",
110
+ "edit",
111
+ String(prNumber),
112
+ "--body-file",
113
+ tmpFile,
114
+ ]);
115
+ } finally {
116
+ await unlink(tmpFile).catch(() => {});
117
+ }
118
+ } catch {
119
+ // Non-fatal — never block Claude
120
+ }
121
+
122
+ process.exit(0);
123
+ }
124
+
125
+ main().catch(() => process.exit(0));
@@ -8,6 +8,8 @@
8
8
  import { readFile } from "fs/promises";
9
9
  import { existsSync } from "fs";
10
10
  import { resolve, join, relative } from "path";
11
+ import { execFile } from "child_process";
12
+ import { promisify } from "util";
11
13
  import { parseTranscript, type TranscriptResult } from "./transcript.ts";
12
14
  import {
13
15
  listNotes,
@@ -21,6 +23,8 @@ import {
21
23
  } from "../attribution/differ.ts";
22
24
  import { SESSION_ID_RE } from "../attribution/checkpoint.ts";
23
25
 
26
+ const execFileAsync = promisify(execFile);
27
+
24
28
  export interface MetricsData {
25
29
  repoRoot: string;
26
30
  sessionId: string;
@@ -49,6 +53,30 @@ async function readSessionStart(repoRoot: string): Promise<Date | null> {
49
53
  }
50
54
  }
51
55
 
56
+ async function getBranchStartTime(repoRoot: string): Promise<Date | null> {
57
+ for (const ref of ["origin/HEAD", "origin/main", "origin/master"]) {
58
+ try {
59
+ const { stdout: base } = await execFileAsync(
60
+ "git",
61
+ ["merge-base", "HEAD", ref],
62
+ { cwd: repoRoot },
63
+ );
64
+ const forkPoint = base.trim();
65
+ if (!forkPoint) continue;
66
+ const { stdout: log } = await execFileAsync(
67
+ "git",
68
+ ["log", "--reverse", "--format=%ct", `${forkPoint}..HEAD`],
69
+ { cwd: repoRoot },
70
+ );
71
+ const firstTs = log.trim().split("\n").filter(Boolean)[0];
72
+ if (firstTs) return new Date(parseInt(firstTs, 10) * 1000);
73
+ } catch {
74
+ continue;
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
52
80
  async function readJsonlForSession(
53
81
  filePath: string,
54
82
  sessionId: string,
@@ -130,16 +158,12 @@ export async function collectMetrics(
130
158
  const root = repoRoot ?? resolve(process.cwd());
131
159
  const logDir = join(root, ".claude", "logs");
132
160
 
133
- const sessionId = sessionIdArg ?? (await resolveSessionId(root));
134
- if (!sessionId) {
135
- console.error(
136
- "Error: No session ID found. Logs may be empty or not yet generated.",
137
- );
138
- console.error("Usage: claude-attribution metrics [session-id]");
139
- process.exit(1);
140
- }
161
+ const sessionId = sessionIdArg ?? (await resolveSessionId(root)) ?? "";
141
162
 
142
- const sessionStart = await readSessionStart(root);
163
+ // /start marker takes precedence; fall back to earliest commit on the branch
164
+ // so that /start is never required for correct scoping.
165
+ const sessionStart =
166
+ (await readSessionStart(root)) ?? (await getBranchStartTime(root));
143
167
 
144
168
  const [toolEntries, agentEntries, transcript, attributions] =
145
169
  await Promise.all([
@@ -5,14 +5,7 @@
5
5
  *
6
6
  * If no path is given, installs into the current working directory.
7
7
  */
8
- import {
9
- readFile,
10
- writeFile,
11
- appendFile,
12
- copyFile,
13
- chmod,
14
- mkdir,
15
- } from "fs/promises";
8
+ import { readFile, writeFile, appendFile, chmod, mkdir } from "fs/promises";
16
9
  import { existsSync } from "fs";
17
10
  import { execFile } from "child_process";
18
11
  import { promisify } from "util";
@@ -84,65 +77,6 @@ function detectHookManager(repoRoot: string): "husky" | "lefthook" | "none" {
84
77
  return "none";
85
78
  }
86
79
 
87
- async function installPrePushHook(repoRoot: string): Promise<void> {
88
- const manager = detectHookManager(repoRoot);
89
- const runLine = `claude-attribution hook pre-push || true`;
90
-
91
- if (manager === "husky") {
92
- const huskyHook = join(repoRoot, ".husky", "pre-push");
93
- if (existsSync(huskyHook)) {
94
- const existing = await readFile(huskyHook, "utf8");
95
- if (!existing.includes("claude-attribution")) {
96
- await writeFile(
97
- huskyHook,
98
- existing.trimEnd() + "\n\n# claude-attribution\n" + runLine + "\n",
99
- );
100
- console.log(" (appended to existing .husky/pre-push)");
101
- }
102
- } else {
103
- await writeFile(
104
- huskyHook,
105
- `#!/bin/sh\n# claude-attribution\n${runLine}\n`,
106
- );
107
- await chmod(huskyHook, 0o755);
108
- }
109
- return;
110
- }
111
-
112
- if (manager === "lefthook") {
113
- console.log("");
114
- console.log(" ⚠️ Lefthook detected. Add this to lefthook.yml manually:");
115
- console.log(" pre-push:");
116
- console.log(" commands:");
117
- console.log(" claude-attribution:");
118
- console.log(` run: ${runLine}`);
119
- console.log("");
120
- return;
121
- }
122
-
123
- // Plain git hooks
124
- const hookDest = join(repoRoot, ".git", "hooks", "pre-push");
125
- const rawTemplate = await readFile(
126
- join(ATTRIBUTION_ROOT, "src", "setup", "templates", "pre-push.sh"),
127
- "utf8",
128
- );
129
- if (existsSync(hookDest)) {
130
- const existing = await readFile(hookDest, "utf8");
131
- if (!existing.includes("claude-attribution")) {
132
- await writeFile(
133
- hookDest,
134
- existing.trimEnd() + "\n\n# claude-attribution\n" + rawTemplate,
135
- );
136
- await chmod(hookDest, 0o755);
137
- return;
138
- }
139
- // Already ours — replace
140
- }
141
-
142
- await writeFile(hookDest, rawTemplate);
143
- await chmod(hookDest, 0o755);
144
- }
145
-
146
80
  async function installGitHook(repoRoot: string): Promise<void> {
147
81
  const manager = detectHookManager(repoRoot);
148
82
  const runLine = `claude-attribution hook post-commit || true`;
@@ -247,29 +181,30 @@ async function main() {
247
181
  await mergeHooks(settingsPath, hooksConfig);
248
182
  console.log("✓ Merged hooks into .claude/settings.json");
249
183
 
250
- // 2. Install post-commit and pre-push git hooks
184
+ // 2. Install post-commit git hook
251
185
  await installGitHook(targetRepo);
252
186
  console.log("✓ Installed .git/hooks/post-commit");
253
- await installPrePushHook(targetRepo);
254
- console.log(
255
- "✓ Installed .git/hooks/pre-push (pushes attribution notes to origin)",
256
- );
257
187
 
258
- // Ensure git push also pushes notes by default
188
+ // Configure git push to also push notes by default (avoids needing a separate push).
189
+ // Use --unset-all first so re-running install doesn't accumulate duplicate refspecs,
190
+ // then --add to append alongside any other user-configured push refspecs.
191
+ const notesRefspec =
192
+ "refs/notes/claude-attribution:refs/notes/claude-attribution";
259
193
  try {
194
+ // Remove any existing entries for this refspec (idempotent — exits non-zero if missing, that's fine)
260
195
  await execFileAsync(
261
196
  "git",
262
- [
263
- "config",
264
- "--add",
265
- "remote.origin.push",
266
- "refs/notes/claude-attribution:refs/notes/claude-attribution",
267
- ],
197
+ ["config", "--unset-all", "remote.origin.push", notesRefspec],
198
+ { cwd: targetRepo },
199
+ ).catch(() => {
200
+ // exit 5 = key not found; safe to ignore
201
+ });
202
+ await execFileAsync(
203
+ "git",
204
+ ["config", "--add", "remote.origin.push", notesRefspec],
268
205
  { cwd: targetRepo },
269
206
  );
270
- console.log(
271
- "✓ Configured remote.origin.push for attribution notes refspec",
272
- );
207
+ console.log("✓ Configured remote.origin.push to include attribution notes");
273
208
  } catch {
274
209
  console.log(
275
210
  " (skipped git config remote.origin.push — no origin remote or git unavailable)",
@@ -300,8 +235,31 @@ async function main() {
300
235
  await writeFile(join(commandsDir, "pr.md"), prTemplate);
301
236
  console.log("✓ Installed .claude/commands/pr.md (/pr command)");
302
237
 
238
+ // 4. Install GitHub Actions workflow for automatic PR metrics injection
239
+ const workflowsDir = join(targetRepo, ".github", "workflows");
240
+ await mkdir(workflowsDir, { recursive: true });
241
+ const workflowTemplate = await readFile(
242
+ join(
243
+ ATTRIBUTION_ROOT,
244
+ "src",
245
+ "setup",
246
+ "templates",
247
+ "pr-metrics-workflow.yml",
248
+ ),
249
+ "utf8",
250
+ );
251
+ await writeFile(
252
+ join(workflowsDir, "claude-attribution-pr.yml"),
253
+ workflowTemplate,
254
+ );
255
+ console.log(
256
+ "✓ Installed .github/workflows/claude-attribution-pr.yml (auto-injects metrics on PR open)",
257
+ );
258
+
303
259
  console.log("\nDone! claude-attribution is active for this repo.");
304
- console.log("Use /pr in Claude Code to create a PR with metrics embedded.");
260
+ console.log(
261
+ "Commit .claude/settings.json and .github/workflows/claude-attribution-pr.yml to share with the team.",
262
+ );
305
263
  }
306
264
 
307
265
  main().catch((err) => {
@@ -19,6 +19,15 @@
19
19
  "command": "claude-attribution hook post-tool-use"
20
20
  }
21
21
  ]
22
+ },
23
+ {
24
+ "matcher": "Bash",
25
+ "hooks": [
26
+ {
27
+ "type": "command",
28
+ "command": "claude-attribution hook post-bash"
29
+ }
30
+ ]
22
31
  }
23
32
  ],
24
33
  "SubagentStart": [
@@ -0,0 +1,84 @@
1
+ name: Claude Code Attribution Metrics
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize]
6
+
7
+ permissions:
8
+ contents: read
9
+ pull-requests: write
10
+
11
+ jobs:
12
+ metrics:
13
+ name: Claude Code Attribution Metrics
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ fetch-depth: 0
19
+
20
+ - name: Fetch attribution notes
21
+ run: git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
22
+
23
+ - name: Install claude-attribution
24
+ run: npm install -g claude-attribution
25
+
26
+ - name: Generate metrics
27
+ run: claude-attribution metrics > /tmp/claude-attribution-metrics.md || true
28
+
29
+ - name: Inject metrics into PR body
30
+ uses: actions/github-script@v7
31
+ with:
32
+ script: |
33
+ const fs = require('fs');
34
+ let metrics;
35
+ try {
36
+ metrics = fs.readFileSync('/tmp/claude-attribution-metrics.md', 'utf8').trim();
37
+ } catch {
38
+ console.log('No metrics output, skipping.');
39
+ return;
40
+ }
41
+ if (!metrics) {
42
+ console.log('Empty metrics output, skipping.');
43
+ return;
44
+ }
45
+
46
+ const { data: pr } = await github.rest.pulls.get({
47
+ owner: context.repo.owner,
48
+ repo: context.repo.repo,
49
+ pull_number: context.payload.pull_request.number,
50
+ });
51
+
52
+ const body = pr.body || '';
53
+ const start = '<!-- claude-attribution metrics -->';
54
+ const end = '<!-- /claude-attribution metrics -->';
55
+
56
+ // On 'opened': skip if the local post-bash hook already injected metrics.
57
+ // On 'synchronize': always update to reflect new commits.
58
+ if (context.payload.action === 'opened' && body.includes(start) && body.includes(end)) {
59
+ const existing = body.slice(body.indexOf(start) + start.length, body.indexOf(end)).trim();
60
+ if (existing) {
61
+ console.log('Metrics already injected by local hook, skipping.');
62
+ return;
63
+ }
64
+ }
65
+ const block = `${start}\n${metrics}\n${end}`;
66
+
67
+ let newBody;
68
+ if (body.includes(start) && body.includes(end)) {
69
+ const startIdx = body.indexOf(start);
70
+ const endIdx = body.indexOf(end) + end.length;
71
+ newBody = body.slice(0, startIdx) + block + body.slice(endIdx);
72
+ } else if (body.includes(start)) {
73
+ newBody = body.slice(0, body.indexOf(start)) + block;
74
+ } else {
75
+ newBody = body ? `${body}\n\n${block}` : block;
76
+ }
77
+
78
+ await github.rest.pulls.update({
79
+ owner: context.repo.owner,
80
+ repo: context.repo.repo,
81
+ pull_number: context.payload.pull_request.number,
82
+ body: newBody,
83
+ });
84
+ console.log('Metrics injected into PR body.');
@@ -163,6 +163,17 @@ async function main() {
163
163
  console.log(" (no slash commands found)");
164
164
  }
165
165
 
166
+ // 6. Remove GitHub Actions workflow
167
+ const workflowPath = join(
168
+ targetRepo,
169
+ ".github",
170
+ "workflows",
171
+ "claude-attribution-pr.yml",
172
+ );
173
+ if (await removeFile(workflowPath)) {
174
+ console.log("✓ Removed .github/workflows/claude-attribution-pr.yml");
175
+ }
176
+
166
177
  console.log("\nDone! claude-attribution has been removed from this repo.");
167
178
  console.log(
168
179
  "Note: .claude/logs/ and .claude/attribution-state/ are preserved.",
@@ -1,4 +0,0 @@
1
- #!/bin/sh
2
- # Auto-installed by claude-attribution
3
- # Pushes attribution git notes to origin so GitHub Actions can read them
4
- claude-attribution hook pre-push || true