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 +49 -2
- package/package.json +1 -1
- package/src/__tests__/copilot-session.test.ts +128 -0
- package/src/__tests__/differ.test.ts +18 -4
- package/src/__tests__/git-notes.test.ts +13 -8
- package/src/__tests__/integration.test.ts +525 -12
- package/src/__tests__/minimap-bulk.test.ts +17 -0
- package/src/__tests__/minimap.test.ts +7 -7
- package/src/attribution/commit.ts +35 -8
- package/src/attribution/differ.ts +3 -4
- package/src/attribution/git-notes.ts +12 -8
- package/src/attribution/minimap.ts +115 -5
- package/src/commands/note-ai-commit.ts +2 -2
- package/src/export/merge-pr-artifacts.ts +271 -0
- package/src/export/pr-summary.ts +23 -1
- package/src/metrics/collect.ts +269 -28
- package/src/metrics/copilot-session.ts +35 -1
- package/src/metrics/local-session.ts +5 -2
- package/src/metrics/transcript.ts +18 -3
- package/src/setup/templates/claude-attribution-export.yml +7 -2
- package/src/setup/templates/pr-metrics-workflow.yml +8 -6
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%
|
|
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
|
@@ -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("
|
|
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("
|
|
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("
|
|
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:
|
|
71
|
-
human:
|
|
72
|
-
total: 3,
|
|
73
|
-
pctAi:
|
|
74
|
-
});
|
|
75
|
-
expect(result.totals).toMatchObject({
|
|
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");
|