claude-attribution 1.9.0 → 1.9.5

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
@@ -68,6 +68,51 @@ Up to three workflows are installed into repos that use this tool — one always
68
68
 
69
69
  ---
70
70
 
71
+ ## Publishing this package to npm
72
+
73
+ This repo is set up to publish to npm from GitHub Actions on the **self-hosted runner** using an npm publish token. That avoids local OTP prompts while still fitting the repo's network restrictions.
74
+
75
+ ### One-time npm + GitHub setup
76
+
77
+ This part must be done by a package owner:
78
+
79
+ 1. In npm, open the `claude-attribution` package and set **Publishing access** to **Require two-factor authentication or a granular access token with bypass 2fa enabled**.
80
+ 2. Create a **granular npm access token** that can publish `claude-attribution` and has **bypass 2FA** enabled.
81
+ 3. In GitHub, save that token as the `NPM_TOKEN` Actions secret for this repository (or an org secret exposed to this repo).
82
+ 4. Optional: you can leave the npm trusted publisher entry in place, but this self-hosted workflow authenticates with `NPM_TOKEN`.
83
+ 5. Plan to rotate the npm token periodically. npm currently caps granular token lifetime at 90 days.
84
+
85
+ After that, the workflow in `.github/workflows/publish-npm.yml` can publish on the self-hosted runner without an interactive OTP prompt.
86
+
87
+ ### Release process
88
+
89
+ This repo uses a **bump → merge → tag** publish flow:
90
+
91
+ 1. Bump `package.json` to the release version and update `CHANGELOG.md` in the PR.
92
+ 2. Merge that PR to `main`.
93
+ 3. Tag the merged commit from `main`:
94
+
95
+ ```bash
96
+ git checkout main
97
+ git pull --ff-only
98
+ git tag v1.9.1
99
+ git push origin v1.9.1
100
+ ```
101
+
102
+ 4. GitHub Actions runs `publish-npm.yml` and publishes that exact version to npm.
103
+
104
+ ### Important rules
105
+
106
+ - The git tag must exactly match `package.json` without the `v` prefix.
107
+ - Example: tag `v1.9.1` requires `"version": "1.9.1"` in `package.json`
108
+ - The workflow fails if the tag and package version do not match.
109
+ - The workflow also fails if that package version is already published.
110
+ - `NPM_TOKEN` must exist in GitHub Actions before the tag is pushed.
111
+ - The npm token must be a granular publish token with bypass-2FA enabled for this package.
112
+ - The self-hosted workflow publishes **without** `--provenance` because npm only accepts provenance from GitHub-hosted runners.
113
+
114
+ ---
115
+
71
116
  ## For Repo Maintainers: Installing Into a Repo
72
117
 
73
118
  ### Prerequisites
@@ -164,7 +209,7 @@ git push origin refs/notes/claude-attribution-map
164
209
  Marks every currently tracked file as AI-written at HEAD. After this, PR metrics will show:
165
210
  ```
166
211
  Codebase: ~100% AI (4150 / 4150 lines)
167
- This PR: 184 lines changed (4% of codebase) · 77% Claude edits · 142 AI lines
212
+ This PR: 184 lines changed (4% of codebase) · 77% AI edits · 142 AI-attributed changed lines
168
213
  ```
169
214
 
170
215
  **Option 2 — Repo is human-written, or a mix (`--human` / no flag):**
@@ -251,7 +296,7 @@ The metrics block injected into the PR body looks like (when the cumulative mini
251
296
  > ## AI Coding Metrics
252
297
  >
253
298
  > **Codebase: ~77% AI** (3200 / 4150 lines)
254
- > **This PR:** 184 lines changed (4% of codebase) · 77% AI edits · 142 AI lines
299
+ > **This PR:** 184 lines changed (4% of codebase) · 77% AI edits · 142 AI-attributed changed lines
255
300
  > **Session:** 12 prompts · 24m total (18m AI · 6m human)
256
301
  > **Assistant runtime:** Claude Code (claude-sonnet-4-6)
257
302
  >
@@ -278,6 +323,8 @@ For **Copilot CLI** sessions, the same block is rendered with provider-aware dif
278
323
  - model usage shows **Known Tokens** instead of Claude-style input/output/cache columns
279
324
  - cost is shown as **unavailable** unless durable local billing data exists
280
325
 
326
+ The `This PR` line is based on the branch diff against the base branch, not the full final size of every touched file.
327
+
281
328
  The block is wrapped in HTML comments for idempotent updates — re-running replaces the existing block rather than appending:
282
329
 
283
330
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-attribution",
3
- "version": "1.9.0",
3
+ "version": "1.9.5",
4
4
  "description": "AI code attribution tracking for Claude Code and GitHub Copilot sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -102,6 +102,7 @@ describe("copilot-session", () => {
102
102
  expect(parsed?.activeMinutes).toBe(3);
103
103
  expect(parsed?.aiMinutes).toBe(2);
104
104
  expect(parsed?.humanMinutes).toBe(1);
105
+ expect(parsed?.signalSourceLabel).toBe("Copilot session logs");
105
106
  expect(parsed?.costMode).toBe("unavailable");
106
107
  } finally {
107
108
  process.env.HOME = originalHome;
@@ -138,4 +139,131 @@ describe("copilot-session", () => {
138
139
  await ctx.cleanup();
139
140
  }
140
141
  });
142
+
143
+ test("scopes Copilot session metrics to the provided start time", async () => {
144
+ const ctx = await createTempContext("copilot-session-since");
145
+ const originalHome = process.env.HOME;
146
+ try {
147
+ process.env.HOME = ctx.home;
148
+ const sessionId = "copilot-session-2";
149
+ await writeCopilotSession(ctx.home, sessionId, [
150
+ {
151
+ type: "session.start",
152
+ timestamp: "2026-04-01T10:00:00.000Z",
153
+ data: {
154
+ context: { cwd: ctx.repo, gitRoot: ctx.repo, branch: "feature/copilot" },
155
+ },
156
+ },
157
+ {
158
+ type: "user.message",
159
+ timestamp: "2026-04-01T10:00:00.000Z",
160
+ data: { content: "Old work" },
161
+ },
162
+ {
163
+ type: "assistant.message",
164
+ timestamp: "2026-04-01T10:01:00.000Z",
165
+ data: { outputTokens: 320 },
166
+ },
167
+ {
168
+ type: "assistant.turn_end",
169
+ timestamp: "2026-04-01T10:01:00.000Z",
170
+ },
171
+ {
172
+ type: "tool.execution_complete",
173
+ timestamp: "2026-04-01T10:01:00.000Z",
174
+ data: { model: "gpt-5.4" },
175
+ },
176
+ {
177
+ type: "user.message",
178
+ timestamp: "2026-04-01T11:00:00.000Z",
179
+ data: { content: "Current work" },
180
+ },
181
+ {
182
+ type: "assistant.message",
183
+ timestamp: "2026-04-01T11:02:00.000Z",
184
+ data: { outputTokens: 120 },
185
+ },
186
+ {
187
+ type: "assistant.turn_end",
188
+ timestamp: "2026-04-01T11:02:00.000Z",
189
+ },
190
+ {
191
+ type: "tool.execution_complete",
192
+ timestamp: "2026-04-01T11:02:00.000Z",
193
+ data: { model: "gpt-5.4" },
194
+ },
195
+ ]);
196
+
197
+ const parsed = await parseCopilotSession(
198
+ sessionId,
199
+ ctx.repo,
200
+ undefined,
201
+ new Date("2026-04-01T10:30:00.000Z"),
202
+ );
203
+
204
+ expect(parsed).not.toBeNull();
205
+ expect(parsed?.humanPromptCount).toBe(1);
206
+ expect(parsed?.activeMinutes).toBe(2);
207
+ expect(parsed?.aiMinutes).toBe(2);
208
+ expect(parsed?.humanMinutes).toBe(0);
209
+ expect(parsed?.totals.totalCalls).toBe(1);
210
+ expect(parsed?.totals.totalOutputTokens).toBe(120);
211
+ } finally {
212
+ process.env.HOME = originalHome;
213
+ await ctx.cleanup();
214
+ }
215
+ });
216
+
217
+ test("keeps a recent prompt as the start anchor when /start is set immediately after it", async () => {
218
+ const ctx = await createTempContext("copilot-session-anchor");
219
+ const originalHome = process.env.HOME;
220
+ try {
221
+ process.env.HOME = ctx.home;
222
+ const sessionId = "copilot-session-3";
223
+ await writeCopilotSession(ctx.home, sessionId, [
224
+ {
225
+ type: "session.start",
226
+ timestamp: "2026-04-01T10:00:00.000Z",
227
+ data: {
228
+ context: { cwd: ctx.repo, gitRoot: ctx.repo, branch: "feature/copilot" },
229
+ },
230
+ },
231
+ {
232
+ type: "user.message",
233
+ timestamp: "2026-04-01T10:00:00.000Z",
234
+ data: { content: "Kick off the fix" },
235
+ },
236
+ {
237
+ type: "assistant.message",
238
+ timestamp: "2026-04-01T10:02:00.000Z",
239
+ data: { outputTokens: 240 },
240
+ },
241
+ {
242
+ type: "assistant.turn_end",
243
+ timestamp: "2026-04-01T10:02:00.000Z",
244
+ },
245
+ {
246
+ type: "tool.execution_complete",
247
+ timestamp: "2026-04-01T10:02:00.000Z",
248
+ data: { model: "gpt-5.4" },
249
+ },
250
+ ]);
251
+
252
+ const parsed = await parseCopilotSession(
253
+ sessionId,
254
+ ctx.repo,
255
+ undefined,
256
+ new Date("2026-04-01T10:00:30.000Z"),
257
+ );
258
+
259
+ expect(parsed).not.toBeNull();
260
+ expect(parsed?.humanPromptCount).toBe(1);
261
+ expect(parsed?.activeMinutes).toBe(2);
262
+ expect(parsed?.aiMinutes).toBe(2);
263
+ expect(parsed?.humanMinutes).toBe(0);
264
+ } finally {
265
+ process.env.HOME = originalHome;
266
+ await ctx.cleanup();
267
+ }
268
+ });
141
269
  });
@@ -98,14 +98,28 @@ describe("attributeLines — basic classification", () => {
98
98
  expect(stats.pctAi).toBe(67);
99
99
  });
100
100
 
101
- test("empty lines always HUMAN regardless of snapshot", () => {
101
+ test("new blank lines in an AI-authored file count as AI", () => {
102
102
  const before: string[] = [];
103
103
  const after = ["", "const x = 1;", ""];
104
104
  const committed = ["", "const x = 1;", ""];
105
- const { attribution } = attributeLines(before, after, committed);
106
- expect(attribution[0]).toBe("HUMAN"); // empty line
105
+ const { attribution, stats } = attributeLines(before, after, committed);
106
+ expect(attribution[0]).toBe("AI"); // new blank line
107
107
  expect(attribution[1]).toBe("AI"); // real content Claude wrote
108
- expect(attribution[2]).toBe("HUMAN"); // empty line
108
+ expect(attribution[2]).toBe("AI"); // new blank line
109
+ expect(stats.ai).toBe(3);
110
+ expect(stats.human).toBe(0);
111
+ expect(stats.pctAi).toBe(100);
112
+ });
113
+
114
+ test("pre-existing blank lines stay HUMAN", () => {
115
+ const before = ["", "const x = 1;"];
116
+ const after = ["", "const x = 1;", "const y = 2;"];
117
+ const committed = ["", "const x = 1;"];
118
+ const { attribution, stats } = attributeLines(before, after, committed);
119
+ expect(attribution[0]).toBe("HUMAN");
120
+ expect(attribution[1]).toBe("HUMAN");
121
+ expect(stats.ai).toBe(0);
122
+ expect(stats.human).toBe(2);
109
123
  });
110
124
  });
111
125
 
@@ -65,14 +65,19 @@ expect(await listNotes(ctx.repo)).toEqual([]);
65
65
  const result = await buildAllAiResult(ctx.repo, sha);
66
66
  expect(result.commit).toBe(sha);
67
67
  expect(result.files).toHaveLength(1);
68
- expect(result.files[0]).toMatchObject({
69
- path: "ai.txt",
70
- ai: 2,
71
- human: 1,
72
- total: 3,
73
- pctAi: 67,
74
- });
75
- expect(result.totals).toMatchObject({ ai: 2, human: 1, total: 3, pctAi: 67 });
68
+ expect(result.files[0]).toMatchObject({
69
+ path: "ai.txt",
70
+ ai: 3,
71
+ human: 0,
72
+ total: 3,
73
+ pctAi: 100,
74
+ });
75
+ expect(result.totals).toMatchObject({
76
+ ai: 3,
77
+ human: 0,
78
+ total: 3,
79
+ pctAi: 100,
80
+ });
76
81
 
77
82
  const meta = await getCommitMeta(ctx.repo, sha);
78
83
  expect(meta.authorName).toBe("Test User");