claude-attribution 1.6.0 → 1.8.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 CHANGED
@@ -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
- **`remote.origin.push` refspecs** — the installer adds two refspecs so that `git push` (without an explicit refspec) automatically includes both notes refs: `refs/notes/claude-attribution` (per-commit attribution) and `refs/notes/claude-attribution-map` (cumulative minimap). No pre-push hook is installed a hook that pushes notes concurrently with the main push causes SSH connection conflicts on GitHub.
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), re-run the installer it updates the absolute paths in `settings.json` and the git hook:
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 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.
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. The `remote.origin.push` refspec is also removed from git config.
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
 
@@ -353,7 +367,19 @@ Example output:
353
367
  "files": [
354
368
  { "path": "src/components/Foo.tsx", "ai": 82, "human": 10, "mixed": 2, "total": 94, "pctAi": 87 }
355
369
  ],
356
- "totals": { "ai": 142, "human": 38, "mixed": 4, "total": 184, "pctAi": 77 }
370
+ "totals": { "ai": 142, "human": 38, "mixed": 4, "total": 184, "pctAi": 77 },
371
+ "modelUsage": [
372
+ { "modelFull": "claude-sonnet-4.5", "modelShort": "Sonnet", "calls": 12, "inputTokens": 54000, "outputTokens": 9000, "cacheCreationTokens": 3200, "cacheReadTokens": 18000 }
373
+ ],
374
+ "sessionMetrics": {
375
+ "toolCounts": { "WebSearch": 2, "WebFetch": 1 },
376
+ "agentCounts": { "code-review": 1 },
377
+ "skillNames": ["pr"],
378
+ "humanPromptCount": 6,
379
+ "activeMinutes": 28,
380
+ "aiMinutes": 18,
381
+ "humanMinutes": 10
382
+ }
357
383
  }
358
384
  ```
359
385
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-attribution",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "AI code attribution tracking for Claude Code sessions — checkpoint-based line diff approach",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,140 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import {
5
+ buildAllAiResult,
6
+ committedContent,
7
+ committedContentAt,
8
+ currentBranch,
9
+ filesInCommit,
10
+ filesInCommitAt,
11
+ getBranchCommitShas,
12
+ getCommitMeta,
13
+ headSha,
14
+ isKnownAiActorCommit,
15
+ listNotes,
16
+ readNote,
17
+ writeNote,
18
+ } from "../attribution/git-notes.ts";
19
+ import {
20
+ commitAll,
21
+ createTempContext,
22
+ currentSha,
23
+ initGitRepo,
24
+ runCommand,
25
+ } from "./integration-helpers.ts";
26
+
27
+ describe("git-notes", () => {
28
+ test("reads repo metadata and writes attribution notes", async () => {
29
+ const ctx = await createTempContext("claude-attribution-git-notes");
30
+ try {
31
+ await initGitRepo(ctx.repo);
32
+ await writeFile(join(ctx.repo, "baseline.txt"), "baseline");
33
+ await commitAll(ctx.repo, "baseline");
34
+ const initialSha = await currentSha(ctx.repo);
35
+
36
+ await writeFile(join(ctx.repo, "ai.txt"), "alpha\n\nbeta");
37
+ await writeFile(
38
+ join(ctx.repo, "binary.bin"),
39
+ Buffer.from([0, 1, 2, 3]),
40
+ );
41
+ await commitAll(ctx.repo, "add AI-attributed files");
42
+ const sha = await currentSha(ctx.repo);
43
+
44
+ expect(await headSha(ctx.repo)).toBe(sha);
45
+ expect(await currentBranch(ctx.repo)).not.toBeNull();
46
+
47
+ const changedFiles = await filesInCommit(ctx.repo);
48
+ expect(changedFiles.includes("ai.txt")).toBe(true);
49
+ expect(changedFiles.includes("binary.bin")).toBe(true);
50
+
51
+ const changedFilesAtSha = await filesInCommitAt(ctx.repo, sha);
52
+ expect(changedFilesAtSha.includes("ai.txt")).toBe(true);
53
+ expect(changedFilesAtSha.includes("binary.bin")).toBe(true);
54
+
55
+ expect(await committedContent(ctx.repo, "ai.txt")).toBe("alpha\n\nbeta");
56
+ expect(await committedContent(ctx.repo, "missing.txt")).toBeNull();
57
+ expect(await committedContentAt(ctx.repo, sha, "ai.txt")).toBe(
58
+ "alpha\n\nbeta",
59
+ );
60
+ expect(await committedContentAt(ctx.repo, sha, "missing.txt")).toBeNull();
61
+
62
+ expect(await readNote(ctx.repo, sha)).toBeNull();
63
+ expect(await listNotes(ctx.repo)).toEqual([]);
64
+
65
+ const result = await buildAllAiResult(ctx.repo, sha);
66
+ expect(result.commit).toBe(sha);
67
+ expect(result.files).toHaveLength(1);
68
+ expect(result.files[0]).toMatchObject({
69
+ path: "ai.txt",
70
+ ai: 2,
71
+ human: 1,
72
+ total: 3,
73
+ pctAi: 67,
74
+ });
75
+ expect(result.totals).toMatchObject({ ai: 2, human: 1, total: 3, pctAi: 67 });
76
+
77
+ const meta = await getCommitMeta(ctx.repo, sha);
78
+ expect(meta.authorName).toBe("Test User");
79
+ expect(meta.authorEmail).toBe("test@example.com");
80
+ expect(meta.message).toBe("add AI-attributed files");
81
+ expect(meta.timestamp).toMatch(/T/);
82
+
83
+ await writeNote(result, ctx.repo, sha);
84
+ const stored = await readNote(ctx.repo, sha);
85
+ expect(stored?.totals).toEqual(result.totals);
86
+ expect(await listNotes(ctx.repo)).toEqual([sha]);
87
+
88
+ const branchShas = await getBranchCommitShas(ctx.repo);
89
+ expect(branchShas.includes(sha)).toBe(true);
90
+ expect(branchShas.includes(initialSha)).toBe(true);
91
+ } finally {
92
+ await ctx.cleanup();
93
+ }
94
+ });
95
+
96
+ test("returns null for detached HEAD branches", async () => {
97
+ const ctx = await createTempContext("claude-attribution-detached-head");
98
+ try {
99
+ await initGitRepo(ctx.repo);
100
+ await writeFile(join(ctx.repo, "file.txt"), "content");
101
+ await commitAll(ctx.repo, "initial");
102
+ const sha = await currentSha(ctx.repo);
103
+
104
+ await runCommand("git", ["checkout", sha], { cwd: ctx.repo });
105
+ expect(await currentBranch(ctx.repo)).toBeNull();
106
+ } finally {
107
+ await ctx.cleanup();
108
+ }
109
+ });
110
+
111
+ test("detects known AI actor commit metadata", () => {
112
+ expect(
113
+ isKnownAiActorCommit({
114
+ authorName: "github-actions[bot]",
115
+ authorEmail: "41898282+github-actions[bot]@users.noreply.github.com",
116
+ message: "Automated commit",
117
+ timestamp: new Date().toISOString(),
118
+ }),
119
+ ).toBe(true);
120
+
121
+ expect(
122
+ isKnownAiActorCommit({
123
+ authorName: "Test User",
124
+ authorEmail: "test@example.com",
125
+ message:
126
+ "feat: add change\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>",
127
+ timestamp: new Date().toISOString(),
128
+ }),
129
+ ).toBe(true);
130
+
131
+ expect(
132
+ isKnownAiActorCommit({
133
+ authorName: "Test User",
134
+ authorEmail: "test@example.com",
135
+ message: "Regular human commit",
136
+ timestamp: new Date().toISOString(),
137
+ }),
138
+ ).toBe(false);
139
+ });
140
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir } from "fs/promises";
3
+ import {
4
+ installedRepoRegistryPath,
5
+ pruneMissingInstalledRepos,
6
+ readInstalledRepoRegistry,
7
+ registerInstalledRepo,
8
+ unregisterInstalledRepo,
9
+ writeInstalledRepoRegistry,
10
+ } from "../setup/installed-repos.ts";
11
+ import { createTempContext, initGitRepo } from "./integration-helpers.ts";
12
+
13
+ describe("installed repo registry", () => {
14
+ test("registers, updates, prunes, and unregisters repos", async () => {
15
+ const ctx = await createTempContext("claude-attribution-installed-repos");
16
+ try {
17
+ const repo1 = ctx.repo;
18
+ const repo2 = `${ctx.root}/second-repo`;
19
+ await mkdir(repo2, { recursive: true });
20
+ await initGitRepo(repo1);
21
+ await initGitRepo(repo2);
22
+
23
+ await registerInstalledRepo(repo1, "1.6.0", ctx.home);
24
+ await registerInstalledRepo(repo2, "1.6.0", ctx.home);
25
+ await registerInstalledRepo(repo1, "1.7.0", ctx.home);
26
+
27
+ const registryPath = installedRepoRegistryPath(ctx.home);
28
+ expect(registryPath).toContain(".claude/claude-attribution/installed-repos.json");
29
+
30
+ const records = await readInstalledRepoRegistry(ctx.home);
31
+ expect(records).toHaveLength(2);
32
+ expect(records.map((record) => record.path)).toEqual([repo1, repo2].sort());
33
+ expect(records.find((record) => record.path === repo1)?.installedVersion).toBe(
34
+ "1.7.0",
35
+ );
36
+
37
+ await unregisterInstalledRepo(repo2, ctx.home);
38
+ expect(await readInstalledRepoRegistry(ctx.home)).toHaveLength(1);
39
+
40
+ await writeInstalledRepoRegistry(
41
+ [
42
+ {
43
+ path: repo1,
44
+ firstInstalledAt: new Date(0).toISOString(),
45
+ lastUpdatedAt: new Date().toISOString(),
46
+ installedVersion: "1.7.0",
47
+ },
48
+ {
49
+ path: `${ctx.root}/missing-repo`,
50
+ firstInstalledAt: new Date(0).toISOString(),
51
+ lastUpdatedAt: new Date().toISOString(),
52
+ installedVersion: "1.0.0",
53
+ },
54
+ ],
55
+ ctx.home,
56
+ );
57
+ const pruned = await pruneMissingInstalledRepos(ctx.home);
58
+ expect(pruned.kept).toHaveLength(1);
59
+ expect(pruned.removed).toHaveLength(1);
60
+ expect((await readInstalledRepoRegistry(ctx.home)).map((record) => record.path)).toEqual(
61
+ [repo1],
62
+ );
63
+ } finally {
64
+ await ctx.cleanup();
65
+ }
66
+ });
67
+ });
@@ -0,0 +1,132 @@
1
+ import { execFile } from "child_process";
2
+ import {
3
+ mkdir,
4
+ mkdtemp,
5
+ realpath,
6
+ readFile,
7
+ rm,
8
+ writeFile,
9
+ } from "fs/promises";
10
+ import { tmpdir } from "os";
11
+ import { dirname, join, resolve } from "path";
12
+ import { promisify } from "util";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ export const REPO_ROOT = resolve(import.meta.dir, "..", "..");
17
+ export const CLI_BIN = join(REPO_ROOT, "bin", "claude-attribution");
18
+
19
+ export interface CommandResult {
20
+ stdout: string;
21
+ stderr: string;
22
+ exitCode: number;
23
+ }
24
+
25
+ export interface TempTestContext {
26
+ root: string;
27
+ home: string;
28
+ repo: string;
29
+ cleanup: () => Promise<void>;
30
+ }
31
+
32
+ export async function runCommand(
33
+ command: string,
34
+ args: string[],
35
+ options: {
36
+ cwd?: string;
37
+ env?: NodeJS.ProcessEnv;
38
+ allowFailure?: boolean;
39
+ } = {},
40
+ ): Promise<CommandResult> {
41
+ try {
42
+ const result = await execFileAsync(command, args, {
43
+ cwd: options.cwd,
44
+ env: { ...process.env, ...options.env },
45
+ });
46
+ return {
47
+ stdout: result.stdout.trimEnd(),
48
+ stderr: result.stderr?.trimEnd() ?? "",
49
+ exitCode: 0,
50
+ };
51
+ } catch (error) {
52
+ const execError = error as Error & {
53
+ stdout?: string;
54
+ stderr?: string;
55
+ code?: number;
56
+ };
57
+ if (!options.allowFailure) throw error;
58
+ return {
59
+ stdout: execError.stdout?.trimEnd() ?? "",
60
+ stderr: execError.stderr?.trimEnd() ?? "",
61
+ exitCode: execError.code ?? 1,
62
+ };
63
+ }
64
+ }
65
+
66
+ export async function createTempContext(
67
+ prefix: string,
68
+ ): Promise<TempTestContext> {
69
+ const root = await realpath(await mkdtemp(join(tmpdir(), `${prefix}-`)));
70
+ const home = join(root, "home");
71
+ const repo = join(root, "repo");
72
+ await mkdir(home, { recursive: true });
73
+ await mkdir(join(home, ".claude", "projects"), { recursive: true });
74
+ await mkdir(repo, { recursive: true });
75
+ return {
76
+ root,
77
+ home,
78
+ repo,
79
+ cleanup: async () => {
80
+ await rm(root, { recursive: true, force: true });
81
+ },
82
+ };
83
+ }
84
+
85
+ export async function initGitRepo(repo: string): Promise<void> {
86
+ await runCommand("git", ["init"], { cwd: repo });
87
+ await configureGitIdentity(repo);
88
+ }
89
+
90
+ export async function configureGitIdentity(repo: string): Promise<void> {
91
+ await runCommand("git", ["config", "user.name", "Test User"], { cwd: repo });
92
+ await runCommand("git", ["config", "user.email", "test@example.com"], {
93
+ cwd: repo,
94
+ });
95
+ }
96
+
97
+ export async function commitAll(repo: string, message: string): Promise<void> {
98
+ await runCommand("git", ["add", "."], { cwd: repo });
99
+ await runCommand("git", ["commit", "-m", message], { cwd: repo });
100
+ }
101
+
102
+ export async function currentSha(repo: string): Promise<string> {
103
+ const result = await runCommand("git", ["rev-parse", "HEAD"], { cwd: repo });
104
+ return result.stdout.trim();
105
+ }
106
+
107
+ export async function writeJsonl(
108
+ path: string,
109
+ entries: unknown[],
110
+ ): Promise<void> {
111
+ await mkdir(dirname(path), { recursive: true });
112
+ await writeFile(path, entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n");
113
+ }
114
+
115
+ export function transcriptDirFor(home: string, repo: string): string {
116
+ return join(home, ".claude", "projects", repo.replace(/\//g, "-"));
117
+ }
118
+
119
+ export async function writeTranscript(
120
+ home: string,
121
+ repo: string,
122
+ sessionId: string,
123
+ entries: unknown[],
124
+ ): Promise<void> {
125
+ const dir = transcriptDirFor(home, repo);
126
+ await mkdir(dir, { recursive: true });
127
+ await writeJsonl(join(dir, `${sessionId}.jsonl`), entries);
128
+ }
129
+
130
+ export async function readJson(path: string): Promise<unknown> {
131
+ return JSON.parse(await readFile(path, "utf8"));
132
+ }