claude-attribution 1.6.0 → 1.9.0
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 +96 -40
- package/package.json +2 -2
- package/src/__tests__/bulk-update.test.ts +100 -0
- package/src/__tests__/checkpoint.test.ts +66 -0
- 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 +168 -0
- package/src/__tests__/installed-repos.test.ts +68 -0
- package/src/__tests__/integration-helpers.ts +143 -0
- package/src/__tests__/integration.test.ts +640 -0
- package/src/__tests__/minimap-bulk.test.ts +88 -0
- package/src/__tests__/notes-sync.test.ts +38 -0
- package/src/__tests__/runtime.test.ts +44 -0
- package/src/__tests__/session-metrics-helpers.test.ts +65 -0
- package/src/__tests__/session-metrics.test.ts +68 -0
- package/src/__tests__/transcript.test.ts +61 -0
- package/src/attribution/commit.ts +118 -21
- package/src/attribution/differ.ts +98 -20
- package/src/attribution/git-notes.ts +52 -1
- package/src/attribution/notes-sync.ts +113 -0
- package/src/attribution/runtime.ts +35 -0
- package/src/cli.ts +7 -3
- package/src/commands/note-ai-commit.ts +3 -12
- package/src/commands/update.ts +84 -0
- package/src/metrics/claude-projects.ts +48 -0
- package/src/metrics/collect.ts +288 -187
- package/src/metrics/copilot-session.ts +383 -0
- package/src/metrics/local-session.ts +12 -0
- package/src/metrics/session-metrics.ts +179 -0
- package/src/metrics/transcript.ts +56 -17
- package/src/setup/install.ts +18 -30
- package/src/setup/installed-repos.ts +142 -0
- package/src/setup/uninstall.ts +16 -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
|
|
|
@@ -135,7 +135,7 @@ The installer makes the following changes to the target repo:
|
|
|
135
135
|
|
|
136
136
|
**`.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.
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
**Best-effort notes sync** — after each commit, the post-commit hook writes the attribution notes locally and then tries to push `refs/notes/claude-attribution` and `refs/notes/claude-attribution-map` to `origin`. The commit note now carries durable session metadata too (model usage, notable tool counts, agent counts, skills, and session timing), so PR metrics can be rebuilt in CI even when local `.claude` logs and transcripts are unavailable. If a notes push is rejected because the remote ref moved first, it fetches the remote notes ref, runs `git notes merge`, and retries the push. No `pre-push` hook or `remote.origin.push` refspec is installed, so plain `git push` does not fail just because notes metadata raced elsewhere.
|
|
139
139
|
|
|
140
140
|
**`.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).
|
|
141
141
|
|
|
@@ -182,7 +182,21 @@ git push origin refs/notes/claude-attribution-map
|
|
|
182
182
|
|
|
183
183
|
### Re-installing
|
|
184
184
|
|
|
185
|
-
If you reinstall `claude-attribution` globally (e.g. after upgrading),
|
|
185
|
+
If you reinstall `claude-attribution` globally (e.g. after upgrading), you can now refresh every tracked repo in one shot:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
claude-attribution update
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
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
|
+
|
|
193
|
+
To force a reinstall even when the recorded version already matches, use:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
claude-attribution update --force
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
You can still re-run the installer manually for a single repo when needed:
|
|
186
200
|
|
|
187
201
|
```bash
|
|
188
202
|
claude-attribution install ~/Code/your-repo
|
|
@@ -196,7 +210,7 @@ To remove claude-attribution from a repo:
|
|
|
196
210
|
claude-attribution uninstall ~/Code/your-repo
|
|
197
211
|
```
|
|
198
212
|
|
|
199
|
-
This removes hooks from `.claude/settings.json`, removes `.git/hooks/post-commit`, removes the slash commands, removes `.github/workflows/claude-attribution-pr.yml`, `.github/workflows/claude-attribution-export.yml`, and `.github/workflows/claude-attribution-gha.yml` (if present), 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.
|
|
213
|
+
This removes hooks from `.claude/settings.json`, removes `.git/hooks/post-commit`, removes the slash commands, removes `.github/workflows/claude-attribution-pr.yml`, `.github/workflows/claude-attribution-export.yml`, and `.github/workflows/claude-attribution-gha.yml` (if present), 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. Any legacy `remote.origin.push` notes refspecs are also removed from git config. The repo is also removed from the global installed-repo registry used by `claude-attribution update`.
|
|
200
214
|
|
|
201
215
|
---
|
|
202
216
|
|
|
@@ -228,33 +242,41 @@ Metrics are injected automatically — no command needed.
|
|
|
228
242
|
|
|
229
243
|
**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.
|
|
230
244
|
|
|
231
|
-
**When you create the PR yourself** (`gh pr create`, GitHub UI, Copilot): the `claude-attribution-pr.yml` GitHub Actions workflow fires on `opened` and
|
|
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 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.
|
|
232
246
|
|
|
233
247
|
**On every new push to an open PR**: the workflow fires on `synchronize` and updates the attribution percentages to reflect new commits.
|
|
234
248
|
|
|
235
249
|
The metrics block injected into the PR body looks like (when the cumulative minimap exists):
|
|
236
250
|
|
|
237
|
-
> ##
|
|
251
|
+
> ## AI Coding Metrics
|
|
238
252
|
>
|
|
239
253
|
> **Codebase: ~77% AI** (3200 / 4150 lines)
|
|
240
|
-
> **This PR:** 184 lines changed (4% of codebase) · 77%
|
|
254
|
+
> **This PR:** 184 lines changed (4% of codebase) · 77% AI edits · 142 AI lines
|
|
255
|
+
> **Session:** 12 prompts · 24m total (18m AI · 6m human)
|
|
256
|
+
> **Assistant runtime:** Claude Code (claude-sonnet-4-6)
|
|
241
257
|
>
|
|
242
258
|
> | Model | Calls | Input | Output | Cache |
|
|
243
259
|
> |-------|-------|-------|--------|-------|
|
|
244
260
|
> | Sonnet | 45 | 120K | 35K | 10K |
|
|
245
261
|
> | **Total** | 45 | 120K | 35K | 10K |
|
|
246
262
|
>
|
|
247
|
-
> **
|
|
263
|
+
> **Estimated cost:** ~$1.23
|
|
248
264
|
>
|
|
249
265
|
> <details><summary>Tools · Agents · Files</summary>
|
|
250
266
|
>
|
|
251
|
-
> **
|
|
267
|
+
> **External tools:** WebSearch ×2
|
|
252
268
|
>
|
|
253
269
|
> </details>
|
|
254
270
|
|
|
255
271
|
Before running `init --ai` (or on a fresh install with no minimap), the headline falls back to the session-only view:
|
|
256
272
|
|
|
257
|
-
> **AI contribution: ~77%** (142 of 184 committed lines)
|
|
273
|
+
> **AI contribution: ~77%** (142 of 184 committed lines)
|
|
274
|
+
|
|
275
|
+
For **Copilot CLI** sessions, the same block is rendered with provider-aware differences:
|
|
276
|
+
|
|
277
|
+
- assistant runtime shows as `GitHub Copilot CLI`
|
|
278
|
+
- model usage shows **Known Tokens** instead of Claude-style input/output/cache columns
|
|
279
|
+
- cost is shown as **unavailable** unless durable local billing data exists
|
|
258
280
|
|
|
259
281
|
The block is wrapped in HTML comments for idempotent updates — re-running replaces the existing block rather than appending:
|
|
260
282
|
|
|
@@ -287,31 +309,19 @@ claude-attribution metrics
|
|
|
287
309
|
The output is markdown you paste into your PR description:
|
|
288
310
|
|
|
289
311
|
```markdown
|
|
290
|
-
##
|
|
291
|
-
|
|
292
|
-
**Session ID:** `abc-123-...`
|
|
293
|
-
|
|
294
|
-
### Tools Used
|
|
295
|
-
- **Edit:** 47 calls
|
|
296
|
-
- **Read:** 31 calls
|
|
297
|
-
...
|
|
312
|
+
## AI Coding Metrics
|
|
298
313
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
314
|
+
**Codebase: ~77% AI** (3200 / 4150 lines)
|
|
315
|
+
**This PR:** 184 lines changed (4% of codebase) · 77% AI edits · 142 AI lines
|
|
316
|
+
**Session:** 12 prompts · 24m total (18m AI · 6m human)
|
|
317
|
+
**Assistant runtime:** GitHub Copilot CLI (v1.0.15 · gpt-5.4)
|
|
302
318
|
|
|
303
|
-
|
|
319
|
+
| Model | Calls | Known Tokens |
|
|
320
|
+
|-------|-------|--------------|
|
|
321
|
+
| gpt-5.4 | 12 | 48K |
|
|
322
|
+
| **Total** | 12 | 48K |
|
|
304
323
|
|
|
305
|
-
|
|
306
|
-
- **AI-authored lines:** 142
|
|
307
|
-
- **Human-authored lines:** 38
|
|
308
|
-
- **Mixed lines (AI wrote, human modified):** 4
|
|
309
|
-
- **Total committed lines:** 184
|
|
310
|
-
- **AI contribution:** ~77%
|
|
311
|
-
|
|
312
|
-
#### Files Modified by Claude
|
|
313
|
-
- `src/components/Foo.tsx` — 89% AI (82 lines)
|
|
314
|
-
- `src/hooks/useBar.ts` — 61% AI (44 lines)
|
|
324
|
+
**Estimated cost:** unavailable — Copilot session data does not expose enough local billing data to estimate spend reliably.
|
|
315
325
|
```
|
|
316
326
|
|
|
317
327
|
### Running with a specific session ID
|
|
@@ -322,7 +332,35 @@ If you have multiple sessions and want metrics for a specific one:
|
|
|
322
332
|
claude-attribution metrics <session-id>
|
|
323
333
|
```
|
|
324
334
|
|
|
325
|
-
|
|
335
|
+
Claude session IDs are shown in `.claude/logs/tool-usage.jsonl`. Copilot CLI session IDs are the directory names under `~/.copilot/session-state/`.
|
|
336
|
+
|
|
337
|
+
### Copilot CLI support
|
|
338
|
+
|
|
339
|
+
`claude-attribution` now supports **local GitHub Copilot CLI sessions** without adding a separate Copilot-specific install flow.
|
|
340
|
+
|
|
341
|
+
How it works:
|
|
342
|
+
|
|
343
|
+
1. The existing repo install still provides the post-commit hook and PR workflow.
|
|
344
|
+
2. On commit or `/metrics`, the tool first tries the normal Claude-local data sources.
|
|
345
|
+
3. If Claude session data is unavailable, it looks for a matching Copilot CLI session under `~/.copilot/session-state/<session-id>/events.jsonl`.
|
|
346
|
+
4. It matches sessions by repo path, branch, and recency, then extracts:
|
|
347
|
+
- prompt count
|
|
348
|
+
- AI vs human active time
|
|
349
|
+
- tool usage
|
|
350
|
+
- skill usage
|
|
351
|
+
- agent usage
|
|
352
|
+
- dominant model / known token counts
|
|
353
|
+
5. That normalized session data is attached to the git note so PR metrics can still be rebuilt later from durable note metadata.
|
|
354
|
+
|
|
355
|
+
Optional prompt markers:
|
|
356
|
+
|
|
357
|
+
- If your Copilot CLI workflow includes prompts beginning with `start work` and `create pr`, those are used as a tighter timing window.
|
|
358
|
+
- If not, the tool falls back to the broader repo/branch session window automatically.
|
|
359
|
+
|
|
360
|
+
Limitations:
|
|
361
|
+
|
|
362
|
+
- Copilot CLI local session-state currently does **not** expose enough durable billing data to estimate cost reliably, so cost is rendered as **unavailable**.
|
|
363
|
+
- Copilot CLI does not provide Claude-style file checkpoints, so attribution may fall back to commit/diff heuristics when no checkpoints exist.
|
|
326
364
|
|
|
327
365
|
### Checking raw attribution data
|
|
328
366
|
|
|
@@ -353,7 +391,19 @@ Example output:
|
|
|
353
391
|
"files": [
|
|
354
392
|
{ "path": "src/components/Foo.tsx", "ai": 82, "human": 10, "mixed": 2, "total": 94, "pctAi": 87 }
|
|
355
393
|
],
|
|
356
|
-
"totals": { "ai": 142, "human": 38, "mixed": 4, "total": 184, "pctAi": 77 }
|
|
394
|
+
"totals": { "ai": 142, "human": 38, "mixed": 4, "total": 184, "pctAi": 77 },
|
|
395
|
+
"modelUsage": [
|
|
396
|
+
{ "modelFull": "claude-sonnet-4.5", "modelShort": "Sonnet", "calls": 12, "inputTokens": 54000, "outputTokens": 9000, "cacheCreationTokens": 3200, "cacheReadTokens": 18000 }
|
|
397
|
+
],
|
|
398
|
+
"sessionMetrics": {
|
|
399
|
+
"toolCounts": { "WebSearch": 2, "WebFetch": 1 },
|
|
400
|
+
"agentCounts": { "code-review": 1 },
|
|
401
|
+
"skillNames": ["pr"],
|
|
402
|
+
"humanPromptCount": 6,
|
|
403
|
+
"activeMinutes": 28,
|
|
404
|
+
"aiMinutes": 18,
|
|
405
|
+
"humanMinutes": 10
|
|
406
|
+
}
|
|
357
407
|
}
|
|
358
408
|
```
|
|
359
409
|
|
|
@@ -502,7 +552,7 @@ When no destination is configured, the workflow runs in **dry-run mode** — it
|
|
|
502
552
|
|
|
503
553
|
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`).
|
|
504
554
|
|
|
505
|
-
**Model pricing (org variables — update when Anthropic changes pricing):**
|
|
555
|
+
**Model pricing (Claude-only org variables — update when Anthropic changes pricing):**
|
|
506
556
|
|
|
507
557
|
| Variable | Default | Description |
|
|
508
558
|
|----------|---------|-------------|
|
|
@@ -515,7 +565,7 @@ Set secrets at the org level so they apply to all repos that use this tool. Mult
|
|
|
515
565
|
| `CLAUDE_PRICE_CACHE_READ_MULT` | `0.1` | Fraction of input price for cache reads |
|
|
516
566
|
| `CLAUDE_PRICE_CACHE_WRITE_MULT` | `1.25` | Fraction of input price for cache writes |
|
|
517
567
|
|
|
518
|
-
|
|
568
|
+
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.
|
|
519
569
|
|
|
520
570
|
|
|
521
571
|
---
|
|
@@ -536,7 +586,13 @@ When generating PR metrics, branch commits that have no git note are checked for
|
|
|
536
586
|
| `Co-authored-by:` trailer | `Co-authored-by: Claude <...>` |
|
|
537
587
|
| `Co-authored-by:` trailer | `Co-authored-by: GitHub Copilot <...>` |
|
|
538
588
|
|
|
539
|
-
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.
|
|
589
|
+
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.
|
|
590
|
+
|
|
591
|
+
This hosted/bot path is intentionally conservative:
|
|
592
|
+
|
|
593
|
+
- attribution: **heuristic but durable enough for PR metrics**
|
|
594
|
+
- assistant runtime: **best effort**
|
|
595
|
+
- time/cost: **not available** from current hosted signals
|
|
540
596
|
|
|
541
597
|
**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.
|
|
542
598
|
|
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.0",
|
|
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,100 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { readInstalledRepoRegistry } from "../setup/installed-repos.ts";
|
|
5
|
+
import {
|
|
6
|
+
CLI_BIN,
|
|
7
|
+
REPO_ROOT,
|
|
8
|
+
commitAll,
|
|
9
|
+
createTempContext,
|
|
10
|
+
initGitRepo,
|
|
11
|
+
runCommand,
|
|
12
|
+
} from "./integration-helpers.ts";
|
|
13
|
+
|
|
14
|
+
describe("bulk update command", () => {
|
|
15
|
+
test("updates tracked repos and prunes missing paths", async () => {
|
|
16
|
+
const ctx = await createTempContext("claude-attribution-bulk-update");
|
|
17
|
+
try {
|
|
18
|
+
const repoA = join(ctx.root, "repo-a");
|
|
19
|
+
const repoB = join(ctx.root, "repo-b");
|
|
20
|
+
await mkdir(repoA, { recursive: true });
|
|
21
|
+
await mkdir(repoB, { recursive: true });
|
|
22
|
+
await initGitRepo(repoA);
|
|
23
|
+
await initGitRepo(repoB);
|
|
24
|
+
await writeFile(join(repoA, "README.md"), "repo a\n");
|
|
25
|
+
await writeFile(join(repoB, "README.md"), "repo b\n");
|
|
26
|
+
await commitAll(repoA, "initial");
|
|
27
|
+
await commitAll(repoB, "initial");
|
|
28
|
+
|
|
29
|
+
await runCommand(CLI_BIN, ["install", repoA], {
|
|
30
|
+
cwd: repoA,
|
|
31
|
+
env: { HOME: ctx.home },
|
|
32
|
+
});
|
|
33
|
+
await runCommand(CLI_BIN, ["install", repoB], {
|
|
34
|
+
cwd: repoB,
|
|
35
|
+
env: { HOME: ctx.home },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await writeFile(
|
|
39
|
+
join(repoA, ".claude", "attribution-state", "installed-version"),
|
|
40
|
+
"0.9.0",
|
|
41
|
+
);
|
|
42
|
+
await writeFile(
|
|
43
|
+
join(repoB, ".claude", "attribution-state", "installed-version"),
|
|
44
|
+
"0.9.0",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const registryPath = join(
|
|
48
|
+
ctx.home,
|
|
49
|
+
".claude",
|
|
50
|
+
"claude-attribution",
|
|
51
|
+
"installed-repos.json",
|
|
52
|
+
);
|
|
53
|
+
const registry = JSON.parse(await readFile(registryPath, "utf8")) as Array<{
|
|
54
|
+
path: string;
|
|
55
|
+
firstInstalledAt: string;
|
|
56
|
+
lastUpdatedAt: string;
|
|
57
|
+
installedVersion?: string;
|
|
58
|
+
}>;
|
|
59
|
+
registry.push({
|
|
60
|
+
path: join(ctx.root, "missing-repo"),
|
|
61
|
+
firstInstalledAt: new Date(0).toISOString(),
|
|
62
|
+
lastUpdatedAt: new Date(0).toISOString(),
|
|
63
|
+
installedVersion: "0.1.0",
|
|
64
|
+
});
|
|
65
|
+
await writeFile(registryPath, JSON.stringify(registry, null, 2) + "\n");
|
|
66
|
+
|
|
67
|
+
const update = await runCommand(CLI_BIN, ["update"], {
|
|
68
|
+
cwd: ctx.repo,
|
|
69
|
+
env: { HOME: ctx.home },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(update.stdout).toContain("Pruned missing repo");
|
|
73
|
+
expect(update.stdout).toContain("Updated:");
|
|
74
|
+
expect(update.stdout).toContain("Summary: 2 updated, 0 already current, 1 pruned, 0 failed");
|
|
75
|
+
|
|
76
|
+
const packageVersion = JSON.parse(
|
|
77
|
+
await readFile(join(REPO_ROOT, "package.json"), "utf8"),
|
|
78
|
+
) as { version: string };
|
|
79
|
+
expect(
|
|
80
|
+
(await readFile(
|
|
81
|
+
join(repoA, ".claude", "attribution-state", "installed-version"),
|
|
82
|
+
"utf8",
|
|
83
|
+
)).trim(),
|
|
84
|
+
).toBe(packageVersion.version);
|
|
85
|
+
expect(
|
|
86
|
+
(await readFile(
|
|
87
|
+
join(repoB, ".claude", "attribution-state", "installed-version"),
|
|
88
|
+
"utf8",
|
|
89
|
+
)).trim(),
|
|
90
|
+
).toBe(packageVersion.version);
|
|
91
|
+
|
|
92
|
+
const tracked = await readInstalledRepoRegistry(ctx.home);
|
|
93
|
+
expect(tracked.map((record) => record.path).sort()).toEqual(
|
|
94
|
+
[repoA, repoB].sort(),
|
|
95
|
+
);
|
|
96
|
+
} finally {
|
|
97
|
+
await ctx.cleanup();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import {
|
|
5
|
+
clearCheckpoints,
|
|
6
|
+
loadCheckpoint,
|
|
7
|
+
readCurrentSession,
|
|
8
|
+
saveCheckpoint,
|
|
9
|
+
validateSessionId,
|
|
10
|
+
writeCurrentSession,
|
|
11
|
+
} from "../attribution/checkpoint.ts";
|
|
12
|
+
import { createTempContext } from "./integration-helpers.ts";
|
|
13
|
+
|
|
14
|
+
describe("checkpoint", () => {
|
|
15
|
+
test("saves, loads, and clears checkpoints", async () => {
|
|
16
|
+
const ctx = await createTempContext("claude-attribution-checkpoint");
|
|
17
|
+
try {
|
|
18
|
+
const sessionId = "session-123";
|
|
19
|
+
const filePath = join(ctx.repo, "src", "file.ts");
|
|
20
|
+
await mkdir(join(ctx.repo, "src"), { recursive: true });
|
|
21
|
+
await writeFile(filePath, "before\nafter");
|
|
22
|
+
|
|
23
|
+
await saveCheckpoint(sessionId, filePath, "before");
|
|
24
|
+
const before = await loadCheckpoint(sessionId, filePath, "before");
|
|
25
|
+
expect(before?.filePath).toBe(filePath);
|
|
26
|
+
expect(before?.lines).toEqual(["before", "after"]);
|
|
27
|
+
expect(before?.timestamp).toMatch(/T/);
|
|
28
|
+
|
|
29
|
+
await writeFile(filePath, "revised");
|
|
30
|
+
await saveCheckpoint(sessionId, filePath, "after");
|
|
31
|
+
const after = await loadCheckpoint(sessionId, filePath, "after");
|
|
32
|
+
expect(after?.lines).toEqual(["revised"]);
|
|
33
|
+
|
|
34
|
+
await clearCheckpoints(sessionId);
|
|
35
|
+
expect(await loadCheckpoint(sessionId, filePath, "before")).toBeNull();
|
|
36
|
+
expect(await loadCheckpoint(sessionId, filePath, "after")).toBeNull();
|
|
37
|
+
} finally {
|
|
38
|
+
await ctx.cleanup();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("handles missing files and current session markers", async () => {
|
|
43
|
+
const ctx = await createTempContext("claude-attribution-session-marker");
|
|
44
|
+
try {
|
|
45
|
+
const sessionId = "session-abc";
|
|
46
|
+
const missingFile = join(ctx.repo, "missing.ts");
|
|
47
|
+
|
|
48
|
+
expect(await readCurrentSession(ctx.repo)).toBeNull();
|
|
49
|
+
await writeCurrentSession(ctx.repo, sessionId);
|
|
50
|
+
expect(await readCurrentSession(ctx.repo)).toBe(sessionId);
|
|
51
|
+
|
|
52
|
+
await saveCheckpoint(sessionId, missingFile, "before");
|
|
53
|
+
const checkpoint = await loadCheckpoint(sessionId, missingFile, "before");
|
|
54
|
+
expect(checkpoint?.lines).toEqual([""]);
|
|
55
|
+
|
|
56
|
+
await clearCheckpoints("missing-session");
|
|
57
|
+
} finally {
|
|
58
|
+
await ctx.cleanup();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("rejects invalid session identifiers", () => {
|
|
63
|
+
expect(() => validateSessionId("ok_session-1")).not.toThrow();
|
|
64
|
+
expect(() => validateSessionId("../escape")).toThrow(/Invalid session_id/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -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;", ""];
|