@searls/turbocommit 0.15.0 → 0.15.2

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
@@ -18,16 +18,35 @@ 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**, **PostToolUse**, **SessionStart**,
22
- **SessionEnd**, and **Stop**.
23
- - Codex: **PreToolUse**, **PostToolUse**, **SessionStart**, **PreCompact**,
24
- and **Stop**.
25
-
26
- - **PreToolUse** tracks the paths supplied to editing tools and snapshots the
27
- repository before shell commands.
21
+ - Claude Code: **PreToolUse**, **PostToolUse**, **PostToolUseFailure**,
22
+ **SessionStart**, **SessionEnd**, and **Stop**.
23
+ - Codex: **PreToolUse**, **PostToolUse**, **SessionStart**, **SessionEnd**,
24
+ **PreCompact**, and **Stop**.
25
+
26
+ - **PreToolUse** tentatively claims the paths supplied to editing tools while
27
+ recovery finishes, promotes the claim only when the tool may proceed, and
28
+ snapshots the repository before shell commands.
28
29
  - **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.
30
+ Concurrent commands in one session share ownership. Commands from different
31
+ sessions retain hashed overlap evidence instead of claiming each other's
32
+ paths. Once every involved turn stops and no shell remains active in that
33
+ checkout, unchanged and otherwise-unclaimed paths are committed separately
34
+ as recovered shell changes. Evidence is scoped to its exact worktree and is
35
+ permanently discarded if file contents or executable modes change, or if an
36
+ unfinished shell snapshot expires. Claims from a session whose turn has been
37
+ idle for over an hour no longer block shell attribution, so a turn that never
38
+ stopped cannot leave its paths unowned forever.
39
+ - **PostToolUseFailure**, **Stop**, and **SessionEnd** finalize shell snapshots
40
+ whose normal post hook did not arrive. Claude normally finishes within its
41
+ SessionEnd hook. If its internal deadline expires, Turbocommit stores the
42
+ worktree in a hidden local rescue ref before starting a detached worker. The
43
+ worker can recreate a removed checkout at its recorded commit, but it leaves
44
+ the rescue intact if the original checkout identity or branch has changed.
45
+ Every completed deadline rescue remains reachable through a hidden local
46
+ safety ref, including detached-HEAD recovery. Codex preserves the transcript
47
+ and starts the normal commit and recovery path in a detached worker so it can
48
+ finish beyond Codex's three-second hook deadline. Read-only tracking is then
49
+ removed.
31
50
  - **SessionStart / SessionEnd** chain sessions across `/clear` boundaries
32
51
  so planning context survives into the eventual commit.
33
52
  - **PreCompact** on Codex buffers visible transcript context before compaction.
@@ -76,6 +95,8 @@ dirty, and excludes newly discovered embedded repositories. Existing tracked
76
95
  submodule gitlinks remain eligible so child commits can update their parent.
77
96
  During an active merge, cherry-pick, revert, or rebase, Git forbids partial
78
97
  commits, so Turbocommit commits the operation's resolved index as a whole.
98
+ Recovered overlap evidence is deferred until the operation ends because its
99
+ generic commit must remain path-scoped.
79
100
 
80
101
  Retry manifests created by older versions did not record path ownership. On
81
102
  upgrade, Turbocommit discards those unsafe retry entries and records a
@@ -127,10 +148,11 @@ brew uninstall turbocommit
127
148
  ## Where turbocommit goes in your hook chain
128
149
 
129
150
  `turbocommit install` adds one hook group per event for each installed harness.
130
- The hooks are fire-and-forget: they always exit 0 and never block the agent.
151
+ The hooks ordinarily exit 0. A modifying PreToolUse hook denies the tool when
152
+ turbocommit cannot safely coordinate with an in-progress recovery, so the
153
+ agent can retry instead of making an unowned change.
131
154
 
132
155
  If you rearrange your hooks manually, the turbocommit hooks can go anywhere.
133
- Order doesn't matter since they never block.
134
156
 
135
157
  Codex users should restart Codex and run `/hooks` after install to review and
136
158
  trust the hook commands.
package/cli.js CHANGED
@@ -1,15 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { readStdin } = require('./lib/io')
3
+ const { spawn } = require('child_process')
4
+ const crypto = require('crypto')
5
+ const fs = require('fs')
6
+ const path = require('path')
7
+ const { readStdin, ensureDir } = require('./lib/io')
4
8
  const { install, uninstall } = require('./lib/install')
5
9
  const { init, deinit } = require('./lib/init')
6
- const { run, runPreCompact } = require('./lib/run')
7
- const { handleTrack, handlePostTrack } = require('./lib/track')
8
- const { handleSessionStart, handleSessionEnd } = require('./lib/session')
10
+ const { run, runPreCompact, recoverBashOverlaps } = require('./lib/run')
11
+ const { handleTrack, handlePostTrack, finalizeBashSnapshots, recordBashSessionStop, readTracking } = require('./lib/track')
12
+ const { handleSessionStart, handleSessionEnd, turbocommitDir } = require('./lib/session')
9
13
  const { doctor } = require('./lib/doctor')
10
14
  const { monitor } = require('./lib/monitor')
11
- const { gitRoot } = require('./lib/git')
15
+ const { gitRoot, pushClean } = require('./lib/git')
16
+ const { activeConfig } = require('./lib/config')
17
+ const { logEvent } = require('./lib/log')
12
18
  const { parseHarnessArg, normalizeHookInput } = require('./lib/harness')
19
+ const {
20
+ createSessionEndRescue,
21
+ restoreSessionEndRescue,
22
+ matchesSessionEndRescueRoot,
23
+ finalizeRestoredSessionEndRescue,
24
+ preserveSessionEndRecoveredCommit,
25
+ recordPreservedSessionEndRescue,
26
+ removeSessionEndRescueWorktree,
27
+ cleanupSessionEndRescue,
28
+ isClean
29
+ } = require('./lib/rescue')
13
30
 
14
31
  const VERSION = require('./package.json').version
15
32
 
@@ -59,6 +76,10 @@ function main (argv) {
59
76
  return cmdDeinit()
60
77
  case 'hook':
61
78
  return cmdHook(parsed.args.slice(1), parsed.harness)
79
+ case 'session-end-worker':
80
+ return cmdSessionEndWorker(parsed.args.slice(1), parsed.harness)
81
+ case 'session-end-rescue-worker':
82
+ return cmdSessionEndRescueWorker(parsed.args.slice(1))
62
83
  case 'run':
63
84
  return cmdRunDeprecated()
64
85
  case '--version':
@@ -165,6 +186,7 @@ function cmdMonitor (harness) {
165
186
  }
166
187
 
167
188
  function cmdHook (argv, harness) {
189
+ if (process.env.TURBOCOMMIT_DISABLED) return
168
190
  const event = argv[0]
169
191
  try {
170
192
  const input = readStdin()
@@ -173,7 +195,11 @@ function cmdHook (argv, harness) {
173
195
  const root = gitRoot(hookInput.cwd || process.cwd())
174
196
  switch (event) {
175
197
  case 'pre-tool-use':
176
- handleTrack(hookInput, root)
198
+ try {
199
+ if (handleTrack(hookInput, root) === false) denyPreToolUse()
200
+ } catch {
201
+ denyPreToolUse()
202
+ }
177
203
  return
178
204
  case 'post-tool-use':
179
205
  handlePostTrack(hookInput, root)
@@ -183,6 +209,13 @@ function cmdHook (argv, harness) {
183
209
  return
184
210
  case 'session-end':
185
211
  handleSessionEnd(hookInput, root)
212
+ if (hookInput.harness === 'claude') {
213
+ let completed = false
214
+ try { completed = completeSessionEnd(hookInput, root, 45000) } catch {}
215
+ if (!completed) rescueClaudeSessionEnd(hookInput, root)
216
+ } else {
217
+ spawnSessionEndWorker(hookInput, root, harness)
218
+ }
186
219
  return
187
220
  case 'pre-compact':
188
221
  runPreCompact(hookInput)
@@ -199,6 +232,214 @@ function cmdHook (argv, harness) {
199
232
  }
200
233
  }
201
234
 
235
+ function spawnSessionEndWorker (hookInput, root, harness) {
236
+ if (process.env.TURBOCOMMIT_DISABLED || !root || !hookInput.sessionId) return
237
+ const transcript = copySessionEndTranscript(root, hookInput.transcriptPath)
238
+ const payload = Buffer.from(JSON.stringify({
239
+ sessionId: hookInput.sessionId,
240
+ event: 'SessionEnd',
241
+ cwd: root,
242
+ harness: hookInput.harness || harness,
243
+ transcriptPath: transcript.path,
244
+ temporaryTranscript: transcript.temporary,
245
+ model: hookInput.model
246
+ })).toString('base64url')
247
+ const args = [__filename, 'session-end-worker', payload]
248
+ if (harness) args.push('--harness', harness)
249
+ const child = spawn(process.execPath, args, {
250
+ cwd: stableWorkerCwd(root),
251
+ detached: true,
252
+ stdio: 'ignore'
253
+ })
254
+ child.on('error', () => {
255
+ if (transcript.temporary) cleanupSessionEndTranscript(root, transcript.path)
256
+ })
257
+ child.unref()
258
+ }
259
+
260
+ function cmdSessionEndWorker (argv, harness) {
261
+ if (process.env.TURBOCOMMIT_DISABLED) return
262
+ let hookInput
263
+ try {
264
+ hookInput = JSON.parse(Buffer.from(argv[0], 'base64url').toString('utf8'))
265
+ } catch {
266
+ return
267
+ }
268
+ hookInput.harness = hookInput.harness || harness
269
+ const root = gitRoot(hookInput.cwd || process.cwd())
270
+ try {
271
+ if (!root || !hookInput.sessionId) return
272
+ completeSessionEnd(hookInput, root, Infinity)
273
+ } finally {
274
+ if (hookInput.temporaryTranscript) cleanupTemporarySessionEndTranscript(root, hookInput.transcriptPath)
275
+ }
276
+ }
277
+
278
+ function completeSessionEnd (hookInput, root, waitMs, { deferPush = false } = {}) {
279
+ if (!root || !hookInput.sessionId) return false
280
+ const deadline = waitMs === Infinity ? Infinity : Date.now() + waitMs
281
+ const remainingWait = () => deadline === Infinity ? Infinity : Math.max(0, deadline - Date.now())
282
+ const finalized = finalizeBashSnapshots(root, hookInput.sessionId, Date.now(), remainingWait())
283
+ if (finalized == null) return false
284
+ if (readTracking(root, hookInput.sessionId).length > 0) {
285
+ run(hookInput, { recoveryDeadline: deadline, operationDeadline: deadline, deferPush })
286
+ return readTracking(root, hookInput.sessionId).length === 0
287
+ }
288
+ recordBashSessionStop(root, hookInput.sessionId)
289
+ const recoveryConfig = deferPush ? { ...activeConfig(root).config, push: false } : undefined
290
+ recoverBashOverlaps(root, hookInput, recoveryConfig, remainingWait())
291
+ return true
292
+ }
293
+
294
+ function rescueClaudeSessionEnd (hookInput, root) {
295
+ if (!root || !hookInput.sessionId) return
296
+ const transcript = copySessionEndTranscript(root, hookInput.transcriptPath)
297
+ const rescueInput = {
298
+ ...hookInput,
299
+ transcriptPath: transcript.path,
300
+ temporaryTranscript: transcript.temporary
301
+ }
302
+ const rescue = createSessionEndRescue(root, rescueInput)
303
+ if (!rescue) {
304
+ if (transcript.temporary) cleanupTemporarySessionEndTranscript(root, transcript.path)
305
+ return
306
+ }
307
+ spawnSessionEndRescueWorker(rescue)
308
+ }
309
+
310
+ function spawnSessionEndRescueWorker (rescue) {
311
+ const payload = Buffer.from(JSON.stringify(rescue)).toString('base64url')
312
+ const child = spawn(process.execPath, [__filename, 'session-end-rescue-worker', payload], {
313
+ cwd: stableWorkerCwd(rescue.root),
314
+ detached: true,
315
+ stdio: 'ignore'
316
+ })
317
+ child.on('error', () => {})
318
+ child.unref()
319
+ }
320
+
321
+ function cmdSessionEndRescueWorker (argv) {
322
+ let rescue
323
+ try {
324
+ rescue = JSON.parse(Buffer.from(argv[0], 'base64url').toString('utf8'))
325
+ } catch {
326
+ return
327
+ }
328
+ let recreated = false
329
+ let completed = false
330
+ try {
331
+ const existingRoot = gitRoot(rescue.root)
332
+ if (existingRoot) {
333
+ if (!matchesSessionEndRescueRoot(rescue, existingRoot)) return
334
+ try { completed = completeSessionEnd(rescue.hookInput, existingRoot, Infinity) } catch {}
335
+ }
336
+ if (!completed && !fs.existsSync(rescue.root)) {
337
+ recreated = restoreRescueWithRetry(rescue)
338
+ if (recreated) {
339
+ try { completed = completeSessionEnd(rescue.hookInput, rescue.root, Infinity, { deferPush: true }) } catch {}
340
+ }
341
+ }
342
+ if (!completed) return
343
+ const clean = isClean(rescue.root)
344
+ if (!recreated) {
345
+ const recovered = preserveSessionEndRecoveredCommit(rescue, rescue.root)
346
+ if (!recovered) return
347
+ if (clean) {
348
+ cleanupSessionEndRescue(rescue)
349
+ } else {
350
+ recordPreservedSessionEndRescue(rescue, recovered, 'dirty-existing-worktree')
351
+ }
352
+ } else {
353
+ const finalized = finalizeRestoredSessionEndRescue(rescue, { clean })
354
+ if (finalized.resolved) {
355
+ pushRestoredSessionEndRescue(rescue, finalized)
356
+ if (isClean(rescue.root)) {
357
+ cleanupSessionEndRescue(rescue, { removeWorktree: true })
358
+ } else {
359
+ recordPreservedSessionEndRescue(rescue, finalized, 'dirty-attached-worktree')
360
+ }
361
+ } else if (finalized.preserved && finalized.removeWorktree) {
362
+ removeSessionEndRescueWorktree(rescue)
363
+ }
364
+ }
365
+ if (rescue.hookInput?.temporaryTranscript) {
366
+ cleanupTemporarySessionEndTranscript(rescue.root, rescue.hookInput.transcriptPath)
367
+ }
368
+ } catch {
369
+ // The rescue ref and record remain available for a later retry.
370
+ }
371
+ }
372
+
373
+ function pushRestoredSessionEndRescue (rescue, finalized) {
374
+ const config = activeConfig(rescue.root).config
375
+ if (config.push !== true) return
376
+ const pushed = finalized.attached && pushClean(rescue.root)
377
+ logEvent(pushed ? 'push' : 'push-fail', {
378
+ harness: rescue.hookInput?.harness,
379
+ project: path.basename(rescue.root),
380
+ branch: rescue.branch
381
+ })
382
+ }
383
+
384
+ function restoreRescueWithRetry (rescue) {
385
+ const deadline = Date.now() + 30000
386
+ const sleeper = new Int32Array(new SharedArrayBuffer(4))
387
+ while (!fs.existsSync(rescue.root)) {
388
+ if (restoreSessionEndRescue(rescue)) return true
389
+ if (Date.now() >= deadline) return false
390
+ Atomics.wait(sleeper, 0, 0, 100)
391
+ }
392
+ return false
393
+ }
394
+
395
+ function stableWorkerCwd (root) {
396
+ const base = turbocommitDir(root)
397
+ return base ? path.dirname(base) : root
398
+ }
399
+
400
+ function copySessionEndTranscript (root, source) {
401
+ if (!source) return { path: source, temporary: false }
402
+ const base = turbocommitDir(root)
403
+ if (!base) return { path: source, temporary: false }
404
+ const dir = path.join(base, 'session-end-transcripts')
405
+ const destination = path.join(dir, `${process.pid}-${crypto.randomUUID()}.jsonl`)
406
+ try {
407
+ ensureDir(dir)
408
+ fs.copyFileSync(source, destination)
409
+ return { path: destination, temporary: true }
410
+ } catch {
411
+ return { path: source, temporary: false }
412
+ }
413
+ }
414
+
415
+ function cleanupSessionEndTranscript (root, transcriptPath) {
416
+ const base = turbocommitDir(root)
417
+ if (!base || !transcriptPath) return false
418
+ const dir = path.join(base, 'session-end-transcripts')
419
+ if (path.dirname(path.resolve(transcriptPath)) !== path.resolve(dir)) return false
420
+ try { fs.unlinkSync(transcriptPath) } catch {}
421
+ return !fs.existsSync(transcriptPath)
422
+ }
423
+
424
+ function cleanupTemporarySessionEndTranscript (root, transcriptPath) {
425
+ if (root && cleanupSessionEndTranscript(root, transcriptPath)) return
426
+ if (!transcriptPath) return
427
+ const resolved = path.resolve(transcriptPath)
428
+ if (path.basename(path.dirname(resolved)) !== 'session-end-transcripts') return
429
+ if (!/^\d+-[0-9a-f-]{36}\.jsonl$/i.test(path.basename(resolved))) return
430
+ try { fs.unlinkSync(resolved) } catch {}
431
+ }
432
+
433
+ function denyPreToolUse () {
434
+ process.stdout.write(JSON.stringify({
435
+ hookSpecificOutput: {
436
+ hookEventName: 'PreToolUse',
437
+ permissionDecision: 'deny',
438
+ permissionDecisionReason: 'turbocommit could not safely persist path ownership; retry the tool'
439
+ }
440
+ }) + '\n')
441
+ }
442
+
202
443
  function cmdRunDeprecated () {
203
444
  let hookInput
204
445
  try {
package/lib/agent.js CHANGED
@@ -32,14 +32,18 @@ function renderPrompt (template, transcript) {
32
32
  return template.replace(/\{\{transcript\}\}/g, () => transcript)
33
33
  }
34
34
 
35
- function runAgent (root, command, prompt) {
35
+ function runAgent (root, command, prompt, opts = {}) {
36
36
  const binary = command.split(/\s+/)[0]
37
37
  const whichResult = tryRun(`which ${binary}`, {})
38
38
  if (whichResult.code !== 0) return null
39
+ const timeout = Number.isFinite(opts.deadline)
40
+ ? Math.min(45000, Math.max(0, opts.deadline - Date.now()))
41
+ : 45000
42
+ if (timeout <= 0) return null
39
43
 
40
44
  const result = tryRun(command, {
41
45
  cwd: root,
42
- timeout: 45000,
46
+ timeout,
43
47
  input: prompt,
44
48
  env: { ...process.env, TURBOCOMMIT_DISABLED: '1', CLAUDECODE: '' }
45
49
  })
@@ -54,21 +58,21 @@ function defaultCommand (harness) {
54
58
  return harness === 'codex' ? DEFAULT_CODEX_COMMAND : DEFAULT_COMMAND
55
59
  }
56
60
 
57
- function runTitleAgent (root, titleCfg, transcript, harness) {
61
+ function runTitleAgent (root, titleCfg, transcript, harness, opts) {
58
62
  const command = titleCfg.command || defaultCommand(harness)
59
63
  const template = titleCfg.prompt || DEFAULT_TITLE_PROMPT
60
64
  const prompt = renderPrompt(template, transcript)
61
- const result = runAgent(root, command, prompt)
65
+ const result = runAgent(root, command, prompt, opts)
62
66
  if (!result) return null
63
67
  // Take only first line and enforce 72 char limit
64
68
  return result.split('\n')[0].slice(0, 72) || null
65
69
  }
66
70
 
67
- function runBodyAgent (root, bodyCfg, transcript, harness) {
71
+ function runBodyAgent (root, bodyCfg, transcript, harness, opts) {
68
72
  const command = bodyCfg.command || defaultCommand(harness)
69
73
  const template = bodyCfg.prompt || DEFAULT_BODY_PROMPT
70
74
  const prompt = renderPrompt(template, transcript)
71
- return runAgent(root, command, prompt)
75
+ return runAgent(root, command, prompt, opts)
72
76
  }
73
77
 
74
78
  module.exports = {
package/lib/git.js CHANGED
@@ -121,15 +121,18 @@ function stagePaths (cwd, filePaths) {
121
121
  return changed
122
122
  }
123
123
 
124
- function commitPaths (cwd, filePaths, headline, body) {
124
+ function commitPaths (cwd, filePaths, headline, body, opts = {}) {
125
+ if (opts.pathScoped && hasRepositoryOperation(cwd)) return null
125
126
  const changed = stagePaths(cwd, filePaths)
126
- return commitStagedPaths(cwd, changed, headline, body)
127
+ return commitStagedPaths(cwd, changed, headline, body, opts)
127
128
  }
128
129
 
129
- function commitStagedPaths (cwd, filePaths, headline, body) {
130
+ function commitStagedPaths (cwd, filePaths, headline, body, opts = {}) {
130
131
  const paths = pathspecs(cwd, filePaths)
131
132
  if (paths.length === 0) return null
132
- if (hasRepositoryOperation(cwd)) return commitStaged(cwd, headline, body)
133
+ if (hasRepositoryOperation(cwd)) {
134
+ return opts.pathScoped ? null : commitStaged(cwd, headline, body)
135
+ }
133
136
  git(`commit --only -m "${esc(headline)}" -m "${esc(body)}" --no-verify -- ${paths.map(quote).join(' ')}`, { cwd })
134
137
  return git('rev-parse HEAD', { cwd })
135
138
  }
@@ -289,5 +292,6 @@ module.exports = {
289
292
  hasCommits,
290
293
  currentBranch,
291
294
  pushClean,
292
- isGitlink
295
+ isGitlink,
296
+ hasRepositoryOperation
293
297
  }
package/lib/install.js CHANGED
@@ -14,11 +14,15 @@ const HOOK_DEFS = {
14
14
  matcher: 'Bash',
15
15
  hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
16
16
  },
17
+ PostToolUseFailure: {
18
+ matcher: 'Bash',
19
+ hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
20
+ },
17
21
  SessionStart: {
18
22
  hooks: [{ type: 'command', command: 'turbocommit hook session-start --harness claude' }]
19
23
  },
20
24
  SessionEnd: {
21
- hooks: [{ type: 'command', command: 'turbocommit hook session-end --harness claude' }]
25
+ hooks: [{ type: 'command', command: 'turbocommit hook session-end --harness claude', timeout: 60 }]
22
26
  },
23
27
  Stop: {
24
28
  hooks: [{ type: 'command', command: 'turbocommit hook stop --harness claude' }]
@@ -38,6 +42,9 @@ const CODEX_HOOK_DEFS = {
38
42
  matcher: 'resume|clear',
39
43
  hooks: [{ type: 'command', command: 'turbocommit hook session-start --harness codex' }]
40
44
  },
45
+ SessionEnd: {
46
+ hooks: [{ type: 'command', command: 'turbocommit hook session-end --harness codex', timeout: 3 }]
47
+ },
41
48
  PreCompact: {
42
49
  matcher: 'manual|auto',
43
50
  hooks: [{ type: 'command', command: 'turbocommit hook pre-compact --harness codex' }]
@@ -71,12 +78,12 @@ function hasTurbocommit (groups) {
71
78
  */
72
79
  function hasExactHooks (groups, def) {
73
80
  if (!Array.isArray(groups)) return false
74
- const expected = def.hooks.map(h => h.command)
75
- return expected.every(cmd =>
81
+ return def.hooks.every(expected =>
76
82
  groups.some(g => {
77
83
  const hooks = g && g.hooks ? g.hooks : []
78
84
  const matcherMatches = def.matcher === undefined || g.matcher === def.matcher
79
- return matcherMatches && hooks.some(h => h.command === cmd)
85
+ return matcherMatches && hooks.some(actual =>
86
+ Object.entries(expected).every(([key, value]) => actual[key] === value))
80
87
  })
81
88
  )
82
89
  }