@searls/turbocommit 0.16.1 → 0.16.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
@@ -76,12 +76,18 @@ Each touched checkout:
76
76
  - Reads its own merged global and project configuration.
77
77
  - Stages only paths attributed to the current session, preserving unrelated
78
78
  staged and unstaged work.
79
- - Gets an independent commit with the full turn transcript.
79
+ - Gets an independent commit with the turn transcript (up to 16 KiB).
80
80
  - Gives the configured title agent repository-specific bounded diff context.
81
81
 
82
82
  Turbocommit attempts every commit before pushing every successful commit whose
83
- repository has `push: true`. Failures are retained and retried on a later Stop,
84
- along with every transcript accumulated before the commit succeeds.
83
+ repository has `push: true`. Staging and committing are retried a few times
84
+ when Git is momentarily unavailable, for example while Xcode holds the index
85
+ lock. Failures are retained and retried on a later Stop in that checkout by
86
+ any session, along with every transcript accumulated before the commit
87
+ succeeds, and the failed session's claims are released so they do not block
88
+ other sessions meanwhile. Each `fail` monitor event records Git's error. A
89
+ deletion the session already staged with `git rm` is committed with the rest
90
+ of its paths.
85
91
 
86
92
  Separate worktrees are committed independently. Enabled submodules are
87
93
  committed and pushed before enabled parent repositories. When one turn creates
@@ -112,7 +118,10 @@ upgrade, Turbocommit discards those unsafe retry entries and records a
112
118
  `legacy-manifest` monitor event rather than guessing which files they owned.
113
119
 
114
120
  The commit message headline is generated by a title agent (configurable).
115
- The body contains the full prompt/response transcript. When planning
121
+ The body contains the prompt/response transcript, capped at 16 KiB of UTF-8 text.
122
+ Oversized bodies retain the beginning and end with an explicit truncation marker.
123
+ The cap also applies to combined planning context and agent-generated bodies;
124
+ Git trailers are appended separately and preserved. When planning
116
125
  context was buffered from ancestor sessions, it appears under a
117
126
  `## Planning` section before the `## Implementation` section.
118
127
 
package/lib/git.js CHANGED
@@ -115,12 +115,46 @@ function stageAll (cwd) {
115
115
 
116
116
  function stagePaths (cwd, filePaths) {
117
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 })
118
+ if (changed.length === 0) return []
119
+ // A deletion the session already staged (git rm) matches nothing on disk or
120
+ // in the index, and `git add` aborts on such a pathspec. It is already
121
+ // staged, so only the remaining paths are added.
122
+ const paths = pathspecs(cwd, changed.filter(file => existsOnDisk(file) || isIndexed(cwd, file)))
123
+ if (paths.length > 0) git(`add -A -- ${paths.map(quote).join(' ')}`, { cwd })
121
124
  return changed
122
125
  }
123
126
 
127
+ function existsOnDisk (file) {
128
+ try {
129
+ fs.lstatSync(file)
130
+ return true
131
+ } catch {
132
+ return false
133
+ }
134
+ }
135
+
136
+ function isIndexed (cwd, file) {
137
+ const spec = pathspecs(cwd, [file])
138
+ if (spec.length === 0) return false
139
+ return git(`ls-files --cached -z -- ${quote(spec[0])}`, { cwd }) !== ''
140
+ }
141
+
142
+ /**
143
+ * Git refuses to work while another process holds the index (Xcode and other
144
+ * tools take `.git/index.lock` briefly), so a staging or commit step is tried
145
+ * again before its failure is allowed to strand a turn's work.
146
+ */
147
+ function withGitRetry (operation, attempts = 3, delayMs = 250) {
148
+ for (let attempt = 1; ; attempt++) {
149
+ try {
150
+ return operation()
151
+ } catch (err) {
152
+ if (attempt >= attempts) throw err
153
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs)
154
+ }
155
+ }
156
+ }
157
+
124
158
  function commitPaths (cwd, filePaths, headline, body, opts = {}) {
125
159
  if (opts.pathScoped && hasRepositoryOperation(cwd)) return null
126
160
  const changed = stagePaths(cwd, filePaths)
@@ -271,6 +305,7 @@ function hasRepositoryOperation (cwd) {
271
305
  }
272
306
 
273
307
  module.exports = {
308
+ withGitRetry,
274
309
  git,
275
310
  canonicalRoot,
276
311
  canonicalTrackedPath,
package/lib/run.js CHANGED
@@ -2,9 +2,9 @@ const fs = require('fs')
2
2
  const os = require('os')
3
3
  const path = require('path')
4
4
  const { loadJson } = require('./io')
5
- const { parseTranscript, parseCodexTranscript, formatBody, formatTitleTranscript, extractHeadline, extractModel, extractCodexModel } = require('./transcript')
5
+ const { parseTranscript, parseCodexTranscript, formatBody, capTranscript, formatTitleTranscript, extractHeadline, extractModel, extractCodexModel } = require('./transcript')
6
6
  const { runTitleAgent, runBodyAgent } = require('./agent')
7
- const { gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean, hasRepositoryOperation } = require('./git')
7
+ const { withGitRetry, 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
10
  const {
@@ -281,6 +281,8 @@ function runSingle (input, opts = {}) {
281
281
  const project = path.basename(root)
282
282
  const branch = currentBranch(root)
283
283
  let context = 0
284
+ // Work a failed commit could not land is retained for a later Stop.
285
+ let retained = null
284
286
  try { context = fs.statSync(transcriptPath).size } catch {}
285
287
 
286
288
  const sessionId = hookInput.sessionId
@@ -344,6 +346,7 @@ function runSingle (input, opts = {}) {
344
346
  logEvent('start', { harness: hookInput.harness, project, branch, context })
345
347
 
346
348
  const formattedTranscript = formatBody(effectivePairs)
349
+ if (sessionId) retained = { body: formattedTranscript, paths: ownedPaths }
347
350
 
348
351
  // Title: agent by default, transcript if opted out
349
352
  let headline
@@ -385,7 +388,7 @@ function runSingle (input, opts = {}) {
385
388
  }
386
389
 
387
390
  // Wrap body lines if configured
388
- const wrappedBody = wrapText(combinedBody, config.body?.maxLineLength)
391
+ const wrappedBody = capTranscript(wrapText(combinedBody, config.body?.maxLineLength))
389
392
 
390
393
  // Resolve coauthor trailer
391
394
  const coauthor = resolveCoauthor(config, transcriptPath, root, {
@@ -397,9 +400,9 @@ function runSingle (input, opts = {}) {
397
400
  const redactions = buildRedactions()
398
401
  const safeHeadline = redact(headline, redactions)
399
402
  const safeBody = redact(wrappedBody + tag, redactions)
400
- const sha = sessionId
403
+ const sha = withGitRetry(() => sessionId
401
404
  ? commitPaths(root, ownedPaths, safeHeadline, safeBody)
402
- : addAndCommit(root, safeHeadline, safeBody)
405
+ : addAndCommit(root, safeHeadline, safeBody))
403
406
  if (!sha) {
404
407
  if (sessionId) cleanupTracking(root, sessionId)
405
408
  logEvent('skip', {
@@ -432,7 +435,8 @@ function runSingle (input, opts = {}) {
432
435
  }
433
436
  cleanupStale(root)
434
437
  } catch (err) {
435
- logEvent('fail', { harness: hookInput.harness, project, branch, context })
438
+ logEvent('fail', { harness: hookInput.harness, project, branch, context, error: failureMessage(err) })
439
+ if (retained) retainFailedWork(root, sessionId, retained, pairs.length)
436
440
  throw err
437
441
  }
438
442
  }
@@ -534,14 +538,14 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
534
538
 
535
539
  try {
536
540
  logEvent('start', { harness: hookInput.harness, project, branch, context })
537
- const stagedPaths = stagePaths(root, currentPaths)
541
+ const stagedPaths = withGitRetry(() => stagePaths(root, currentPaths))
538
542
  if (stagedPaths.length === 0) {
539
543
  manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
540
544
  logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
541
545
  continue
542
546
  }
543
547
 
544
- const rawBody = repo.work.map(item => item.body).join('\n\n---\n\n')
548
+ const rawBody = capTranscript(repo.work.map(item => item.body).join('\n\n---\n\n'))
545
549
  const redactions = buildRedactions()
546
550
  const changeContext = redact(stagedChangeContext(root, 10000, stagedPaths), redactions)
547
551
  const titleInput = `${changeContext}\n\nTranscript:\n${rawBody}`.slice(0, 20000)
@@ -562,7 +566,7 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
562
566
  ? readMultiWatermark(root, [sessionId, ...ancestors])
563
567
  : null
564
568
  if (parent?.commit) body = `Continuation of ${parent.commit.slice(0, 7)}\n\n${body}`
565
- body = wrapText(body, config.body?.maxLineLength)
569
+ body = capTranscript(wrapText(body, config.body?.maxLineLength))
566
570
 
567
571
  const trailers = []
568
572
  for (const work of repo.work) {
@@ -582,7 +586,7 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
582
586
 
583
587
  const safeHeadline = redact(headline, redactions)
584
588
  const safeBody = redact(body, redactions)
585
- const sha = commitStagedPaths(root, stagedPaths, safeHeadline, safeBody)
589
+ const sha = withGitRetry(() => commitStagedPaths(root, stagedPaths, safeHeadline, safeBody))
586
590
  if (!sha) throw new Error('Tracked paths changed before commit')
587
591
  if (sessionId) saveMultiWatermark(root, sessionId, sha)
588
592
  if (root === anchor && sessionId) {
@@ -597,8 +601,8 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
597
601
  context,
598
602
  title: safeHeadline
599
603
  })
600
- } catch {
601
- logEvent('fail', { harness: hookInput.harness, project, branch, context })
604
+ } catch (err) {
605
+ logEvent('fail', { harness: hookInput.harness, project, branch, context, error: failureMessage(err) })
602
606
  }
603
607
  }
604
608
 
@@ -631,6 +635,30 @@ function runMulti (hookInput, anchor, currentRepos, manifest, opts = {}) {
631
635
  cleanupStale(anchor)
632
636
  }
633
637
 
638
+ /**
639
+ * A single-repository commit that failed joins the retry manifest, which any
640
+ * later Stop in this checkout replays. The session's tracking file is
641
+ * released so its claims stop blocking other sessions in the meantime.
642
+ */
643
+ function retainFailedWork (root, sessionId, retained, pairCount) {
644
+ try {
645
+ const manifest = loadManifest(root)
646
+ appendWork(manifest, root, {
647
+ key: cryptoKey(`${sessionId}\n${pairCount}\n${retained.body}`),
648
+ body: retained.body,
649
+ sessionIds: [],
650
+ paths: retained.paths
651
+ })
652
+ saveManifest(root, manifest)
653
+ cleanupTracking(root, sessionId)
654
+ } catch {}
655
+ }
656
+
657
+ function failureMessage (err) {
658
+ const text = err?.stderr?.toString?.().trim() || err?.message || String(err)
659
+ return text.split('\n')[0].slice(0, 200)
660
+ }
661
+
634
662
  function transcriptSize (transcriptPath) {
635
663
  try {
636
664
  return fs.statSync(transcriptPath).size
package/lib/transcript.js CHANGED
@@ -121,7 +121,23 @@ function formatPair (p) {
121
121
  */
122
122
  function formatBody (pairs) {
123
123
  if (pairs.length === 0) return '(no transcript)'
124
- return pairs.map(formatPair).join('\n\n---\n\n')
124
+ return capTranscript(pairs.map(formatPair).join('\n\n---\n\n'))
125
+ }
126
+
127
+ // Keep both the original intent and latest outcome, with a hard UTF-8 byte cap.
128
+ function capTranscript (text) {
129
+ const budget = 16 * 1024
130
+ const bytes = Buffer.from(text)
131
+ if (bytes.length <= budget) return text
132
+
133
+ const marker = '\n\n[... transcript truncated ...]\n\n'
134
+ const available = budget - Buffer.byteLength(marker)
135
+ let headEnd = Math.floor(available / 2)
136
+ let tailStart = bytes.length - (available - headEnd)
137
+ // Move cuts to UTF-8 code point boundaries.
138
+ while ((bytes[headEnd] & 0xc0) === 0x80) headEnd--
139
+ while ((bytes[tailStart] & 0xc0) === 0x80) tailStart++
140
+ return bytes.subarray(0, headEnd).toString() + marker + bytes.subarray(tailStart).toString()
125
141
  }
126
142
 
127
143
  /**
@@ -134,7 +150,7 @@ function formatTitleTranscript (pairs, budget) {
134
150
  budget = budget || 20000
135
151
  if (pairs.length === 0) return '(no transcript)'
136
152
 
137
- const full = formatBody(pairs)
153
+ const full = pairs.map(formatPair).join('\n\n---\n\n')
138
154
  if (full.length <= budget) return full
139
155
 
140
156
  const sep = '\n\n---\n\n'
@@ -221,6 +237,7 @@ module.exports = {
221
237
  parseCodexTranscript,
222
238
  formatPair,
223
239
  formatBody,
240
+ capTranscript,
224
241
  formatTitleTranscript,
225
242
  extractHeadline,
226
243
  fallbackHeadline,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@searls/turbocommit",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
4
4
  "description": "Auto-commit after every AI coding agent turn",
5
5
  "bin": {
6
6
  "turbocommit": "./cli.js"