claude-attribution 1.9.4 → 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/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 +269 -5
- package/src/attribution/commit.ts +23 -5
- package/src/attribution/differ.ts +3 -4
- package/src/attribution/git-notes.ts +3 -3
- package/src/attribution/minimap.ts +97 -0
- 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 +57 -18
- 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/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");
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type MinimapResult,
|
|
12
12
|
} from "../attribution/minimap.ts";
|
|
13
13
|
import { readInstalledRepoRegistry } from "../setup/installed-repos.ts";
|
|
14
|
+
import { writeMergedPrArtifacts } from "../export/merge-pr-artifacts.ts";
|
|
14
15
|
import {
|
|
15
16
|
CLI_BIN,
|
|
16
17
|
configureGitIdentity,
|
|
@@ -32,7 +33,7 @@ describe("integration", () => {
|
|
|
32
33
|
try {
|
|
33
34
|
await initGitRepo(ctx.repo);
|
|
34
35
|
const filePath = join(ctx.repo, "src.ts");
|
|
35
|
-
await writeFile(filePath, "const value = 1
|
|
36
|
+
await writeFile(filePath, "const value = 1;");
|
|
36
37
|
await commitAll(ctx.repo, "initial");
|
|
37
38
|
|
|
38
39
|
const sessionId = "session-commit-1";
|
|
@@ -44,7 +45,7 @@ describe("integration", () => {
|
|
|
44
45
|
sessionId,
|
|
45
46
|
);
|
|
46
47
|
await saveCheckpoint(sessionId, filePath, "before");
|
|
47
|
-
await writeFile(filePath, "const value = 2;\nconst nextValue = 3
|
|
48
|
+
await writeFile(filePath, "const value = 2;\n\nconst nextValue = 3;");
|
|
48
49
|
await saveCheckpoint(sessionId, filePath, "after");
|
|
49
50
|
|
|
50
51
|
await writeJsonl(join(ctx.repo, ".claude", "logs", "tool-usage.jsonl"), [
|
|
@@ -121,6 +122,12 @@ describe("integration", () => {
|
|
|
121
122
|
expect(note.sessionMetrics?.agentCounts).toEqual({ "code-review": 1 });
|
|
122
123
|
expect(note.sessionMetrics?.humanPromptCount).toBe(1);
|
|
123
124
|
expect(note.sessionMetrics?.activeMinutes).toBe(2);
|
|
125
|
+
expect(note.files.find((file) => file.path === "src.ts")).toMatchObject({
|
|
126
|
+
ai: 3,
|
|
127
|
+
human: 0,
|
|
128
|
+
total: 3,
|
|
129
|
+
pctAi: 100,
|
|
130
|
+
});
|
|
124
131
|
expect(note.assistantRuntime).toEqual({
|
|
125
132
|
vendor: "claude",
|
|
126
133
|
client: "Claude Code",
|
|
@@ -207,6 +214,9 @@ describe("integration", () => {
|
|
|
207
214
|
expect(metrics.stdout).toContain("**Codebase: ~100% AI**");
|
|
208
215
|
expect(metrics.stdout).toContain("**This PR:** 1 lines changed");
|
|
209
216
|
expect(metrics.stdout).toContain("**Session:** 3 prompts · 15m total (10m AI · 5m human)");
|
|
217
|
+
expect(metrics.stdout).toContain(
|
|
218
|
+
"**Session/model source:** Git note snapshot (Copilot-derived)",
|
|
219
|
+
);
|
|
210
220
|
expect(metrics.stdout).toContain(
|
|
211
221
|
"**Assistant runtime:** GitHub Copilot CLI (v1.0.14)",
|
|
212
222
|
);
|
|
@@ -313,6 +323,260 @@ describe("integration", () => {
|
|
|
313
323
|
"**This PR:** 2 lines changed (2% of codebase) · 100% AI edits · 2 AI-attributed changed lines",
|
|
314
324
|
);
|
|
315
325
|
expect(metrics.stdout).not.toContain("**This PR:** 102 lines changed");
|
|
326
|
+
expect(metrics.stdout).toContain(
|
|
327
|
+
"- `README.md` — 100% AI (2 / 2 changed lines)",
|
|
328
|
+
);
|
|
329
|
+
expect(metrics.stdout).not.toContain("- `README.md` — 2% AI (2 lines)");
|
|
330
|
+
} finally {
|
|
331
|
+
await ctx.cleanup();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("metrics preserves AI carry-forward when the branch base is a merge commit without a minimap note", async () => {
|
|
336
|
+
const ctx = await createTempContext("claude-attribution-merge-minimap");
|
|
337
|
+
try {
|
|
338
|
+
await initGitRepo(ctx.repo);
|
|
339
|
+
await runCommand("git", ["branch", "-M", "main"], { cwd: ctx.repo });
|
|
340
|
+
|
|
341
|
+
const remote = join(ctx.root, "origin.git");
|
|
342
|
+
await runCommand("git", ["init", "--bare", remote], { cwd: ctx.root });
|
|
343
|
+
await runCommand("git", ["remote", "add", "origin", remote], {
|
|
344
|
+
cwd: ctx.repo,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const filePath = join(ctx.repo, "app.ts");
|
|
348
|
+
await writeFile(filePath, "one\n");
|
|
349
|
+
await commitAll(ctx.repo, "base");
|
|
350
|
+
const baseSha = await currentSha(ctx.repo);
|
|
351
|
+
await writeMinimap(
|
|
352
|
+
{
|
|
353
|
+
commit: baseSha,
|
|
354
|
+
timestamp: "2026-04-01T10:00:00.000Z",
|
|
355
|
+
files: [
|
|
356
|
+
{
|
|
357
|
+
path: "app.ts",
|
|
358
|
+
ai_hashes: "",
|
|
359
|
+
ai: 0,
|
|
360
|
+
human: 2,
|
|
361
|
+
total: 2,
|
|
362
|
+
pctAi: 0,
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
totals: { ai: 0, human: 2, total: 2, pctAi: 0 },
|
|
366
|
+
},
|
|
367
|
+
ctx.repo,
|
|
368
|
+
baseSha,
|
|
369
|
+
);
|
|
370
|
+
await runCommand("git", ["push", "-u", "origin", "main"], { cwd: ctx.repo });
|
|
371
|
+
|
|
372
|
+
await runCommand("git", ["checkout", "-b", "feature/base-ai"], {
|
|
373
|
+
cwd: ctx.repo,
|
|
374
|
+
});
|
|
375
|
+
await writeFile(filePath, "one\ntwo\n");
|
|
376
|
+
await commitAll(ctx.repo, "ai base");
|
|
377
|
+
const aiBaseSha = await currentSha(ctx.repo);
|
|
378
|
+
await writeMinimap(
|
|
379
|
+
{
|
|
380
|
+
commit: aiBaseSha,
|
|
381
|
+
timestamp: "2026-04-01T10:05:00.000Z",
|
|
382
|
+
files: [
|
|
383
|
+
{
|
|
384
|
+
path: "app.ts",
|
|
385
|
+
ai_hashes: hashSetToString(new Set([hashLine("two")])),
|
|
386
|
+
ai: 1,
|
|
387
|
+
human: 2,
|
|
388
|
+
total: 3,
|
|
389
|
+
pctAi: 33,
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
totals: { ai: 1, human: 2, total: 3, pctAi: 33 },
|
|
393
|
+
},
|
|
394
|
+
ctx.repo,
|
|
395
|
+
aiBaseSha,
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
await runCommand("git", ["checkout", "main"], { cwd: ctx.repo });
|
|
399
|
+
await runCommand("git", ["merge", "--no-ff", "feature/base-ai", "-m", "merge ai"], {
|
|
400
|
+
cwd: ctx.repo,
|
|
401
|
+
});
|
|
402
|
+
await runCommand("git", ["push", "origin", "main"], { cwd: ctx.repo });
|
|
403
|
+
|
|
404
|
+
await runCommand("git", ["checkout", "-b", "feature/followup"], {
|
|
405
|
+
cwd: ctx.repo,
|
|
406
|
+
});
|
|
407
|
+
await writeCopilotSession(ctx.home, "merge-session-1", [
|
|
408
|
+
{
|
|
409
|
+
type: "session.start",
|
|
410
|
+
timestamp: "2026-04-01T10:10:00.000Z",
|
|
411
|
+
data: {
|
|
412
|
+
context: {
|
|
413
|
+
cwd: ctx.repo,
|
|
414
|
+
gitRoot: ctx.repo,
|
|
415
|
+
branch: "feature/followup",
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
]);
|
|
420
|
+
await mkdir(join(ctx.repo, ".claude", "attribution-state"), {
|
|
421
|
+
recursive: true,
|
|
422
|
+
});
|
|
423
|
+
await writeFile(
|
|
424
|
+
join(ctx.repo, ".claude", "attribution-state", "current-session"),
|
|
425
|
+
"merge-session-1",
|
|
426
|
+
);
|
|
427
|
+
await saveCheckpoint("merge-session-1", filePath, "before");
|
|
428
|
+
await writeFile(filePath, "one\ntwo\nthree\n");
|
|
429
|
+
await saveCheckpoint("merge-session-1", filePath, "after");
|
|
430
|
+
await runCommand("git", ["add", "app.ts"], { cwd: ctx.repo });
|
|
431
|
+
await runCommand("git", ["commit", "-m", "followup"], { cwd: ctx.repo });
|
|
432
|
+
|
|
433
|
+
await runCommand(CLI_BIN, ["hook", "post-commit"], {
|
|
434
|
+
cwd: ctx.repo,
|
|
435
|
+
env: {
|
|
436
|
+
HOME: ctx.home,
|
|
437
|
+
COPILOT_CLI: "1",
|
|
438
|
+
COPILOT_CLI_BINARY_VERSION: "1.0.15",
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const metrics = await runCommand(CLI_BIN, ["metrics"], {
|
|
443
|
+
cwd: ctx.repo,
|
|
444
|
+
env: {
|
|
445
|
+
HOME: ctx.home,
|
|
446
|
+
COPILOT_CLI: "1",
|
|
447
|
+
COPILOT_CLI_BINARY_VERSION: "1.0.15",
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
expect(metrics.stdout).toContain("**Codebase: ~50% AI** (2 / 4 lines)");
|
|
452
|
+
expect(metrics.stdout).toContain(
|
|
453
|
+
"**This PR:** 1 lines changed (25% of codebase) · 100% AI edits · 1 AI-attributed changed lines",
|
|
454
|
+
);
|
|
455
|
+
} finally {
|
|
456
|
+
await ctx.cleanup();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("merged PR helper preserves AI minimap on squash merge commits", async () => {
|
|
461
|
+
const ctx = await createTempContext("claude-attribution-squash-merge");
|
|
462
|
+
try {
|
|
463
|
+
await initGitRepo(ctx.repo);
|
|
464
|
+
await runCommand("git", ["branch", "-M", "main"], { cwd: ctx.repo });
|
|
465
|
+
|
|
466
|
+
const remote = join(ctx.root, "origin.git");
|
|
467
|
+
await runCommand("git", ["init", "--bare", remote], { cwd: ctx.root });
|
|
468
|
+
await runCommand("git", ["remote", "add", "origin", remote], {
|
|
469
|
+
cwd: ctx.repo,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
await writeFile(join(ctx.repo, "app.ts"), "one\n");
|
|
473
|
+
await commitAll(ctx.repo, "base");
|
|
474
|
+
const baseSha = await currentSha(ctx.repo);
|
|
475
|
+
await writeMinimap(
|
|
476
|
+
{
|
|
477
|
+
commit: baseSha,
|
|
478
|
+
timestamp: "2026-04-03T09:00:00.000Z",
|
|
479
|
+
files: [
|
|
480
|
+
{
|
|
481
|
+
path: "app.ts",
|
|
482
|
+
ai_hashes: hashSetToString(
|
|
483
|
+
new Set([hashLine("one"), hashLine("")]),
|
|
484
|
+
),
|
|
485
|
+
ai: 2,
|
|
486
|
+
human: 0,
|
|
487
|
+
total: 2,
|
|
488
|
+
pctAi: 100,
|
|
489
|
+
},
|
|
490
|
+
],
|
|
491
|
+
totals: { ai: 2, human: 0, total: 2, pctAi: 100 },
|
|
492
|
+
},
|
|
493
|
+
ctx.repo,
|
|
494
|
+
baseSha,
|
|
495
|
+
);
|
|
496
|
+
await runCommand("git", ["push", "-u", "origin", "main"], { cwd: ctx.repo });
|
|
497
|
+
|
|
498
|
+
await runCommand("git", ["checkout", "-b", "feature/ai-change"], {
|
|
499
|
+
cwd: ctx.repo,
|
|
500
|
+
});
|
|
501
|
+
await writeFile(join(ctx.repo, "app.ts"), "one\ntwo\n");
|
|
502
|
+
await commitAll(ctx.repo, "feature work");
|
|
503
|
+
const headSha = await currentSha(ctx.repo);
|
|
504
|
+
|
|
505
|
+
const featureNote: AttributionResult = {
|
|
506
|
+
commit: headSha,
|
|
507
|
+
session: "session-squash-merge-1",
|
|
508
|
+
branch: "feature/ai-change",
|
|
509
|
+
timestamp: "2026-04-03T09:05:00.000Z",
|
|
510
|
+
files: [
|
|
511
|
+
{
|
|
512
|
+
path: "app.ts",
|
|
513
|
+
ai: 3,
|
|
514
|
+
human: 0,
|
|
515
|
+
mixed: 0,
|
|
516
|
+
total: 3,
|
|
517
|
+
pctAi: 100,
|
|
518
|
+
},
|
|
519
|
+
],
|
|
520
|
+
totals: { ai: 3, human: 0, mixed: 0, total: 3, pctAi: 100 },
|
|
521
|
+
};
|
|
522
|
+
await writeNote(featureNote, ctx.repo, headSha);
|
|
523
|
+
await writeMinimap(
|
|
524
|
+
{
|
|
525
|
+
commit: headSha,
|
|
526
|
+
timestamp: "2026-04-03T09:05:00.000Z",
|
|
527
|
+
files: [
|
|
528
|
+
{
|
|
529
|
+
path: "app.ts",
|
|
530
|
+
ai_hashes: hashSetToString(
|
|
531
|
+
new Set([hashLine("one"), hashLine("two"), hashLine("")]),
|
|
532
|
+
),
|
|
533
|
+
ai: 3,
|
|
534
|
+
human: 0,
|
|
535
|
+
total: 3,
|
|
536
|
+
pctAi: 100,
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
totals: { ai: 3, human: 0, total: 3, pctAi: 100 },
|
|
540
|
+
},
|
|
541
|
+
ctx.repo,
|
|
542
|
+
headSha,
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
await runCommand("git", ["checkout", "main"], { cwd: ctx.repo });
|
|
546
|
+
await runCommand("git", ["merge", "--squash", "feature/ai-change"], {
|
|
547
|
+
cwd: ctx.repo,
|
|
548
|
+
});
|
|
549
|
+
await commitAll(ctx.repo, "squash merge feature");
|
|
550
|
+
const mergeSha = await currentSha(ctx.repo);
|
|
551
|
+
|
|
552
|
+
const before = await runCommand(
|
|
553
|
+
"git",
|
|
554
|
+
["notes", "--ref", "refs/notes/claude-attribution-map", "show", mergeSha],
|
|
555
|
+
{ cwd: ctx.repo },
|
|
556
|
+
).catch(() => null);
|
|
557
|
+
expect(before).toBeNull();
|
|
558
|
+
|
|
559
|
+
await writeMergedPrArtifacts({
|
|
560
|
+
repoRoot: ctx.repo,
|
|
561
|
+
mergeCommitSha: mergeSha,
|
|
562
|
+
prHeadSha: headSha,
|
|
563
|
+
results: [featureNote],
|
|
564
|
+
baseRef: "main",
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const mergedMinimap = JSON.parse(
|
|
568
|
+
(
|
|
569
|
+
await runCommand(
|
|
570
|
+
"git",
|
|
571
|
+
["notes", "--ref", "refs/notes/claude-attribution-map", "show", mergeSha],
|
|
572
|
+
{ cwd: ctx.repo },
|
|
573
|
+
)
|
|
574
|
+
).stdout,
|
|
575
|
+
) as MinimapResult;
|
|
576
|
+
expect(mergedMinimap.totals.pctAi).toBe(100);
|
|
577
|
+
expect(mergedMinimap.files.find((file) => file.path === "app.ts")?.human).toBe(
|
|
578
|
+
0,
|
|
579
|
+
);
|
|
316
580
|
} finally {
|
|
317
581
|
await ctx.cleanup();
|
|
318
582
|
}
|
|
@@ -579,7 +843,7 @@ describe("integration", () => {
|
|
|
579
843
|
);
|
|
580
844
|
await writeFile(
|
|
581
845
|
join(ctx.repo, "agent.ts"),
|
|
582
|
-
"export const generated = true
|
|
846
|
+
"export const generated = true;",
|
|
583
847
|
);
|
|
584
848
|
await commitAll(
|
|
585
849
|
ctx.repo,
|
|
@@ -592,9 +856,9 @@ describe("integration", () => {
|
|
|
592
856
|
});
|
|
593
857
|
|
|
594
858
|
expect(metrics.stdout).toContain("## AI Coding Metrics");
|
|
595
|
-
expect(metrics.stdout).toContain("**AI contribution: ~
|
|
859
|
+
expect(metrics.stdout).toContain("**AI contribution: ~100%**");
|
|
596
860
|
expect(metrics.stdout).toContain("**Assistant runtime:** GitHub Copilot");
|
|
597
|
-
expect(metrics.stdout).toContain("`agent.ts` —
|
|
861
|
+
expect(metrics.stdout).toContain("`agent.ts` — 100% AI (1 lines)");
|
|
598
862
|
expect(metrics.stdout).not.toContain("**Estimated cost:**");
|
|
599
863
|
} finally {
|
|
600
864
|
await ctx.cleanup();
|
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
import {
|
|
60
60
|
computeMinimapFile,
|
|
61
61
|
hashSetFromString,
|
|
62
|
-
|
|
62
|
+
resolveMinimap,
|
|
63
63
|
writeMinimap,
|
|
64
64
|
MINIMAP_NOTES_REF,
|
|
65
65
|
type MinimapFileState,
|
|
@@ -140,6 +140,23 @@ async function resolveSessionForCommit(
|
|
|
140
140
|
return await resolveCopilotSessionId(repoRoot, branch).catch(() => null);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
async function readSessionStart(repoRoot: string): Promise<Date | null> {
|
|
144
|
+
const markerPath = join(
|
|
145
|
+
repoRoot,
|
|
146
|
+
".claude",
|
|
147
|
+
"attribution-state",
|
|
148
|
+
"session-start",
|
|
149
|
+
);
|
|
150
|
+
if (!existsSync(markerPath)) return null;
|
|
151
|
+
try {
|
|
152
|
+
const raw = await readFile(markerPath, "utf8");
|
|
153
|
+
const marker = JSON.parse(raw) as { timestamp?: string };
|
|
154
|
+
return marker.timestamp ? new Date(marker.timestamp) : null;
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
143
160
|
function mergeAssistantRuntime(
|
|
144
161
|
runtime: AssistantRuntimeInfo | undefined,
|
|
145
162
|
provider: "claude" | "copilot" | undefined,
|
|
@@ -263,15 +280,18 @@ async function main() {
|
|
|
263
280
|
|
|
264
281
|
// Attach session usage metadata (non-fatal if unavailable)
|
|
265
282
|
if (sessionId) {
|
|
283
|
+
const sessionStart = await readSessionStart(repoRoot);
|
|
266
284
|
const [tx, toolEntries, agentEntries] = await Promise.all([
|
|
267
|
-
parseLocalSession(sessionId, repoRoot).catch(() => null),
|
|
285
|
+
parseLocalSession(sessionId, repoRoot, sessionStart).catch(() => null),
|
|
268
286
|
readJsonlForSession<ToolLogEntry>(
|
|
269
287
|
join(repoRoot, ".claude", "logs", "tool-usage.jsonl"),
|
|
270
288
|
sessionId,
|
|
289
|
+
sessionStart ?? undefined,
|
|
271
290
|
).catch(() => []),
|
|
272
291
|
readJsonlForSession<AgentActivityEntry>(
|
|
273
292
|
join(repoRoot, ".claude", "logs", "agent-activity.jsonl"),
|
|
274
293
|
sessionId,
|
|
294
|
+
sessionStart ?? undefined,
|
|
275
295
|
).catch(() => []),
|
|
276
296
|
]);
|
|
277
297
|
const mergedRuntime = mergeAssistantRuntime(
|
|
@@ -308,9 +328,7 @@ async function main() {
|
|
|
308
328
|
try {
|
|
309
329
|
// Load parent commit's minimap for carry-forward (parentSha fetched at top of main)
|
|
310
330
|
|
|
311
|
-
const prevMinimap = parentSha
|
|
312
|
-
? await readMinimap(repoRoot, parentSha)
|
|
313
|
-
: null;
|
|
331
|
+
const prevMinimap = parentSha ? await resolveMinimap(repoRoot, parentSha) : null;
|
|
314
332
|
|
|
315
333
|
// Build lookup: file path → Set of AI hashes from previous minimap
|
|
316
334
|
const prevAiByFile = new Map<string, Set<string>>();
|
|
@@ -92,7 +92,9 @@ export function hashLine(line: string): string {
|
|
|
92
92
|
* - HUMAN: everything else (existed before Claude, or written after Claude
|
|
93
93
|
* finished without a checkpoint)
|
|
94
94
|
*
|
|
95
|
-
*
|
|
95
|
+
* Blank lines follow the same hash-based rules as any other line. This means a
|
|
96
|
+
* newly introduced blank line is attributed as AI when it appears in `after`
|
|
97
|
+
* but not `before`, while pre-existing blank lines remain HUMAN.
|
|
96
98
|
*
|
|
97
99
|
* **Identical-line limitation**: This algorithm is set-based. If Claude and a
|
|
98
100
|
* human both write a line with the same trimmed content (e.g., a closing `}`),
|
|
@@ -120,9 +122,6 @@ export function attributeLines(
|
|
|
120
122
|
const attribution: LineAttribution[] = committedLines.map((line) => {
|
|
121
123
|
const hash = hashLine(line);
|
|
122
124
|
|
|
123
|
-
// Blank lines carry no signal
|
|
124
|
-
if (line.trim() === "") return "HUMAN";
|
|
125
|
-
|
|
126
125
|
if (afterHashes.has(hash) && !beforeHashes.has(hash)) {
|
|
127
126
|
return "AI";
|
|
128
127
|
}
|
|
@@ -323,7 +323,7 @@ function inferAiActorRuntime(meta: CommitMeta): AssistantRuntimeInfo | undefined
|
|
|
323
323
|
|
|
324
324
|
/**
|
|
325
325
|
* Build a 100% AI AttributionResult for a commit without running the
|
|
326
|
-
* checkpoint-based differ. All
|
|
326
|
+
* checkpoint-based differ. All committed lines are marked AI.
|
|
327
327
|
*
|
|
328
328
|
* Used by `note-ai-commit` (to write git notes in GHA) and by `collect.ts`
|
|
329
329
|
* (to synthesize attribution at metrics time for unattributed AI actor commits).
|
|
@@ -343,8 +343,8 @@ export async function buildAllAiResult(
|
|
|
343
343
|
const content = await committedContentAt(repoRoot, sha, relPath);
|
|
344
344
|
if (content === null || content.includes("\0")) return null;
|
|
345
345
|
const lines = content.split("\n");
|
|
346
|
-
const ai = lines.
|
|
347
|
-
const human =
|
|
346
|
+
const ai = lines.length;
|
|
347
|
+
const human = 0;
|
|
348
348
|
const total = lines.length;
|
|
349
349
|
return {
|
|
350
350
|
path: relPath,
|