@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,15 @@
1
+ import type { HookInput, RepoState, HookPlan } from "./hook-types.js";
2
+ /**
3
+ * Gather the current git repo state needed for planning.
4
+ * Runs git commands — this is the I/O boundary.
5
+ */
6
+ export declare function gatherRepoState(input: HookInput): RepoState | null;
7
+ /**
8
+ * Execute a hook plan: stage files, commit, sync.
9
+ * Returns exit code and optional stderr for agent feedback.
10
+ */
11
+ export declare function executePlan(plan: HookPlan, input: HookInput, state: RepoState): {
12
+ exitCode: number;
13
+ stderr?: string;
14
+ };
15
+ export declare function findWorktreeForBranch(porcelainOutput: string, branch: string): string | null;
@@ -0,0 +1,311 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readFileSync, realpathSync, mkdirSync, copyFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { readConfig } from "../commands/config.js";
6
+ import { HOOK_EXPLAINER } from "./hook-types.js";
7
+ import { extractTaskFromTranscript, buildCommitPlanWithTask } from "./hook-plan.js";
8
+ /**
9
+ * Gather the current git repo state needed for planning.
10
+ * Runs git commands — this is the I/O boundary.
11
+ */
12
+ export function gatherRepoState(input) {
13
+ const filePath = input.tool_input.file_path ?? null;
14
+ let repoRoot;
15
+ try {
16
+ repoRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf-8" }).trim();
17
+ }
18
+ catch {
19
+ return null; // not in a git repo
20
+ }
21
+ const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf-8" }).trim();
22
+ let insideRepo = true;
23
+ let gitignored = false;
24
+ let relPath = null;
25
+ if (filePath) {
26
+ // Resolve symlinks so /var/... matches /private/var/... on macOS
27
+ const resolvedFile = existsSync(filePath) ? realpathSync(filePath) : filePath;
28
+ insideRepo = resolvedFile.startsWith(repoRoot + "/");
29
+ if (insideRepo) {
30
+ relPath = resolvedFile.slice(repoRoot.length + 1);
31
+ try {
32
+ execSync(`git check-ignore -q -- "${filePath}"`, { stdio: "ignore" });
33
+ gitignored = true;
34
+ }
35
+ catch {
36
+ gitignored = false;
37
+ }
38
+ }
39
+ }
40
+ let hasRemote = true;
41
+ try {
42
+ execSync("git remote get-url origin", { stdio: "ignore" });
43
+ }
44
+ catch {
45
+ hasRemote = false;
46
+ }
47
+ let targetBranch = "";
48
+ if (hasRemote) {
49
+ try {
50
+ const ref = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
51
+ encoding: "utf-8",
52
+ }).trim();
53
+ targetBranch = ref.replace("refs/remotes/origin/", "");
54
+ }
55
+ catch {
56
+ targetBranch = "main";
57
+ }
58
+ }
59
+ let currentBranch = "";
60
+ try {
61
+ currentBranch = execSync("git symbolic-ref --short HEAD", { encoding: "utf-8" }).trim();
62
+ }
63
+ catch {
64
+ // detached HEAD
65
+ }
66
+ const inMerge = existsSync(join(gitDir, "MERGE_HEAD"));
67
+ let hasStagedChanges = false;
68
+ try {
69
+ execSync("git diff --cached --quiet", { stdio: "ignore" });
70
+ }
71
+ catch {
72
+ hasStagedChanges = true;
73
+ }
74
+ let deletedFiles = [];
75
+ if (!filePath) {
76
+ try {
77
+ const deleted = execSync(`git -C "${repoRoot}" ls-files --deleted`, {
78
+ encoding: "utf-8",
79
+ }).trim();
80
+ if (deleted)
81
+ deletedFiles = deleted.split("\n");
82
+ }
83
+ catch {
84
+ // ignore
85
+ }
86
+ }
87
+ return {
88
+ repoRoot,
89
+ gitDir,
90
+ relPath,
91
+ insideRepo,
92
+ gitignored,
93
+ hasRemote,
94
+ targetBranch,
95
+ currentBranch,
96
+ inMerge,
97
+ hasStagedChanges,
98
+ deletedFiles,
99
+ };
100
+ }
101
+ /**
102
+ * Execute a hook plan: stage files, commit, sync.
103
+ * Returns exit code and optional stderr for agent feedback.
104
+ */
105
+ export function executePlan(plan, input, state) {
106
+ if (plan.action === "skip")
107
+ return { exitCode: 0 };
108
+ if (plan.action === "commit-merge") {
109
+ // Stage the file if provided
110
+ const filePath = input.tool_input.file_path;
111
+ if (filePath) {
112
+ execSync(`git add -- "${filePath}"`);
113
+ }
114
+ try {
115
+ execSync(`git commit -m "${escapeForShell(plan.message)}"`);
116
+ }
117
+ catch (e) {
118
+ // Let git's exit code pass through (e.g. 128 for unresolved merge paths)
119
+ const code = getExitCode(e);
120
+ return { exitCode: code, stderr: getStdout(e) };
121
+ }
122
+ if (plan.sync)
123
+ return executeSync(plan.sync);
124
+ return { exitCode: 0 };
125
+ }
126
+ // commit-and-sync
127
+ const { commit, sync } = plan;
128
+ // Stage deletions
129
+ for (const file of commit.filesToRemove) {
130
+ try {
131
+ execSync(`git -C "${state.repoRoot}" rm --cached --quiet -- "${file}"`, {
132
+ stdio: "ignore",
133
+ });
134
+ }
135
+ catch {
136
+ // ignore
137
+ }
138
+ }
139
+ // Stage file edits
140
+ for (const file of commit.filesToStage) {
141
+ execSync(`git add -- "${file}"`);
142
+ }
143
+ // Check if there's anything staged (may have been a no-op)
144
+ try {
145
+ execSync("git diff --cached --quiet", { stdio: "ignore" });
146
+ return { exitCode: 0 }; // nothing to commit
147
+ }
148
+ catch {
149
+ // has staged changes — continue
150
+ }
151
+ // Try to enrich commit message with task from transcript
152
+ let finalCommit = commit;
153
+ if (input.transcript_path) {
154
+ const expanded = input.transcript_path.replace(/^~/, homedir());
155
+ try {
156
+ const content = readFileSync(expanded, "utf-8");
157
+ const task = extractTaskFromTranscript(content);
158
+ if (task) {
159
+ finalCommit = buildCommitPlanWithTask(input, state, task);
160
+ }
161
+ }
162
+ catch {
163
+ // best-effort
164
+ }
165
+ }
166
+ // Commit
167
+ if (finalCommit.body) {
168
+ execSync(`git commit -m "${escapeForShell(finalCommit.subject)}" -m "${escapeForShell(finalCommit.body)}"`);
169
+ }
170
+ else {
171
+ execSync(`git commit -m "${escapeForShell(finalCommit.subject)}"`);
172
+ }
173
+ // Snapshot transcript into the commit (opt-in via config)
174
+ amendWithTranscriptSnapshot(input, state);
175
+ if (sync)
176
+ return executeSync(sync);
177
+ return { exitCode: 0 };
178
+ }
179
+ function amendWithTranscriptSnapshot(input, state) {
180
+ try {
181
+ const config = readConfig();
182
+ if (config.get("commit-transcripts") !== "true")
183
+ return;
184
+ if (!input.transcript_path || !input.session_id)
185
+ return;
186
+ const expanded = input.transcript_path.replace(/^~/, homedir());
187
+ if (!existsSync(expanded))
188
+ return;
189
+ const snapshotDir = join(state.repoRoot, ".transcripts");
190
+ mkdirSync(snapshotDir, { recursive: true });
191
+ const shortSession = input.session_id.slice(0, 8);
192
+ const epoch = Math.floor(Date.now() / 1000);
193
+ const snapshotName = `${shortSession}-${epoch}.jsonl`;
194
+ copyFileSync(expanded, join(snapshotDir, snapshotName));
195
+ execSync(`git add -- "${snapshotDir}"`, { cwd: state.repoRoot });
196
+ execSync(`git commit --amend --no-edit`, { cwd: state.repoRoot });
197
+ }
198
+ catch {
199
+ // best-effort — don't fail the hook if snapshot fails
200
+ }
201
+ }
202
+ function executeSync(sync) {
203
+ const { targetBranch, currentBranch } = sync;
204
+ // Pull from origin
205
+ try {
206
+ execSync(`git pull origin "${targetBranch}" --no-rebase 2>&1`, { encoding: "utf-8" });
207
+ }
208
+ catch (e) {
209
+ return conflictExit(getStdout(e), targetBranch);
210
+ }
211
+ // Merge local target branch into worktree branch
212
+ if (currentBranch && currentBranch !== targetBranch) {
213
+ try {
214
+ execSync(`git merge "${targetBranch}" --no-edit 2>&1`, { encoding: "utf-8" });
215
+ }
216
+ catch (e) {
217
+ return conflictExit(getStdout(e), targetBranch);
218
+ }
219
+ }
220
+ // Push, retry once on failure
221
+ try {
222
+ execSync(`git push origin "HEAD:${targetBranch}" 2>&1`, { encoding: "utf-8" });
223
+ }
224
+ catch {
225
+ // Retry: pull then push
226
+ try {
227
+ execSync(`git pull origin "${targetBranch}" --no-rebase 2>&1`, { encoding: "utf-8" });
228
+ }
229
+ catch (e) {
230
+ return conflictExit(getStdout(e), targetBranch);
231
+ }
232
+ try {
233
+ execSync(`git push origin "HEAD:${targetBranch}" 2>&1`, { encoding: "utf-8" });
234
+ }
235
+ catch (e) {
236
+ return pushExit(getStdout(e), targetBranch);
237
+ }
238
+ }
239
+ // Keep local target branch in sync
240
+ try {
241
+ execSync(`git fetch origin "${targetBranch}:${targetBranch}" 2>/dev/null`);
242
+ }
243
+ catch {
244
+ // If fetch fails (branch checked out), try ff-merge in the worktree
245
+ try {
246
+ const wtOutput = execSync(`git worktree list --porcelain`, { encoding: "utf-8" });
247
+ const mainWt = findWorktreeForBranch(wtOutput, targetBranch);
248
+ if (mainWt) {
249
+ try {
250
+ execSync(`git -C "${mainWt}" merge --ff-only "origin/${targetBranch}" 2>/dev/null`);
251
+ }
252
+ catch {
253
+ // best-effort
254
+ }
255
+ }
256
+ }
257
+ catch {
258
+ // ignore
259
+ }
260
+ }
261
+ return { exitCode: 0 };
262
+ }
263
+ export function findWorktreeForBranch(porcelainOutput, branch) {
264
+ const blocks = porcelainOutput.split("\n\n");
265
+ for (const block of blocks) {
266
+ const lines = block.split("\n");
267
+ let worktreePath = "";
268
+ let branchRef = "";
269
+ for (const line of lines) {
270
+ if (line.startsWith("worktree "))
271
+ worktreePath = line.slice(9);
272
+ if (line.startsWith("branch "))
273
+ branchRef = line.slice(7);
274
+ }
275
+ if (branchRef === `refs/heads/${branch}` && worktreePath) {
276
+ return worktreePath;
277
+ }
278
+ }
279
+ return null;
280
+ }
281
+ function conflictExit(output, targetBranch) {
282
+ return {
283
+ exitCode: 2,
284
+ stderr: `TRUNK-SYNC CONFLICT: ${HOOK_EXPLAINER} Another agent changed the same file, creating a merge conflict. The file now contains git conflict markers (<<<<<<< / ======= / >>>>>>>).\n\ngit output:\n${output}\n\nTo resolve: just read the conflicting file and edit it to the correct content (remove the conflict markers). This hook will detect the merge state and complete the sync automatically.`,
285
+ };
286
+ }
287
+ function pushExit(output, targetBranch) {
288
+ return {
289
+ exitCode: 2,
290
+ stderr: `TRUNK-SYNC FAILED: ${HOOK_EXPLAINER} The push to remote failed.\n\ngit output:\n${output}\n\nTo resolve: run "git pull origin ${targetBranch} --no-rebase" then "git push origin HEAD:${targetBranch}". If there are conflicts, read the conflicting files and edit them to remove the conflict markers — the hook will complete the sync on your next edit.`,
291
+ };
292
+ }
293
+ function escapeForShell(s) {
294
+ return s.replace(/"/g, '\\"');
295
+ }
296
+ function getExitCode(e) {
297
+ if (typeof e === "object" && e !== null && "status" in e) {
298
+ const status = e.status;
299
+ if (typeof status === "number")
300
+ return status;
301
+ }
302
+ return 1;
303
+ }
304
+ function getStdout(e) {
305
+ if (typeof e === "object" && e !== null && "stdout" in e) {
306
+ return String(e.stdout);
307
+ }
308
+ if (e instanceof Error)
309
+ return e.message;
310
+ return String(e);
311
+ }
@@ -0,0 +1,26 @@
1
+ import type { HookInput, RepoState, HookPlan, CommitPlan } from "./hook-types.js";
2
+ /**
3
+ * Parse the raw JSON string from hook stdin into a typed HookInput.
4
+ */
5
+ export declare function parseHookInput(json: string): HookInput;
6
+ /**
7
+ * Pure decision logic: given parsed input and repo state, decide what to do.
8
+ * No I/O, no git commands — only data in, plan out.
9
+ */
10
+ export declare function planHook(input: HookInput, state: RepoState): HookPlan;
11
+ /**
12
+ * Build a commit plan with a task-based subject (when transcript extraction succeeds).
13
+ */
14
+ export declare function buildCommitPlanWithTask(input: HookInput, state: RepoState, task: string | null): CommitPlan;
15
+ export declare function buildSessionPrefix(sessionId: string | null): string;
16
+ export declare function buildCommitBody(input: HookInput, _relPath: string | null): string | null;
17
+ /**
18
+ * Extract the first user message from a JSONL transcript.
19
+ * Filters out hook feedback, plan headers, XML tags, and empty lines.
20
+ * Returns first 72 chars or null.
21
+ */
22
+ export declare function extractTaskFromTranscript(content: string): string | null;
23
+ /**
24
+ * Summarize a list of deleted files: "file.txt (+2 more)"
25
+ */
26
+ export declare function summarizeDeletions(files: string[]): string;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Parse the raw JSON string from hook stdin into a typed HookInput.
3
+ */
4
+ export function parseHookInput(json) {
5
+ const raw = JSON.parse(json);
6
+ return {
7
+ tool_name: raw.tool_name ?? null,
8
+ tool_input: raw.tool_input ?? {},
9
+ session_id: raw.session_id ?? null,
10
+ transcript_path: raw.transcript_path ?? null,
11
+ };
12
+ }
13
+ /**
14
+ * Pure decision logic: given parsed input and repo state, decide what to do.
15
+ * No I/O, no git commands — only data in, plan out.
16
+ */
17
+ export function planHook(input, state) {
18
+ const filePath = input.tool_input.file_path ?? null;
19
+ const sync = buildSyncPlan(state);
20
+ // No file_path and no deleted files → nothing to do
21
+ if (!filePath && state.deletedFiles.length === 0) {
22
+ return { action: "skip" };
23
+ }
24
+ // File path provided but outside the repo → skip
25
+ if (filePath && !state.insideRepo) {
26
+ return { action: "skip" };
27
+ }
28
+ // File path provided but gitignored → skip
29
+ if (filePath && state.gitignored) {
30
+ return { action: "skip" };
31
+ }
32
+ // In merge state → complete the merge
33
+ if (state.inMerge) {
34
+ const relPath = filePath ? state.relPath : summarizeDeletions(state.deletedFiles);
35
+ const sessionPrefix = buildSessionPrefix(input.session_id);
36
+ const message = `${sessionPrefix}resolve merge conflict in ${relPath}`;
37
+ const filesToStage = filePath ? [filePath] : [];
38
+ return {
39
+ action: "commit-merge",
40
+ message,
41
+ sync,
42
+ };
43
+ }
44
+ // Normal commit path
45
+ const commit = buildCommitPlan(input, state);
46
+ return { action: "commit-and-sync", commit, sync };
47
+ }
48
+ function buildSyncPlan(state) {
49
+ if (!state.hasRemote)
50
+ return null;
51
+ return {
52
+ targetBranch: state.targetBranch,
53
+ currentBranch: state.currentBranch,
54
+ };
55
+ }
56
+ function buildCommitPlan(input, state) {
57
+ const filePath = input.tool_input.file_path ?? null;
58
+ const action = filePath
59
+ ? (input.tool_name ?? "update").toLowerCase()
60
+ : "delete";
61
+ const relPath = filePath
62
+ ? state.relPath
63
+ : summarizeDeletions(state.deletedFiles);
64
+ const filesToStage = filePath ? [filePath] : [];
65
+ const filesToRemove = filePath ? [] : state.deletedFiles;
66
+ const sessionPrefix = buildSessionPrefix(input.session_id);
67
+ const subject = `${sessionPrefix}${action} ${relPath}`;
68
+ const body = buildCommitBody(input, filePath ? relPath : null);
69
+ return { filesToStage, filesToRemove, subject, body };
70
+ }
71
+ /**
72
+ * Build a commit plan with a task-based subject (when transcript extraction succeeds).
73
+ */
74
+ export function buildCommitPlanWithTask(input, state, task) {
75
+ const base = buildCommitPlan(input, state);
76
+ if (!task)
77
+ return base;
78
+ const filePath = input.tool_input.file_path ?? null;
79
+ const relPath = filePath ? state.relPath : summarizeDeletions(state.deletedFiles);
80
+ const sessionPrefix = buildSessionPrefix(input.session_id);
81
+ const subject = `${sessionPrefix}${task}`;
82
+ // When task is present, include File: line in body
83
+ let body = `File: ${relPath}`;
84
+ if (input.session_id)
85
+ body += `\nSession: ${input.session_id}`;
86
+ return { ...base, subject, body: body || null };
87
+ }
88
+ export function buildSessionPrefix(sessionId) {
89
+ if (sessionId)
90
+ return `auto(${sessionId.slice(0, 8)}): `;
91
+ return "auto: ";
92
+ }
93
+ export function buildCommitBody(input, _relPath) {
94
+ if (!input.session_id)
95
+ return null;
96
+ return `Session: ${input.session_id}`;
97
+ }
98
+ /**
99
+ * Extract the first user message from a JSONL transcript.
100
+ * Filters out hook feedback, plan headers, XML tags, and empty lines.
101
+ * Returns first 72 chars or null.
102
+ */
103
+ export function extractTaskFromTranscript(content) {
104
+ const lines = content.split("\n");
105
+ for (const line of lines) {
106
+ if (!line.trim())
107
+ continue;
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(line);
111
+ }
112
+ catch {
113
+ continue;
114
+ }
115
+ if (!isUserMessage(parsed))
116
+ continue;
117
+ const msg = parsed.message;
118
+ const texts = extractTextContent(msg.content);
119
+ for (const text of texts) {
120
+ const candidate = filterTaskLine(text);
121
+ if (candidate)
122
+ return candidate.slice(0, 72);
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+ function isUserMessage(obj) {
128
+ if (typeof obj !== "object" || obj === null)
129
+ return false;
130
+ const rec = obj;
131
+ if (rec.type !== "user")
132
+ return false;
133
+ if (typeof rec.message !== "object" || rec.message === null)
134
+ return false;
135
+ const msg = rec.message;
136
+ return msg.role === "user";
137
+ }
138
+ function extractTextContent(content) {
139
+ if (typeof content === "string")
140
+ return [content];
141
+ if (Array.isArray(content)) {
142
+ return content.filter((item) => typeof item === "string");
143
+ }
144
+ return [];
145
+ }
146
+ function filterTaskLine(text) {
147
+ const lines = text.split("\n");
148
+ for (const line of lines) {
149
+ const trimmed = line.trim();
150
+ if (!trimmed)
151
+ continue;
152
+ if (trimmed.startsWith("Stop hook feedback:"))
153
+ return null;
154
+ if (trimmed === "Implement the following plan:")
155
+ continue;
156
+ if (trimmed.startsWith("<"))
157
+ continue;
158
+ // Strip leading markdown headers
159
+ const stripped = trimmed.replace(/^#{1,}\s+/, "");
160
+ if (stripped)
161
+ return stripped;
162
+ }
163
+ return null;
164
+ }
165
+ /**
166
+ * Summarize a list of deleted files: "file.txt (+2 more)"
167
+ */
168
+ export function summarizeDeletions(files) {
169
+ if (files.length === 0)
170
+ return "";
171
+ const first = files[0];
172
+ if (files.length === 1)
173
+ return first;
174
+ return `${first} (+${files.length - 1} more)`;
175
+ }
@@ -0,0 +1,54 @@
1
+ /** Raw JSON from Claude's PostToolUse hook stdin */
2
+ export interface HookInput {
3
+ tool_name: string | null;
4
+ tool_input: {
5
+ file_path?: string;
6
+ };
7
+ session_id: string | null;
8
+ transcript_path: string | null;
9
+ }
10
+ /** Git state gathered before planning */
11
+ export interface RepoState {
12
+ repoRoot: string;
13
+ gitDir: string;
14
+ /** file_path relative to repoRoot, or null if no file_path */
15
+ relPath: string | null;
16
+ /** true when file_path is inside the repo */
17
+ insideRepo: boolean;
18
+ /** true when file_path is gitignored */
19
+ gitignored: boolean;
20
+ /** true when origin remote exists */
21
+ hasRemote: boolean;
22
+ /** default branch on origin (e.g. "main"), empty when no remote */
23
+ targetBranch: string;
24
+ /** current branch name */
25
+ currentBranch: string;
26
+ /** true when MERGE_HEAD exists */
27
+ inMerge: boolean;
28
+ /** true when staging area has changes */
29
+ hasStagedChanges: boolean;
30
+ /** tracked files that have been deleted from the working tree */
31
+ deletedFiles: string[];
32
+ }
33
+ export interface SyncPlan {
34
+ targetBranch: string;
35
+ currentBranch: string;
36
+ }
37
+ export interface CommitPlan {
38
+ filesToStage: string[];
39
+ filesToRemove: string[];
40
+ subject: string;
41
+ body: string | null;
42
+ }
43
+ export type HookPlan = {
44
+ action: "skip";
45
+ } | {
46
+ action: "commit-and-sync";
47
+ commit: CommitPlan;
48
+ sync: SyncPlan | null;
49
+ } | {
50
+ action: "commit-merge";
51
+ message: string;
52
+ sync: SyncPlan | null;
53
+ };
54
+ export declare const HOOK_EXPLAINER = "A PostToolUse hook automatically commits and syncs every file change to keep multiple agents in sync on trunk.";
@@ -0,0 +1 @@
1
+ export const HOOK_EXPLAINER = "A PostToolUse hook automatically commits and syncs every file change to keep multiple agents in sync on trunk.";
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "PostToolUse": [
4
+ {
5
+ "matcher": "Edit|Write|Bash",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/trunk-sync.sh",
10
+ "statusMessage": "Syncing to trunk"
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@susu-eng/trunk-sync",
3
+ "version": "2.3.0",
4
+ "type": "module",
5
+ "description": "Maximum continuous integration for multi-agent coding — every edit is committed and pushed to trunk immediately",
6
+ "bin": {
7
+ "trunk-sync": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist/**/*.js",
11
+ "dist/**/*.d.ts",
12
+ "!dist/**/*.test.*",
13
+ "scripts/",
14
+ "hooks/",
15
+ "rules/",
16
+ ".claude-plugin/"
17
+ ],
18
+ "devDependencies": {
19
+ "@types/node": "^22",
20
+ "tsx": "^4",
21
+ "typescript": "^5"
22
+ },
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/elimydlarz/trunk-sync"
27
+ },
28
+ "keywords": [
29
+ "claude-code",
30
+ "multi-agent",
31
+ "git",
32
+ "sync",
33
+ "trunk-based-development"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsc",
37
+ "postbuild": "chmod +x dist/cli.js",
38
+ "dev": "tsx src/cli.ts",
39
+ "test": "node --test --test-reporter spec 'dist/**/*.test.js'"
40
+ }
41
+ }
@@ -0,0 +1,29 @@
1
+ # Trunk Sync
2
+
3
+ Every file write is auto-committed and pushed by a PostToolUse hook. Every edit is integrated to `origin/main` immediately — maximum continuous integration. This keeps multiple agents working against near-current trunk at all times.
4
+
5
+ ## How it works
6
+
7
+ Each agent runs in its own git worktree (via `claude -w`), isolated from other agents. After every `Edit` or `Write`, the hook:
8
+
9
+ 1. Commits the changed file with agent context
10
+ 2. Pulls from `origin/main` (`--no-rebase`) and pushes to `origin/main`
11
+ 3. Retries once if another agent pushed between pull and push
12
+
13
+ If another agent changed the same file, you get a merge conflict. The conflict and resolution work identically whether the other agent is in a local worktree or on a remote machine.
14
+
15
+ ## When you see TRUNK-SYNC CONFLICT
16
+
17
+ Another agent changed the same file. Git left conflict markers in the file. Just read the file, edit it to the correct content (remove the `<<<<<<<` / `=======` / `>>>>>>>` markers), and the hook will detect the merge state and complete the sync automatically on your next edit.
18
+
19
+ Do NOT run git commands to resolve — just fix the file contents.
20
+
21
+ ## Before you start
22
+
23
+ Run `git pull` once at the beginning of your session to start from the latest trunk. After that, the hook handles all pulls and pushes.
24
+
25
+ ## Don't
26
+
27
+ - Make manual git commits — the hook handles it
28
+ - Run `git pull` or `git push` after your initial pull — the hook handles it
29
+ - Edit or delete files in `.transcripts/` — these are auto-generated session snapshots
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
4
+ exec node "$SCRIPT_DIR/dist/lib/hook-entry.js"