@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 +33 -11
- package/cli.js +247 -6
- package/lib/agent.js +10 -6
- package/lib/git.js +9 -5
- package/lib/install.js +11 -4
- package/lib/rescue.js +308 -0
- package/lib/run.js +128 -13
- package/lib/session.js +40 -1
- package/lib/track.js +679 -48
- package/package.json +1 -1
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**, **
|
|
22
|
-
**SessionEnd**, and **Stop**.
|
|
23
|
-
- Codex: **PreToolUse**, **PostToolUse**, **SessionStart**, **
|
|
24
|
-
and **Stop**.
|
|
25
|
-
|
|
26
|
-
- **PreToolUse**
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
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(
|
|
85
|
+
return matcherMatches && hooks.some(actual =>
|
|
86
|
+
Object.entries(expected).every(([key, value]) => actual[key] === value))
|
|
80
87
|
})
|
|
81
88
|
)
|
|
82
89
|
}
|