claude-attribution 1.8.0 → 1.9.4
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 +115 -38
- package/package.json +2 -2
- package/src/__tests__/claude-projects.test.ts +42 -0
- package/src/__tests__/copilot-session.test.ts +141 -0
- package/src/__tests__/differ.test.ts +14 -2
- package/src/__tests__/git-notes.test.ts +28 -0
- package/src/__tests__/installed-repos.test.ts +2 -1
- package/src/__tests__/integration-helpers.ts +12 -1
- package/src/__tests__/integration.test.ts +396 -8
- package/src/__tests__/minimap-bulk.test.ts +17 -0
- package/src/__tests__/minimap.test.ts +7 -7
- package/src/__tests__/runtime.test.ts +12 -1
- package/src/__tests__/transcript.test.ts +61 -0
- package/src/attribution/commit.ts +77 -39
- package/src/attribution/differ.ts +76 -20
- package/src/attribution/git-notes.ts +35 -5
- package/src/attribution/minimap.ts +18 -5
- package/src/attribution/runtime.ts +8 -7
- package/src/metrics/claude-projects.ts +48 -0
- package/src/metrics/collect.ts +333 -75
- package/src/metrics/copilot-session.ts +383 -0
- package/src/metrics/local-session.ts +12 -0
- package/src/metrics/session-metrics.ts +22 -0
- package/src/metrics/transcript.ts +56 -17
- package/src/setup/installed-repos.ts +4 -4
- package/src/setup/uninstall.ts +7 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# claude-attribution
|
|
2
2
|
|
|
3
|
-
AI code attribution for Claude Code. After every `git commit`, a one-line summary appears in your terminal:
|
|
3
|
+
AI code attribution for Claude Code and GitHub Copilot. After every `git commit`, a one-line summary appears in your terminal:
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
[claude-attribution] a3f1b2c — 142 AI / 38 human / 4 mixed lines (77% AI)
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
When a PR is opened, full metrics — model usage, token counts, tool calls, and attribution percentages — are injected automatically into the PR body. No copy-paste, no manual tracking.
|
|
9
|
+
When a PR is opened, full metrics — model usage, token counts when available, tool calls, and attribution percentages — are injected automatically into the PR body. No copy-paste, no manual tracking.
|
|
10
10
|
|
|
11
11
|
**Quick start:**
|
|
12
12
|
|
|
@@ -19,13 +19,13 @@ git commit -m "chore: install claude-attribution hooks"
|
|
|
19
19
|
git push
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
**Using Copilot or `@claude` (claude-code-action)?**
|
|
22
|
+
**Using Copilot or `@claude` (claude-code-action)?** Local Copilot CLI sessions are parsed from `~/.copilot/session-state/...`, and bot commits are auto-detected as 100% AI when no local session data exists. See [Copilot CLI Support](#copilot-cli-support) and [AI Actor Attribution](#ai-actor-attribution-copilot-bot-claude-gha).
|
|
23
23
|
|
|
24
24
|
**Requirements:** [Bun](https://bun.sh) (preferred) or Node 18+, and `gh` (GitHub CLI) authenticated for the `/pr` command.
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
28
|
-
Measures which lines in a commit were written by
|
|
28
|
+
Measures which lines in a commit were written by an AI assistant vs. a human — using checkpoint snapshots and line-level SHA-256 comparison when available, with explicit fallbacks for Copilot CLI sessions and hosted AI bot commits.
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
@@ -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):**
|
|
@@ -188,7 +233,7 @@ If you reinstall `claude-attribution` globally (e.g. after upgrading), you can n
|
|
|
188
233
|
claude-attribution update
|
|
189
234
|
```
|
|
190
235
|
|
|
191
|
-
The installer records each repo in a per-user registry at `~/.claude/claude-attribution/installed-repos.json`. `update` re-runs the installer for every still-valid tracked repo, skips repos already on the current CLI version, and prunes paths that no longer exist or are no longer git repos.
|
|
236
|
+
The installer records each repo installed with `claude-attribution` v1.8.0 or later in a per-user registry at `~/.claude/claude-attribution/installed-repos.json`. `update` re-runs the installer for every still-valid tracked repo in that registry, skips repos already on the current CLI version, and prunes paths that no longer exist or are no longer git repos. Repos installed before v1.8.0 will not appear there until you run `claude-attribution install` for them once on the newer CLI.
|
|
192
237
|
|
|
193
238
|
To force a reinstall even when the recorded version already matches, use:
|
|
194
239
|
|
|
@@ -242,33 +287,43 @@ Metrics are injected automatically — no command needed.
|
|
|
242
287
|
|
|
243
288
|
**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.
|
|
244
289
|
|
|
245
|
-
**When you create the PR yourself** (`gh pr create`, GitHub UI, Copilot): the `claude-attribution-pr.yml` GitHub Actions workflow fires on `opened` and
|
|
290
|
+
**When you create the PR yourself** (`gh pr create`, GitHub UI, Copilot): the `claude-attribution-pr.yml` GitHub Actions workflow fires on `opened` and rebuilds metrics from durable git notes when available. If only CI-visible data exists, it falls back to attribution-only metrics because local session logs are not available in CI.
|
|
246
291
|
|
|
247
292
|
**On every new push to an open PR**: the workflow fires on `synchronize` and updates the attribution percentages to reflect new commits.
|
|
248
293
|
|
|
249
294
|
The metrics block injected into the PR body looks like (when the cumulative minimap exists):
|
|
250
295
|
|
|
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%
|
|
299
|
+
> **This PR:** 184 lines changed (4% of codebase) · 77% AI edits · 142 AI-attributed changed lines
|
|
300
|
+
> **Session:** 12 prompts · 24m total (18m AI · 6m human)
|
|
301
|
+
> **Assistant runtime:** Claude Code (claude-sonnet-4-6)
|
|
255
302
|
>
|
|
256
303
|
> | Model | Calls | Input | Output | Cache |
|
|
257
304
|
> |-------|-------|-------|--------|-------|
|
|
258
305
|
> | Sonnet | 45 | 120K | 35K | 10K |
|
|
259
306
|
> | **Total** | 45 | 120K | 35K | 10K |
|
|
260
307
|
>
|
|
261
|
-
> **
|
|
308
|
+
> **Estimated cost:** ~$1.23
|
|
262
309
|
>
|
|
263
310
|
> <details><summary>Tools · Agents · Files</summary>
|
|
264
311
|
>
|
|
265
|
-
> **
|
|
312
|
+
> **External tools:** WebSearch ×2
|
|
266
313
|
>
|
|
267
314
|
> </details>
|
|
268
315
|
|
|
269
316
|
Before running `init --ai` (or on a fresh install with no minimap), the headline falls back to the session-only view:
|
|
270
317
|
|
|
271
|
-
> **AI contribution: ~77%** (142 of 184 committed lines)
|
|
318
|
+
> **AI contribution: ~77%** (142 of 184 committed lines)
|
|
319
|
+
|
|
320
|
+
For **Copilot CLI** sessions, the same block is rendered with provider-aware differences:
|
|
321
|
+
|
|
322
|
+
- assistant runtime shows as `GitHub Copilot CLI`
|
|
323
|
+
- model usage shows **Known Tokens** instead of Claude-style input/output/cache columns
|
|
324
|
+
- cost is shown as **unavailable** unless durable local billing data exists
|
|
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.
|
|
272
327
|
|
|
273
328
|
The block is wrapped in HTML comments for idempotent updates — re-running replaces the existing block rather than appending:
|
|
274
329
|
|
|
@@ -301,31 +356,19 @@ claude-attribution metrics
|
|
|
301
356
|
The output is markdown you paste into your PR description:
|
|
302
357
|
|
|
303
358
|
```markdown
|
|
304
|
-
##
|
|
305
|
-
|
|
306
|
-
**Session ID:** `abc-123-...`
|
|
359
|
+
## AI Coding Metrics
|
|
307
360
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
361
|
+
**Codebase: ~77% AI** (3200 / 4150 lines)
|
|
362
|
+
**This PR:** 184 lines changed (4% of codebase) · 77% AI edits · 142 AI lines
|
|
363
|
+
**Session:** 12 prompts · 24m total (18m AI · 6m human)
|
|
364
|
+
**Assistant runtime:** GitHub Copilot CLI (v1.0.15 · gpt-5.4)
|
|
312
365
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
366
|
+
| Model | Calls | Known Tokens |
|
|
367
|
+
|-------|-------|--------------|
|
|
368
|
+
| gpt-5.4 | 12 | 48K |
|
|
369
|
+
| **Total** | 12 | 48K |
|
|
316
370
|
|
|
317
|
-
**
|
|
318
|
-
|
|
319
|
-
### Code Attribution
|
|
320
|
-
- **AI-authored lines:** 142
|
|
321
|
-
- **Human-authored lines:** 38
|
|
322
|
-
- **Mixed lines (AI wrote, human modified):** 4
|
|
323
|
-
- **Total committed lines:** 184
|
|
324
|
-
- **AI contribution:** ~77%
|
|
325
|
-
|
|
326
|
-
#### Files Modified by Claude
|
|
327
|
-
- `src/components/Foo.tsx` — 89% AI (82 lines)
|
|
328
|
-
- `src/hooks/useBar.ts` — 61% AI (44 lines)
|
|
371
|
+
**Estimated cost:** unavailable — Copilot session data does not expose enough local billing data to estimate spend reliably.
|
|
329
372
|
```
|
|
330
373
|
|
|
331
374
|
### Running with a specific session ID
|
|
@@ -336,7 +379,35 @@ If you have multiple sessions and want metrics for a specific one:
|
|
|
336
379
|
claude-attribution metrics <session-id>
|
|
337
380
|
```
|
|
338
381
|
|
|
339
|
-
|
|
382
|
+
Claude session IDs are shown in `.claude/logs/tool-usage.jsonl`. Copilot CLI session IDs are the directory names under `~/.copilot/session-state/`.
|
|
383
|
+
|
|
384
|
+
### Copilot CLI support
|
|
385
|
+
|
|
386
|
+
`claude-attribution` now supports **local GitHub Copilot CLI sessions** without adding a separate Copilot-specific install flow.
|
|
387
|
+
|
|
388
|
+
How it works:
|
|
389
|
+
|
|
390
|
+
1. The existing repo install still provides the post-commit hook and PR workflow.
|
|
391
|
+
2. On commit or `/metrics`, the tool first tries the normal Claude-local data sources.
|
|
392
|
+
3. If Claude session data is unavailable, it looks for a matching Copilot CLI session under `~/.copilot/session-state/<session-id>/events.jsonl`.
|
|
393
|
+
4. It matches sessions by repo path, branch, and recency, then extracts:
|
|
394
|
+
- prompt count
|
|
395
|
+
- AI vs human active time
|
|
396
|
+
- tool usage
|
|
397
|
+
- skill usage
|
|
398
|
+
- agent usage
|
|
399
|
+
- dominant model / known token counts
|
|
400
|
+
5. That normalized session data is attached to the git note so PR metrics can still be rebuilt later from durable note metadata.
|
|
401
|
+
|
|
402
|
+
Optional prompt markers:
|
|
403
|
+
|
|
404
|
+
- If your Copilot CLI workflow includes prompts beginning with `start work` and `create pr`, those are used as a tighter timing window.
|
|
405
|
+
- If not, the tool falls back to the broader repo/branch session window automatically.
|
|
406
|
+
|
|
407
|
+
Limitations:
|
|
408
|
+
|
|
409
|
+
- Copilot CLI local session-state currently does **not** expose enough durable billing data to estimate cost reliably, so cost is rendered as **unavailable**.
|
|
410
|
+
- Copilot CLI does not provide Claude-style file checkpoints, so attribution may fall back to commit/diff heuristics when no checkpoints exist.
|
|
340
411
|
|
|
341
412
|
### Checking raw attribution data
|
|
342
413
|
|
|
@@ -528,7 +599,7 @@ When no destination is configured, the workflow runs in **dry-run mode** — it
|
|
|
528
599
|
|
|
529
600
|
Set secrets at the org level so they apply to all repos that use this tool. Multiple export destinations can be active simultaneously (e.g. both `OTEL_EXPORTER_OTLP_ENDPOINT` and `METRICS_WEBHOOK_URL`).
|
|
530
601
|
|
|
531
|
-
**Model pricing (org variables — update when Anthropic changes pricing):**
|
|
602
|
+
**Model pricing (Claude-only org variables — update when Anthropic changes pricing):**
|
|
532
603
|
|
|
533
604
|
| Variable | Default | Description |
|
|
534
605
|
|----------|---------|-------------|
|
|
@@ -541,7 +612,7 @@ Set secrets at the org level so they apply to all repos that use this tool. Mult
|
|
|
541
612
|
| `CLAUDE_PRICE_CACHE_READ_MULT` | `0.1` | Fraction of input price for cache reads |
|
|
542
613
|
| `CLAUDE_PRICE_CACHE_WRITE_MULT` | `1.25` | Fraction of input price for cache writes |
|
|
543
614
|
|
|
544
|
-
|
|
615
|
+
These pricing variables apply to **Claude token-based cost estimation only**. Copilot metrics currently report cost as **unavailable** unless a future durable billing/token source is added. Unrecognized Claude model names fall back to Opus pricing. Set these as org-level **variables** (not secrets) — they're not sensitive.
|
|
545
616
|
|
|
546
617
|
|
|
547
618
|
---
|
|
@@ -562,7 +633,13 @@ When generating PR metrics, branch commits that have no git note are checked for
|
|
|
562
633
|
| `Co-authored-by:` trailer | `Co-authored-by: Claude <...>` |
|
|
563
634
|
| `Co-authored-by:` trailer | `Co-authored-by: GitHub Copilot <...>` |
|
|
564
635
|
|
|
565
|
-
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.
|
|
636
|
+
If detected, all non-blank committed lines are counted as AI in the metrics output and the assistant runtime is labeled as Copilot or Claude when that can be inferred. No git note is written — this is a metrics-time synthesis only.
|
|
637
|
+
|
|
638
|
+
This hosted/bot path is intentionally conservative:
|
|
639
|
+
|
|
640
|
+
- attribution: **heuristic but durable enough for PR metrics**
|
|
641
|
+
- assistant runtime: **best effort**
|
|
642
|
+
- time/cost: **not available** from current hosted signals
|
|
566
643
|
|
|
567
644
|
**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.
|
|
568
645
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-attribution",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "AI code attribution tracking for Claude Code
|
|
3
|
+
"version": "1.9.4",
|
|
4
|
+
"description": "AI code attribution tracking for Claude Code and GitHub Copilot sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"claude-attribution": "bin/claude-attribution"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { createTempContext } from "./integration-helpers.ts";
|
|
5
|
+
import {
|
|
6
|
+
claudeProjectKey,
|
|
7
|
+
resolveClaudeProjectDir,
|
|
8
|
+
} from "../metrics/claude-projects.ts";
|
|
9
|
+
|
|
10
|
+
describe("claude project directory resolution", () => {
|
|
11
|
+
test("sanitizes dotted repo paths the same way Claude transcripts do", () => {
|
|
12
|
+
expect(claudeProjectKey("/Users/alice.smith/Code/my-repo")).toBe(
|
|
13
|
+
"-Users-alice-smith-Code-my-repo",
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("resolves sanitized transcript directories", async () => {
|
|
18
|
+
const ctx = await createTempContext("claude-projects");
|
|
19
|
+
try {
|
|
20
|
+
const repo = "/Users/alice.smith/Code/my-repo";
|
|
21
|
+
const projectsRoot = join(ctx.home, ".claude", "projects");
|
|
22
|
+
const dir = join(projectsRoot, "-Users-alice-smith-Code-my-repo");
|
|
23
|
+
await mkdir(dir, { recursive: true });
|
|
24
|
+
expect(await resolveClaudeProjectDir(repo, projectsRoot)).toBe(dir);
|
|
25
|
+
} finally {
|
|
26
|
+
await ctx.cleanup();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("falls back to legacy slash-only keys for backward compatibility", async () => {
|
|
31
|
+
const ctx = await createTempContext("claude-projects-legacy");
|
|
32
|
+
try {
|
|
33
|
+
const repo = "/Users/alice/Code/my-repo";
|
|
34
|
+
const projectsRoot = join(ctx.home, ".claude", "projects");
|
|
35
|
+
const dir = join(projectsRoot, "-Users-alice-Code-my-repo");
|
|
36
|
+
await mkdir(dir, { recursive: true });
|
|
37
|
+
expect(await resolveClaudeProjectDir(repo, projectsRoot)).toBe(dir);
|
|
38
|
+
} finally {
|
|
39
|
+
await ctx.cleanup();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseCopilotSession, resolveCopilotSessionId } from "../metrics/copilot-session.ts";
|
|
3
|
+
import { createTempContext, writeCopilotSession } from "./integration-helpers.ts";
|
|
4
|
+
|
|
5
|
+
describe("copilot-session", () => {
|
|
6
|
+
test("parses local Copilot session metrics", async () => {
|
|
7
|
+
const ctx = await createTempContext("copilot-session");
|
|
8
|
+
const originalHome = process.env.HOME;
|
|
9
|
+
try {
|
|
10
|
+
process.env.HOME = ctx.home;
|
|
11
|
+
const sessionId = "copilot-session-1";
|
|
12
|
+
await writeCopilotSession(ctx.home, sessionId, [
|
|
13
|
+
{
|
|
14
|
+
type: "session.start",
|
|
15
|
+
timestamp: "2026-04-01T10:00:00.000Z",
|
|
16
|
+
data: {
|
|
17
|
+
context: {
|
|
18
|
+
cwd: ctx.repo,
|
|
19
|
+
gitRoot: ctx.repo,
|
|
20
|
+
branch: "feature/copilot",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
type: "user.message",
|
|
26
|
+
timestamp: "2026-04-01T10:00:00.000Z",
|
|
27
|
+
data: { content: "Investigate parser" },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
type: "tool.execution_start",
|
|
31
|
+
timestamp: "2026-04-01T10:00:05.000Z",
|
|
32
|
+
data: { toolName: "web_search" },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
type: "tool.execution_start",
|
|
36
|
+
timestamp: "2026-04-01T10:00:06.000Z",
|
|
37
|
+
data: { toolName: "skill", arguments: { skill: "pr" } },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
type: "tool.execution_complete",
|
|
41
|
+
timestamp: "2026-04-01T10:01:00.000Z",
|
|
42
|
+
data: { model: "gpt-5.4" },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: "assistant.message",
|
|
46
|
+
timestamp: "2026-04-01T10:01:00.000Z",
|
|
47
|
+
data: { outputTokens: 320 },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: "assistant.turn_end",
|
|
51
|
+
timestamp: "2026-04-01T10:01:00.000Z",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: "subagent.started",
|
|
55
|
+
timestamp: "2026-04-01T10:01:10.000Z",
|
|
56
|
+
data: { agentName: "ux-reviewer" },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: "subagent.completed",
|
|
60
|
+
timestamp: "2026-04-01T10:01:30.000Z",
|
|
61
|
+
data: { model: "gpt-5-mini", totalTokens: 80 },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: "user.message",
|
|
65
|
+
timestamp: "2026-04-01T10:02:00.000Z",
|
|
66
|
+
data: { content: "Create the PR" },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
type: "assistant.turn_end",
|
|
70
|
+
timestamp: "2026-04-01T10:03:00.000Z",
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const parsed = await parseCopilotSession(sessionId, ctx.repo);
|
|
75
|
+
|
|
76
|
+
expect(parsed).not.toBeNull();
|
|
77
|
+
expect(parsed?.provider).toBe("copilot");
|
|
78
|
+
expect(parsed?.humanPromptCount).toBe(2);
|
|
79
|
+
expect(parsed?.toolCounts).toEqual({ web_search: 1, skill: 1 });
|
|
80
|
+
expect(parsed?.skillNames).toEqual(["pr"]);
|
|
81
|
+
expect(parsed?.agentCounts).toEqual({ "ux-reviewer": 1 });
|
|
82
|
+
expect(parsed?.byModel).toEqual([
|
|
83
|
+
{
|
|
84
|
+
modelShort: "Unknown",
|
|
85
|
+
modelFull: "gpt-5-mini",
|
|
86
|
+
calls: 1,
|
|
87
|
+
inputTokens: 0,
|
|
88
|
+
outputTokens: 80,
|
|
89
|
+
cacheCreationTokens: 0,
|
|
90
|
+
cacheReadTokens: 0,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
modelShort: "Unknown",
|
|
94
|
+
modelFull: "gpt-5.4",
|
|
95
|
+
calls: 1,
|
|
96
|
+
inputTokens: 0,
|
|
97
|
+
outputTokens: 320,
|
|
98
|
+
cacheCreationTokens: 0,
|
|
99
|
+
cacheReadTokens: 0,
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
expect(parsed?.activeMinutes).toBe(3);
|
|
103
|
+
expect(parsed?.aiMinutes).toBe(2);
|
|
104
|
+
expect(parsed?.humanMinutes).toBe(1);
|
|
105
|
+
expect(parsed?.costMode).toBe("unavailable");
|
|
106
|
+
} finally {
|
|
107
|
+
process.env.HOME = originalHome;
|
|
108
|
+
await ctx.cleanup();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("resolves the best matching session for a repo and branch", async () => {
|
|
113
|
+
const ctx = await createTempContext("copilot-session-resolve");
|
|
114
|
+
const originalHome = process.env.HOME;
|
|
115
|
+
try {
|
|
116
|
+
process.env.HOME = ctx.home;
|
|
117
|
+
await writeCopilotSession(ctx.home, "older-session", [
|
|
118
|
+
{
|
|
119
|
+
type: "session.start",
|
|
120
|
+
timestamp: "2026-04-01T09:00:00.000Z",
|
|
121
|
+
data: { context: { gitRoot: ctx.repo, branch: "main" } },
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
await writeCopilotSession(ctx.home, "best-session", [
|
|
125
|
+
{
|
|
126
|
+
type: "session.start",
|
|
127
|
+
timestamp: "2026-04-01T10:00:00.000Z",
|
|
128
|
+
data: {
|
|
129
|
+
context: { gitRoot: ctx.repo, cwd: ctx.repo, branch: "feature/copilot" },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
const resolved = await resolveCopilotSessionId(ctx.repo, "feature/copilot");
|
|
135
|
+
expect(resolved).toBe("best-session");
|
|
136
|
+
} finally {
|
|
137
|
+
process.env.HOME = originalHome;
|
|
138
|
+
await ctx.cleanup();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -75,8 +75,8 @@ describe("attributeLines — basic classification", () => {
|
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
test("line not in after, committed file longer than after-snapshot → HUMAN", () => {
|
|
78
|
-
//
|
|
79
|
-
//
|
|
78
|
+
// Insertions are treated as HUMAN unless we can anchor them as one-for-one
|
|
79
|
+
// replacements of Claude-authored lines.
|
|
80
80
|
const before: string[] = [];
|
|
81
81
|
const after = ["const x = 1;"]; // only 1 line
|
|
82
82
|
// Human committed 2 lines — position 1 is beyond after-snapshot length
|
|
@@ -86,6 +86,18 @@ describe("attributeLines — basic classification", () => {
|
|
|
86
86
|
expect(attribution[1]).toBe("HUMAN"); // position 1 is beyond after-snapshot — no MIXED
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
test("human insertion above AI lines does not create false MIXED classifications", () => {
|
|
90
|
+
const before: string[] = [];
|
|
91
|
+
const after = ["const a = 1;", "const b = 2;"];
|
|
92
|
+
const committed = ["// intro", "const a = 1;", "const b = 2;"];
|
|
93
|
+
const { attribution, stats } = attributeLines(before, after, committed);
|
|
94
|
+
expect(attribution).toEqual(["HUMAN", "AI", "AI"]);
|
|
95
|
+
expect(stats.ai).toBe(2);
|
|
96
|
+
expect(stats.human).toBe(1);
|
|
97
|
+
expect(stats.mixed).toBe(0);
|
|
98
|
+
expect(stats.pctAi).toBe(67);
|
|
99
|
+
});
|
|
100
|
+
|
|
89
101
|
test("empty lines always → HUMAN regardless of snapshot", () => {
|
|
90
102
|
const before: string[] = [];
|
|
91
103
|
const after = ["", "const x = 1;", ""];
|
|
@@ -137,4 +137,32 @@ timestamp: new Date().toISOString(),
|
|
|
137
137
|
}),
|
|
138
138
|
).toBe(false);
|
|
139
139
|
});
|
|
140
|
+
|
|
141
|
+
test("buildAllAiResult tags hosted Copilot commits with assistant runtime", async () => {
|
|
142
|
+
const ctx = await createTempContext("claude-attribution-copilot-bot");
|
|
143
|
+
try {
|
|
144
|
+
await initGitRepo(ctx.repo);
|
|
145
|
+
await runCommand("git", ["config", "user.name", "copilot[bot]"], { cwd: ctx.repo });
|
|
146
|
+
await runCommand(
|
|
147
|
+
"git",
|
|
148
|
+
["config", "user.email", "000000+copilot[bot]@users.noreply.github.com"],
|
|
149
|
+
{ cwd: ctx.repo },
|
|
150
|
+
);
|
|
151
|
+
await writeFile(join(ctx.repo, "agent.ts"), "export const generated = true;\n");
|
|
152
|
+
await commitAll(
|
|
153
|
+
ctx.repo,
|
|
154
|
+
"feat: add hosted change\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>",
|
|
155
|
+
);
|
|
156
|
+
const sha = await currentSha(ctx.repo);
|
|
157
|
+
|
|
158
|
+
const result = await buildAllAiResult(ctx.repo, sha);
|
|
159
|
+
|
|
160
|
+
expect(result.assistantRuntime).toEqual({
|
|
161
|
+
vendor: "copilot",
|
|
162
|
+
client: "GitHub Copilot",
|
|
163
|
+
});
|
|
164
|
+
} finally {
|
|
165
|
+
await ctx.cleanup();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
140
168
|
});
|
|
@@ -34,8 +34,9 @@ describe("installed repo registry", () => {
|
|
|
34
34
|
"1.7.0",
|
|
35
35
|
);
|
|
36
36
|
|
|
37
|
-
await unregisterInstalledRepo(repo2, ctx.home);
|
|
37
|
+
expect(await unregisterInstalledRepo(repo2, ctx.home)).toBe(true);
|
|
38
38
|
expect(await readInstalledRepoRegistry(ctx.home)).toHaveLength(1);
|
|
39
|
+
expect(await unregisterInstalledRepo(repo2, ctx.home)).toBe(false);
|
|
39
40
|
|
|
40
41
|
await writeInstalledRepoRegistry(
|
|
41
42
|
[
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { tmpdir } from "os";
|
|
11
11
|
import { dirname, join, resolve } from "path";
|
|
12
12
|
import { promisify } from "util";
|
|
13
|
+
import { claudeProjectKey } from "../metrics/claude-projects.ts";
|
|
13
14
|
|
|
14
15
|
const execFileAsync = promisify(execFile);
|
|
15
16
|
|
|
@@ -113,7 +114,7 @@ export async function writeJsonl(
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
export function transcriptDirFor(home: string, repo: string): string {
|
|
116
|
-
return join(home, ".claude", "projects", repo
|
|
117
|
+
return join(home, ".claude", "projects", claudeProjectKey(repo));
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
export async function writeTranscript(
|
|
@@ -127,6 +128,16 @@ export async function writeTranscript(
|
|
|
127
128
|
await writeJsonl(join(dir, `${sessionId}.jsonl`), entries);
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
export async function writeCopilotSession(
|
|
132
|
+
home: string,
|
|
133
|
+
sessionId: string,
|
|
134
|
+
entries: unknown[],
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
const dir = join(home, ".copilot", "session-state", sessionId);
|
|
137
|
+
await mkdir(dir, { recursive: true });
|
|
138
|
+
await writeJsonl(join(dir, "events.jsonl"), entries);
|
|
139
|
+
}
|
|
140
|
+
|
|
130
141
|
export async function readJson(path: string): Promise<unknown> {
|
|
131
142
|
return JSON.parse(await readFile(path, "utf8"));
|
|
132
143
|
}
|