@searls/turbocommit 0.15.1 → 0.15.3

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
@@ -25,15 +25,19 @@ turbocommit registers hooks with the harnesses you use:
25
25
 
26
26
  - **PreToolUse** tentatively claims the paths supplied to editing tools while
27
27
  recovery finishes, promotes the claim only when the tool may proceed, and
28
- snapshots the repository before shell commands.
29
- - **PostToolUse** attributes paths that became dirty during a shell command.
28
+ snapshots the repository before shell commands and MCP tool calls.
29
+ - **PostToolUse** attributes paths that became dirty during a shell command or
30
+ MCP tool call, so files a tool rewrites without naming them (an Xcode project
31
+ file, for example) belong to the session that changed them.
30
32
  Concurrent commands in one session share ownership. Commands from different
31
33
  sessions retain hashed overlap evidence instead of claiming each other's
32
34
  paths. Once every involved turn stops and no shell remains active in that
33
35
  checkout, unchanged and otherwise-unclaimed paths are committed separately
34
36
  as recovered shell changes. Evidence is scoped to its exact worktree and is
35
37
  permanently discarded if file contents or executable modes change, or if an
36
- unfinished shell snapshot expires.
38
+ unfinished shell snapshot expires. Claims from a session whose turn has been
39
+ idle for over an hour no longer block shell attribution, so a turn that never
40
+ stopped cannot leave its paths unowned forever.
37
41
  - **PostToolUseFailure**, **Stop**, and **SessionEnd** finalize shell snapshots
38
42
  whose normal post hook did not arrive. Claude normally finishes within its
39
43
  SessionEnd hook. If its internal deadline expires, Turbocommit stores the
package/lib/install.js CHANGED
@@ -11,11 +11,11 @@ const HOOK_DEFS = {
11
11
  hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness claude' }]
12
12
  },
13
13
  PostToolUse: {
14
- matcher: 'Bash',
14
+ matcher: 'Bash|mcp__.*',
15
15
  hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
16
16
  },
17
17
  PostToolUseFailure: {
18
- matcher: 'Bash',
18
+ matcher: 'Bash|mcp__.*',
19
19
  hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
20
20
  },
21
21
  SessionStart: {
@@ -35,7 +35,7 @@ const CODEX_HOOK_DEFS = {
35
35
  hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness codex' }]
36
36
  },
37
37
  PostToolUse: {
38
- matcher: 'Bash',
38
+ matcher: 'Bash|mcp__.*',
39
39
  hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness codex' }]
40
40
  },
41
41
  SessionStart: {
package/lib/session.js CHANGED
@@ -328,11 +328,10 @@ function trackingMayDescribeModifications (file) {
328
328
  } catch {
329
329
  return true
330
330
  }
331
+ const { describesModification } = require('./track')
331
332
  return lines.some(line => {
332
333
  try {
333
- const entry = JSON.parse(line)
334
- return entry.tool !== 'Bash' ||
335
- (entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
334
+ return describesModification(JSON.parse(line))
336
335
  } catch {
337
336
  return true
338
337
  }
package/lib/track.js CHANGED
@@ -8,6 +8,11 @@ const { turbocommitDir } = require('./session')
8
8
  const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository, gitCommonDir } = require('./git')
9
9
 
10
10
  const BASH_SNAPSHOT_TTL_MS = 60 * 60 * 1000
11
+ // A session's tracking file is rewritten on every tool call and deleted when
12
+ // its turn stops. One that has been idle this long belongs to a turn that
13
+ // never stopped, so its claims must not block other sessions from owning
14
+ // paths they change with shell commands.
15
+ const CLAIM_TTL_MS = 60 * 60 * 1000
11
16
 
12
17
  /**
13
18
  * Directory under the git common dir where turbocommit stores tracking state.
@@ -40,6 +45,8 @@ const FILE_PATH_KEYS = [
40
45
  'notebook_path',
41
46
  'relative_path',
42
47
  'relativePath',
48
+ 'sourcePath',
49
+ 'destinationPath',
43
50
  'uri'
44
51
  ]
45
52
  const FILE_PATH_ARRAY_KEYS = ['file_paths', 'filePaths', 'paths', 'files']
@@ -119,6 +126,15 @@ function extractFilePaths (toolName, toolInput, cwd) {
119
126
  })
120
127
  }
121
128
 
129
+ /**
130
+ * Bash and MCP tools change paths they never name (a shell script, an Xcode
131
+ * project file rewritten as a side effect), so both are attributed by
132
+ * snapshotting the checkout before and after the call.
133
+ */
134
+ function snapshotsRepository (toolName) {
135
+ return toolName === 'Bash' || (typeof toolName === 'string' && toolName.startsWith('mcp__'))
136
+ }
137
+
122
138
  /**
123
139
  * PreToolUse handler. Persists ownership before waiting for overlap recovery.
124
140
  * Returns false when the caller must deny the tool rather than let it run
@@ -153,9 +169,9 @@ function handleTrack (input, root, opts = {}) {
153
169
  let pendingSnapshot = false
154
170
  let preclaim = null
155
171
  try {
156
- if (toolName === 'Bash') {
157
- entry.command = toolInput.command
158
- savePendingBashSnapshot(root, sessionId, toolUseId, cwd)
172
+ if (snapshotsRepository(toolName)) {
173
+ if (toolName === 'Bash') entry.command = toolInput.command
174
+ savePendingBashSnapshot(root, sessionId, toolUseId, cwd, toolName)
159
175
  pendingSnapshot = true
160
176
  appendTracking(root, sessionId, entry)
161
177
  } else {
@@ -205,7 +221,7 @@ function handlePostTrack (input, root) {
205
221
 
206
222
  const sessionId = hookInput.sessionId || hookInput.session_id
207
223
  const toolName = hookInput.toolName || hookInput.tool_name
208
- if (!sessionId || toolName !== 'Bash') return
224
+ if (!sessionId || !snapshotsRepository(toolName)) return
209
225
 
210
226
  const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
211
227
  const release = acquireBashOverlapRecoveryLock(root, 5000)
@@ -234,7 +250,7 @@ function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
234
250
  const candidates = changed.filter(file => !before.has(file) && !claimed.has(file))
235
251
  const files = overlapping ? [] : candidates
236
252
 
237
- const entry = { tool: 'Bash', phase: 'post', t: endedAt, cwd }
253
+ const entry = { tool: snapshot.toolName || 'Bash', phase: 'post', t: endedAt, cwd }
238
254
  if (overlapping) {
239
255
  entry.overlapping = true
240
256
  entry.overlapEventId = recordBashOverlap(root, snapshot, overlaps, candidates, endedAt)
@@ -263,12 +279,13 @@ function bashSnapshotPath (root, sessionId, toolUseId) {
263
279
  return path.join(dir, key + '.json')
264
280
  }
265
281
 
266
- function savePendingBashSnapshot (root, sessionId, toolUseId, cwd) {
282
+ function savePendingBashSnapshot (root, sessionId, toolUseId, cwd, toolName = 'Bash') {
267
283
  const checkout = canonicalRoot(root)
268
284
  writeBashSnapshot(root, sessionId, toolUseId, {
269
285
  root: checkout,
270
286
  cwd: cwd || checkout,
271
287
  sessionId,
288
+ toolName,
272
289
  toolUseId: toolUseId || null,
273
290
  createdAt: Date.now(),
274
291
  pending: true
@@ -431,9 +448,11 @@ function claimedPathsBySessions (root, excludedSessionId) {
431
448
  } catch {
432
449
  files = []
433
450
  }
451
+ const cutoff = Date.now() - CLAIM_TTL_MS
434
452
  for (const file of files) {
435
453
  if (file === excludedSessionId + '.jsonl' || !file.endsWith('.jsonl')) continue
436
454
  try {
455
+ if (fs.statSync(path.join(dir, file)).mtimeMs < cutoff) continue
437
456
  const entries = fs.readFileSync(path.join(dir, file), 'utf8').trim().split('\n')
438
457
  for (const line of entries) {
439
458
  const entry = JSON.parse(line)
@@ -452,6 +471,7 @@ function claimedPathsBySessions (root, excludedSessionId) {
452
471
  for (const file of claims) {
453
472
  if (!file.endsWith('.json')) continue
454
473
  try {
474
+ if (fs.statSync(path.join(claimsDir, file)).mtimeMs < cutoff) continue
455
475
  const claim = JSON.parse(fs.readFileSync(path.join(claimsDir, file), 'utf8'))
456
476
  if (claim.sessionId === excludedSessionId || !Array.isArray(claim.entry?.files)) continue
457
477
  for (const claimed of claim.entry.files) result.add(canonicalTrackedPath(claimed))
@@ -910,12 +930,15 @@ function parseInput (input) {
910
930
  /**
911
931
  * Check whether a session has tracked any file-modifying tool calls.
912
932
  * A Bash pre-hook alone doesn't count because shell commands may be read-only.
913
- * A Bash post-hook counts only when its snapshot found newly dirty paths.
933
+ * A snapshot post-hook counts only when it found newly dirty paths.
914
934
  */
915
935
  function hasTrackedModifications (root, sessionId) {
916
- return readTracking(root, sessionId).some(entry =>
917
- entry.tool !== 'Bash' || (entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
918
- )
936
+ return readTracking(root, sessionId).some(describesModification)
937
+ }
938
+
939
+ function describesModification (entry) {
940
+ if (entry.phase === 'post') return Array.isArray(entry.files) && entry.files.length > 0
941
+ return entry.tool !== 'Bash'
919
942
  }
920
943
 
921
944
  function readTracking (root, sessionId) {
@@ -978,6 +1001,7 @@ module.exports = {
978
1001
  fingerprintTrackedPath,
979
1002
  claimedPathsBySessions,
980
1003
  hasTrackedModifications,
1004
+ describesModification,
981
1005
  cleanupTracking,
982
1006
  extractFilePath,
983
1007
  extractFilePaths,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@searls/turbocommit",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "Auto-commit after every AI coding agent turn",
5
5
  "bin": {
6
6
  "turbocommit": "./cli.js"