@searls/turbocommit 0.14.1 → 0.15.1

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/lib/run.js CHANGED
@@ -4,10 +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, stageAll, commitStaged, stagedChangeContext, hasCommits, currentBranch, pushClean } = require('./git')
7
+ const { gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean, hasRepositoryOperation } = require('./git')
8
8
  const { logEvent } = require('./log')
9
9
  const { wrapText } = require('./wrap')
10
- const { hasTrackedModifications, cleanupTracking, readTracking } = require('./track')
10
+ const {
11
+ hasTrackedModifications,
12
+ cleanupTracking,
13
+ readTracking,
14
+ finalizeBashSnapshots,
15
+ recordBashSessionStop,
16
+ readReadyBashOverlaps,
17
+ pruneBashSnapshots,
18
+ compactBashOverlapEvents,
19
+ resolveBashOverlap,
20
+ invalidateBashOverlap,
21
+ hasActiveBashSnapshot,
22
+ acquireBashOverlapRecoveryLock,
23
+ fingerprintTrackedPath,
24
+ claimedPathsBySessions
25
+ } = require('./track')
11
26
  const { redact, buildRedactions } = require('./redact')
12
27
  const { handleSessionEnd, getAncestors, savePending, collectPending, cleanupConsumed, cleanupStale, readWatermark, saveWatermark, resolveParentCommit } = require('./session')
13
28
  const { activeConfig } = require('./config')
@@ -19,7 +34,7 @@ const {
19
34
  dependencyOrder,
20
35
  loadManifest,
21
36
  readMultiWatermark,
22
- rootsFromEntries,
37
+ trackedChangesFromEntries,
23
38
  saveManifest,
24
39
  saveMultiWatermark
25
40
  } = require('./multi')
@@ -97,7 +112,7 @@ function resolveCoauthor (config, transcriptPath, root, opts = {}) {
97
112
  * Always exits 0 — never blocks Claude, never outputs to stdout.
98
113
  *
99
114
  * Skip/commit decision is based on PreToolUse tracking:
100
- * - If tracking file exists with entries → this agent modified files → commit
115
+ * - If tracking file contains changed paths → commit only those paths
101
116
  * - If tracking file missing/empty → skip, buffer transcript for later pickup
102
117
  */
103
118
  function run (input, opts = {}) {
@@ -108,19 +123,130 @@ function run (input, opts = {}) {
108
123
  if (!hookInput) return
109
124
 
110
125
  const root = gitRoot(hookInput.cwd || process.cwd())
111
- if (!root || activeConfig(root).config.enabled !== true) return
126
+ if (!root) return
127
+ const { config } = activeConfig(root)
128
+ if (config.enabled !== true) return
129
+ const recoveryDeadline = opts.recoveryDeadline ?? recoveryDeadlineFromWait(opts.recoveryWaitMs ?? 5000)
112
130
 
131
+ if (hookInput.sessionId) {
132
+ const finalized = finalizeBashSnapshots(root, hookInput.sessionId, Date.now(), remainingRecoveryWait(recoveryDeadline))
133
+ if (finalized == null) return
134
+ recordBashSessionStop(root, hookInput.sessionId)
135
+ }
136
+
137
+ try {
138
+ return runTrackedStop(hookInput, root, opts)
139
+ } finally {
140
+ const recoveryConfig = opts.deferPush ? { ...config, push: false } : config
141
+ recoverBashOverlaps(root, hookInput, recoveryConfig, remainingRecoveryWait(recoveryDeadline))
142
+ }
143
+ }
144
+
145
+ function recoveryDeadlineFromWait (waitMs) {
146
+ return waitMs === Infinity ? Infinity : Date.now() + waitMs
147
+ }
148
+
149
+ function remainingRecoveryWait (deadline) {
150
+ return deadline === Infinity ? Infinity : Math.max(0, deadline - Date.now())
151
+ }
152
+
153
+ function runTrackedStop (hookInput, root, opts) {
113
154
  const entries = hookInput.sessionId ? readTracking(root, hookInput.sessionId) : []
114
- const roots = rootsFromEntries(root, entries)
155
+ const tracked = trackedChangesFromEntries(root, entries)
115
156
  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)
157
+ if (manifest.discardedLegacyWork > 0) {
158
+ logEvent('skip', {
159
+ harness: hookInput.harness,
160
+ project: path.basename(root),
161
+ branch: currentBranch(root),
162
+ reason: 'legacy-manifest',
163
+ discarded: manifest.discardedLegacyWork
164
+ })
165
+ manifest.discardedLegacyWork = 0
166
+ deleteManifestIfEmpty(root, manifest)
167
+ }
168
+ const hasCrossRootPath = tracked.repos.length > 0 &&
169
+ (tracked.repos.length !== 1 || tracked.repos[0].root !== root)
170
+
171
+ if (tracked.ambiguous.length > 0) {
172
+ return runSingle(hookInput, { paths: [], trackingReason: 'ambiguous-tracking', operationDeadline: opts.operationDeadline })
173
+ }
174
+ if (manifest.repos.length > 0 || tracked.repos.length > 1 || hasCrossRootPath) {
175
+ return runMulti(hookInput, root, tracked.repos, manifest, opts)
176
+ }
177
+ return runSingle(hookInput, { paths: tracked.repos[0]?.paths || [], operationDeadline: opts.operationDeadline })
178
+ }
119
179
 
120
- if (manifest.repos.length > 0 || roots.length > 1 || hasCrossRootPath) {
121
- return runMulti(hookInput, root, roots, manifest)
180
+ function recoverBashOverlaps (root, hookInput, config, waitMs = 5000) {
181
+ if (process.env.TURBOCOMMIT_DISABLED || !root) return
182
+ hookInput = hookInput || {}
183
+ config = config || activeConfig(root).config
184
+ if (config.enabled !== true) return
185
+ const release = acquireBashOverlapRecoveryLock(root, waitMs)
186
+ if (!release) return
187
+ let shouldPush = false
188
+ try {
189
+ pruneBashSnapshots(root)
190
+ if (hasActiveBashSnapshot(root) || hasRepositoryOperation(root)) return
191
+ for (const overlap of readReadyBashOverlaps(root)) {
192
+ const claimed = claimedPathsBySessions(root)
193
+ const changedFingerprint = overlap.paths.some(item =>
194
+ item.fingerprint == null || fingerprintTrackedPath(item.path) !== item.fingerprint)
195
+ const claimedPath = overlap.paths.some(item => claimed.has(item.path))
196
+ if (changedFingerprint || claimedPath) {
197
+ logEvent('skip', {
198
+ harness: hookInput.harness,
199
+ project: path.basename(root),
200
+ branch: currentBranch(root),
201
+ reason: changedFingerprint ? 'bash-overlap-changed' : 'bash-overlap-claimed',
202
+ paths: overlap.paths.length
203
+ })
204
+ if (changedFingerprint) {
205
+ invalidateBashOverlap(root, overlap.eventIds, 'changed-fingerprint')
206
+ }
207
+ continue
208
+ }
209
+
210
+ const paths = changedPaths(root, overlap.paths.map(item => item.path))
211
+ if (paths.length === 0) {
212
+ resolveBashOverlap(root, overlap.eventIds)
213
+ continue
214
+ }
215
+
216
+ const title = 'Recover overlapping shell changes'
217
+ const body = 'Recovered unchanged paths observed during overlapping shell commands.\n\n' +
218
+ overlap.sessionIds.map(sessionId => `Session: ${sessionId}`).join('\n')
219
+ const commit = commitPaths(root, paths, title, body, { pathScoped: true })
220
+ if (!commit) continue
221
+
222
+ logEvent('success', {
223
+ harness: hookInput.harness,
224
+ project: path.basename(root),
225
+ branch: currentBranch(root),
226
+ title,
227
+ recovered: paths.length
228
+ })
229
+ if (config.push === true) shouldPush = true
230
+ resolveBashOverlap(root, overlap.eventIds)
231
+ }
232
+ } catch {
233
+ logEvent('fail', {
234
+ harness: hookInput.harness,
235
+ project: path.basename(root),
236
+ branch: currentBranch(root),
237
+ reason: 'bash-overlap-recovery'
238
+ })
239
+ } finally {
240
+ try { compactBashOverlapEvents(root) } catch {}
241
+ release()
242
+ }
243
+ if (shouldPush) {
244
+ logEvent(pushClean(root) ? 'push' : 'push-fail', {
245
+ harness: hookInput.harness,
246
+ project: path.basename(root),
247
+ branch: currentBranch(root)
248
+ })
122
249
  }
123
- return runSingle(hookInput)
124
250
  }
125
251
 
126
252
  function runSingle (input, opts = {}) {
@@ -153,6 +279,7 @@ function runSingle (input, opts = {}) {
153
279
  try { context = fs.statSync(transcriptPath).size } catch {}
154
280
 
155
281
  const sessionId = hookInput.sessionId
282
+ const ownedPaths = opts.paths || []
156
283
 
157
284
  // Watermark slicing: only include new pairs since last commit in this session
158
285
  const watermark = sessionId ? readWatermark(root, sessionId) : null
@@ -172,21 +299,39 @@ function runSingle (input, opts = {}) {
172
299
  if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
173
300
  savePending(root, sessionId, formatBody(effectivePairs))
174
301
  }
175
- logEvent('skip', { harness: hookInput.harness, project, branch, context })
302
+ cleanupTracking(root, sessionId)
303
+ logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-tracking' })
304
+ cleanupStale(root)
305
+ return
306
+ }
307
+
308
+ if (sessionId && (opts.trackingReason || ownedPaths.length === 0)) {
309
+ if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
310
+ savePending(root, sessionId, formatBody(effectivePairs))
311
+ }
312
+ cleanupTracking(root, sessionId)
313
+ logEvent('skip', {
314
+ harness: hookInput.harness,
315
+ project,
316
+ branch,
317
+ context,
318
+ reason: opts.trackingReason || 'no-path-changes'
319
+ })
176
320
  cleanupStale(root)
177
321
  return
178
322
  }
179
323
 
180
324
  try {
181
325
  // Early exit: tracking fired but all changes were reverted
182
- if (hasCommits(root) && !hasChanges(root)) {
326
+ const hasOwnedChanges = sessionId ? changedPaths(root, ownedPaths).length > 0 : hasChanges(root)
327
+ if (!hasOwnedChanges) {
183
328
  if (sessionId) {
184
329
  if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
185
330
  savePending(root, sessionId, formatBody(effectivePairs))
186
331
  }
187
332
  cleanupTracking(root, sessionId)
188
333
  }
189
- logEvent('skip', { harness: hookInput.harness, project, branch, context })
334
+ logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
190
335
  cleanupStale(root)
191
336
  return
192
337
  }
@@ -199,14 +344,14 @@ function runSingle (input, opts = {}) {
199
344
  let headline
200
345
  if (config.title?.type !== 'transcript') {
201
346
  const titleTranscript = formatTitleTranscript(effectivePairs)
202
- headline = runTitleAgent(root, config.title || {}, titleTranscript, hookInput.harness)
347
+ headline = runTitleAgent(root, config.title || {}, titleTranscript, hookInput.harness, { deadline: opts.operationDeadline })
203
348
  }
204
349
  headline = headline || extractHeadline(effectivePairs)
205
350
 
206
351
  // Body: transcript by default, agent if opted in
207
352
  let body
208
353
  if (config.body?.type === 'agent') {
209
- body = runBodyAgent(root, config.body, formattedTranscript, hookInput.harness)
354
+ body = runBodyAgent(root, config.body, formattedTranscript, hookInput.harness, { deadline: opts.operationDeadline })
210
355
  }
211
356
  body = body || formattedTranscript
212
357
 
@@ -247,11 +392,25 @@ function runSingle (input, opts = {}) {
247
392
  const redactions = buildRedactions()
248
393
  const safeHeadline = redact(headline, redactions)
249
394
  const safeBody = redact(wrappedBody + tag, redactions)
250
- const sha = addAndCommit(root, safeHeadline, safeBody)
395
+ const sha = sessionId
396
+ ? commitPaths(root, ownedPaths, safeHeadline, safeBody)
397
+ : addAndCommit(root, safeHeadline, safeBody)
398
+ if (!sha) {
399
+ if (sessionId) cleanupTracking(root, sessionId)
400
+ logEvent('skip', {
401
+ harness: hookInput.harness,
402
+ project,
403
+ branch,
404
+ context,
405
+ reason: 'no-path-changes'
406
+ })
407
+ cleanupStale(root)
408
+ return
409
+ }
251
410
 
252
411
  logEvent('success', { harness: hookInput.harness, project, branch, context, title: safeHeadline })
253
412
 
254
- if (config.push === true) {
413
+ if (config.push === true && !opts.deferPush) {
255
414
  if (pushClean(root)) {
256
415
  logEvent('push', { harness: hookInput.harness, project, branch })
257
416
  } else {
@@ -273,7 +432,7 @@ function runSingle (input, opts = {}) {
273
432
  }
274
433
  }
275
434
 
276
- function runMulti (hookInput, anchor, currentRoots, manifest) {
435
+ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
277
436
  recordCodexStop(hookInput, anchor)
278
437
 
279
438
  const transcriptPath = resolveTranscriptPath(hookInput)
@@ -305,12 +464,12 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
305
464
  '\n\n## Implementation\n\n' + formattedTranscript
306
465
  : formattedTranscript
307
466
 
308
- const dirtyRoots = currentRoots.filter(root => !hasCommits(root) || hasChanges(root))
309
- const isMultiTurn = currentRoots.length > 1
467
+ const dirtyRepos = currentRepos.filter(repo => changedPaths(repo.root, repo.paths).length > 0)
468
+ const isMultiTurn = currentRepos.length > 1
310
469
  const turnKey = hookInput.raw?.turn_id ||
311
470
  cryptoKey(`${sessionId || 'no-session'}\n${pairs.length}\n${turnBody}`)
312
471
 
313
- if (dirtyRoots.length === 0 && manifest.repos.length === 0) {
472
+ if (dirtyRepos.length === 0 && manifest.repos.length === 0) {
314
473
  if (sessionId) {
315
474
  savePending(anchor, sessionId, formattedTranscript)
316
475
  cleanupTracking(anchor, sessionId)
@@ -319,16 +478,18 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
319
478
  harness: hookInput.harness,
320
479
  project: path.basename(anchor),
321
480
  branch: currentBranch(anchor),
322
- context: transcriptSize(transcriptPath)
481
+ context: transcriptSize(transcriptPath),
482
+ reason: 'no-path-changes'
323
483
  })
324
484
  return
325
485
  }
326
486
 
327
- for (const root of currentRoots) {
328
- appendWork(manifest, root, {
487
+ for (const repo of currentRepos) {
488
+ appendWork(manifest, repo.root, {
329
489
  key: turnKey,
330
490
  body: turnBody,
331
- sessionIds: isMultiTurn && sessionId ? [sessionId] : []
491
+ sessionIds: isMultiTurn && sessionId ? [sessionId] : [],
492
+ paths: repo.paths
332
493
  })
333
494
  }
334
495
 
@@ -342,36 +503,53 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
342
503
  const project = path.basename(root)
343
504
  const branch = currentBranch(root)
344
505
  const context = transcriptSize(transcriptPath)
506
+ const ownedPaths = [...new Set(repo.work.flatMap(item => item.paths || []))]
345
507
 
346
508
  if (config.enabled !== true) {
347
509
  manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
348
510
  logEvent('skip', { harness: hookInput.harness, project, branch, context })
349
511
  continue
350
512
  }
351
- if (hasCommits(root) && !hasChanges(root)) {
513
+ if (ownedPaths.length === 0) {
514
+ logEvent('skip', {
515
+ harness: hookInput.harness,
516
+ project,
517
+ branch,
518
+ context,
519
+ reason: 'missing-manifest-paths'
520
+ })
521
+ continue
522
+ }
523
+ const currentPaths = changedPaths(root, ownedPaths)
524
+ if (currentPaths.length === 0) {
352
525
  manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
353
- logEvent('skip', { harness: hookInput.harness, project, branch, context })
526
+ logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
354
527
  continue
355
528
  }
356
529
 
357
530
  try {
358
531
  logEvent('start', { harness: hookInput.harness, project, branch, context })
359
- stageAll(root)
532
+ const stagedPaths = stagePaths(root, currentPaths)
533
+ if (stagedPaths.length === 0) {
534
+ manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
535
+ logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
536
+ continue
537
+ }
360
538
 
361
539
  const rawBody = repo.work.map(item => item.body).join('\n\n---\n\n')
362
540
  const redactions = buildRedactions()
363
- const changeContext = redact(stagedChangeContext(root, 10000), redactions)
541
+ const changeContext = redact(stagedChangeContext(root, 10000, stagedPaths), redactions)
364
542
  const titleInput = `${changeContext}\n\nTranscript:\n${rawBody}`.slice(0, 20000)
365
543
 
366
544
  let headline
367
545
  if (config.title?.type !== 'transcript') {
368
- headline = runTitleAgent(root, config.title || {}, titleInput, hookInput.harness)
546
+ headline = runTitleAgent(root, config.title || {}, titleInput, hookInput.harness, { deadline: opts.operationDeadline })
369
547
  }
370
548
  headline = headline || extractHeadline(effectivePairs)
371
549
 
372
550
  let body
373
551
  if (config.body?.type === 'agent') {
374
- body = runBodyAgent(root, config.body, rawBody, hookInput.harness)
552
+ body = runBodyAgent(root, config.body, rawBody, hookInput.harness, { deadline: opts.operationDeadline })
375
553
  }
376
554
  body = body || rawBody
377
555
 
@@ -399,7 +577,8 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
399
577
 
400
578
  const safeHeadline = redact(headline, redactions)
401
579
  const safeBody = redact(body, redactions)
402
- const sha = commitStaged(root, safeHeadline, safeBody)
580
+ const sha = commitStagedPaths(root, stagedPaths, safeHeadline, safeBody)
581
+ if (!sha) throw new Error('Tracked paths changed before commit')
403
582
  if (sessionId) saveMultiWatermark(root, sessionId, sha)
404
583
  if (root === anchor && sessionId) {
405
584
  saveWatermark(anchor, sessionId, pairs.length, sha, { source: 'commit' })
@@ -420,6 +599,7 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
420
599
 
421
600
  for (const success of successes) {
422
601
  if (success.config.push !== true) continue
602
+ if (opts.deferPush && success.root === anchor) continue
423
603
  if (pushClean(success.root)) {
424
604
  logEvent('push', {
425
605
  harness: hookInput.harness,
@@ -500,4 +680,4 @@ function recordCodexStop (hookInput, root) {
500
680
  if (hookInput.harness === 'codex' && hookInput.event === 'Stop') handleSessionEnd(hookInput, root)
501
681
  }
502
682
 
503
- module.exports = { run, runPreCompact, formatModelName, resolveCoauthor, readClaudeAttribution, resolveTranscriptPath, recordCodexStop }
683
+ module.exports = { run, runPreCompact, recoverBashOverlaps, formatModelName, resolveCoauthor, readClaudeAttribution, resolveTranscriptPath, recordCodexStop }
package/lib/session.js CHANGED
@@ -257,7 +257,7 @@ function cleanupStale (root, maxAgeMs) {
257
257
  const base = turbocommitDir(root)
258
258
  if (!base) return
259
259
 
260
- for (const sub of ['breadcrumbs', 'chains', 'tracking', 'watermarks']) {
260
+ for (const sub of ['breadcrumbs', 'chains', 'watermarks']) {
261
261
  const dir = path.join(base, sub)
262
262
  let files
263
263
  try {
@@ -276,6 +276,8 @@ function cleanupStale (root, maxAgeMs) {
276
276
  }
277
277
  }
278
278
 
279
+ cleanupStaleTracking(root, now, ttl)
280
+
279
281
  // Clean stale pending directories
280
282
  const pDir = path.join(base, 'pending')
281
283
  let pdirs
@@ -300,6 +302,43 @@ function cleanupStale (root, maxAgeMs) {
300
302
  }
301
303
  }
302
304
 
305
+ function cleanupStaleTracking (root, now, ttl) {
306
+ const dir = path.join(turbocommitDir(root), 'tracking')
307
+ let files
308
+ try {
309
+ files = fs.readdirSync(dir)
310
+ } catch {
311
+ return
312
+ }
313
+ for (const file of files) {
314
+ const fullPath = path.join(dir, file)
315
+ try {
316
+ const stat = fs.statSync(fullPath)
317
+ if (now - stat.mtimeMs <= ttl || trackingMayDescribeModifications(fullPath)) continue
318
+ fs.unlinkSync(fullPath)
319
+ } catch {}
320
+ }
321
+ }
322
+
323
+ function trackingMayDescribeModifications (file) {
324
+ let lines
325
+ try {
326
+ const data = fs.readFileSync(file, 'utf8').trim()
327
+ lines = data ? data.split('\n') : []
328
+ } catch {
329
+ return true
330
+ }
331
+ return lines.some(line => {
332
+ try {
333
+ const entry = JSON.parse(line)
334
+ return entry.tool !== 'Bash' ||
335
+ (entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
336
+ } catch {
337
+ return true
338
+ }
339
+ })
340
+ }
341
+
303
342
  module.exports = {
304
343
  handleSessionEnd,
305
344
  handleSessionStart,