@searls/turbocommit 0.14.1 → 0.15.0
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 +36 -11
- package/cli.js +4 -1
- package/lib/git.js +156 -6
- package/lib/harness.js +2 -0
- package/lib/install.js +11 -3
- package/lib/multi.js +106 -30
- package/lib/run.js +92 -27
- package/lib/track.js +217 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# turbocommit
|
|
4
4
|
|
|
5
|
-
turbocommit creates a git commit containing
|
|
5
|
+
turbocommit creates a git commit containing the paths Claude Code or Codex
|
|
6
6
|
changes on each turn. Think of it like save state in a game emulator: whenever
|
|
7
7
|
you fall in a pit, you can safely rewind and try again.
|
|
8
8
|
|
|
@@ -18,19 +18,22 @@ 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**, **
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
- Claude Code: **PreToolUse**, **PostToolUse**, **SessionStart**,
|
|
22
|
+
**SessionEnd**, and **Stop**.
|
|
23
|
+
- Codex: **PreToolUse**, **PostToolUse**, **SessionStart**, **PreCompact**,
|
|
24
|
+
and **Stop**.
|
|
24
25
|
|
|
25
|
-
- **PreToolUse** tracks
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
- **PreToolUse** tracks the paths supplied to editing tools and snapshots the
|
|
27
|
+
repository before shell commands.
|
|
28
|
+
- **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.
|
|
28
31
|
- **SessionStart / SessionEnd** chain sessions across `/clear` boundaries
|
|
29
32
|
so planning context survives into the eventual commit.
|
|
30
33
|
- **PreCompact** on Codex buffers visible transcript context before compaction.
|
|
31
|
-
- **Stop** fires after every turn. If the session modified files, it
|
|
32
|
-
commits
|
|
33
|
-
by a later session that does commit.
|
|
34
|
+
- **Stop** fires after every turn. If the session modified files, it stages and
|
|
35
|
+
commits only the paths tracked for that session. If not, it buffers the
|
|
36
|
+
transcript for pickup by a later session that does commit.
|
|
34
37
|
|
|
35
38
|
### Changes across multiple repositories
|
|
36
39
|
|
|
@@ -43,7 +46,8 @@ additional repositories.
|
|
|
43
46
|
Each touched checkout:
|
|
44
47
|
|
|
45
48
|
- Reads its own merged global and project configuration.
|
|
46
|
-
-
|
|
49
|
+
- Stages only paths attributed to the current session, preserving unrelated
|
|
50
|
+
staged and unstaged work.
|
|
47
51
|
- Gets an independent commit with the full turn transcript.
|
|
48
52
|
- Gives the configured title agent repository-specific bounded diff context.
|
|
49
53
|
|
|
@@ -56,6 +60,27 @@ committed and pushed before enabled parent repositories. When one turn creates
|
|
|
56
60
|
commits in multiple repositories, each commit includes a shared
|
|
57
61
|
`Turbocommit-Session` trailer.
|
|
58
62
|
|
|
63
|
+
Relative tool paths are matched to the checkout where that path changed. If the
|
|
64
|
+
same relative path is dirty in multiple worktrees, Turbocommit skips the turn
|
|
65
|
+
with an `ambiguous-tracking` monitor reason instead of guessing which checkout
|
|
66
|
+
owns it, then clears that ambiguous tracking so later turns can proceed. Direct
|
|
67
|
+
editing tools such as `apply_patch` resolve relative paths from their recorded
|
|
68
|
+
working directory. Path-bearing MCP tools may report singular paths, path
|
|
69
|
+
arrays, or local file URIs. Pathless tool calls and directory-valued path fields
|
|
70
|
+
do not claim files or prevent later exact edits in the same session from
|
|
71
|
+
committing.
|
|
72
|
+
|
|
73
|
+
Before staging, Turbocommit resolves tracked paths to the exact changed files,
|
|
74
|
+
treats Git pathspec metacharacters literally, drops paths that are no longer
|
|
75
|
+
dirty, and excludes newly discovered embedded repositories. Existing tracked
|
|
76
|
+
submodule gitlinks remain eligible so child commits can update their parent.
|
|
77
|
+
During an active merge, cherry-pick, revert, or rebase, Git forbids partial
|
|
78
|
+
commits, so Turbocommit commits the operation's resolved index as a whole.
|
|
79
|
+
|
|
80
|
+
Retry manifests created by older versions did not record path ownership. On
|
|
81
|
+
upgrade, Turbocommit discards those unsafe retry entries and records a
|
|
82
|
+
`legacy-manifest` monitor event rather than guessing which files they owned.
|
|
83
|
+
|
|
59
84
|
The commit message headline is generated by a title agent (configurable).
|
|
60
85
|
The body contains the full prompt/response transcript. When planning
|
|
61
86
|
context was buffered from ancestor sessions, it appears under a
|
package/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ const { readStdin } = require('./lib/io')
|
|
|
4
4
|
const { install, uninstall } = require('./lib/install')
|
|
5
5
|
const { init, deinit } = require('./lib/init')
|
|
6
6
|
const { run, runPreCompact } = require('./lib/run')
|
|
7
|
-
const { handleTrack } = require('./lib/track')
|
|
7
|
+
const { handleTrack, handlePostTrack } = require('./lib/track')
|
|
8
8
|
const { handleSessionStart, handleSessionEnd } = require('./lib/session')
|
|
9
9
|
const { doctor } = require('./lib/doctor')
|
|
10
10
|
const { monitor } = require('./lib/monitor')
|
|
@@ -175,6 +175,9 @@ function cmdHook (argv, harness) {
|
|
|
175
175
|
case 'pre-tool-use':
|
|
176
176
|
handleTrack(hookInput, root)
|
|
177
177
|
return
|
|
178
|
+
case 'post-tool-use':
|
|
179
|
+
handlePostTrack(hookInput, root)
|
|
180
|
+
return
|
|
178
181
|
case 'session-start':
|
|
179
182
|
handleSessionStart(hookInput, root)
|
|
180
183
|
return
|
package/lib/git.js
CHANGED
|
@@ -13,12 +13,32 @@ function git (args, opts = {}) {
|
|
|
13
13
|
|
|
14
14
|
function gitRoot (cwd) {
|
|
15
15
|
try {
|
|
16
|
-
return git('rev-parse --show-toplevel', { cwd })
|
|
16
|
+
return canonicalRoot(git('rev-parse --show-toplevel', { cwd }))
|
|
17
17
|
} catch {
|
|
18
18
|
return null
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function canonicalRoot (filePath) {
|
|
23
|
+
const target = path.resolve(filePath)
|
|
24
|
+
let probe = target
|
|
25
|
+
while (!fs.existsSync(probe)) {
|
|
26
|
+
const parent = path.dirname(probe)
|
|
27
|
+
if (parent === probe) return target
|
|
28
|
+
probe = parent
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return path.join(fs.realpathSync(probe), path.relative(probe, target))
|
|
32
|
+
} catch {
|
|
33
|
+
return target
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function canonicalTrackedPath (filePath) {
|
|
38
|
+
const target = path.resolve(filePath)
|
|
39
|
+
return path.join(canonicalRoot(path.dirname(target)), path.basename(target))
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
function gitRootForPath (filePath) {
|
|
23
43
|
let probe = filePath
|
|
24
44
|
try {
|
|
@@ -34,6 +54,25 @@ function gitRootForPath (filePath) {
|
|
|
34
54
|
return gitRoot(probe)
|
|
35
55
|
}
|
|
36
56
|
|
|
57
|
+
function gitWorktrees (cwd) {
|
|
58
|
+
try {
|
|
59
|
+
return git('worktree list --porcelain', { cwd })
|
|
60
|
+
.split('\n')
|
|
61
|
+
.filter(line => line.startsWith('worktree '))
|
|
62
|
+
.map(line => line.slice('worktree '.length))
|
|
63
|
+
.map(root => {
|
|
64
|
+
try {
|
|
65
|
+
return fs.realpathSync(root)
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
} catch {
|
|
72
|
+
return []
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
37
76
|
function gitCommonDir (cwd) {
|
|
38
77
|
try {
|
|
39
78
|
const out = git('rev-parse --git-common-dir', { cwd })
|
|
@@ -60,6 +99,10 @@ function hasChanges (cwd) {
|
|
|
60
99
|
return untracked.length > 0
|
|
61
100
|
}
|
|
62
101
|
|
|
102
|
+
function hasPathChanges (cwd, filePath) {
|
|
103
|
+
return changedPaths(cwd, [filePath]).length > 0
|
|
104
|
+
}
|
|
105
|
+
|
|
63
106
|
function addAndCommit (cwd, headline, body) {
|
|
64
107
|
stageAll(cwd)
|
|
65
108
|
commitStaged(cwd, headline, body)
|
|
@@ -70,15 +113,39 @@ function stageAll (cwd) {
|
|
|
70
113
|
git('add -A', { cwd })
|
|
71
114
|
}
|
|
72
115
|
|
|
116
|
+
function stagePaths (cwd, filePaths) {
|
|
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 })
|
|
121
|
+
return changed
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function commitPaths (cwd, filePaths, headline, body) {
|
|
125
|
+
const changed = stagePaths(cwd, filePaths)
|
|
126
|
+
return commitStagedPaths(cwd, changed, headline, body)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function commitStagedPaths (cwd, filePaths, headline, body) {
|
|
130
|
+
const paths = pathspecs(cwd, filePaths)
|
|
131
|
+
if (paths.length === 0) return null
|
|
132
|
+
if (hasRepositoryOperation(cwd)) return commitStaged(cwd, headline, body)
|
|
133
|
+
git(`commit --only -m "${esc(headline)}" -m "${esc(body)}" --no-verify -- ${paths.map(quote).join(' ')}`, { cwd })
|
|
134
|
+
return git('rev-parse HEAD', { cwd })
|
|
135
|
+
}
|
|
136
|
+
|
|
73
137
|
function commitStaged (cwd, headline, body) {
|
|
74
138
|
git(`commit -m "${esc(headline)}" -m "${esc(body)}" --no-verify`, { cwd })
|
|
75
139
|
return git('rev-parse HEAD', { cwd })
|
|
76
140
|
}
|
|
77
141
|
|
|
78
|
-
function stagedChangeContext (cwd, budget = 20000) {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
142
|
+
function stagedChangeContext (cwd, budget = 20000, filePaths) {
|
|
143
|
+
const selected = filePaths && filePaths.length > 0
|
|
144
|
+
? ` -- ${pathspecs(cwd, filePaths).map(quote).join(' ')}`
|
|
145
|
+
: ''
|
|
146
|
+
const status = git(`status --short${selected}`, { cwd })
|
|
147
|
+
const stat = git(`diff --cached --stat${selected}`, { cwd })
|
|
148
|
+
const diff = git(`diff --cached --no-ext-diff --unified=3${selected}`, { cwd })
|
|
82
149
|
const prefix = `Status:\n${status || '(clean)'}\n\nDiff stat:\n${stat || '(none)'}\n\nDiff:\n`
|
|
83
150
|
const remaining = Math.max(0, budget - prefix.length)
|
|
84
151
|
return prefix + (diff.length > remaining ? diff.slice(0, remaining) + '\n[... diff truncated ...]' : diff)
|
|
@@ -88,7 +155,7 @@ function isGitlink (parentRoot, childRoot) {
|
|
|
88
155
|
const relative = path.relative(parentRoot, childRoot)
|
|
89
156
|
if (!relative || relative.startsWith('..')) return false
|
|
90
157
|
try {
|
|
91
|
-
return git(`ls-files --stage --
|
|
158
|
+
return git(`ls-files --stage -- ${quote(literalPathspec(relative))}`, { cwd: parentRoot })
|
|
92
159
|
.split('\n')
|
|
93
160
|
.some(line => line.startsWith('160000 '))
|
|
94
161
|
} catch {
|
|
@@ -126,14 +193,97 @@ function esc (s) {
|
|
|
126
193
|
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')
|
|
127
194
|
}
|
|
128
195
|
|
|
196
|
+
function quote (value) {
|
|
197
|
+
return `'${value.replace(/'/g, '\'"\'"\'')}'`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function pathspecs (cwd, filePaths) {
|
|
201
|
+
cwd = canonicalRoot(cwd)
|
|
202
|
+
return [...new Set(filePaths.map(filePath => path.relative(cwd, canonicalTrackedPath(filePath))))]
|
|
203
|
+
.filter(relative => relative && !relative.startsWith('..'))
|
|
204
|
+
.map(literalPathspec)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function literalPathspec (relative) {
|
|
208
|
+
return `:(literal)${relative}`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function changedPaths (cwd, filePaths) {
|
|
212
|
+
cwd = canonicalRoot(cwd)
|
|
213
|
+
const selected = pathspecs(cwd, filePaths)
|
|
214
|
+
if (selected.length === 0) return []
|
|
215
|
+
return collectChangedPaths(cwd, selected)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function changedPathsInRepository (cwd) {
|
|
219
|
+
cwd = canonicalRoot(cwd)
|
|
220
|
+
return collectChangedPaths(cwd, [])
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function collectChangedPaths (cwd, selected) {
|
|
224
|
+
const args = selected.length > 0 ? ` -- ${selected.map(quote).join(' ')}` : ''
|
|
225
|
+
const tracked = hasCommits(cwd)
|
|
226
|
+
? git(`diff --name-only --no-renames -z HEAD${args}`, { cwd })
|
|
227
|
+
: git(`ls-files --cached -z${args}`, { cwd })
|
|
228
|
+
const untracked = git(`ls-files --others --exclude-standard -z${args}`, { cwd })
|
|
229
|
+
const relativePaths = [...splitNull(tracked), ...splitNull(untracked)]
|
|
230
|
+
return [...new Set(relativePaths)]
|
|
231
|
+
.filter(relative => !isEmbeddedRepository(cwd, relative) || isHeadGitlink(cwd, relative))
|
|
232
|
+
.map(relative => path.join(cwd, relative))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function splitNull (value) {
|
|
236
|
+
return value ? value.split('\0').filter(Boolean) : []
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isEmbeddedRepository (cwd, relative) {
|
|
240
|
+
const candidate = path.join(cwd, relative)
|
|
241
|
+
try {
|
|
242
|
+
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, '.git'))
|
|
243
|
+
} catch {
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isHeadGitlink (cwd, relative) {
|
|
249
|
+
try {
|
|
250
|
+
return git(`ls-tree HEAD -- ${quote(literalPathspec(relative))}`, { cwd })
|
|
251
|
+
.split('\n')
|
|
252
|
+
.some(line => line.startsWith('160000 '))
|
|
253
|
+
} catch {
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function hasRepositoryOperation (cwd) {
|
|
259
|
+
return ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply']
|
|
260
|
+
.some(name => {
|
|
261
|
+
try {
|
|
262
|
+
const gitPath = git(`rev-parse --git-path ${quote(name)}`, { cwd })
|
|
263
|
+
return fs.existsSync(path.isAbsolute(gitPath) ? gitPath : path.join(cwd, gitPath))
|
|
264
|
+
} catch {
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
129
270
|
module.exports = {
|
|
130
271
|
git,
|
|
272
|
+
canonicalRoot,
|
|
273
|
+
canonicalTrackedPath,
|
|
131
274
|
gitRoot,
|
|
132
275
|
gitRootForPath,
|
|
276
|
+
gitWorktrees,
|
|
133
277
|
gitCommonDir,
|
|
134
278
|
hasChanges,
|
|
279
|
+
changedPaths,
|
|
280
|
+
changedPathsInRepository,
|
|
281
|
+
hasPathChanges,
|
|
135
282
|
addAndCommit,
|
|
136
283
|
stageAll,
|
|
284
|
+
stagePaths,
|
|
285
|
+
commitPaths,
|
|
286
|
+
commitStagedPaths,
|
|
137
287
|
commitStaged,
|
|
138
288
|
stagedChangeContext,
|
|
139
289
|
hasCommits,
|
package/lib/harness.js
CHANGED
|
@@ -75,6 +75,7 @@ function normalizeHookInput (input, event, forcedHarness) {
|
|
|
75
75
|
transcriptPath: hookInput.transcript_path,
|
|
76
76
|
toolName: hookInput.tool_name,
|
|
77
77
|
toolInput: hookInput.tool_input || {},
|
|
78
|
+
toolUseId: hookInput.tool_use_id,
|
|
78
79
|
cwd: hookInput.cwd,
|
|
79
80
|
model: hookInput.model,
|
|
80
81
|
source: hookInput.source,
|
|
@@ -85,6 +86,7 @@ function normalizeHookInput (input, event, forcedHarness) {
|
|
|
85
86
|
function eventName (event) {
|
|
86
87
|
const names = {
|
|
87
88
|
'pre-tool-use': 'PreToolUse',
|
|
89
|
+
'post-tool-use': 'PostToolUse',
|
|
88
90
|
'session-start': 'SessionStart',
|
|
89
91
|
'session-end': 'SessionEnd',
|
|
90
92
|
'pre-compact': 'PreCompact',
|
package/lib/install.js
CHANGED
|
@@ -10,6 +10,10 @@ const HOOK_DEFS = {
|
|
|
10
10
|
matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
|
|
11
11
|
hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness claude' }]
|
|
12
12
|
},
|
|
13
|
+
PostToolUse: {
|
|
14
|
+
matcher: 'Bash',
|
|
15
|
+
hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness claude' }]
|
|
16
|
+
},
|
|
13
17
|
SessionStart: {
|
|
14
18
|
hooks: [{ type: 'command', command: 'turbocommit hook session-start --harness claude' }]
|
|
15
19
|
},
|
|
@@ -26,6 +30,10 @@ const CODEX_HOOK_DEFS = {
|
|
|
26
30
|
matcher: 'apply_patch|Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
|
|
27
31
|
hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use --harness codex' }]
|
|
28
32
|
},
|
|
33
|
+
PostToolUse: {
|
|
34
|
+
matcher: 'Bash',
|
|
35
|
+
hooks: [{ type: 'command', command: 'turbocommit hook post-tool-use --harness codex' }]
|
|
36
|
+
},
|
|
29
37
|
SessionStart: {
|
|
30
38
|
matcher: 'resume|clear',
|
|
31
39
|
hooks: [{ type: 'command', command: 'turbocommit hook session-start --harness codex' }]
|
|
@@ -67,7 +75,8 @@ function hasExactHooks (groups, def) {
|
|
|
67
75
|
return expected.every(cmd =>
|
|
68
76
|
groups.some(g => {
|
|
69
77
|
const hooks = g && g.hooks ? g.hooks : []
|
|
70
|
-
|
|
78
|
+
const matcherMatches = def.matcher === undefined || g.matcher === def.matcher
|
|
79
|
+
return matcherMatches && hooks.some(h => h.command === cmd)
|
|
71
80
|
})
|
|
72
81
|
)
|
|
73
82
|
}
|
|
@@ -99,8 +108,7 @@ function removeTurbocommitHooks (groups) {
|
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
/**
|
|
102
|
-
* Install turbocommit
|
|
103
|
-
* SessionEnd, Stop). Each event gets its own group at the end.
|
|
111
|
+
* Install every turbocommit hook event. Each event gets its own group at the end.
|
|
104
112
|
* Cleans up stale entries (including old `turbocommit run`) on install.
|
|
105
113
|
*/
|
|
106
114
|
function installClaude (settingsPath) {
|
package/lib/multi.js
CHANGED
|
@@ -3,7 +3,7 @@ const fs = require('fs')
|
|
|
3
3
|
const path = require('path')
|
|
4
4
|
const { ensureDir, loadJson } = require('./io')
|
|
5
5
|
const { turbocommitDir } = require('./session')
|
|
6
|
-
const { gitRoot, gitRootForPath, isGitlink } = require('./git')
|
|
6
|
+
const { canonicalRoot, canonicalTrackedPath, gitRoot, gitRootForPath, gitWorktrees, hasPathChanges, isGitlink } = require('./git')
|
|
7
7
|
const { activeConfig } = require('./config')
|
|
8
8
|
|
|
9
9
|
function checkoutKey (root) {
|
|
@@ -15,13 +15,16 @@ function manifestPath (anchor) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
function loadManifest (anchor) {
|
|
18
|
-
return loadJson(manifestPath(anchor))
|
|
18
|
+
return normalizeManifest(loadJson(manifestPath(anchor)))
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function saveManifest (anchor, manifest) {
|
|
22
22
|
const file = manifestPath(anchor)
|
|
23
23
|
ensureDir(path.dirname(file))
|
|
24
|
-
fs.writeFileSync(file, JSON.stringify(
|
|
24
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
25
|
+
nextOrder: manifest.nextOrder,
|
|
26
|
+
repos: manifest.repos
|
|
27
|
+
}, null, 2) + '\n')
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
function deleteManifestIfEmpty (anchor, manifest) {
|
|
@@ -34,41 +37,91 @@ function deleteManifestIfEmpty (anchor, manifest) {
|
|
|
34
37
|
} catch {}
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
function
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const add = root => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (seen.has(real)) return
|
|
49
|
-
if (activeConfig(real).config.enabled !== true) return
|
|
50
|
-
seen.add(real)
|
|
51
|
-
roots.push(real)
|
|
40
|
+
function trackedChangesFromEntries (anchor, entries) {
|
|
41
|
+
const pathsByRoot = new Map()
|
|
42
|
+
const ambiguous = []
|
|
43
|
+
const add = (root, file) => {
|
|
44
|
+
root = canonicalRoot(root)
|
|
45
|
+
file = canonicalTrackedPath(file)
|
|
46
|
+
if (!root || activeConfig(root).config.enabled !== true) return
|
|
47
|
+
const relative = path.relative(root, file)
|
|
48
|
+
if (!relative || relative.startsWith('..')) return
|
|
49
|
+
if (!pathsByRoot.has(root)) pathsByRoot.set(root, new Set())
|
|
50
|
+
pathsByRoot.get(root).add(path.join(root, relative))
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
for (const entry of entries) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
const rawFiles = Array.isArray(entry.rawFiles) && entry.rawFiles.length > 0
|
|
55
|
+
? entry.rawFiles
|
|
56
|
+
: Array.isArray(entry.files) ? entry.files : []
|
|
57
|
+
if (entry.tool === 'Bash' && rawFiles.length === 0) continue
|
|
58
|
+
if (rawFiles.length === 0) continue
|
|
59
|
+
|
|
60
|
+
for (const rawFile of rawFiles) {
|
|
61
|
+
const candidates = changedCandidates(anchor, entry.cwd || anchor, rawFile, entry.tool)
|
|
62
|
+
if (candidates.length > 1) {
|
|
63
|
+
ambiguous.push({ tool: entry.tool, path: rawFile, reason: 'multiple-worktrees' })
|
|
64
|
+
} else if (candidates.length === 1) {
|
|
65
|
+
add(candidates[0].root, candidates[0].file)
|
|
66
|
+
}
|
|
60
67
|
}
|
|
61
68
|
}
|
|
62
|
-
|
|
69
|
+
|
|
70
|
+
addEnabledSuperprojects(pathsByRoot, add)
|
|
71
|
+
const repos = [...pathsByRoot].map(([root, paths]) => ({ root, paths: [...paths] }))
|
|
72
|
+
return { repos, ambiguous }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function changedCandidates (anchor, cwd, rawFile, tool) {
|
|
76
|
+
if (path.isAbsolute(rawFile)) return changedCandidate(rawFile)
|
|
77
|
+
|
|
78
|
+
anchor = canonicalRoot(anchor)
|
|
79
|
+
const direct = canonicalTrackedPath(path.resolve(cwd, rawFile))
|
|
80
|
+
const directRoot = gitRootForPath(direct)
|
|
81
|
+
if (!isOpaquePathTool(tool) || (directRoot && directRoot !== anchor)) {
|
|
82
|
+
return changedCandidate(direct)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const relative = path.relative(anchor, direct)
|
|
86
|
+
if (!relative || relative.startsWith('..')) return changedCandidate(direct)
|
|
87
|
+
const candidates = []
|
|
88
|
+
const seen = new Set()
|
|
89
|
+
for (const worktree of gitWorktrees(anchor)) {
|
|
90
|
+
const file = canonicalTrackedPath(path.join(worktree, relative))
|
|
91
|
+
if (isDirectoryPath(file)) continue
|
|
92
|
+
const root = gitRootForPath(file)
|
|
93
|
+
if (!root || seen.has(root) || activeConfig(root).config.enabled !== true) continue
|
|
94
|
+
seen.add(root)
|
|
95
|
+
if (hasPathChanges(root, file)) candidates.push({ root, file })
|
|
96
|
+
}
|
|
97
|
+
return candidates
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isOpaquePathTool (tool) {
|
|
101
|
+
return typeof tool === 'string' && tool.startsWith('mcp__')
|
|
63
102
|
}
|
|
64
103
|
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
104
|
+
function changedCandidate (file) {
|
|
105
|
+
if (isDirectoryPath(file)) return []
|
|
106
|
+
file = canonicalTrackedPath(file)
|
|
107
|
+
const root = gitRootForPath(file)
|
|
108
|
+
if (!root || activeConfig(root).config.enabled !== true) return []
|
|
109
|
+
return hasPathChanges(root, file) ? [{ root, file }] : []
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isDirectoryPath (file) {
|
|
113
|
+
try {
|
|
114
|
+
return fs.statSync(file).isDirectory()
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function addEnabledSuperprojects (pathsByRoot, add) {
|
|
121
|
+
for (const child of [...pathsByRoot.keys()]) {
|
|
68
122
|
const parent = gitRoot(path.dirname(child))
|
|
69
|
-
if (parent && parent !== child && isGitlink(parent, child)) add(parent)
|
|
123
|
+
if (parent && parent !== child && isGitlink(parent, child)) add(parent, child)
|
|
70
124
|
}
|
|
71
|
-
return roots
|
|
72
125
|
}
|
|
73
126
|
|
|
74
127
|
function dependencyOrder (repos) {
|
|
@@ -89,6 +142,29 @@ function appendWork (manifest, root, work) {
|
|
|
89
142
|
return repo
|
|
90
143
|
}
|
|
91
144
|
|
|
145
|
+
function normalizeManifest (stored) {
|
|
146
|
+
const manifest = stored && typeof stored === 'object' ? stored : {}
|
|
147
|
+
const repos = Array.isArray(manifest.repos) ? manifest.repos : []
|
|
148
|
+
let discardedLegacyWork = 0
|
|
149
|
+
const normalizedRepos = []
|
|
150
|
+
|
|
151
|
+
for (const repo of repos) {
|
|
152
|
+
if (!repo || typeof repo.root !== 'string' || !Array.isArray(repo.work)) continue
|
|
153
|
+
const work = repo.work.filter(item => {
|
|
154
|
+
const safe = item && Array.isArray(item.paths) && item.paths.length > 0
|
|
155
|
+
if (!safe) discardedLegacyWork++
|
|
156
|
+
return safe
|
|
157
|
+
})
|
|
158
|
+
if (work.length > 0) normalizedRepos.push({ ...repo, work })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
nextOrder: Number.isInteger(manifest.nextOrder) ? manifest.nextOrder : 0,
|
|
163
|
+
repos: normalizedRepos,
|
|
164
|
+
discardedLegacyWork
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
92
168
|
function multiWatermarkPath (root, sessionId) {
|
|
93
169
|
return path.join(turbocommitDir(root), 'multi-watermarks', checkoutKey(root), sessionId + '.json')
|
|
94
170
|
}
|
|
@@ -115,7 +191,7 @@ module.exports = {
|
|
|
115
191
|
loadManifest,
|
|
116
192
|
manifestPath,
|
|
117
193
|
readMultiWatermark,
|
|
118
|
-
|
|
194
|
+
trackedChangesFromEntries,
|
|
119
195
|
saveManifest,
|
|
120
196
|
saveMultiWatermark
|
|
121
197
|
}
|
package/lib/run.js
CHANGED
|
@@ -4,7 +4,7 @@ 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,
|
|
7
|
+
const { gitRoot, hasChanges, changedPaths, addAndCommit, stagePaths, commitPaths, commitStagedPaths, stagedChangeContext, currentBranch, pushClean } = require('./git')
|
|
8
8
|
const { logEvent } = require('./log')
|
|
9
9
|
const { wrapText } = require('./wrap')
|
|
10
10
|
const { hasTrackedModifications, cleanupTracking, readTracking } = require('./track')
|
|
@@ -19,7 +19,7 @@ const {
|
|
|
19
19
|
dependencyOrder,
|
|
20
20
|
loadManifest,
|
|
21
21
|
readMultiWatermark,
|
|
22
|
-
|
|
22
|
+
trackedChangesFromEntries,
|
|
23
23
|
saveManifest,
|
|
24
24
|
saveMultiWatermark
|
|
25
25
|
} = require('./multi')
|
|
@@ -97,7 +97,7 @@ function resolveCoauthor (config, transcriptPath, root, opts = {}) {
|
|
|
97
97
|
* Always exits 0 — never blocks Claude, never outputs to stdout.
|
|
98
98
|
*
|
|
99
99
|
* Skip/commit decision is based on PreToolUse tracking:
|
|
100
|
-
* - If tracking file
|
|
100
|
+
* - If tracking file contains changed paths → commit only those paths
|
|
101
101
|
* - If tracking file missing/empty → skip, buffer transcript for later pickup
|
|
102
102
|
*/
|
|
103
103
|
function run (input, opts = {}) {
|
|
@@ -111,16 +111,29 @@ function run (input, opts = {}) {
|
|
|
111
111
|
if (!root || activeConfig(root).config.enabled !== true) return
|
|
112
112
|
|
|
113
113
|
const entries = hookInput.sessionId ? readTracking(root, hookInput.sessionId) : []
|
|
114
|
-
const
|
|
114
|
+
const tracked = trackedChangesFromEntries(root, entries)
|
|
115
115
|
const manifest = loadManifest(root)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
if (manifest.discardedLegacyWork > 0) {
|
|
117
|
+
logEvent('skip', {
|
|
118
|
+
harness: hookInput.harness,
|
|
119
|
+
project: path.basename(root),
|
|
120
|
+
branch: currentBranch(root),
|
|
121
|
+
reason: 'legacy-manifest',
|
|
122
|
+
discarded: manifest.discardedLegacyWork
|
|
123
|
+
})
|
|
124
|
+
manifest.discardedLegacyWork = 0
|
|
125
|
+
deleteManifestIfEmpty(root, manifest)
|
|
126
|
+
}
|
|
127
|
+
const hasCrossRootPath = tracked.repos.length > 0 &&
|
|
128
|
+
(tracked.repos.length !== 1 || tracked.repos[0].root !== root)
|
|
119
129
|
|
|
120
|
-
if (
|
|
121
|
-
return
|
|
130
|
+
if (tracked.ambiguous.length > 0) {
|
|
131
|
+
return runSingle(hookInput, { paths: [], trackingReason: 'ambiguous-tracking' })
|
|
132
|
+
}
|
|
133
|
+
if (manifest.repos.length > 0 || tracked.repos.length > 1 || hasCrossRootPath) {
|
|
134
|
+
return runMulti(hookInput, root, tracked.repos, manifest)
|
|
122
135
|
}
|
|
123
|
-
return runSingle(hookInput)
|
|
136
|
+
return runSingle(hookInput, { paths: tracked.repos[0]?.paths || [] })
|
|
124
137
|
}
|
|
125
138
|
|
|
126
139
|
function runSingle (input, opts = {}) {
|
|
@@ -153,6 +166,7 @@ function runSingle (input, opts = {}) {
|
|
|
153
166
|
try { context = fs.statSync(transcriptPath).size } catch {}
|
|
154
167
|
|
|
155
168
|
const sessionId = hookInput.sessionId
|
|
169
|
+
const ownedPaths = opts.paths || []
|
|
156
170
|
|
|
157
171
|
// Watermark slicing: only include new pairs since last commit in this session
|
|
158
172
|
const watermark = sessionId ? readWatermark(root, sessionId) : null
|
|
@@ -172,21 +186,38 @@ function runSingle (input, opts = {}) {
|
|
|
172
186
|
if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
|
|
173
187
|
savePending(root, sessionId, formatBody(effectivePairs))
|
|
174
188
|
}
|
|
175
|
-
logEvent('skip', { harness: hookInput.harness, project, branch, context })
|
|
189
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-tracking' })
|
|
190
|
+
cleanupStale(root)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (sessionId && (opts.trackingReason || ownedPaths.length === 0)) {
|
|
195
|
+
if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
|
|
196
|
+
savePending(root, sessionId, formatBody(effectivePairs))
|
|
197
|
+
}
|
|
198
|
+
cleanupTracking(root, sessionId)
|
|
199
|
+
logEvent('skip', {
|
|
200
|
+
harness: hookInput.harness,
|
|
201
|
+
project,
|
|
202
|
+
branch,
|
|
203
|
+
context,
|
|
204
|
+
reason: opts.trackingReason || 'no-path-changes'
|
|
205
|
+
})
|
|
176
206
|
cleanupStale(root)
|
|
177
207
|
return
|
|
178
208
|
}
|
|
179
209
|
|
|
180
210
|
try {
|
|
181
211
|
// Early exit: tracking fired but all changes were reverted
|
|
182
|
-
|
|
212
|
+
const hasOwnedChanges = sessionId ? changedPaths(root, ownedPaths).length > 0 : hasChanges(root)
|
|
213
|
+
if (!hasOwnedChanges) {
|
|
183
214
|
if (sessionId) {
|
|
184
215
|
if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
|
|
185
216
|
savePending(root, sessionId, formatBody(effectivePairs))
|
|
186
217
|
}
|
|
187
218
|
cleanupTracking(root, sessionId)
|
|
188
219
|
}
|
|
189
|
-
logEvent('skip', { harness: hookInput.harness, project, branch, context })
|
|
220
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
|
|
190
221
|
cleanupStale(root)
|
|
191
222
|
return
|
|
192
223
|
}
|
|
@@ -247,7 +278,21 @@ function runSingle (input, opts = {}) {
|
|
|
247
278
|
const redactions = buildRedactions()
|
|
248
279
|
const safeHeadline = redact(headline, redactions)
|
|
249
280
|
const safeBody = redact(wrappedBody + tag, redactions)
|
|
250
|
-
const sha =
|
|
281
|
+
const sha = sessionId
|
|
282
|
+
? commitPaths(root, ownedPaths, safeHeadline, safeBody)
|
|
283
|
+
: addAndCommit(root, safeHeadline, safeBody)
|
|
284
|
+
if (!sha) {
|
|
285
|
+
if (sessionId) cleanupTracking(root, sessionId)
|
|
286
|
+
logEvent('skip', {
|
|
287
|
+
harness: hookInput.harness,
|
|
288
|
+
project,
|
|
289
|
+
branch,
|
|
290
|
+
context,
|
|
291
|
+
reason: 'no-path-changes'
|
|
292
|
+
})
|
|
293
|
+
cleanupStale(root)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
251
296
|
|
|
252
297
|
logEvent('success', { harness: hookInput.harness, project, branch, context, title: safeHeadline })
|
|
253
298
|
|
|
@@ -273,7 +318,7 @@ function runSingle (input, opts = {}) {
|
|
|
273
318
|
}
|
|
274
319
|
}
|
|
275
320
|
|
|
276
|
-
function runMulti (hookInput, anchor,
|
|
321
|
+
function runMulti (hookInput, anchor, currentRepos, manifest) {
|
|
277
322
|
recordCodexStop(hookInput, anchor)
|
|
278
323
|
|
|
279
324
|
const transcriptPath = resolveTranscriptPath(hookInput)
|
|
@@ -305,12 +350,12 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
|
|
|
305
350
|
'\n\n## Implementation\n\n' + formattedTranscript
|
|
306
351
|
: formattedTranscript
|
|
307
352
|
|
|
308
|
-
const
|
|
309
|
-
const isMultiTurn =
|
|
353
|
+
const dirtyRepos = currentRepos.filter(repo => changedPaths(repo.root, repo.paths).length > 0)
|
|
354
|
+
const isMultiTurn = currentRepos.length > 1
|
|
310
355
|
const turnKey = hookInput.raw?.turn_id ||
|
|
311
356
|
cryptoKey(`${sessionId || 'no-session'}\n${pairs.length}\n${turnBody}`)
|
|
312
357
|
|
|
313
|
-
if (
|
|
358
|
+
if (dirtyRepos.length === 0 && manifest.repos.length === 0) {
|
|
314
359
|
if (sessionId) {
|
|
315
360
|
savePending(anchor, sessionId, formattedTranscript)
|
|
316
361
|
cleanupTracking(anchor, sessionId)
|
|
@@ -319,16 +364,18 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
|
|
|
319
364
|
harness: hookInput.harness,
|
|
320
365
|
project: path.basename(anchor),
|
|
321
366
|
branch: currentBranch(anchor),
|
|
322
|
-
context: transcriptSize(transcriptPath)
|
|
367
|
+
context: transcriptSize(transcriptPath),
|
|
368
|
+
reason: 'no-path-changes'
|
|
323
369
|
})
|
|
324
370
|
return
|
|
325
371
|
}
|
|
326
372
|
|
|
327
|
-
for (const
|
|
328
|
-
appendWork(manifest, root, {
|
|
373
|
+
for (const repo of currentRepos) {
|
|
374
|
+
appendWork(manifest, repo.root, {
|
|
329
375
|
key: turnKey,
|
|
330
376
|
body: turnBody,
|
|
331
|
-
sessionIds: isMultiTurn && sessionId ? [sessionId] : []
|
|
377
|
+
sessionIds: isMultiTurn && sessionId ? [sessionId] : [],
|
|
378
|
+
paths: repo.paths
|
|
332
379
|
})
|
|
333
380
|
}
|
|
334
381
|
|
|
@@ -342,25 +389,42 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
|
|
|
342
389
|
const project = path.basename(root)
|
|
343
390
|
const branch = currentBranch(root)
|
|
344
391
|
const context = transcriptSize(transcriptPath)
|
|
392
|
+
const ownedPaths = [...new Set(repo.work.flatMap(item => item.paths || []))]
|
|
345
393
|
|
|
346
394
|
if (config.enabled !== true) {
|
|
347
395
|
manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
|
|
348
396
|
logEvent('skip', { harness: hookInput.harness, project, branch, context })
|
|
349
397
|
continue
|
|
350
398
|
}
|
|
351
|
-
if (
|
|
399
|
+
if (ownedPaths.length === 0) {
|
|
400
|
+
logEvent('skip', {
|
|
401
|
+
harness: hookInput.harness,
|
|
402
|
+
project,
|
|
403
|
+
branch,
|
|
404
|
+
context,
|
|
405
|
+
reason: 'missing-manifest-paths'
|
|
406
|
+
})
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
const currentPaths = changedPaths(root, ownedPaths)
|
|
410
|
+
if (currentPaths.length === 0) {
|
|
352
411
|
manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
|
|
353
|
-
logEvent('skip', { harness: hookInput.harness, project, branch, context })
|
|
412
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
|
|
354
413
|
continue
|
|
355
414
|
}
|
|
356
415
|
|
|
357
416
|
try {
|
|
358
417
|
logEvent('start', { harness: hookInput.harness, project, branch, context })
|
|
359
|
-
|
|
418
|
+
const stagedPaths = stagePaths(root, currentPaths)
|
|
419
|
+
if (stagedPaths.length === 0) {
|
|
420
|
+
manifest.repos = manifest.repos.filter(candidate => candidate !== repo)
|
|
421
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context, reason: 'no-path-changes' })
|
|
422
|
+
continue
|
|
423
|
+
}
|
|
360
424
|
|
|
361
425
|
const rawBody = repo.work.map(item => item.body).join('\n\n---\n\n')
|
|
362
426
|
const redactions = buildRedactions()
|
|
363
|
-
const changeContext = redact(stagedChangeContext(root, 10000), redactions)
|
|
427
|
+
const changeContext = redact(stagedChangeContext(root, 10000, stagedPaths), redactions)
|
|
364
428
|
const titleInput = `${changeContext}\n\nTranscript:\n${rawBody}`.slice(0, 20000)
|
|
365
429
|
|
|
366
430
|
let headline
|
|
@@ -399,7 +463,8 @@ function runMulti (hookInput, anchor, currentRoots, manifest) {
|
|
|
399
463
|
|
|
400
464
|
const safeHeadline = redact(headline, redactions)
|
|
401
465
|
const safeBody = redact(body, redactions)
|
|
402
|
-
const sha =
|
|
466
|
+
const sha = commitStagedPaths(root, stagedPaths, safeHeadline, safeBody)
|
|
467
|
+
if (!sha) throw new Error('Tracked paths changed before commit')
|
|
403
468
|
if (sessionId) saveMultiWatermark(root, sessionId, sha)
|
|
404
469
|
if (root === anchor && sessionId) {
|
|
405
470
|
saveWatermark(anchor, sessionId, pairs.length, sha, { source: 'commit' })
|
package/lib/track.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
1
2
|
const fs = require('fs')
|
|
2
3
|
const path = require('path')
|
|
4
|
+
const { fileURLToPath } = require('url')
|
|
3
5
|
const { ensureDir } = require('./io')
|
|
4
6
|
const { turbocommitDir } = require('./session')
|
|
7
|
+
const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository } = require('./git')
|
|
8
|
+
|
|
9
|
+
const BASH_SNAPSHOT_TTL_MS = 60 * 60 * 1000
|
|
5
10
|
|
|
6
11
|
/**
|
|
7
12
|
* Directory under the git common dir where turbocommit stores tracking state.
|
|
@@ -21,7 +26,17 @@ function trackingPath (root, sessionId) {
|
|
|
21
26
|
/**
|
|
22
27
|
* Keys to probe in tool_input for a file path (MCP tools, Write, Edit, etc.)
|
|
23
28
|
*/
|
|
24
|
-
const FILE_PATH_KEYS = [
|
|
29
|
+
const FILE_PATH_KEYS = [
|
|
30
|
+
'file_path',
|
|
31
|
+
'filePath',
|
|
32
|
+
'path',
|
|
33
|
+
'file',
|
|
34
|
+
'notebook_path',
|
|
35
|
+
'relative_path',
|
|
36
|
+
'relativePath',
|
|
37
|
+
'uri'
|
|
38
|
+
]
|
|
39
|
+
const FILE_PATH_ARRAY_KEYS = ['file_paths', 'filePaths', 'paths', 'files']
|
|
25
40
|
|
|
26
41
|
/**
|
|
27
42
|
* Extract a file path from tool_input, heuristically checking known keys.
|
|
@@ -29,22 +44,21 @@ const FILE_PATH_KEYS = ['file_path', 'filePath', 'path', 'file', 'notebook_path'
|
|
|
29
44
|
function extractFilePath (toolInput) {
|
|
30
45
|
if (!toolInput || typeof toolInput !== 'object') return null
|
|
31
46
|
for (const key of FILE_PATH_KEYS) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
47
|
+
const value = normalizeRawPath(toolInput[key], key)
|
|
48
|
+
if (value) return value
|
|
35
49
|
}
|
|
36
50
|
return null
|
|
37
51
|
}
|
|
38
52
|
|
|
39
|
-
function
|
|
53
|
+
function extractRawFilePaths (toolName, toolInput) {
|
|
40
54
|
const paths = []
|
|
41
55
|
const seen = new Set()
|
|
42
|
-
const add = value => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (seen.has(
|
|
46
|
-
seen.add(
|
|
47
|
-
paths.push(
|
|
56
|
+
const add = (value, key) => {
|
|
57
|
+
value = normalizeRawPath(value, key)
|
|
58
|
+
if (!value) return
|
|
59
|
+
if (seen.has(value)) return
|
|
60
|
+
seen.add(value)
|
|
61
|
+
paths.push(value)
|
|
48
62
|
}
|
|
49
63
|
|
|
50
64
|
if (toolName === 'apply_patch' && typeof toolInput?.command === 'string') {
|
|
@@ -63,7 +77,10 @@ function extractFilePaths (toolName, toolInput, cwd) {
|
|
|
63
77
|
return
|
|
64
78
|
}
|
|
65
79
|
for (const [key, nested] of Object.entries(value)) {
|
|
66
|
-
if (FILE_PATH_KEYS.includes(key)) add(nested)
|
|
80
|
+
if (FILE_PATH_KEYS.includes(key)) add(nested, key)
|
|
81
|
+
if (FILE_PATH_ARRAY_KEYS.includes(key) && Array.isArray(nested)) {
|
|
82
|
+
for (const item of nested) add(item, key)
|
|
83
|
+
}
|
|
67
84
|
if (nested && typeof nested === 'object') visit(nested)
|
|
68
85
|
}
|
|
69
86
|
}
|
|
@@ -71,6 +88,31 @@ function extractFilePaths (toolName, toolInput, cwd) {
|
|
|
71
88
|
return paths
|
|
72
89
|
}
|
|
73
90
|
|
|
91
|
+
function normalizeRawPath (value, key) {
|
|
92
|
+
if (typeof value !== 'string' || value.length === 0) return null
|
|
93
|
+
if (key !== 'uri') return value
|
|
94
|
+
if (value.startsWith('file://')) {
|
|
95
|
+
try {
|
|
96
|
+
return fileURLToPath(value)
|
|
97
|
+
} catch {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return null
|
|
102
|
+
return value
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractFilePaths (toolName, toolInput, cwd) {
|
|
106
|
+
const seen = new Set()
|
|
107
|
+
return extractRawFilePaths(toolName, toolInput).map(value =>
|
|
108
|
+
path.isAbsolute(value) ? value : path.resolve(cwd || process.cwd(), value)
|
|
109
|
+
).filter(value => {
|
|
110
|
+
if (seen.has(value)) return false
|
|
111
|
+
seen.add(value)
|
|
112
|
+
return true
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
74
116
|
/**
|
|
75
117
|
* PreToolUse handler. Appends a tracking entry for potentially-modifying tools.
|
|
76
118
|
* Always exits 0 (never blocks tool execution).
|
|
@@ -88,15 +130,17 @@ function handleTrack (input, root) {
|
|
|
88
130
|
|
|
89
131
|
const toolInput = hookInput.toolInput || hookInput.tool_input || {}
|
|
90
132
|
|
|
91
|
-
const entry = { tool: toolName, t: Date.now() }
|
|
92
|
-
|
|
93
133
|
const cwd = hookInput.cwd || hookInput.raw?.cwd || root
|
|
134
|
+
const entry = { tool: toolName, t: Date.now(), cwd }
|
|
135
|
+
const rawFiles = extractRawFilePaths(toolName, toolInput)
|
|
94
136
|
const files = extractFilePaths(toolName, toolInput, cwd)
|
|
137
|
+
if (rawFiles.length > 0) entry.rawFiles = rawFiles
|
|
95
138
|
if (files.length > 0) entry.files = files
|
|
96
139
|
|
|
97
140
|
// For Bash, record the command
|
|
98
141
|
if (toolName === 'Bash' && typeof toolInput.command === 'string') {
|
|
99
142
|
entry.command = toolInput.command
|
|
143
|
+
saveBashSnapshot(root, sessionId, hookInput.toolUseId || hookInput.tool_use_id)
|
|
100
144
|
}
|
|
101
145
|
|
|
102
146
|
// Skip Bash with no command (malformed input). All other non-Bash tools
|
|
@@ -110,6 +154,158 @@ function handleTrack (input, root) {
|
|
|
110
154
|
fs.appendFileSync(file, JSON.stringify(entry) + '\n')
|
|
111
155
|
}
|
|
112
156
|
|
|
157
|
+
function handlePostTrack (input, root) {
|
|
158
|
+
const hookInput = typeof input === 'string' ? parseInput(input) : input
|
|
159
|
+
root = root || hookInput?.root
|
|
160
|
+
if (!root || !hookInput) return
|
|
161
|
+
|
|
162
|
+
const sessionId = hookInput.sessionId || hookInput.session_id
|
|
163
|
+
const toolName = hookInput.toolName || hookInput.tool_name
|
|
164
|
+
if (!sessionId || toolName !== 'Bash') return
|
|
165
|
+
|
|
166
|
+
const cwd = hookInput.cwd || hookInput.raw?.cwd || root
|
|
167
|
+
const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
|
|
168
|
+
const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
|
|
169
|
+
if (!snapshot) return
|
|
170
|
+
|
|
171
|
+
const now = Date.now()
|
|
172
|
+
const overlapping = hasOverlappingBashSnapshot(root, snapshot, now)
|
|
173
|
+
snapshot.endedAt = now
|
|
174
|
+
writeBashSnapshot(root, sessionId, toolUseId, snapshot)
|
|
175
|
+
|
|
176
|
+
const before = new Set(snapshot.before.map(canonicalTrackedPath))
|
|
177
|
+
const claimed = claimedPathsByOtherSessions(root, sessionId)
|
|
178
|
+
const files = overlapping
|
|
179
|
+
? []
|
|
180
|
+
: changedPathsInRepository(snapshot.root)
|
|
181
|
+
.map(canonicalTrackedPath)
|
|
182
|
+
.filter(file => !before.has(file) && !claimed.has(file))
|
|
183
|
+
|
|
184
|
+
const entry = { tool: 'Bash', phase: 'post', t: now, cwd }
|
|
185
|
+
if (overlapping) entry.overlapping = true
|
|
186
|
+
if (files.length > 0) {
|
|
187
|
+
entry.rawFiles = files
|
|
188
|
+
entry.files = files
|
|
189
|
+
}
|
|
190
|
+
appendTracking(root, sessionId, entry)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function bashSnapshotDir (root) {
|
|
194
|
+
const base = turbocommitDir(root)
|
|
195
|
+
return base && path.join(base, 'bash-snapshots')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function bashSnapshotPath (root, sessionId, toolUseId) {
|
|
199
|
+
const dir = bashSnapshotDir(root)
|
|
200
|
+
if (!dir) return null
|
|
201
|
+
const key = crypto.createHash('sha256')
|
|
202
|
+
.update(`${sessionId}\0${toolUseId || 'current'}`)
|
|
203
|
+
.digest('hex')
|
|
204
|
+
return path.join(dir, key + '.json')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function saveBashSnapshot (root, sessionId, toolUseId) {
|
|
208
|
+
const checkout = canonicalRoot(root)
|
|
209
|
+
pruneBashSnapshots(root)
|
|
210
|
+
writeBashSnapshot(root, sessionId, toolUseId, {
|
|
211
|
+
root: checkout,
|
|
212
|
+
sessionId,
|
|
213
|
+
toolUseId: toolUseId || null,
|
|
214
|
+
startedAt: Date.now(),
|
|
215
|
+
before: changedPathsInRepository(checkout)
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function writeBashSnapshot (root, sessionId, toolUseId, snapshot) {
|
|
220
|
+
const file = bashSnapshotPath(root, sessionId, toolUseId)
|
|
221
|
+
if (!file) return
|
|
222
|
+
ensureDir(path.dirname(file))
|
|
223
|
+
const temporary = file + `.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
224
|
+
try {
|
|
225
|
+
fs.writeFileSync(temporary, JSON.stringify(snapshot) + '\n')
|
|
226
|
+
fs.renameSync(temporary, file)
|
|
227
|
+
} finally {
|
|
228
|
+
try { fs.unlinkSync(temporary) } catch {}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function loadBashSnapshot (root, sessionId, toolUseId) {
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(fs.readFileSync(bashSnapshotPath(root, sessionId, toolUseId), 'utf8'))
|
|
235
|
+
} catch {
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function hasOverlappingBashSnapshot (root, current, now) {
|
|
241
|
+
const dir = bashSnapshotDir(root)
|
|
242
|
+
let files
|
|
243
|
+
try {
|
|
244
|
+
files = fs.readdirSync(dir)
|
|
245
|
+
} catch {
|
|
246
|
+
return false
|
|
247
|
+
}
|
|
248
|
+
return files.some(file => {
|
|
249
|
+
try {
|
|
250
|
+
const other = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
251
|
+
if (other.sessionId === current.sessionId && other.toolUseId === current.toolUseId) return false
|
|
252
|
+
if (canonicalRoot(other.root) !== canonicalRoot(current.root)) return false
|
|
253
|
+
const endedAt = Number.isFinite(other.endedAt) ? other.endedAt : now
|
|
254
|
+
return other.startedAt <= now && endedAt >= current.startedAt
|
|
255
|
+
} catch {
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function pruneBashSnapshots (root) {
|
|
262
|
+
const dir = bashSnapshotDir(root)
|
|
263
|
+
let files
|
|
264
|
+
try {
|
|
265
|
+
files = fs.readdirSync(dir)
|
|
266
|
+
} catch {
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const cutoff = Date.now() - BASH_SNAPSHOT_TTL_MS
|
|
270
|
+
for (const file of files) {
|
|
271
|
+
try {
|
|
272
|
+
const fullPath = path.join(dir, file)
|
|
273
|
+
const snapshot = JSON.parse(fs.readFileSync(fullPath, 'utf8'))
|
|
274
|
+
if ((snapshot.endedAt || snapshot.startedAt || 0) < cutoff) fs.unlinkSync(fullPath)
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function claimedPathsByOtherSessions (root, sessionId) {
|
|
280
|
+
const result = new Set()
|
|
281
|
+
const dir = trackingDir(root)
|
|
282
|
+
let files
|
|
283
|
+
try {
|
|
284
|
+
files = fs.readdirSync(dir)
|
|
285
|
+
} catch {
|
|
286
|
+
return result
|
|
287
|
+
}
|
|
288
|
+
for (const file of files) {
|
|
289
|
+
if (file === sessionId + '.jsonl' || !file.endsWith('.jsonl')) continue
|
|
290
|
+
try {
|
|
291
|
+
const entries = fs.readFileSync(path.join(dir, file), 'utf8').trim().split('\n')
|
|
292
|
+
for (const line of entries) {
|
|
293
|
+
const entry = JSON.parse(line)
|
|
294
|
+
if (!Array.isArray(entry.files)) continue
|
|
295
|
+
for (const claimed of entry.files) result.add(canonicalTrackedPath(claimed))
|
|
296
|
+
}
|
|
297
|
+
} catch {}
|
|
298
|
+
}
|
|
299
|
+
return result
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function appendTracking (root, sessionId, entry) {
|
|
303
|
+
const file = trackingPath(root, sessionId)
|
|
304
|
+
if (!file) return
|
|
305
|
+
ensureDir(path.dirname(file))
|
|
306
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n')
|
|
307
|
+
}
|
|
308
|
+
|
|
113
309
|
function parseInput (input) {
|
|
114
310
|
try {
|
|
115
311
|
return JSON.parse(input)
|
|
@@ -120,12 +316,13 @@ function parseInput (input) {
|
|
|
120
316
|
|
|
121
317
|
/**
|
|
122
318
|
* Check whether a session has tracked any file-modifying tool calls.
|
|
123
|
-
* Bash
|
|
124
|
-
*
|
|
125
|
-
* The definitive signal comes from Write/Edit/NotebookEdit/MCP tools.
|
|
319
|
+
* A Bash pre-hook alone doesn't count because shell commands may be read-only.
|
|
320
|
+
* A Bash post-hook counts only when its snapshot found newly dirty paths.
|
|
126
321
|
*/
|
|
127
322
|
function hasTrackedModifications (root, sessionId) {
|
|
128
|
-
return readTracking(root, sessionId).some(entry =>
|
|
323
|
+
return readTracking(root, sessionId).some(entry =>
|
|
324
|
+
entry.tool !== 'Bash' || (entry.phase === 'post' && Array.isArray(entry.files) && entry.files.length > 0)
|
|
325
|
+
)
|
|
129
326
|
}
|
|
130
327
|
|
|
131
328
|
function readTracking (root, sessionId) {
|
|
@@ -156,10 +353,12 @@ function cleanupTracking (root, sessionId) {
|
|
|
156
353
|
|
|
157
354
|
module.exports = {
|
|
158
355
|
handleTrack,
|
|
356
|
+
handlePostTrack,
|
|
159
357
|
hasTrackedModifications,
|
|
160
358
|
cleanupTracking,
|
|
161
359
|
extractFilePath,
|
|
162
360
|
extractFilePaths,
|
|
361
|
+
extractRawFilePaths,
|
|
163
362
|
readTracking,
|
|
164
363
|
trackingDir,
|
|
165
364
|
trackingPath
|