@searls/turbocommit 0.14.1 → 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,22 @@ 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.
34
37
 
35
38
  ### Changes across multiple repositories
36
39
 
@@ -43,7 +46,8 @@ additional repositories.
43
46
  Each touched checkout:
44
47
 
45
48
  - Reads its own merged global and project configuration.
46
- - Keeps the normal `git add -A` behavior.
49
+ - Stages only paths attributed to the current session, preserving unrelated
50
+ staged and unstaged work.
47
51
  - Gets an independent commit with the full turn transcript.
48
52
  - Gives the configured title agent repository-specific bounded diff context.
49
53
 
@@ -56,6 +60,27 @@ committed and pushed before enabled parent repositories. When one turn creates
56
60
  commits in multiple repositories, each commit includes a shared
57
61
  `Turbocommit-Session` trailer.
58
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.
83
+
59
84
  The commit message headline is generated by a title agent (configurable).
60
85
  The body contains the full prompt/response transcript. When planning
61
86
  context was buffered from ancestor sessions, it appears under a
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
@@ -13,12 +13,32 @@ function git (args, opts = {}) {
13
13
 
14
14
  function gitRoot (cwd) {
15
15
  try {
16
- return git('rev-parse --show-toplevel', { cwd })
16
+ return canonicalRoot(git('rev-parse --show-toplevel', { cwd }))
17
17
  } catch {
18
18
  return null
19
19
  }
20
20
  }
21
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
+
22
42
  function gitRootForPath (filePath) {
23
43
  let probe = filePath
24
44
  try {
@@ -34,6 +54,25 @@ function gitRootForPath (filePath) {
34
54
  return gitRoot(probe)
35
55
  }
36
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
+
37
76
  function gitCommonDir (cwd) {
38
77
  try {
39
78
  const out = git('rev-parse --git-common-dir', { cwd })
@@ -60,6 +99,10 @@ function hasChanges (cwd) {
60
99
  return untracked.length > 0
61
100
  }
62
101
 
102
+ function hasPathChanges (cwd, filePath) {
103
+ return changedPaths(cwd, [filePath]).length > 0
104
+ }
105
+
63
106
  function addAndCommit (cwd, headline, body) {
64
107
  stageAll(cwd)
65
108
  commitStaged(cwd, headline, body)
@@ -70,15 +113,39 @@ function stageAll (cwd) {
70
113
  git('add -A', { cwd })
71
114
  }
72
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
+
73
137
  function commitStaged (cwd, headline, body) {
74
138
  git(`commit -m "${esc(headline)}" -m "${esc(body)}" --no-verify`, { cwd })
75
139
  return git('rev-parse HEAD', { cwd })
76
140
  }
77
141
 
78
- function stagedChangeContext (cwd, budget = 20000) {
79
- const status = git('status --short', { cwd })
80
- const stat = git('diff --cached --stat', { cwd })
81
- const diff = git('diff --cached --no-ext-diff --unified=3', { cwd })
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 })
82
149
  const prefix = `Status:\n${status || '(clean)'}\n\nDiff stat:\n${stat || '(none)'}\n\nDiff:\n`
83
150
  const remaining = Math.max(0, budget - prefix.length)
84
151
  return prefix + (diff.length > remaining ? diff.slice(0, remaining) + '\n[... diff truncated ...]' : diff)
@@ -88,7 +155,7 @@ function isGitlink (parentRoot, childRoot) {
88
155
  const relative = path.relative(parentRoot, childRoot)
89
156
  if (!relative || relative.startsWith('..')) return false
90
157
  try {
91
- return git(`ls-files --stage -- "${esc(relative)}"`, { cwd: parentRoot })
158
+ return git(`ls-files --stage -- ${quote(literalPathspec(relative))}`, { cwd: parentRoot })
92
159
  .split('\n')
93
160
  .some(line => line.startsWith('160000 '))
94
161
  } catch {
@@ -126,14 +193,97 @@ function esc (s) {
126
193
  return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')
127
194
  }
128
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
+
129
270
  module.exports = {
130
271
  git,
272
+ canonicalRoot,
273
+ canonicalTrackedPath,
131
274
  gitRoot,
132
275
  gitRootForPath,
276
+ gitWorktrees,
133
277
  gitCommonDir,
134
278
  hasChanges,
279
+ changedPaths,
280
+ changedPathsInRepository,
281
+ hasPathChanges,
135
282
  addAndCommit,
136
283
  stageAll,
284
+ stagePaths,
285
+ commitPaths,
286
+ commitStagedPaths,
137
287
  commitStaged,
138
288
  stagedChangeContext,
139
289
  hasCommits,
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
  },
@@ -26,6 +30,10 @@ const CODEX_HOOK_DEFS = {
26
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 CHANGED
@@ -3,7 +3,7 @@ const fs = require('fs')
3
3
  const path = require('path')
4
4
  const { ensureDir, loadJson } = require('./io')
5
5
  const { turbocommitDir } = require('./session')
6
- const { gitRoot, gitRootForPath, isGitlink } = require('./git')
6
+ const { canonicalRoot, canonicalTrackedPath, gitRoot, gitRootForPath, gitWorktrees, hasPathChanges, isGitlink } = require('./git')
7
7
  const { activeConfig } = require('./config')
8
8
 
9
9
  function checkoutKey (root) {
@@ -15,13 +15,16 @@ function manifestPath (anchor) {
15
15
  }
16
16
 
17
17
  function loadManifest (anchor) {
18
- return loadJson(manifestPath(anchor)) || { nextOrder: 0, repos: [] }
18
+ return normalizeManifest(loadJson(manifestPath(anchor)))
19
19
  }
20
20
 
21
21
  function saveManifest (anchor, manifest) {
22
22
  const file = manifestPath(anchor)
23
23
  ensureDir(path.dirname(file))
24
- fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n')
24
+ fs.writeFileSync(file, JSON.stringify({
25
+ nextOrder: manifest.nextOrder,
26
+ repos: manifest.repos
27
+ }, null, 2) + '\n')
25
28
  }
26
29
 
27
30
  function deleteManifestIfEmpty (anchor, manifest) {
@@ -34,41 +37,91 @@ function deleteManifestIfEmpty (anchor, manifest) {
34
37
  } catch {}
35
38
  }
36
39
 
37
- function rootsFromEntries (anchor, entries) {
38
- const roots = []
39
- const seen = new Set()
40
- const add = root => {
41
- if (!root) return
42
- let real
43
- try {
44
- real = fs.realpathSync(root)
45
- } catch {
46
- return
47
- }
48
- if (seen.has(real)) return
49
- if (activeConfig(real).config.enabled !== true) return
50
- seen.add(real)
51
- roots.push(real)
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))
52
51
  }
53
52
 
54
53
  for (const entry of entries) {
55
- if (entry.tool === 'Bash') continue
56
- if (Array.isArray(entry.files) && entry.files.length > 0) {
57
- for (const file of entry.files) add(gitRootForPath(file) || anchor)
58
- } else {
59
- add(anchor)
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
+ }
60
67
  }
61
68
  }
62
- return addEnabledSuperprojects(roots, add)
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__')
63
102
  }
64
103
 
65
- function addEnabledSuperprojects (roots, add) {
66
- for (let i = 0; i < roots.length; i++) {
67
- const child = roots[i]
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()]) {
68
122
  const parent = gitRoot(path.dirname(child))
69
- if (parent && parent !== child && isGitlink(parent, child)) add(parent)
123
+ if (parent && parent !== child && isGitlink(parent, child)) add(parent, child)
70
124
  }
71
- return roots
72
125
  }
73
126
 
74
127
  function dependencyOrder (repos) {
@@ -89,6 +142,29 @@ function appendWork (manifest, root, work) {
89
142
  return repo
90
143
  }
91
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
+
92
168
  function multiWatermarkPath (root, sessionId) {
93
169
  return path.join(turbocommitDir(root), 'multi-watermarks', checkoutKey(root), sessionId + '.json')
94
170
  }
@@ -115,7 +191,7 @@ module.exports = {
115
191
  loadManifest,
116
192
  manifestPath,
117
193
  readMultiWatermark,
118
- rootsFromEntries,
194
+ trackedChangesFromEntries,
119
195
  saveManifest,
120
196
  saveMultiWatermark
121
197
  }
package/lib/run.js CHANGED
@@ -4,7 +4,7 @@ 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, stageAll, commitStaged, stagedChangeContext, 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
10
  const { hasTrackedModifications, cleanupTracking, readTracking } = require('./track')
@@ -19,7 +19,7 @@ const {
19
19
  dependencyOrder,
20
20
  loadManifest,
21
21
  readMultiWatermark,
22
- rootsFromEntries,
22
+ trackedChangesFromEntries,
23
23
  saveManifest,
24
24
  saveMultiWatermark
25
25
  } = require('./multi')
@@ -97,7 +97,7 @@ function resolveCoauthor (config, transcriptPath, root, opts = {}) {
97
97
  * Always exits 0 — never blocks Claude, never outputs to stdout.
98
98
  *
99
99
  * Skip/commit decision is based on PreToolUse tracking:
100
- * - If tracking file exists with entriesthis agent modified files → commit
100
+ * - If tracking file contains changed pathscommit only those paths
101
101
  * - If tracking file missing/empty → skip, buffer transcript for later pickup
102
102
  */
103
103
  function run (input, opts = {}) {
@@ -111,16 +111,29 @@ function run (input, opts = {}) {
111
111
  if (!root || activeConfig(root).config.enabled !== true) return
112
112
 
113
113
  const entries = hookInput.sessionId ? readTracking(root, hookInput.sessionId) : []
114
- const roots = rootsFromEntries(root, entries)
114
+ const tracked = trackedChangesFromEntries(root, entries)
115
115
  const manifest = loadManifest(root)
116
- const hasCrossRootPath = entries.some(entry =>
117
- Array.isArray(entry.files) && entry.files.length > 0
118
- ) && (roots.length !== 1 || roots[0] !== 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)
119
129
 
120
- if (manifest.repos.length > 0 || roots.length > 1 || hasCrossRootPath) {
121
- return runMulti(hookInput, root, roots, manifest)
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)
122
135
  }
123
- return runSingle(hookInput)
136
+ return runSingle(hookInput, { paths: tracked.repos[0]?.paths || [] })
124
137
  }
125
138
 
126
139
  function runSingle (input, opts = {}) {
@@ -153,6 +166,7 @@ function runSingle (input, opts = {}) {
153
166
  try { context = fs.statSync(transcriptPath).size } catch {}
154
167
 
155
168
  const sessionId = hookInput.sessionId
169
+ const ownedPaths = opts.paths || []
156
170
 
157
171
  // Watermark slicing: only include new pairs since last commit in this session
158
172
  const watermark = sessionId ? readWatermark(root, sessionId) : null
@@ -172,21 +186,38 @@ function runSingle (input, opts = {}) {
172
186
  if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
173
187
  savePending(root, sessionId, formatBody(effectivePairs))
174
188
  }
175
- 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
+ })
176
206
  cleanupStale(root)
177
207
  return
178
208
  }
179
209
 
180
210
  try {
181
211
  // Early exit: tracking fired but all changes were reverted
182
- if (hasCommits(root) && !hasChanges(root)) {
212
+ const hasOwnedChanges = sessionId ? changedPaths(root, ownedPaths).length > 0 : hasChanges(root)
213
+ if (!hasOwnedChanges) {
183
214
  if (sessionId) {
184
215
  if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
185
216
  savePending(root, sessionId, formatBody(effectivePairs))
186
217
  }
187
218
  cleanupTracking(root, sessionId)
188
219
  }
189
- logEvent('skip', { harness: hookInput.harness, project, branch, context })
220
+ logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
190
221
  cleanupStale(root)
191
222
  return
192
223
  }
@@ -247,7 +278,21 @@ function runSingle (input, opts = {}) {
247
278
  const redactions = buildRedactions()
248
279
  const safeHeadline = redact(headline, redactions)
249
280
  const safeBody = redact(wrappedBody + tag, redactions)
250
- 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
+ }
251
296
 
252
297
  logEvent('success', { harness: hookInput.harness, project, branch, context, title: safeHeadline })
253
298
 
@@ -273,7 +318,7 @@ function runSingle (input, opts = {}) {
273
318
  }
274
319
  }
275
320
 
276
- function runMulti (hookInput, anchor, currentRoots, manifest) {
321
+ function runMulti (hookInput, anchor, currentRepos, manifest) {
277
322
  recordCodexStop(hookInput, anchor)
278
323
 
279
324
  const transcriptPath = resolveTranscriptPath(hookInput)
@@ -305,12 +350,12 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
305
350
  '\n\n## Implementation\n\n' + formattedTranscript
306
351
  : formattedTranscript
307
352
 
308
- const dirtyRoots = currentRoots.filter(root => !hasCommits(root) || hasChanges(root))
309
- const isMultiTurn = currentRoots.length > 1
353
+ const dirtyRepos = currentRepos.filter(repo => changedPaths(repo.root, repo.paths).length > 0)
354
+ const isMultiTurn = currentRepos.length > 1
310
355
  const turnKey = hookInput.raw?.turn_id ||
311
356
  cryptoKey(`${sessionId || 'no-session'}\n${pairs.length}\n${turnBody}`)
312
357
 
313
- if (dirtyRoots.length === 0 && manifest.repos.length === 0) {
358
+ if (dirtyRepos.length === 0 && manifest.repos.length === 0) {
314
359
  if (sessionId) {
315
360
  savePending(anchor, sessionId, formattedTranscript)
316
361
  cleanupTracking(anchor, sessionId)
@@ -319,16 +364,18 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
319
364
  harness: hookInput.harness,
320
365
  project: path.basename(anchor),
321
366
  branch: currentBranch(anchor),
322
- context: transcriptSize(transcriptPath)
367
+ context: transcriptSize(transcriptPath),
368
+ reason: 'no-path-changes'
323
369
  })
324
370
  return
325
371
  }
326
372
 
327
- for (const root of currentRoots) {
328
- appendWork(manifest, root, {
373
+ for (const repo of currentRepos) {
374
+ appendWork(manifest, repo.root, {
329
375
  key: turnKey,
330
376
  body: turnBody,
331
- sessionIds: isMultiTurn && sessionId ? [sessionId] : []
377
+ sessionIds: isMultiTurn && sessionId ? [sessionId] : [],
378
+ paths: repo.paths
332
379
  })
333
380
  }
334
381
 
@@ -342,25 +389,42 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
342
389
  const project = path.basename(root)
343
390
  const branch = currentBranch(root)
344
391
  const context = transcriptSize(transcriptPath)
392
+ const ownedPaths = [...new Set(repo.work.flatMap(item => item.paths || []))]
345
393
 
346
394
  if (config.enabled !== true) {
347
395
  manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
348
396
  logEvent('skip', { harness: hookInput.harness, project, branch, context })
349
397
  continue
350
398
  }
351
- if (hasCommits(root) && !hasChanges(root)) {
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) {
352
411
  manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
353
- logEvent('skip', { harness: hookInput.harness, project, branch, context })
412
+ logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
354
413
  continue
355
414
  }
356
415
 
357
416
  try {
358
417
  logEvent('start', { harness: hookInput.harness, project, branch, context })
359
- stageAll(root)
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
+ }
360
424
 
361
425
  const rawBody = repo.work.map(item => item.body).join('\n\n---\n\n')
362
426
  const redactions = buildRedactions()
363
- const changeContext = redact(stagedChangeContext(root, 10000), redactions)
427
+ const changeContext = redact(stagedChangeContext(root, 10000, stagedPaths), redactions)
364
428
  const titleInput = `${changeContext}\n\nTranscript:\n${rawBody}`.slice(0, 20000)
365
429
 
366
430
  let headline
@@ -399,7 +463,8 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
399
463
 
400
464
  const safeHeadline = redact(headline, redactions)
401
465
  const safeBody = redact(body, redactions)
402
- const sha = commitStaged(root, safeHeadline, safeBody)
466
+ const sha = commitStagedPaths(root, stagedPaths, safeHeadline, safeBody)
467
+ if (!sha) throw new Error('Tracked paths changed before commit')
403
468
  if (sessionId) saveMultiWatermark(root, sessionId, sha)
404
469
  if (root === anchor && sessionId) {
405
470
  saveWatermark(anchor, sessionId, pairs.length, sha, { source: 'commit' })
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,22 +44,21 @@ 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
 
39
- function extractFilePaths (toolName, toolInput, cwd) {
53
+ function extractRawFilePaths (toolName, toolInput) {
40
54
  const paths = []
41
55
  const seen = new Set()
42
- const add = value => {
43
- if (typeof value !== 'string' || value.length === 0) return
44
- const resolved = path.isAbsolute(value) ? value : path.resolve(cwd || process.cwd(), value)
45
- if (seen.has(resolved)) return
46
- seen.add(resolved)
47
- paths.push(resolved)
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)
48
62
  }
49
63
 
50
64
  if (toolName === 'apply_patch' && typeof toolInput?.command === 'string') {
@@ -63,7 +77,10 @@ function extractFilePaths (toolName, toolInput, cwd) {
63
77
  return
64
78
  }
65
79
  for (const [key, nested] of Object.entries(value)) {
66
- if (FILE_PATH_KEYS.includes(key)) add(nested)
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
+ }
67
84
  if (nested && typeof nested === 'object') visit(nested)
68
85
  }
69
86
  }
@@ -71,6 +88,31 @@ function extractFilePaths (toolName, toolInput, cwd) {
71
88
  return paths
72
89
  }
73
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
+
74
116
  /**
75
117
  * PreToolUse handler. Appends a tracking entry for potentially-modifying tools.
76
118
  * Always exits 0 (never blocks tool execution).
@@ -88,15 +130,17 @@ function handleTrack (input, root) {
88
130
 
89
131
  const toolInput = hookInput.toolInput || hookInput.tool_input || {}
90
132
 
91
- const entry = { tool: toolName, t: Date.now() }
92
-
93
133
  const cwd = hookInput.cwd || hookInput.raw?.cwd || root
134
+ const entry = { tool: toolName, t: Date.now(), cwd }
135
+ const rawFiles = extractRawFilePaths(toolName, toolInput)
94
136
  const files = extractFilePaths(toolName, toolInput, cwd)
137
+ if (rawFiles.length > 0) entry.rawFiles = rawFiles
95
138
  if (files.length > 0) entry.files = files
96
139
 
97
140
  // For Bash, record the command
98
141
  if (toolName === 'Bash' && typeof toolInput.command === 'string') {
99
142
  entry.command = toolInput.command
143
+ saveBashSnapshot(root, sessionId, hookInput.toolUseId || hookInput.tool_use_id)
100
144
  }
101
145
 
102
146
  // Skip Bash with no command (malformed input). All other non-Bash tools
@@ -110,6 +154,158 @@ function handleTrack (input, root) {
110
154
  fs.appendFileSync(file, JSON.stringify(entry) + '\n')
111
155
  }
112
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
+
113
309
  function parseInput (input) {
114
310
  try {
115
311
  return JSON.parse(input)
@@ -120,12 +316,13 @@ function parseInput (input) {
120
316
 
121
317
  /**
122
318
  * Check whether a session has tracked any file-modifying tool calls.
123
- * Bash entries alone don't count Bash is too noisy (ls, git status, etc.)
124
- * and we can't reliably distinguish read-only from write commands.
125
- * 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.
126
321
  */
127
322
  function hasTrackedModifications (root, sessionId) {
128
- return readTracking(root, sessionId).some(entry => entry.tool !== 'Bash')
323
+ return readTracking(root, sessionId).some(entry =>
324
+ entry.tool !== 'Bash' || (entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
325
+ )
129
326
  }
130
327
 
131
328
  function readTracking (root, sessionId) {
@@ -156,10 +353,12 @@ function cleanupTracking (root, sessionId) {
156
353
 
157
354
  module.exports = {
158
355
  handleTrack,
356
+ handlePostTrack,
159
357
  hasTrackedModifications,
160
358
  cleanupTracking,
161
359
  extractFilePath,
162
360
  extractFilePaths,
361
+ extractRawFilePaths,
163
362
  readTracking,
164
363
  trackingDir,
165
364
  trackingPath
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@searls/turbocommit",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Auto-commit after every AI coding agent turn",
5
5
  "bin": {
6
6
  "turbocommit": "./cli.js"