@searls/turbocommit 0.13.2 → 0.15.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  # turbocommit
4
4
 
5
- turbocommit creates a git commit containing everything Claude Code or Codex
5
+ turbocommit creates a git commit containing the paths Claude Code or Codex
6
6
  changes on each turn. Think of it like save state in a game emulator: whenever
7
7
  you fall in a pit, you can safely rewind and try again.
8
8
 
@@ -18,19 +18,68 @@ sessions concurrently and detangle which agent committed what after the fact.
18
18
 
19
19
  turbocommit registers hooks with the harnesses you use:
20
20
 
21
- - Claude Code: **PreToolUse**, **SessionStart**, **SessionEnd**, and **Stop**.
22
- - Codex: **PreToolUse**, **SessionStart**, **PreCompact**, and
23
- **Stop**.
21
+ - Claude Code: **PreToolUse**, **PostToolUse**, **SessionStart**,
22
+ **SessionEnd**, and **Stop**.
23
+ - Codex: **PreToolUse**, **PostToolUse**, **SessionStart**, **PreCompact**,
24
+ and **Stop**.
24
25
 
25
- - **PreToolUse** tracks which sessions actually modify files (Write, Edit,
26
- MultiEdit, NotebookEdit, MCP tools). Read-only sessions (Grep, Read,
27
- Bash-only) are never committed.
26
+ - **PreToolUse** tracks the paths supplied to editing tools and snapshots the
27
+ repository before shell commands.
28
+ - **PostToolUse** attributes paths that became dirty during a shell command.
29
+ Commands that overlap another shell command fail closed and claim nothing.
30
+ Read-only commands remain uncommitted.
28
31
  - **SessionStart / SessionEnd** chain sessions across `/clear` boundaries
29
32
  so planning context survives into the eventual commit.
30
33
  - **PreCompact** on Codex buffers visible transcript context before compaction.
31
- - **Stop** fires after every turn. If the session modified files, it
32
- commits with `git add -A`. If not, it buffers the transcript for pickup
33
- by a later session that does commit.
34
+ - **Stop** fires after every turn. If the session modified files, it stages and
35
+ commits only the paths tracked for that session. If not, it buffers the
36
+ transcript for pickup by a later session that does commit.
37
+
38
+ ### Changes across multiple repositories
39
+
40
+ A session that starts in an enabled repository can commit changes in other
41
+ enabled local checkouts during the same turn. Turbocommit discovers each
42
+ checkout from explicit file paths supplied to Claude Code tools, Codex
43
+ `apply_patch`, and path-bearing MCP tools. Shell commands alone do not discover
44
+ additional repositories.
45
+
46
+ Each touched checkout:
47
+
48
+ - Reads its own merged global and project configuration.
49
+ - Stages only paths attributed to the current session, preserving unrelated
50
+ staged and unstaged work.
51
+ - Gets an independent commit with the full turn transcript.
52
+ - Gives the configured title agent repository-specific bounded diff context.
53
+
54
+ Turbocommit attempts every commit before pushing every successful commit whose
55
+ repository has `push: true`. Failures are retained and retried on a later Stop,
56
+ along with every transcript accumulated before the commit succeeds.
57
+
58
+ Separate worktrees are committed independently. Enabled submodules are
59
+ committed and pushed before enabled parent repositories. When one turn creates
60
+ commits in multiple repositories, each commit includes a shared
61
+ `Turbocommit-Session` trailer.
62
+
63
+ Relative tool paths are matched to the checkout where that path changed. If the
64
+ same relative path is dirty in multiple worktrees, Turbocommit skips the turn
65
+ with an `ambiguous-tracking` monitor reason instead of guessing which checkout
66
+ owns it, then clears that ambiguous tracking so later turns can proceed. Direct
67
+ editing tools such as `apply_patch` resolve relative paths from their recorded
68
+ working directory. Path-bearing MCP tools may report singular paths, path
69
+ arrays, or local file URIs. Pathless tool calls and directory-valued path fields
70
+ do not claim files or prevent later exact edits in the same session from
71
+ committing.
72
+
73
+ Before staging, Turbocommit resolves tracked paths to the exact changed files,
74
+ treats Git pathspec metacharacters literally, drops paths that are no longer
75
+ dirty, and excludes newly discovered embedded repositories. Existing tracked
76
+ submodule gitlinks remain eligible so child commits can update their parent.
77
+ During an active merge, cherry-pick, revert, or rebase, Git forbids partial
78
+ commits, so Turbocommit commits the operation's resolved index as a whole.
79
+
80
+ Retry manifests created by older versions did not record path ownership. On
81
+ upgrade, Turbocommit discards those unsafe retry entries and records a
82
+ `legacy-manifest` monitor event rather than guessing which files they owned.
34
83
 
35
84
  The commit message headline is generated by a title agent (configurable).
36
85
  The body contains the full prompt/response transcript. When planning
package/cli.js CHANGED
@@ -4,7 +4,7 @@ const { readStdin } = require('./lib/io')
4
4
  const { install, uninstall } = require('./lib/install')
5
5
  const { init, deinit } = require('./lib/init')
6
6
  const { run, runPreCompact } = require('./lib/run')
7
- const { handleTrack } = require('./lib/track')
7
+ const { handleTrack, handlePostTrack } = require('./lib/track')
8
8
  const { handleSessionStart, handleSessionEnd } = require('./lib/session')
9
9
  const { doctor } = require('./lib/doctor')
10
10
  const { monitor } = require('./lib/monitor')
@@ -175,6 +175,9 @@ function cmdHook (argv, harness) {
175
175
  case 'pre-tool-use':
176
176
  handleTrack(hookInput, root)
177
177
  return
178
+ case 'post-tool-use':
179
+ handlePostTrack(hookInput, root)
180
+ return
178
181
  case 'session-start':
179
182
  handleSessionStart(hookInput, root)
180
183
  return
package/lib/git.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const { execSync } = require('child_process')
2
+ const fs = require('fs')
2
3
  const path = require('path')
3
4
 
4
5
  function git (args, opts = {}) {
@@ -12,12 +13,66 @@ function git (args, opts = {}) {
12
13
 
13
14
  function gitRoot (cwd) {
14
15
  try {
15
- return git('rev-parse --show-toplevel', { cwd })
16
+ return canonicalRoot(git('rev-parse --show-toplevel', { cwd }))
16
17
  } catch {
17
18
  return null
18
19
  }
19
20
  }
20
21
 
22
+ function canonicalRoot (filePath) {
23
+ const target = path.resolve(filePath)
24
+ let probe = target
25
+ while (!fs.existsSync(probe)) {
26
+ const parent = path.dirname(probe)
27
+ if (parent === probe) return target
28
+ probe = parent
29
+ }
30
+ try {
31
+ return path.join(fs.realpathSync(probe), path.relative(probe, target))
32
+ } catch {
33
+ return target
34
+ }
35
+ }
36
+
37
+ function canonicalTrackedPath (filePath) {
38
+ const target = path.resolve(filePath)
39
+ return path.join(canonicalRoot(path.dirname(target)), path.basename(target))
40
+ }
41
+
42
+ function gitRootForPath (filePath) {
43
+ let probe = filePath
44
+ try {
45
+ if (!fs.statSync(probe).isDirectory()) probe = path.dirname(probe)
46
+ } catch {
47
+ probe = path.dirname(probe)
48
+ }
49
+ while (probe && !fs.existsSync(probe)) {
50
+ const parent = path.dirname(probe)
51
+ if (parent === probe) return null
52
+ probe = parent
53
+ }
54
+ return gitRoot(probe)
55
+ }
56
+
57
+ function gitWorktrees (cwd) {
58
+ try {
59
+ return git('worktree list --porcelain', { cwd })
60
+ .split('\n')
61
+ .filter(line => line.startsWith('worktree '))
62
+ .map(line => line.slice('worktree '.length))
63
+ .map(root => {
64
+ try {
65
+ return fs.realpathSync(root)
66
+ } catch {
67
+ return null
68
+ }
69
+ })
70
+ .filter(Boolean)
71
+ } catch {
72
+ return []
73
+ }
74
+ }
75
+
21
76
  function gitCommonDir (cwd) {
22
77
  try {
23
78
  const out = git('rev-parse --git-common-dir', { cwd })
@@ -44,12 +99,70 @@ function hasChanges (cwd) {
44
99
  return untracked.length > 0
45
100
  }
46
101
 
102
+ function hasPathChanges (cwd, filePath) {
103
+ return changedPaths(cwd, [filePath]).length > 0
104
+ }
105
+
47
106
  function addAndCommit (cwd, headline, body) {
107
+ stageAll(cwd)
108
+ commitStaged(cwd, headline, body)
109
+ return git('rev-parse HEAD', { cwd })
110
+ }
111
+
112
+ function stageAll (cwd) {
48
113
  git('add -A', { cwd })
114
+ }
115
+
116
+ function stagePaths (cwd, filePaths) {
117
+ const changed = changedPaths(cwd, filePaths)
118
+ const paths = pathspecs(cwd, changed)
119
+ if (paths.length === 0) return []
120
+ git(`add -A -- ${paths.map(quote).join(' ')}`, { cwd })
121
+ return changed
122
+ }
123
+
124
+ function commitPaths (cwd, filePaths, headline, body) {
125
+ const changed = stagePaths(cwd, filePaths)
126
+ return commitStagedPaths(cwd, changed, headline, body)
127
+ }
128
+
129
+ function commitStagedPaths (cwd, filePaths, headline, body) {
130
+ const paths = pathspecs(cwd, filePaths)
131
+ if (paths.length === 0) return null
132
+ if (hasRepositoryOperation(cwd)) return commitStaged(cwd, headline, body)
133
+ git(`commit --only -m "${esc(headline)}" -m "${esc(body)}" --no-verify -- ${paths.map(quote).join(' ')}`, { cwd })
134
+ return git('rev-parse HEAD', { cwd })
135
+ }
136
+
137
+ function commitStaged (cwd, headline, body) {
49
138
  git(`commit -m "${esc(headline)}" -m "${esc(body)}" --no-verify`, { cwd })
50
139
  return git('rev-parse HEAD', { cwd })
51
140
  }
52
141
 
142
+ function stagedChangeContext (cwd, budget = 20000, filePaths) {
143
+ const selected = filePaths && filePaths.length > 0
144
+ ? ` -- ${pathspecs(cwd, filePaths).map(quote).join(' ')}`
145
+ : ''
146
+ const status = git(`status --short${selected}`, { cwd })
147
+ const stat = git(`diff --cached --stat${selected}`, { cwd })
148
+ const diff = git(`diff --cached --no-ext-diff --unified=3${selected}`, { cwd })
149
+ const prefix = `Status:\n${status || '(clean)'}\n\nDiff stat:\n${stat || '(none)'}\n\nDiff:\n`
150
+ const remaining = Math.max(0, budget - prefix.length)
151
+ return prefix + (diff.length > remaining ? diff.slice(0, remaining) + '\n[... diff truncated ...]' : diff)
152
+ }
153
+
154
+ function isGitlink (parentRoot, childRoot) {
155
+ const relative = path.relative(parentRoot, childRoot)
156
+ if (!relative || relative.startsWith('..')) return false
157
+ try {
158
+ return git(`ls-files --stage -- ${quote(literalPathspec(relative))}`, { cwd: parentRoot })
159
+ .split('\n')
160
+ .some(line => line.startsWith('160000 '))
161
+ } catch {
162
+ return false
163
+ }
164
+ }
165
+
53
166
  function hasCommits (cwd) {
54
167
  try {
55
168
  git('rev-parse HEAD', { cwd })
@@ -80,13 +193,101 @@ function esc (s) {
80
193
  return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')
81
194
  }
82
195
 
196
+ function quote (value) {
197
+ return `'${value.replace(/'/g, '\'"\'"\'')}'`
198
+ }
199
+
200
+ function pathspecs (cwd, filePaths) {
201
+ cwd = canonicalRoot(cwd)
202
+ return [...new Set(filePaths.map(filePath => path.relative(cwd, canonicalTrackedPath(filePath))))]
203
+ .filter(relative => relative && !relative.startsWith('..'))
204
+ .map(literalPathspec)
205
+ }
206
+
207
+ function literalPathspec (relative) {
208
+ return `:(literal)${relative}`
209
+ }
210
+
211
+ function changedPaths (cwd, filePaths) {
212
+ cwd = canonicalRoot(cwd)
213
+ const selected = pathspecs(cwd, filePaths)
214
+ if (selected.length === 0) return []
215
+ return collectChangedPaths(cwd, selected)
216
+ }
217
+
218
+ function changedPathsInRepository (cwd) {
219
+ cwd = canonicalRoot(cwd)
220
+ return collectChangedPaths(cwd, [])
221
+ }
222
+
223
+ function collectChangedPaths (cwd, selected) {
224
+ const args = selected.length > 0 ? ` -- ${selected.map(quote).join(' ')}` : ''
225
+ const tracked = hasCommits(cwd)
226
+ ? git(`diff --name-only --no-renames -z HEAD${args}`, { cwd })
227
+ : git(`ls-files --cached -z${args}`, { cwd })
228
+ const untracked = git(`ls-files --others --exclude-standard -z${args}`, { cwd })
229
+ const relativePaths = [...splitNull(tracked), ...splitNull(untracked)]
230
+ return [...new Set(relativePaths)]
231
+ .filter(relative => !isEmbeddedRepository(cwd, relative) || isHeadGitlink(cwd, relative))
232
+ .map(relative => path.join(cwd, relative))
233
+ }
234
+
235
+ function splitNull (value) {
236
+ return value ? value.split('\0').filter(Boolean) : []
237
+ }
238
+
239
+ function isEmbeddedRepository (cwd, relative) {
240
+ const candidate = path.join(cwd, relative)
241
+ try {
242
+ return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, '.git'))
243
+ } catch {
244
+ return false
245
+ }
246
+ }
247
+
248
+ function isHeadGitlink (cwd, relative) {
249
+ try {
250
+ return git(`ls-tree HEAD -- ${quote(literalPathspec(relative))}`, { cwd })
251
+ .split('\n')
252
+ .some(line => line.startsWith('160000 '))
253
+ } catch {
254
+ return false
255
+ }
256
+ }
257
+
258
+ function hasRepositoryOperation (cwd) {
259
+ return ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply']
260
+ .some(name => {
261
+ try {
262
+ const gitPath = git(`rev-parse --git-path ${quote(name)}`, { cwd })
263
+ return fs.existsSync(path.isAbsolute(gitPath) ? gitPath : path.join(cwd, gitPath))
264
+ } catch {
265
+ return false
266
+ }
267
+ })
268
+ }
269
+
83
270
  module.exports = {
84
271
  git,
272
+ canonicalRoot,
273
+ canonicalTrackedPath,
85
274
  gitRoot,
275
+ gitRootForPath,
276
+ gitWorktrees,
86
277
  gitCommonDir,
87
278
  hasChanges,
279
+ changedPaths,
280
+ changedPathsInRepository,
281
+ hasPathChanges,
88
282
  addAndCommit,
283
+ stageAll,
284
+ stagePaths,
285
+ commitPaths,
286
+ commitStagedPaths,
287
+ commitStaged,
288
+ stagedChangeContext,
89
289
  hasCommits,
90
290
  currentBranch,
91
- pushClean
291
+ pushClean,
292
+ isGitlink
92
293
  }
package/lib/harness.js CHANGED
@@ -75,6 +75,7 @@ function normalizeHookInput (input, event, forcedHarness) {
75
75
  transcriptPath: hookInput.transcript_path,
76
76
  toolName: hookInput.tool_name,
77
77
  toolInput: hookInput.tool_input || {},
78
+ toolUseId: hookInput.tool_use_id,
78
79
  cwd: hookInput.cwd,
79
80
  model: hookInput.model,
80
81
  source: hookInput.source,
@@ -85,6 +86,7 @@ function normalizeHookInput (input, event, forcedHarness) {
85
86
  function eventName (event) {
86
87
  const names = {
87
88
  'pre-tool-use': 'PreToolUse',
89
+ 'post-tool-use': 'PostToolUse',
88
90
  'session-start': 'SessionStart',
89
91
  'session-end': 'SessionEnd',
90
92
  'pre-compact': 'PreCompact',
package/lib/install.js CHANGED
@@ -10,6 +10,10 @@ const HOOK_DEFS = {
10
10
  matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
11
11
  hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness claude' }]
12
12
  },
13
+ PostToolUse: {
14
+ matcher: 'Bash',
15
+ hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
16
+ },
13
17
  SessionStart: {
14
18
  hooks: [{ type: 'command', command: 'turbocommit hook session-start --harness claude' }]
15
19
  },
@@ -23,9 +27,13 @@ const HOOK_DEFS = {
23
27
 
24
28
  const CODEX_HOOK_DEFS = {
25
29
  PreToolUse: {
26
- matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
30
+ matcher: 'apply_patch|Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
27
31
  hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness codex' }]
28
32
  },
33
+ PostToolUse: {
34
+ matcher: 'Bash',
35
+ hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness codex' }]
36
+ },
29
37
  SessionStart: {
30
38
  matcher: 'resume|clear',
31
39
  hooks: [{ type: 'command', command: 'turbocommit hook session-start --harness codex' }]
@@ -67,7 +75,8 @@ function hasExactHooks (groups, def) {
67
75
  return expected.every(cmd =>
68
76
  groups.some(g => {
69
77
  const hooks = g && g.hooks ? g.hooks : []
70
- return hooks.some(h => h.command === cmd)
78
+ const matcherMatches = def.matcher === undefined || g.matcher === def.matcher
79
+ return matcherMatches && hooks.some(h => h.command === cmd)
71
80
  })
72
81
  )
73
82
  }
@@ -99,8 +108,7 @@ function removeTurbocommitHooks (groups) {
99
108
  }
100
109
 
101
110
  /**
102
- * Install turbocommit hooks for all 4 events (PreToolUse, SessionStart,
103
- * SessionEnd, Stop). Each event gets its own group at the end.
111
+ * Install every turbocommit hook event. Each event gets its own group at the end.
104
112
  * Cleans up stale entries (including old `turbocommit run`) on install.
105
113
  */
106
114
  function installClaude (settingsPath) {
package/lib/multi.js ADDED
@@ -0,0 +1,197 @@
1
+ const crypto = require('crypto')
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const { ensureDir, loadJson } = require('./io')
5
+ const { turbocommitDir } = require('./session')
6
+ const { canonicalRoot, canonicalTrackedPath, gitRoot, gitRootForPath, gitWorktrees, hasPathChanges, isGitlink } = require('./git')
7
+ const { activeConfig } = require('./config')
8
+
9
+ function checkoutKey (root) {
10
+ return crypto.createHash('sha256').update(fs.realpathSync(root)).digest('hex').slice(0, 16)
11
+ }
12
+
13
+ function manifestPath (anchor) {
14
+ return path.join(turbocommitDir(anchor), 'multi', checkoutKey(anchor), 'manifest.json')
15
+ }
16
+
17
+ function loadManifest (anchor) {
18
+ return normalizeManifest(loadJson(manifestPath(anchor)))
19
+ }
20
+
21
+ function saveManifest (anchor, manifest) {
22
+ const file = manifestPath(anchor)
23
+ ensureDir(path.dirname(file))
24
+ fs.writeFileSync(file, JSON.stringify({
25
+ nextOrder: manifest.nextOrder,
26
+ repos: manifest.repos
27
+ }, null, 2) + '\n')
28
+ }
29
+
30
+ function deleteManifestIfEmpty (anchor, manifest) {
31
+ if (manifest.repos.length > 0) {
32
+ saveManifest(anchor, manifest)
33
+ return
34
+ }
35
+ try {
36
+ fs.unlinkSync(manifestPath(anchor))
37
+ } catch {}
38
+ }
39
+
40
+ function trackedChangesFromEntries (anchor, entries) {
41
+ const pathsByRoot = new Map()
42
+ const ambiguous = []
43
+ const add = (root, file) => {
44
+ root = canonicalRoot(root)
45
+ file = canonicalTrackedPath(file)
46
+ if (!root || activeConfig(root).config.enabled !== true) return
47
+ const relative = path.relative(root, file)
48
+ if (!relative || relative.startsWith('..')) return
49
+ if (!pathsByRoot.has(root)) pathsByRoot.set(root, new Set())
50
+ pathsByRoot.get(root).add(path.join(root, relative))
51
+ }
52
+
53
+ for (const entry of entries) {
54
+ const rawFiles = Array.isArray(entry.rawFiles) && entry.rawFiles.length > 0
55
+ ? entry.rawFiles
56
+ : Array.isArray(entry.files) ? entry.files : []
57
+ if (entry.tool === 'Bash' && rawFiles.length === 0) continue
58
+ if (rawFiles.length === 0) continue
59
+
60
+ for (const rawFile of rawFiles) {
61
+ const candidates = changedCandidates(anchor, entry.cwd || anchor, rawFile, entry.tool)
62
+ if (candidates.length > 1) {
63
+ ambiguous.push({ tool: entry.tool, path: rawFile, reason: 'multiple-worktrees' })
64
+ } else if (candidates.length === 1) {
65
+ add(candidates[0].root, candidates[0].file)
66
+ }
67
+ }
68
+ }
69
+
70
+ addEnabledSuperprojects(pathsByRoot, add)
71
+ const repos = [...pathsByRoot].map(([root, paths]) => ({ root, paths: [...paths] }))
72
+ return { repos, ambiguous }
73
+ }
74
+
75
+ function changedCandidates (anchor, cwd, rawFile, tool) {
76
+ if (path.isAbsolute(rawFile)) return changedCandidate(rawFile)
77
+
78
+ anchor = canonicalRoot(anchor)
79
+ const direct = canonicalTrackedPath(path.resolve(cwd, rawFile))
80
+ const directRoot = gitRootForPath(direct)
81
+ if (!isOpaquePathTool(tool) || (directRoot && directRoot !== anchor)) {
82
+ return changedCandidate(direct)
83
+ }
84
+
85
+ const relative = path.relative(anchor, direct)
86
+ if (!relative || relative.startsWith('..')) return changedCandidate(direct)
87
+ const candidates = []
88
+ const seen = new Set()
89
+ for (const worktree of gitWorktrees(anchor)) {
90
+ const file = canonicalTrackedPath(path.join(worktree, relative))
91
+ if (isDirectoryPath(file)) continue
92
+ const root = gitRootForPath(file)
93
+ if (!root || seen.has(root) || activeConfig(root).config.enabled !== true) continue
94
+ seen.add(root)
95
+ if (hasPathChanges(root, file)) candidates.push({ root, file })
96
+ }
97
+ return candidates
98
+ }
99
+
100
+ function isOpaquePathTool (tool) {
101
+ return typeof tool === 'string' && tool.startsWith('mcp__')
102
+ }
103
+
104
+ function changedCandidate (file) {
105
+ if (isDirectoryPath(file)) return []
106
+ file = canonicalTrackedPath(file)
107
+ const root = gitRootForPath(file)
108
+ if (!root || activeConfig(root).config.enabled !== true) return []
109
+ return hasPathChanges(root, file) ? [{ root, file }] : []
110
+ }
111
+
112
+ function isDirectoryPath (file) {
113
+ try {
114
+ return fs.statSync(file).isDirectory()
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ function addEnabledSuperprojects (pathsByRoot, add) {
121
+ for (const child of [...pathsByRoot.keys()]) {
122
+ const parent = gitRoot(path.dirname(child))
123
+ if (parent && parent !== child && isGitlink(parent, child)) add(parent, child)
124
+ }
125
+ }
126
+
127
+ function dependencyOrder (repos) {
128
+ return [...repos].sort((a, b) => {
129
+ if (isGitlink(a.root, b.root)) return 1
130
+ if (isGitlink(b.root, a.root)) return -1
131
+ return a.order - b.order
132
+ })
133
+ }
134
+
135
+ function appendWork (manifest, root, work) {
136
+ let repo = manifest.repos.find(candidate => candidate.root === root)
137
+ if (!repo) {
138
+ repo = { root, order: manifest.nextOrder++, work: [] }
139
+ manifest.repos.push(repo)
140
+ }
141
+ if (!repo.work.some(item => item.key === work.key)) repo.work.push(work)
142
+ return repo
143
+ }
144
+
145
+ function normalizeManifest (stored) {
146
+ const manifest = stored && typeof stored === 'object' ? stored : {}
147
+ const repos = Array.isArray(manifest.repos) ? manifest.repos : []
148
+ let discardedLegacyWork = 0
149
+ const normalizedRepos = []
150
+
151
+ for (const repo of repos) {
152
+ if (!repo || typeof repo.root !== 'string' || !Array.isArray(repo.work)) continue
153
+ const work = repo.work.filter(item => {
154
+ const safe = item && Array.isArray(item.paths) && item.paths.length > 0
155
+ if (!safe) discardedLegacyWork++
156
+ return safe
157
+ })
158
+ if (work.length > 0) normalizedRepos.push({ ...repo, work })
159
+ }
160
+
161
+ return {
162
+ nextOrder: Number.isInteger(manifest.nextOrder) ? manifest.nextOrder : 0,
163
+ repos: normalizedRepos,
164
+ discardedLegacyWork
165
+ }
166
+ }
167
+
168
+ function multiWatermarkPath (root, sessionId) {
169
+ return path.join(turbocommitDir(root), 'multi-watermarks', checkoutKey(root), sessionId + '.json')
170
+ }
171
+
172
+ function readMultiWatermark (root, sessionIds) {
173
+ for (const sessionId of sessionIds) {
174
+ const value = loadJson(multiWatermarkPath(root, sessionId))
175
+ if (value?.commit) return value
176
+ }
177
+ return null
178
+ }
179
+
180
+ function saveMultiWatermark (root, sessionId, commit) {
181
+ const file = multiWatermarkPath(root, sessionId)
182
+ ensureDir(path.dirname(file))
183
+ fs.writeFileSync(file, JSON.stringify({ commit }) + '\n')
184
+ }
185
+
186
+ module.exports = {
187
+ appendWork,
188
+ checkoutKey,
189
+ deleteManifestIfEmpty,
190
+ dependencyOrder,
191
+ loadManifest,
192
+ manifestPath,
193
+ readMultiWatermark,
194
+ trackedChangesFromEntries,
195
+ saveManifest,
196
+ saveMultiWatermark
197
+ }
package/lib/run.js CHANGED
@@ -4,15 +4,25 @@ const path = require('path')
4
4
  const { loadJson } = require('./io')
5
5
  const { parseTranscript, parseCodexTranscript, formatBody, formatTitleTranscript, extractHeadline, extractModel, extractCodexModel } = require('./transcript')
6
6
  const { runTitleAgent, runBodyAgent } = require('./agent')
7
- const { gitRoot, hasChanges, addAndCommit, hasCommits, currentBranch, pushClean } = require('./git')
7
+ const { gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean } = require('./git')
8
8
  const { logEvent } = require('./log')
9
9
  const { wrapText } = require('./wrap')
10
- const { hasTrackedModifications, cleanupTracking } = require('./track')
10
+ const { hasTrackedModifications, cleanupTracking, readTracking } = require('./track')
11
11
  const { redact, buildRedactions } = require('./redact')
12
12
  const { handleSessionEnd, getAncestors, savePending, collectPending, cleanupConsumed, cleanupStale, readWatermark, saveWatermark, resolveParentCommit } = require('./session')
13
13
  const { activeConfig } = require('./config')
14
14
  const { normalizeHookInput } = require('./harness')
15
15
  const { resolveCodexTranscriptPath } = require('./codex')
16
+ const {
17
+ appendWork,
18
+ deleteManifestIfEmpty,
19
+ dependencyOrder,
20
+ loadManifest,
21
+ readMultiWatermark,
22
+ trackedChangesFromEntries,
23
+ saveManifest,
24
+ saveMultiWatermark
25
+ } = require('./multi')
16
26
 
17
27
  /**
18
28
  * Map a model ID like "claude-opus-4-6" to a friendly name like "Claude Opus 4.6".
@@ -87,11 +97,47 @@ function resolveCoauthor (config, transcriptPath, root, opts = {}) {
87
97
  * Always exits 0 — never blocks Claude, never outputs to stdout.
88
98
  *
89
99
  * Skip/commit decision is based on PreToolUse tracking:
90
- * - If tracking file exists with entriesthis agent modified files → commit
100
+ * - If tracking file contains changed pathscommit only those paths
91
101
  * - If tracking file missing/empty → skip, buffer transcript for later pickup
92
102
  */
93
103
  function run (input, opts = {}) {
94
104
  if (process.env.TURBOCOMMIT_DISABLED) return
105
+ const hookInput = typeof input === 'string'
106
+ ? normalizeHookInput(input, 'stop', opts.harness)
107
+ : input
108
+ if (!hookInput) return
109
+
110
+ const root = gitRoot(hookInput.cwd || process.cwd())
111
+ if (!root || activeConfig(root).config.enabled !== true) return
112
+
113
+ const entries = hookInput.sessionId ? readTracking(root, hookInput.sessionId) : []
114
+ const tracked = trackedChangesFromEntries(root, entries)
115
+ const manifest = loadManifest(root)
116
+ if (manifest.discardedLegacyWork > 0) {
117
+ logEvent('skip', {
118
+ harness: hookInput.harness,
119
+ project: path.basename(root),
120
+ branch: currentBranch(root),
121
+ reason: 'legacy-manifest',
122
+ discarded: manifest.discardedLegacyWork
123
+ })
124
+ manifest.discardedLegacyWork = 0
125
+ deleteManifestIfEmpty(root, manifest)
126
+ }
127
+ const hasCrossRootPath = tracked.repos.length > 0 &&
128
+ (tracked.repos.length !== 1 || tracked.repos[0].root !== root)
129
+
130
+ if (tracked.ambiguous.length > 0) {
131
+ return runSingle(hookInput, { paths: [], trackingReason: 'ambiguous-tracking' })
132
+ }
133
+ if (manifest.repos.length > 0 || tracked.repos.length > 1 || hasCrossRootPath) {
134
+ return runMulti(hookInput, root, tracked.repos, manifest)
135
+ }
136
+ return runSingle(hookInput, { paths: tracked.repos[0]?.paths || [] })
137
+ }
138
+
139
+ function runSingle (input, opts = {}) {
140
+ if (process.env.TURBOCOMMIT_DISABLED) return
95
141
 
96
142
  const hookInput = typeof input === 'string'
97
143
  ? normalizeHookInput(input, 'stop', opts.harness)
@@ -120,6 +166,7 @@ function run (input, opts = {}) {
120
166
  try { context = fs.statSync(transcriptPath).size } catch {}
121
167
 
122
168
  const sessionId = hookInput.sessionId
169
+ const ownedPaths = opts.paths || []
123
170
 
124
171
  // Watermark slicing: only include new pairs since last commit in this session
125
172
  const watermark = sessionId ? readWatermark(root, sessionId) : null
@@ -139,21 +186,38 @@ function run (input, opts = {}) {
139
186
  if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
140
187
  savePending(root, sessionId, formatBody(effectivePairs))
141
188
  }
142
- logEvent('skip', { harness: hookInput.harness, project, branch, context })
189
+ logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-tracking' })
190
+ cleanupStale(root)
191
+ return
192
+ }
193
+
194
+ if (sessionId && (opts.trackingReason || ownedPaths.length === 0)) {
195
+ if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
196
+ savePending(root, sessionId, formatBody(effectivePairs))
197
+ }
198
+ cleanupTracking(root, sessionId)
199
+ logEvent('skip', {
200
+ harness: hookInput.harness,
201
+ project,
202
+ branch,
203
+ context,
204
+ reason: opts.trackingReason || 'no-path-changes'
205
+ })
143
206
  cleanupStale(root)
144
207
  return
145
208
  }
146
209
 
147
210
  try {
148
211
  // Early exit: tracking fired but all changes were reverted
149
- if (hasCommits(root) && !hasChanges(root)) {
212
+ const hasOwnedChanges = sessionId ? changedPaths(root, ownedPaths).length > 0 : hasChanges(root)
213
+ if (!hasOwnedChanges) {
150
214
  if (sessionId) {
151
215
  if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
152
216
  savePending(root, sessionId, formatBody(effectivePairs))
153
217
  }
154
218
  cleanupTracking(root, sessionId)
155
219
  }
156
- logEvent('skip', { harness: hookInput.harness, project, branch, context })
220
+ logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
157
221
  cleanupStale(root)
158
222
  return
159
223
  }
@@ -214,7 +278,21 @@ function run (input, opts = {}) {
214
278
  const redactions = buildRedactions()
215
279
  const safeHeadline = redact(headline, redactions)
216
280
  const safeBody = redact(wrappedBody + tag, redactions)
217
- const sha = addAndCommit(root, safeHeadline, safeBody)
281
+ const sha = sessionId
282
+ ? commitPaths(root, ownedPaths, safeHeadline, safeBody)
283
+ : addAndCommit(root, safeHeadline, safeBody)
284
+ if (!sha) {
285
+ if (sessionId) cleanupTracking(root, sessionId)
286
+ logEvent('skip', {
287
+ harness: hookInput.harness,
288
+ project,
289
+ branch,
290
+ context,
291
+ reason: 'no-path-changes'
292
+ })
293
+ cleanupStale(root)
294
+ return
295
+ }
218
296
 
219
297
  logEvent('success', { harness: hookInput.harness, project, branch, context, title: safeHeadline })
220
298
 
@@ -240,6 +318,211 @@ function run (input, opts = {}) {
240
318
  }
241
319
  }
242
320
 
321
+ function runMulti (hookInput, anchor, currentRepos, manifest) {
322
+ recordCodexStop(hookInput, anchor)
323
+
324
+ const transcriptPath = resolveTranscriptPath(hookInput)
325
+ const pairs = hookInput.harness === 'codex'
326
+ ? parseCodexTranscript(transcriptPath)
327
+ : parseTranscript(transcriptPath)
328
+ const sessionId = hookInput.sessionId
329
+ const watermark = sessionId ? readWatermark(anchor, sessionId) : null
330
+ const watermarkPairCount = watermark && Number.isInteger(watermark.pairs) ? watermark.pairs : 0
331
+ const newPairs = watermark ? pairs.slice(watermarkPairCount) : pairs
332
+ const precompactWatermark = watermark && watermark.source === 'precompact'
333
+ const selfPrecompactPending = sessionId
334
+ ? collectPending(anchor, [sessionId], { source: 'precompact' })
335
+ : []
336
+ let effectivePairs = newPairs.length > 0 ? newPairs : pairs
337
+ if (precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0) {
338
+ const basePairs = Number.isInteger(watermark.basePairs) ? watermark.basePairs : 0
339
+ const bufferedPairs = pairs.slice(basePairs, watermarkPairCount)
340
+ effectivePairs = bufferedPairs.length > 0 ? bufferedPairs : pairs
341
+ }
342
+ const formattedTranscript = formatBody(effectivePairs)
343
+ const ancestors = sessionId ? getAncestors(anchor, sessionId) : []
344
+ const pending = sessionId ? collectPending(anchor, [...ancestors].reverse()) : []
345
+ if (precompactWatermark && newPairs.length > 0 && selfPrecompactPending.length > 0) {
346
+ pending.push(...selfPrecompactPending)
347
+ }
348
+ const turnBody = pending.length > 0
349
+ ? '## Planning\n\n' + pending.join('\n\n---\n\n') +
350
+ '\n\n## Implementation\n\n' + formattedTranscript
351
+ : formattedTranscript
352
+
353
+ const dirtyRepos = currentRepos.filter(repo => changedPaths(repo.root, repo.paths).length > 0)
354
+ const isMultiTurn = currentRepos.length > 1
355
+ const turnKey = hookInput.raw?.turn_id ||
356
+ cryptoKey(`${sessionId || 'no-session'}\n${pairs.length}\n${turnBody}`)
357
+
358
+ if (dirtyRepos.length === 0 && manifest.repos.length === 0) {
359
+ if (sessionId) {
360
+ savePending(anchor, sessionId, formattedTranscript)
361
+ cleanupTracking(anchor, sessionId)
362
+ }
363
+ logEvent('skip', {
364
+ harness: hookInput.harness,
365
+ project: path.basename(anchor),
366
+ branch: currentBranch(anchor),
367
+ context: transcriptSize(transcriptPath),
368
+ reason: 'no-path-changes'
369
+ })
370
+ return
371
+ }
372
+
373
+ for (const repo of currentRepos) {
374
+ appendWork(manifest, repo.root, {
375
+ key: turnKey,
376
+ body: turnBody,
377
+ sessionIds: isMultiTurn && sessionId ? [sessionId] : [],
378
+ paths: repo.paths
379
+ })
380
+ }
381
+
382
+ saveManifest(anchor, manifest)
383
+ const successes = []
384
+ const ordered = dependencyOrder(manifest.repos)
385
+
386
+ for (const repo of ordered) {
387
+ const root = repo.root
388
+ const { config } = activeConfig(root)
389
+ const project = path.basename(root)
390
+ const branch = currentBranch(root)
391
+ const context = transcriptSize(transcriptPath)
392
+ const ownedPaths = [...new Set(repo.work.flatMap(item => item.paths || []))]
393
+
394
+ if (config.enabled !== true) {
395
+ manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
396
+ logEvent('skip', { harness: hookInput.harness, project, branch, context })
397
+ continue
398
+ }
399
+ if (ownedPaths.length === 0) {
400
+ logEvent('skip', {
401
+ harness: hookInput.harness,
402
+ project,
403
+ branch,
404
+ context,
405
+ reason: 'missing-manifest-paths'
406
+ })
407
+ continue
408
+ }
409
+ const currentPaths = changedPaths(root, ownedPaths)
410
+ if (currentPaths.length === 0) {
411
+ manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
412
+ logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
413
+ continue
414
+ }
415
+
416
+ try {
417
+ logEvent('start', { harness: hookInput.harness, project, branch, context })
418
+ const stagedPaths = stagePaths(root, currentPaths)
419
+ if (stagedPaths.length === 0) {
420
+ manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
421
+ logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
422
+ continue
423
+ }
424
+
425
+ const rawBody = repo.work.map(item => item.body).join('\n\n---\n\n')
426
+ const redactions = buildRedactions()
427
+ const changeContext = redact(stagedChangeContext(root, 10000, stagedPaths), redactions)
428
+ const titleInput = `${changeContext}\n\nTranscript:\n${rawBody}`.slice(0, 20000)
429
+
430
+ let headline
431
+ if (config.title?.type !== 'transcript') {
432
+ headline = runTitleAgent(root, config.title || {}, titleInput, hookInput.harness)
433
+ }
434
+ headline = headline || extractHeadline(effectivePairs)
435
+
436
+ let body
437
+ if (config.body?.type === 'agent') {
438
+ body = runBodyAgent(root, config.body, rawBody, hookInput.harness)
439
+ }
440
+ body = body || rawBody
441
+
442
+ const parent = sessionId
443
+ ? readMultiWatermark(root, [sessionId, ...ancestors])
444
+ : null
445
+ if (parent?.commit) body = `Continuation of ${parent.commit.slice(0, 7)}\n\n${body}`
446
+ body = wrapText(body, config.body?.maxLineLength)
447
+
448
+ const trailers = []
449
+ for (const work of repo.work) {
450
+ for (const id of work.sessionIds || []) {
451
+ if (!trailers.includes(id)) trailers.push(id)
452
+ }
453
+ }
454
+ if (trailers.length > 0) {
455
+ body += '\n\n' + trailers.map(id => `Turbocommit-Session: ${id}`).join('\n')
456
+ }
457
+
458
+ const coauthor = resolveCoauthor(config, transcriptPath, root, {
459
+ harness: hookInput.harness,
460
+ model: hookInput.model
461
+ })
462
+ if (coauthor) body += '\n\n' + coauthor
463
+
464
+ const safeHeadline = redact(headline, redactions)
465
+ const safeBody = redact(body, redactions)
466
+ const sha = commitStagedPaths(root, stagedPaths, safeHeadline, safeBody)
467
+ if (!sha) throw new Error('Tracked paths changed before commit')
468
+ if (sessionId) saveMultiWatermark(root, sessionId, sha)
469
+ if (root === anchor && sessionId) {
470
+ saveWatermark(anchor, sessionId, pairs.length, sha, { source: 'commit' })
471
+ }
472
+ manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
473
+ successes.push({ root, config, project, branch })
474
+ logEvent('success', {
475
+ harness: hookInput.harness,
476
+ project,
477
+ branch,
478
+ context,
479
+ title: safeHeadline
480
+ })
481
+ } catch {
482
+ logEvent('fail', { harness: hookInput.harness, project, branch, context })
483
+ }
484
+ }
485
+
486
+ for (const success of successes) {
487
+ if (success.config.push !== true) continue
488
+ if (pushClean(success.root)) {
489
+ logEvent('push', {
490
+ harness: hookInput.harness,
491
+ project: success.project,
492
+ branch: success.branch
493
+ })
494
+ } else {
495
+ logEvent('push-fail', {
496
+ harness: hookInput.harness,
497
+ project: success.project,
498
+ branch: success.branch
499
+ })
500
+ }
501
+ }
502
+
503
+ if (sessionId) {
504
+ if (!readWatermark(anchor, sessionId)) {
505
+ saveWatermark(anchor, sessionId, pairs.length, undefined, { source: 'multi' })
506
+ }
507
+ cleanupConsumed(anchor, [...ancestors, sessionId])
508
+ cleanupTracking(anchor, sessionId)
509
+ }
510
+ deleteManifestIfEmpty(anchor, manifest)
511
+ cleanupStale(anchor)
512
+ }
513
+
514
+ function transcriptSize (transcriptPath) {
515
+ try {
516
+ return fs.statSync(transcriptPath).size
517
+ } catch {
518
+ return 0
519
+ }
520
+ }
521
+
522
+ function cryptoKey (value) {
523
+ return require('crypto').createHash('sha256').update(value).digest('hex').slice(0, 20)
524
+ }
525
+
243
526
  function runPreCompact (input, opts = {}) {
244
527
  if (process.env.TURBOCOMMIT_DISABLED) return
245
528
  const hookInput = typeof input === 'string'
package/lib/track.js CHANGED
@@ -1,7 +1,12 @@
1
+ const crypto = require('crypto')
1
2
  const fs = require('fs')
2
3
  const path = require('path')
4
+ const { fileURLToPath } = require('url')
3
5
  const { ensureDir } = require('./io')
4
6
  const { turbocommitDir } = require('./session')
7
+ const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository } = require('./git')
8
+
9
+ const BASH_SNAPSHOT_TTL_MS = 60 * 60 * 1000
5
10
 
6
11
  /**
7
12
  * Directory under the git common dir where turbocommit stores tracking state.
@@ -21,7 +26,17 @@ function trackingPath (root, sessionId) {
21
26
  /**
22
27
  * Keys to probe in tool_input for a file path (MCP tools, Write, Edit, etc.)
23
28
  */
24
- const FILE_PATH_KEYS = ['file_path', 'filePath', 'path', 'file', 'notebook_path']
29
+ const FILE_PATH_KEYS = [
30
+ 'file_path',
31
+ 'filePath',
32
+ 'path',
33
+ 'file',
34
+ 'notebook_path',
35
+ 'relative_path',
36
+ 'relativePath',
37
+ 'uri'
38
+ ]
39
+ const FILE_PATH_ARRAY_KEYS = ['file_paths', 'filePaths', 'paths', 'files']
25
40
 
26
41
  /**
27
42
  * Extract a file path from tool_input, heuristically checking known keys.
@@ -29,13 +44,75 @@ const FILE_PATH_KEYS = ['file_path', 'filePath', 'path', 'file', 'notebook_path'
29
44
  function extractFilePath (toolInput) {
30
45
  if (!toolInput || typeof toolInput !== 'object') return null
31
46
  for (const key of FILE_PATH_KEYS) {
32
- if (typeof toolInput[key] === 'string' && toolInput[key].length > 0) {
33
- return toolInput[key]
34
- }
47
+ const value = normalizeRawPath(toolInput[key], key)
48
+ if (value) return value
35
49
  }
36
50
  return null
37
51
  }
38
52
 
53
+ function extractRawFilePaths (toolName, toolInput) {
54
+ const paths = []
55
+ const seen = new Set()
56
+ const add = (value, key) => {
57
+ value = normalizeRawPath(value, key)
58
+ if (!value) return
59
+ if (seen.has(value)) return
60
+ seen.add(value)
61
+ paths.push(value)
62
+ }
63
+
64
+ if (toolName === 'apply_patch' && typeof toolInput?.command === 'string') {
65
+ for (const line of toolInput.command.split('\n')) {
66
+ const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/) ||
67
+ line.match(/^\*\*\* Move to: (.+)$/)
68
+ if (match) add(match[1])
69
+ }
70
+ return paths
71
+ }
72
+
73
+ const visit = value => {
74
+ if (!value || typeof value !== 'object') return
75
+ if (Array.isArray(value)) {
76
+ for (const item of value) visit(item)
77
+ return
78
+ }
79
+ for (const [key, nested] of Object.entries(value)) {
80
+ if (FILE_PATH_KEYS.includes(key)) add(nested, key)
81
+ if (FILE_PATH_ARRAY_KEYS.includes(key) && Array.isArray(nested)) {
82
+ for (const item of nested) add(item, key)
83
+ }
84
+ if (nested && typeof nested === 'object') visit(nested)
85
+ }
86
+ }
87
+ visit(toolInput)
88
+ return paths
89
+ }
90
+
91
+ function normalizeRawPath (value, key) {
92
+ if (typeof value !== 'string' || value.length === 0) return null
93
+ if (key !== 'uri') return value
94
+ if (value.startsWith('file://')) {
95
+ try {
96
+ return fileURLToPath(value)
97
+ } catch {
98
+ return null
99
+ }
100
+ }
101
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return null
102
+ return value
103
+ }
104
+
105
+ function extractFilePaths (toolName, toolInput, cwd) {
106
+ const seen = new Set()
107
+ return extractRawFilePaths(toolName, toolInput).map(value =>
108
+ path.isAbsolute(value) ? value : path.resolve(cwd || process.cwd(), value)
109
+ ).filter(value => {
110
+ if (seen.has(value)) return false
111
+ seen.add(value)
112
+ return true
113
+ })
114
+ }
115
+
39
116
  /**
40
117
  * PreToolUse handler. Appends a tracking entry for potentially-modifying tools.
41
118
  * Always exits 0 (never blocks tool execution).
@@ -53,17 +130,17 @@ function handleTrack (input, root) {
53
130
 
54
131
  const toolInput = hookInput.toolInput || hookInput.tool_input || {}
55
132
 
56
- const entry = { tool: toolName, t: Date.now() }
57
-
58
- // Extract file path for file-modifying tools
59
- const filePath = extractFilePath(toolInput)
60
- if (filePath) {
61
- entry.file = filePath
62
- }
133
+ const cwd = hookInput.cwd || hookInput.raw?.cwd || root
134
+ const entry = { tool: toolName, t: Date.now(), cwd }
135
+ const rawFiles = extractRawFilePaths(toolName, toolInput)
136
+ const files = extractFilePaths(toolName, toolInput, cwd)
137
+ if (rawFiles.length > 0) entry.rawFiles = rawFiles
138
+ if (files.length > 0) entry.files = files
63
139
 
64
140
  // For Bash, record the command
65
141
  if (toolName === 'Bash' && typeof toolInput.command === 'string') {
66
142
  entry.command = toolInput.command
143
+ saveBashSnapshot(root, sessionId, hookInput.toolUseId || hookInput.tool_use_id)
67
144
  }
68
145
 
69
146
  // Skip Bash with no command (malformed input). All other non-Bash tools
@@ -77,6 +154,158 @@ function handleTrack (input, root) {
77
154
  fs.appendFileSync(file, JSON.stringify(entry) + '\n')
78
155
  }
79
156
 
157
+ function handlePostTrack (input, root) {
158
+ const hookInput = typeof input === 'string' ? parseInput(input) : input
159
+ root = root || hookInput?.root
160
+ if (!root || !hookInput) return
161
+
162
+ const sessionId = hookInput.sessionId || hookInput.session_id
163
+ const toolName = hookInput.toolName || hookInput.tool_name
164
+ if (!sessionId || toolName !== 'Bash') return
165
+
166
+ const cwd = hookInput.cwd || hookInput.raw?.cwd || root
167
+ const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
168
+ const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
169
+ if (!snapshot) return
170
+
171
+ const now = Date.now()
172
+ const overlapping = hasOverlappingBashSnapshot(root, snapshot, now)
173
+ snapshot.endedAt = now
174
+ writeBashSnapshot(root, sessionId, toolUseId, snapshot)
175
+
176
+ const before = new Set(snapshot.before.map(canonicalTrackedPath))
177
+ const claimed = claimedPathsByOtherSessions(root, sessionId)
178
+ const files = overlapping
179
+ ? []
180
+ : changedPathsInRepository(snapshot.root)
181
+ .map(canonicalTrackedPath)
182
+ .filter(file => !before.has(file) && !claimed.has(file))
183
+
184
+ const entry = { tool: 'Bash', phase: 'post', t: now, cwd }
185
+ if (overlapping) entry.overlapping = true
186
+ if (files.length > 0) {
187
+ entry.rawFiles = files
188
+ entry.files = files
189
+ }
190
+ appendTracking(root, sessionId, entry)
191
+ }
192
+
193
+ function bashSnapshotDir (root) {
194
+ const base = turbocommitDir(root)
195
+ return base && path.join(base, 'bash-snapshots')
196
+ }
197
+
198
+ function bashSnapshotPath (root, sessionId, toolUseId) {
199
+ const dir = bashSnapshotDir(root)
200
+ if (!dir) return null
201
+ const key = crypto.createHash('sha256')
202
+ .update(`${sessionId}\0${toolUseId || 'current'}`)
203
+ .digest('hex')
204
+ return path.join(dir, key + '.json')
205
+ }
206
+
207
+ function saveBashSnapshot (root, sessionId, toolUseId) {
208
+ const checkout = canonicalRoot(root)
209
+ pruneBashSnapshots(root)
210
+ writeBashSnapshot(root, sessionId, toolUseId, {
211
+ root: checkout,
212
+ sessionId,
213
+ toolUseId: toolUseId || null,
214
+ startedAt: Date.now(),
215
+ before: changedPathsInRepository(checkout)
216
+ })
217
+ }
218
+
219
+ function writeBashSnapshot (root, sessionId, toolUseId, snapshot) {
220
+ const file = bashSnapshotPath(root, sessionId, toolUseId)
221
+ if (!file) return
222
+ ensureDir(path.dirname(file))
223
+ const temporary = file + `.${process.pid}.${crypto.randomUUID()}.tmp`
224
+ try {
225
+ fs.writeFileSync(temporary, JSON.stringify(snapshot) + '\n')
226
+ fs.renameSync(temporary, file)
227
+ } finally {
228
+ try { fs.unlinkSync(temporary) } catch {}
229
+ }
230
+ }
231
+
232
+ function loadBashSnapshot (root, sessionId, toolUseId) {
233
+ try {
234
+ return JSON.parse(fs.readFileSync(bashSnapshotPath(root, sessionId, toolUseId), 'utf8'))
235
+ } catch {
236
+ return null
237
+ }
238
+ }
239
+
240
+ function hasOverlappingBashSnapshot (root, current, now) {
241
+ const dir = bashSnapshotDir(root)
242
+ let files
243
+ try {
244
+ files = fs.readdirSync(dir)
245
+ } catch {
246
+ return false
247
+ }
248
+ return files.some(file => {
249
+ try {
250
+ const other = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
251
+ if (other.sessionId === current.sessionId && other.toolUseId === current.toolUseId) return false
252
+ if (canonicalRoot(other.root) !== canonicalRoot(current.root)) return false
253
+ const endedAt = Number.isFinite(other.endedAt) ? other.endedAt : now
254
+ return other.startedAt <= now && endedAt >= current.startedAt
255
+ } catch {
256
+ return false
257
+ }
258
+ })
259
+ }
260
+
261
+ function pruneBashSnapshots (root) {
262
+ const dir = bashSnapshotDir(root)
263
+ let files
264
+ try {
265
+ files = fs.readdirSync(dir)
266
+ } catch {
267
+ return
268
+ }
269
+ const cutoff = Date.now() - BASH_SNAPSHOT_TTL_MS
270
+ for (const file of files) {
271
+ try {
272
+ const fullPath = path.join(dir, file)
273
+ const snapshot = JSON.parse(fs.readFileSync(fullPath, 'utf8'))
274
+ if ((snapshot.endedAt || snapshot.startedAt || 0) < cutoff) fs.unlinkSync(fullPath)
275
+ } catch {}
276
+ }
277
+ }
278
+
279
+ function claimedPathsByOtherSessions (root, sessionId) {
280
+ const result = new Set()
281
+ const dir = trackingDir(root)
282
+ let files
283
+ try {
284
+ files = fs.readdirSync(dir)
285
+ } catch {
286
+ return result
287
+ }
288
+ for (const file of files) {
289
+ if (file === sessionId + '.jsonl' || !file.endsWith('.jsonl')) continue
290
+ try {
291
+ const entries = fs.readFileSync(path.join(dir, file), 'utf8').trim().split('\n')
292
+ for (const line of entries) {
293
+ const entry = JSON.parse(line)
294
+ if (!Array.isArray(entry.files)) continue
295
+ for (const claimed of entry.files) result.add(canonicalTrackedPath(claimed))
296
+ }
297
+ } catch {}
298
+ }
299
+ return result
300
+ }
301
+
302
+ function appendTracking (root, sessionId, entry) {
303
+ const file = trackingPath(root, sessionId)
304
+ if (!file) return
305
+ ensureDir(path.dirname(file))
306
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n')
307
+ }
308
+
80
309
  function parseInput (input) {
81
310
  try {
82
311
  return JSON.parse(input)
@@ -87,26 +316,29 @@ function parseInput (input) {
87
316
 
88
317
  /**
89
318
  * Check whether a session has tracked any file-modifying tool calls.
90
- * Bash entries alone don't count Bash is too noisy (ls, git status, etc.)
91
- * and we can't reliably distinguish read-only from write commands.
92
- * The definitive signal comes from Write/Edit/NotebookEdit/MCP tools.
319
+ * A Bash pre-hook alone doesn't count because shell commands may be read-only.
320
+ * A Bash post-hook counts only when its snapshot found newly dirty paths.
93
321
  */
94
322
  function hasTrackedModifications (root, sessionId) {
323
+ return readTracking(root, sessionId).some(entry =>
324
+ entry.tool !== 'Bash' || (entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
325
+ )
326
+ }
327
+
328
+ function readTracking (root, sessionId) {
95
329
  const file = trackingPath(root, sessionId)
96
330
  try {
97
331
  const data = fs.readFileSync(file, 'utf8')
98
- if (!data) return false
99
- const lines = data.trim().split('\n')
100
- return lines.some(line => {
332
+ if (!data) return []
333
+ return data.trim().split('\n').map(line => {
101
334
  try {
102
- const entry = JSON.parse(line)
103
- return entry.tool !== 'Bash'
335
+ return JSON.parse(line)
104
336
  } catch {
105
- return false
337
+ return null
106
338
  }
107
- })
339
+ }).filter(Boolean)
108
340
  } catch {
109
- return false
341
+ return []
110
342
  }
111
343
  }
112
344
 
@@ -121,9 +353,13 @@ function cleanupTracking (root, sessionId) {
121
353
 
122
354
  module.exports = {
123
355
  handleTrack,
356
+ handlePostTrack,
124
357
  hasTrackedModifications,
125
358
  cleanupTracking,
126
359
  extractFilePath,
360
+ extractFilePaths,
361
+ extractRawFilePaths,
362
+ readTracking,
127
363
  trackingDir,
128
364
  trackingPath
129
365
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@searls/turbocommit",
3
- "version": "0.13.2",
3
+ "version": "0.15.0",
4
4
  "description": "Auto-commit after every AI coding agent turn",
5
5
  "bin": {
6
6
  "turbocommit": "./cli.js"