@susu-eng/trunk-sync 2.3.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.
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "trunk-sync",
3
+ "owner": {
4
+ "name": "Eli Mydlarz"
5
+ },
6
+ "metadata": {
7
+ "description": "trunk-sync plugin marketplace",
8
+ "version": "1.0.0"
9
+ },
10
+ "plugins": [
11
+ {
12
+ "name": "trunk-sync",
13
+ "source": {
14
+ "source": "github",
15
+ "repo": "elimydlarz/trunk-sync"
16
+ },
17
+ "description": "Auto-commit and push every file edit, keeping multiple agents in sync on trunk",
18
+ "author": {
19
+ "name": "Eli Mydlarz"
20
+ },
21
+ "repository": "https://github.com/elimydlarz/trunk-sync",
22
+ "license": "MIT",
23
+ "keywords": ["hooks", "git", "multi-agent", "sync", "trunk-based-development"]
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "trunk-sync",
3
+ "version": "1.0.0",
4
+ "description": "Maximum continuous integration for multi-agent coding — every edit is committed and pushed to trunk immediately",
5
+ "author": {
6
+ "name": "Eli Mydlarz"
7
+ },
8
+ "repository": "https://github.com/elimydlarz/trunk-sync",
9
+ "license": "MIT",
10
+ "keywords": ["hooks", "git", "multi-agent", "sync", "trunk-based-development"]
11
+ }
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # trunk-sync
2
+
3
+ Run multiple Claude Code agents on the same repo without breaking each other's work, and understand any line of generated code on demand.
4
+
5
+ ## Trunk-Sync — maximum continuous integration for coding agents
6
+
7
+ Every file edit is committed and pushed to `origin/main` automatically. Agents work in parallel — on local worktrees, across remote machines, any mix — with agentic conflict resolution. No more wasted time resolving conflicts by hand, remembering to commit, or discovering that an agent never pushed its work.
8
+
9
+ ## Seance — talk to dead coding agents
10
+
11
+ Point at any line of code, and seance rewinds the codebase and the Claude session back to the exact moment that line was written. Ask the agent what it was thinking. Understand generated code easily, on demand — stop worrying about keeping up with every change your agents make.
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ npm install -g trunk-sync
17
+ trunk-sync install
18
+ ```
19
+
20
+ This installs the plugin at **project scope** (active in the current repo only). To install at **user scope** (active in all repos):
21
+
22
+ ```bash
23
+ trunk-sync install --scope user
24
+ ```
25
+
26
+ ### Prerequisites
27
+
28
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI
29
+ - `jq` on the machine running Claude Code
30
+ - A git repo with a remote (`origin`)
31
+
32
+ ### Scopes
33
+
34
+ | Scope | Config location | Effect |
35
+ |-------|----------------|--------|
36
+ | `project` (default) | `.claude/plugins.json` | Active in this repo only — committed to git so collaborators get it too |
37
+ | `user` | `~/.claude/plugins.json` | Active in all repos for this user |
38
+
39
+ ## Using Trunk-Sync
40
+
41
+ ```bash
42
+ claude -w # each invocation gets its own worktree
43
+ ```
44
+
45
+ Launch as many agents as you need. They all push to the same trunk. After every `Edit` or `Write`, trunk-sync commits, pulls, and pushes — automatically. If two agents edit the same file, trunk-sync tells the agent to resolve the conflict by editing the file normally. No git commands, no manual merging.
46
+
47
+ ## Using Seance
48
+
49
+ ```bash
50
+ # Rewind and resume the session that wrote line 42
51
+ trunk-sync seance src/main.ts:42
52
+
53
+ # Just show which session wrote it, without launching Claude
54
+ trunk-sync seance src/main.ts:42 --inspect
55
+
56
+ # List all trunk-sync sessions in the repo
57
+ trunk-sync seance --list
58
+ ```
59
+
60
+ Seance traces `git blame` back to the commit, rewinds the session transcript to that point, checks out the code at that commit, and resumes Claude with the same context it had when it wrote the line. The resumed agent is read-only — it explains and explores but cannot edit.
61
+
62
+ ## Transcript commits
63
+
64
+ By default, seance finds session transcripts on the local filesystem (`~/.claude/projects/<slug>/<sessionId>.jsonl`). This works when you're tracing code written on the same machine, but the transcript won't exist if the code was written by an agent on a different machine, in CI, or if the local transcript has been cleaned up.
65
+
66
+ **Enable transcript commits** to solve this — each auto-commit will include a snapshot of the session transcript in `.transcripts/`, so the conversation travels with the code in git history:
67
+
68
+ ```bash
69
+ trunk-sync config commit-transcripts true
70
+ ```
71
+
72
+ With this enabled, seance can find the transcript directly in the commit via `git diff-tree`, regardless of which machine wrote the code. This is the recommended setup for teams and multi-machine workflows.
73
+
74
+ To disable:
75
+
76
+ ```bash
77
+ trunk-sync config commit-transcripts false
78
+ ```
79
+
80
+ ### Security note
81
+
82
+ Transcripts contain your full conversation with Claude, which may include sensitive context, proprietary code discussions, or credentials you pasted into the chat. With `commit-transcripts=true`, these are committed to git — meaning anyone with repo access can read them. Encryption of snapshots before commit is a likely future addition, which would let transcripts hitch a ride on git without being readable in the clear. For now, only enable this on repos where you're comfortable with transcript visibility, or where access is already restricted.
83
+
84
+ ## For humans
85
+
86
+ Developer docs, architecture, and testing: [.humans/README.md](.humans/README.md)
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { configCommand } from "./commands/config.js";
4
+ import { installCommand } from "./commands/install.js";
5
+ import { seanceCommand } from "./commands/seance.js";
6
+ const require = createRequire(import.meta.url);
7
+ const pkg = require("../package.json");
8
+ const USAGE = `trunk-sync v${pkg.version}
9
+
10
+ Usage: trunk-sync <command> [options]
11
+
12
+ Commands:
13
+ install Install the trunk-sync Claude Code plugin
14
+ seance Find which Claude session wrote a line of code
15
+ config Read or write trunk-sync configuration
16
+
17
+ Options:
18
+ --version Show version
19
+ -h, --help Show this help message`;
20
+ const command = process.argv[2];
21
+ if (!command || command === "--help" || command === "-h") {
22
+ console.log(USAGE);
23
+ process.exit(0);
24
+ }
25
+ if (command === "--version") {
26
+ console.log(pkg.version);
27
+ process.exit(0);
28
+ }
29
+ const subArgs = process.argv.slice(3);
30
+ switch (command) {
31
+ case "install":
32
+ installCommand(subArgs);
33
+ break;
34
+ case "seance":
35
+ seanceCommand(subArgs);
36
+ break;
37
+ case "config":
38
+ configCommand(subArgs);
39
+ break;
40
+ default:
41
+ console.error(`Unknown command: ${command}\n`);
42
+ console.log(USAGE);
43
+ process.exit(1);
44
+ }
@@ -0,0 +1,4 @@
1
+ export declare function configPath(): string;
2
+ export declare function readConfig(): Map<string, string>;
3
+ export declare function writeConfig(map: Map<string, string>): void;
4
+ export declare function configCommand(args: string[]): void;
@@ -0,0 +1,85 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ const USAGE = `Usage: trunk-sync config Show all config
5
+ trunk-sync config <key>=<value> Set a value
6
+ trunk-sync config --unset <key> Remove a key
7
+
8
+ Config file: ~/.trunk-sync (key=value format)`;
9
+ export function configPath() {
10
+ return join(homedir(), ".trunk-sync");
11
+ }
12
+ export function readConfig() {
13
+ const map = new Map();
14
+ let content;
15
+ try {
16
+ content = readFileSync(configPath(), "utf-8");
17
+ }
18
+ catch {
19
+ return map;
20
+ }
21
+ for (const line of content.split("\n")) {
22
+ const trimmed = line.trim();
23
+ if (!trimmed || trimmed.startsWith("#"))
24
+ continue;
25
+ const eq = trimmed.indexOf("=");
26
+ if (eq === -1)
27
+ continue;
28
+ map.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
29
+ }
30
+ return map;
31
+ }
32
+ export function writeConfig(map) {
33
+ const lines = [];
34
+ for (const [key, value] of map) {
35
+ lines.push(`${key}=${value}`);
36
+ }
37
+ writeFileSync(configPath(), lines.join("\n") + "\n");
38
+ }
39
+ export function configCommand(args) {
40
+ if (args.includes("--help") || args.includes("-h")) {
41
+ console.log(USAGE);
42
+ return;
43
+ }
44
+ const unsetIndex = args.indexOf("--unset");
45
+ if (unsetIndex !== -1) {
46
+ const key = args[unsetIndex + 1];
47
+ if (!key) {
48
+ console.error("Usage: trunk-sync config --unset <key>");
49
+ process.exit(1);
50
+ }
51
+ const map = readConfig();
52
+ if (!map.has(key)) {
53
+ console.error(`Key not found: ${key}`);
54
+ process.exit(1);
55
+ }
56
+ map.delete(key);
57
+ writeConfig(map);
58
+ console.log(`Unset ${key}`);
59
+ return;
60
+ }
61
+ const positional = args.filter((a) => !a.startsWith("--"));
62
+ if (positional.length === 0) {
63
+ const map = readConfig();
64
+ if (map.size === 0) {
65
+ console.log("No config set. Config file: ~/.trunk-sync");
66
+ return;
67
+ }
68
+ for (const [key, value] of map) {
69
+ console.log(`${key}=${value}`);
70
+ }
71
+ return;
72
+ }
73
+ const arg = positional[0];
74
+ const eq = arg.indexOf("=");
75
+ if (eq === -1) {
76
+ console.error(`Expected key=value, got: ${arg}`);
77
+ process.exit(1);
78
+ }
79
+ const key = arg.slice(0, eq);
80
+ const value = arg.slice(eq + 1);
81
+ const map = readConfig();
82
+ map.set(key, value);
83
+ writeConfig(map);
84
+ console.log(`Set ${key}=${value}`);
85
+ }
@@ -0,0 +1 @@
1
+ export declare function installCommand(args: string[]): void;
@@ -0,0 +1,66 @@
1
+ import { execSync } from "node:child_process";
2
+ import { getGitRoot, commandExists } from "../lib/git.js";
3
+ export function installCommand(args) {
4
+ if (args.includes("--help") || args.includes("-h")) {
5
+ console.log(`Usage: trunk-sync install [--scope user|project]
6
+
7
+ Installs the trunk-sync Claude Code plugin.
8
+
9
+ Options:
10
+ --scope <scope> Installation scope: "project" (default) or "user"
11
+ project — active in this repo only (.claude/plugins.json)
12
+ user — active in all repos (~/.claude/plugins.json)
13
+ -h, --help Show this help message`);
14
+ return;
15
+ }
16
+ const scopeIdx = args.indexOf("--scope");
17
+ const scope = scopeIdx !== -1 ? args[scopeIdx + 1] : "project";
18
+ if (scope !== "project" && scope !== "user") {
19
+ console.error(`Invalid scope: ${scope}. Must be "project" or "user".`);
20
+ process.exit(1);
21
+ }
22
+ // Precondition checks (git repo and remote are soft warnings — trunk-sync
23
+ // works without them, just with reduced functionality)
24
+ if (!getGitRoot()) {
25
+ console.warn("Warning: not inside a git repository. trunk-sync needs git to auto-commit and sync.");
26
+ }
27
+ else {
28
+ try {
29
+ execSync("git remote get-url origin", { stdio: "ignore" });
30
+ }
31
+ catch {
32
+ // No remote is fine — hook will commit locally and skip pushing
33
+ }
34
+ }
35
+ if (!commandExists("jq")) {
36
+ console.error("jq is required. Install: brew install jq / apt install jq");
37
+ process.exit(1);
38
+ }
39
+ if (!commandExists("claude")) {
40
+ console.error("Claude Code CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code");
41
+ process.exit(1);
42
+ }
43
+ // Add GitHub repo as a marketplace source
44
+ console.log("Adding trunk-sync marketplace...");
45
+ try {
46
+ execSync(`claude plugin marketplace add elimydlarz/trunk-sync --scope ${scope}`, { stdio: "inherit" });
47
+ }
48
+ catch {
49
+ // May already be added — continue to install
50
+ }
51
+ // Install the plugin from the marketplace
52
+ console.log(`Installing trunk-sync plugin (scope: ${scope})...`);
53
+ try {
54
+ execSync(`claude plugin install trunk-sync@trunk-sync --scope ${scope}`, {
55
+ stdio: "inherit",
56
+ });
57
+ }
58
+ catch {
59
+ console.error("Plugin installation failed.");
60
+ process.exit(1);
61
+ }
62
+ console.log(`\ntrunk-sync installed successfully (scope: ${scope}).
63
+
64
+ Every file edit will now auto-commit and sync to the remote.
65
+ Works on main, on branches, or in worktrees (claude -w).`);
66
+ }
@@ -0,0 +1 @@
1
+ export declare function seanceCommand(args: string[]): void;
@@ -0,0 +1,228 @@
1
+ import { execSync, spawnSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join, relative, resolve } from "node:path";
4
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs";
5
+ import { parseFileRef, blame, getCommitBody, getCommitSubject, getCommitDate, getCommitTimestamp, extractSessionId, findSnapshotInCommit, commandExists, shortSha, getGitRoot, } from "../lib/git.js";
6
+ const USAGE = `Usage: trunk-sync seance <file:line> [--inspect]
7
+ trunk-sync seance --list
8
+
9
+ Find which Claude session wrote a line of code and fork that session.
10
+
11
+ Arguments:
12
+ <file:line> File path and line number, e.g. src/main.ts:42
13
+
14
+ Options:
15
+ --inspect Show commit and session info without launching Claude
16
+ --list List all trunk-sync sessions found in git history
17
+ -h, --help Show this help message`;
18
+ function listSessions() {
19
+ let output;
20
+ try {
21
+ output = execSync('git log --format="%H" --grep="^auto("', {
22
+ encoding: "utf-8",
23
+ }).trim();
24
+ }
25
+ catch {
26
+ output = "";
27
+ }
28
+ if (!output) {
29
+ console.log("No trunk-sync sessions found in git history.");
30
+ return;
31
+ }
32
+ const shas = output.split("\n");
33
+ const seen = new Map();
34
+ for (const sha of shas) {
35
+ const body = getCommitBody(sha);
36
+ const sessionId = extractSessionId(body);
37
+ if (sessionId && !seen.has(sessionId)) {
38
+ seen.set(sessionId, {
39
+ sha,
40
+ subject: getCommitSubject(sha),
41
+ date: getCommitDate(sha),
42
+ });
43
+ }
44
+ }
45
+ if (seen.size === 0) {
46
+ console.log("No trunk-sync sessions found in git history.");
47
+ return;
48
+ }
49
+ console.log("SESSION_ID SUBJECT DATE");
50
+ console.log("─".repeat(100));
51
+ for (const [sessionId, { subject, date }] of seen) {
52
+ const truncSubject = subject.length > 48 ? subject.slice(0, 45) + "..." : subject;
53
+ const shortDate = date.slice(0, 19);
54
+ console.log(`${sessionId.padEnd(38)}${truncSubject.padEnd(50)}${shortDate}`);
55
+ }
56
+ }
57
+ /**
58
+ * Derive the Claude project slug for a given directory path.
59
+ * Claude replaces "/" and "." with "-" in the absolute path.
60
+ */
61
+ function projectSlug(dirPath) {
62
+ return dirPath.replace(/[/.]/g, "-");
63
+ }
64
+ /**
65
+ * Create a truncated copy of a session transcript, containing only messages
66
+ * up to the given timestamp. The copy is written into the worktree's project
67
+ * directory so Claude can find it via --resume.
68
+ */
69
+ function rewindTranscript(transcriptPath, commitTimestamp, worktreePath) {
70
+ const expandedPath = transcriptPath.replace(/^~/, process.env.HOME || "~");
71
+ if (!existsSync(expandedPath))
72
+ return null;
73
+ const cutoff = new Date(commitTimestamp).getTime();
74
+ const lines = readFileSync(expandedPath, "utf-8").split("\n").filter(Boolean);
75
+ // Find the last line whose timestamp is <= the commit timestamp.
76
+ let cutIndex = -1;
77
+ for (let i = 0; i < lines.length; i++) {
78
+ try {
79
+ const obj = JSON.parse(lines[i]);
80
+ const ts = obj.timestamp;
81
+ if (ts && new Date(ts).getTime() <= cutoff) {
82
+ cutIndex = i;
83
+ }
84
+ }
85
+ catch {
86
+ // Non-JSON lines (shouldn't happen) — include them if before cutoff
87
+ }
88
+ }
89
+ if (cutIndex < 0)
90
+ return null;
91
+ const newId = randomUUID();
92
+ // Write into the project directory Claude will use for the worktree.
93
+ const home = process.env.HOME || "~";
94
+ const projectDir = join(home, ".claude", "projects", projectSlug(worktreePath));
95
+ mkdirSync(projectDir, { recursive: true });
96
+ const newPath = join(projectDir, `${newId}.jsonl`);
97
+ // Rewrite sessionId and cwd so Claude recognises this as a valid session.
98
+ const rewritten = lines.slice(0, cutIndex + 1).map((line) => {
99
+ try {
100
+ const obj = JSON.parse(line);
101
+ if (obj.sessionId)
102
+ obj.sessionId = newId;
103
+ if (obj.cwd)
104
+ obj.cwd = worktreePath;
105
+ return JSON.stringify(obj);
106
+ }
107
+ catch {
108
+ return line;
109
+ }
110
+ });
111
+ writeFileSync(newPath, rewritten.join("\n") + "\n");
112
+ return { path: newPath, id: newId };
113
+ }
114
+ function inspectOrLaunch(fileRef, inspect) {
115
+ const { file, line } = parseFileRef(fileRef);
116
+ const sha = blame(file, line);
117
+ if (/^0+$/.test(sha)) {
118
+ console.error(`Line ${line} has uncommitted changes.`);
119
+ process.exit(1);
120
+ }
121
+ const body = getCommitBody(sha);
122
+ const sessionId = extractSessionId(body);
123
+ const subject = getCommitSubject(sha);
124
+ if (!sessionId) {
125
+ console.error(`Commit ${shortSha(sha)} was not created by trunk-sync.`);
126
+ process.exit(1);
127
+ }
128
+ if (inspect) {
129
+ console.log(`Commit: ${sha}`);
130
+ console.log(`Subject: ${subject}`);
131
+ console.log(`Session: ${sessionId}`);
132
+ return;
133
+ }
134
+ if (!commandExists("claude")) {
135
+ console.error("Claude Code CLI not found.");
136
+ process.exit(1);
137
+ }
138
+ const root = getGitRoot();
139
+ if (!root) {
140
+ console.error("Not in a git repository.");
141
+ process.exit(1);
142
+ }
143
+ const worktreePath = join(root, ".claude", "worktrees", `seance-${shortSha(sha)}`);
144
+ try {
145
+ execSync(`git worktree add --detach "${worktreePath}" "${sha}"`, { stdio: "pipe" });
146
+ }
147
+ catch {
148
+ console.error(`Failed to create worktree at ${sha}.`);
149
+ process.exit(1);
150
+ }
151
+ const relFile = relative(root, resolve(file));
152
+ const prompt = `*STOP*. *HALT ALL PREVIOUS OPERATIONS AND STOP IMMEDIATELY*. *DO NOT CONTINUE YOUR CURRENT CHAIN OF THOUGHT*. This session already ended. It has been resumed and rewound — including the code — so you can answer questions about why it was written this way. *DO NOT* change any code. Start by explaining ${relFile}:${line} (commit ${shortSha(sha)}) — what does it do, how does it work, and why is it written this way?`;
153
+ // Rewind the session transcript to the commit point.
154
+ // Try snapshot from .transcripts/ in the commit first, fall back to Transcript: field.
155
+ const snapshotRelPath = findSnapshotInCommit(sha);
156
+ let transcriptSource = null;
157
+ if (snapshotRelPath) {
158
+ const snapshotAbsPath = join(root, snapshotRelPath);
159
+ if (existsSync(snapshotAbsPath)) {
160
+ transcriptSource = snapshotAbsPath;
161
+ }
162
+ }
163
+ if (!transcriptSource) {
164
+ const home = process.env.HOME || "~";
165
+ const derived = join(home, ".claude", "projects", projectSlug(root), `${sessionId}.jsonl`);
166
+ if (existsSync(derived)) {
167
+ transcriptSource = derived;
168
+ }
169
+ }
170
+ if (!transcriptSource) {
171
+ console.error(`Commit ${shortSha(sha)} has no transcript (no .transcripts/ snapshot and no transcript at derived path).`);
172
+ process.exit(1);
173
+ }
174
+ const commitTimestamp = getCommitTimestamp(sha);
175
+ const rewound = rewindTranscript(transcriptSource, commitTimestamp, worktreePath);
176
+ if (!rewound) {
177
+ console.error(`Could not rewind transcript for commit ${shortSha(sha)}.`);
178
+ process.exit(1);
179
+ }
180
+ console.log(`Rewound session to commit ${shortSha(sha)} (${commitTimestamp})`);
181
+ console.log(`Forking session ${sessionId} (from commit ${shortSha(sha)}: ${subject})`);
182
+ console.log(`Worktree at ${worktreePath}`);
183
+ const readOnlyTools = "Read,Grep,Glob,Bash(git:*),Agent,WebSearch,WebFetch";
184
+ const systemPrompt = "You are in SEANCE MODE — a read-only forensic session. You MUST NOT edit, write, or create any files. " +
185
+ "Your only job is to explain the code: what it does, how it works, and why it was written this way. " +
186
+ "You do not have access to Edit, Write, or NotebookEdit tools.";
187
+ const args = [
188
+ "--resume", rewound.id,
189
+ "--allowedTools", readOnlyTools,
190
+ "--append-system-prompt", systemPrompt,
191
+ prompt,
192
+ ];
193
+ const result = spawnSync("claude", args, {
194
+ stdio: "inherit",
195
+ cwd: worktreePath,
196
+ });
197
+ // Clean up the rewound transcript file
198
+ if (rewound) {
199
+ try {
200
+ unlinkSync(rewound.path);
201
+ }
202
+ catch { /* best-effort */ }
203
+ }
204
+ try {
205
+ execSync(`git worktree remove "${worktreePath}"`, { stdio: "pipe" });
206
+ }
207
+ catch {
208
+ console.error(`Note: worktree left at ${worktreePath} — remove with: git worktree remove "${worktreePath}"`);
209
+ }
210
+ process.exit(result.status ?? 1);
211
+ }
212
+ export function seanceCommand(args) {
213
+ if (args.includes("--help") || args.includes("-h")) {
214
+ console.log(USAGE);
215
+ return;
216
+ }
217
+ if (args.includes("--list")) {
218
+ listSessions();
219
+ return;
220
+ }
221
+ const positional = args.filter((a) => !a.startsWith("--"));
222
+ if (positional.length === 0) {
223
+ console.log(USAGE);
224
+ return;
225
+ }
226
+ const inspect = args.includes("--inspect");
227
+ inspectOrLaunch(positional[0], inspect);
228
+ }
@@ -0,0 +1,14 @@
1
+ export declare function getGitRoot(): string | null;
2
+ export declare function parseFileRef(ref: string): {
3
+ file: string;
4
+ line: number;
5
+ };
6
+ export declare function blame(file: string, line: number, cwd?: string): string;
7
+ export declare function getCommitBody(sha: string, cwd?: string): string;
8
+ export declare function getCommitSubject(sha: string, cwd?: string): string;
9
+ export declare function getCommitDate(sha: string, cwd?: string): string;
10
+ export declare function extractSessionId(body: string): string | null;
11
+ export declare function getCommitTimestamp(sha: string, cwd?: string): string;
12
+ export declare function commandExists(cmd: string): boolean;
13
+ export declare function shortSha(sha: string): string;
14
+ export declare function findSnapshotInCommit(sha: string, cwd?: string): string | null;
@@ -0,0 +1,71 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ export function getGitRoot() {
4
+ try {
5
+ return execSync("git rev-parse --show-toplevel", { encoding: "utf-8" }).trim();
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
11
+ export function parseFileRef(ref) {
12
+ const lastColon = ref.lastIndexOf(":");
13
+ if (lastColon === -1) {
14
+ throw new Error(`Expected file:line, e.g. src/main.ts:42`);
15
+ }
16
+ const file = ref.slice(0, lastColon);
17
+ const lineStr = ref.slice(lastColon + 1);
18
+ const line = Number(lineStr);
19
+ if (!Number.isInteger(line) || line < 1) {
20
+ throw new Error(`Expected file:line with a positive integer line number, got: ${ref}`);
21
+ }
22
+ if (!existsSync(file)) {
23
+ throw new Error(`File not found: ${file}`);
24
+ }
25
+ return { file, line };
26
+ }
27
+ export function blame(file, line, cwd) {
28
+ const output = execSync(`git blame "${file}" -L ${line},${line} --porcelain`, {
29
+ encoding: "utf-8",
30
+ cwd,
31
+ });
32
+ const sha = output.split("\n")[0].split(" ")[0];
33
+ return sha;
34
+ }
35
+ export function getCommitBody(sha, cwd) {
36
+ return execSync(`git log -1 --format=%b "${sha}"`, { encoding: "utf-8", cwd }).trim();
37
+ }
38
+ export function getCommitSubject(sha, cwd) {
39
+ return execSync(`git log -1 --format=%s "${sha}"`, { encoding: "utf-8", cwd }).trim();
40
+ }
41
+ export function getCommitDate(sha, cwd) {
42
+ return execSync(`git log -1 --format=%ci "${sha}"`, { encoding: "utf-8", cwd }).trim();
43
+ }
44
+ export function extractSessionId(body) {
45
+ const match = body.match(/^Session:\s*(.+)/m);
46
+ return match ? match[1].trim() : null;
47
+ }
48
+ export function getCommitTimestamp(sha, cwd) {
49
+ return execSync(`git log -1 --format=%cI "${sha}"`, { encoding: "utf-8", cwd }).trim();
50
+ }
51
+ export function commandExists(cmd) {
52
+ try {
53
+ execSync(`command -v "${cmd}"`, { stdio: "ignore" });
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ export function shortSha(sha) {
61
+ return sha.slice(0, 8);
62
+ }
63
+ export function findSnapshotInCommit(sha, cwd) {
64
+ try {
65
+ const output = execSync(`git diff-tree --root --no-commit-id --name-only -r "${sha}" -- .transcripts/`, { encoding: "utf-8", cwd }).trim();
66
+ return output || null;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseHookInput, planHook } from "./hook-plan.js";
3
+ import { gatherRepoState, executePlan } from "./hook-execute.js";
4
+ function main() {
5
+ let rawInput = "";
6
+ try {
7
+ rawInput = readFileSync(0, "utf-8");
8
+ }
9
+ catch {
10
+ // no stdin
11
+ }
12
+ const input = parseHookInput(rawInput || "{}");
13
+ const state = gatherRepoState(input);
14
+ // Not in a git repo — no-op
15
+ if (!state)
16
+ process.exit(0);
17
+ const plan = planHook(input, state);
18
+ const result = executePlan(plan, input, state);
19
+ if (result.stderr) {
20
+ process.stderr.write(result.stderr + "\n");
21
+ }
22
+ process.exit(result.exitCode);
23
+ }
24
+ main();