@searls/turbocommit 0.12.0 → 0.13.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 +61 -22
- package/cli.js +65 -42
- package/lib/agent.js +11 -4
- package/lib/codex.js +34 -0
- package/lib/config.js +52 -0
- package/lib/doctor.js +102 -10
- package/lib/git.js +12 -0
- package/lib/harness.js +99 -0
- package/lib/init.js +18 -12
- package/lib/install.js +123 -5
- package/lib/log.js +5 -3
- package/lib/monitor.js +16 -3
- package/lib/run.js +105 -40
- package/lib/session.js +54 -30
- package/lib/track.js +23 -14
- package/lib/transcript.js +84 -1
- package/package.json +5 -2
package/lib/git.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { execSync } = require('child_process')
|
|
2
|
+
const path = require('path')
|
|
2
3
|
|
|
3
4
|
function git (args, opts = {}) {
|
|
4
5
|
const cwd = opts.cwd || process.cwd()
|
|
@@ -17,6 +18,16 @@ function gitRoot (cwd) {
|
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
function gitCommonDir (cwd) {
|
|
22
|
+
try {
|
|
23
|
+
const out = git('rev-parse --git-common-dir', { cwd })
|
|
24
|
+
if (!out) return null
|
|
25
|
+
return path.isAbsolute(out) ? out : path.resolve(cwd, out)
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
function hasChanges (cwd) {
|
|
21
32
|
try {
|
|
22
33
|
git('diff --quiet HEAD', { cwd })
|
|
@@ -72,6 +83,7 @@ function esc (s) {
|
|
|
72
83
|
module.exports = {
|
|
73
84
|
git,
|
|
74
85
|
gitRoot,
|
|
86
|
+
gitCommonDir,
|
|
75
87
|
hasChanges,
|
|
76
88
|
addAndCommit,
|
|
77
89
|
hasCommits,
|
package/lib/harness.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const os = require('os')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
|
|
5
|
+
const HARNESSES = ['claude', 'codex']
|
|
6
|
+
|
|
7
|
+
function parseHarnessArg (argv) {
|
|
8
|
+
const args = []
|
|
9
|
+
let harness = null
|
|
10
|
+
for (let i = 0; i < argv.length; i++) {
|
|
11
|
+
const arg = argv[i]
|
|
12
|
+
if (arg === '--harness') {
|
|
13
|
+
harness = argv[++i]
|
|
14
|
+
} else if (arg.startsWith('--harness=')) {
|
|
15
|
+
harness = arg.slice('--harness='.length)
|
|
16
|
+
} else {
|
|
17
|
+
args.push(arg)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (harness && !HARNESSES.includes(harness)) {
|
|
21
|
+
return { ok: false, error: `Invalid harness: ${harness}`, args, harness }
|
|
22
|
+
}
|
|
23
|
+
return { ok: true, args, harness }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function claudeHome () {
|
|
27
|
+
return path.join(os.homedir(), '.claude')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function codexHome () {
|
|
31
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function harnessHome (harness) {
|
|
35
|
+
return harness === 'codex' ? codexHome() : claudeHome()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function existingHarnesses () {
|
|
39
|
+
return HARNESSES.filter(h => fs.existsSync(harnessHome(h)))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function selectedHarnesses (harness, { create = false } = {}) {
|
|
43
|
+
if (harness) {
|
|
44
|
+
if (create) fs.mkdirSync(harnessHome(harness), { recursive: true })
|
|
45
|
+
return [harness]
|
|
46
|
+
}
|
|
47
|
+
return existingHarnesses()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function inferHarness (hookInput, forcedHarness) {
|
|
51
|
+
if (forcedHarness) return forcedHarness
|
|
52
|
+
if (hookInput && hookInput.hook_event_name) return 'codex'
|
|
53
|
+
return 'claude'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeHookInput (input, event, forcedHarness) {
|
|
57
|
+
let hookInput
|
|
58
|
+
try {
|
|
59
|
+
hookInput = JSON.parse(input)
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
const harness = inferHarness(hookInput, forcedHarness)
|
|
64
|
+
const hookEvent = hookInput.hook_event_name || eventName(event)
|
|
65
|
+
return {
|
|
66
|
+
harness,
|
|
67
|
+
event: hookEvent,
|
|
68
|
+
sessionId: hookInput.session_id,
|
|
69
|
+
transcriptPath: hookInput.transcript_path,
|
|
70
|
+
toolName: hookInput.tool_name,
|
|
71
|
+
toolInput: hookInput.tool_input || {},
|
|
72
|
+
cwd: hookInput.cwd,
|
|
73
|
+
model: hookInput.model,
|
|
74
|
+
source: hookInput.source,
|
|
75
|
+
raw: hookInput
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function eventName (event) {
|
|
80
|
+
const names = {
|
|
81
|
+
'pre-tool-use': 'PreToolUse',
|
|
82
|
+
'session-start': 'SessionStart',
|
|
83
|
+
'session-end': 'SessionEnd',
|
|
84
|
+
'pre-compact': 'PreCompact',
|
|
85
|
+
stop: 'Stop'
|
|
86
|
+
}
|
|
87
|
+
return names[event] || event
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
HARNESSES,
|
|
92
|
+
parseHarnessArg,
|
|
93
|
+
claudeHome,
|
|
94
|
+
codexHome,
|
|
95
|
+
harnessHome,
|
|
96
|
+
existingHarnesses,
|
|
97
|
+
selectedHarnesses,
|
|
98
|
+
normalizeHookInput
|
|
99
|
+
}
|
package/lib/init.js
CHANGED
|
@@ -2,9 +2,10 @@ const fs = require('fs')
|
|
|
2
2
|
const path = require('path')
|
|
3
3
|
const { gitRoot } = require('./git')
|
|
4
4
|
const { writeJson, ensureDir } = require('./io')
|
|
5
|
+
const { neutralProjectConfigPath, legacyProjectConfigPath } = require('./config')
|
|
5
6
|
|
|
6
7
|
function configPath (root) {
|
|
7
|
-
return
|
|
8
|
+
return neutralProjectConfigPath(root)
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
function init (cwd) {
|
|
@@ -13,14 +14,18 @@ function init (cwd) {
|
|
|
13
14
|
return { ok: false, error: 'Not a git repository' }
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const neutralPath = configPath(root)
|
|
18
|
+
const legacyPath = legacyProjectConfigPath(root)
|
|
19
|
+
if (fs.existsSync(neutralPath)) {
|
|
20
|
+
return { ok: true, alreadyExists: true, path: neutralPath }
|
|
21
|
+
}
|
|
22
|
+
if (fs.existsSync(legacyPath)) {
|
|
23
|
+
return { ok: true, alreadyExists: true, path: legacyPath }
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
ensureDir(path.dirname(
|
|
22
|
-
writeJson(
|
|
23
|
-
return { ok: true, alreadyExists: false, path:
|
|
26
|
+
ensureDir(path.dirname(neutralPath))
|
|
27
|
+
writeJson(neutralPath, { enabled: true })
|
|
28
|
+
return { ok: true, alreadyExists: false, path: neutralPath }
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
function deinit (cwd) {
|
|
@@ -29,13 +34,14 @@ function deinit (cwd) {
|
|
|
29
34
|
return { ok: false, error: 'Not a git repository' }
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
const paths = [configPath(root), legacyProjectConfigPath(root)]
|
|
38
|
+
const existing = paths.filter(p => fs.existsSync(p))
|
|
39
|
+
if (existing.length === 0) {
|
|
40
|
+
return { ok: true, existed: false, path: paths[0], paths: [] }
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
fs.unlinkSync(p)
|
|
38
|
-
return { ok: true, existed: true, path:
|
|
43
|
+
for (const p of existing) fs.unlinkSync(p)
|
|
44
|
+
return { ok: true, existed: true, path: existing[0], paths: existing }
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
module.exports = { init, deinit, configPath }
|
package/lib/install.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const os = require('os')
|
|
2
1
|
const path = require('path')
|
|
3
2
|
const { loadJson, writeJson } = require('./io')
|
|
3
|
+
const { selectedHarnesses, claudeHome, codexHome } = require('./harness')
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Hook definitions for each Claude Code event turbocommit uses.
|
|
@@ -21,8 +21,30 @@ const HOOK_DEFS = {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const CODEX_HOOK_DEFS = {
|
|
25
|
+
PreToolUse: {
|
|
26
|
+
matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
|
|
27
|
+
hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use' }]
|
|
28
|
+
},
|
|
29
|
+
SessionStart: {
|
|
30
|
+
matcher: 'resume|clear',
|
|
31
|
+
hooks: [{ type: 'command', command: 'turbocommit hook session-start' }]
|
|
32
|
+
},
|
|
33
|
+
PreCompact: {
|
|
34
|
+
matcher: 'manual|auto',
|
|
35
|
+
hooks: [{ type: 'command', command: 'turbocommit hook pre-compact' }]
|
|
36
|
+
},
|
|
37
|
+
Stop: {
|
|
38
|
+
hooks: [{ type: 'command', command: 'turbocommit hook stop' }]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
function getSettingsPath () {
|
|
25
|
-
return path.join(
|
|
43
|
+
return path.join(claudeHome(), 'settings.json')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getCodexHooksPath () {
|
|
47
|
+
return path.join(codexHome(), 'hooks.json')
|
|
26
48
|
}
|
|
27
49
|
|
|
28
50
|
function hasTurbocommit (groups) {
|
|
@@ -43,6 +65,13 @@ function isFullyInstalled (settings) {
|
|
|
43
65
|
)
|
|
44
66
|
}
|
|
45
67
|
|
|
68
|
+
function isCodexFullyInstalled (hooksConfig) {
|
|
69
|
+
if (!hooksConfig || !hooksConfig.hooks) return false
|
|
70
|
+
return Object.keys(CODEX_HOOK_DEFS).every(event =>
|
|
71
|
+
hasTurbocommit(hooksConfig.hooks[event])
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
46
75
|
function removeTurbocommitHooks (groups) {
|
|
47
76
|
if (!Array.isArray(groups)) return groups
|
|
48
77
|
return groups.map(g => {
|
|
@@ -57,7 +86,7 @@ function removeTurbocommitHooks (groups) {
|
|
|
57
86
|
* SessionEnd, Stop). Each event gets its own group at the end.
|
|
58
87
|
* Cleans up stale entries (including old `turbocommit run`) on install.
|
|
59
88
|
*/
|
|
60
|
-
function
|
|
89
|
+
function installClaude (settingsPath) {
|
|
61
90
|
settingsPath = settingsPath || getSettingsPath()
|
|
62
91
|
const settings = loadJson(settingsPath) || {}
|
|
63
92
|
|
|
@@ -88,7 +117,7 @@ function install (settingsPath) {
|
|
|
88
117
|
return { alreadyInstalled: false, settingsPath }
|
|
89
118
|
}
|
|
90
119
|
|
|
91
|
-
function
|
|
120
|
+
function uninstallClaude (settingsPath) {
|
|
92
121
|
settingsPath = settingsPath || getSettingsPath()
|
|
93
122
|
const settings = loadJson(settingsPath)
|
|
94
123
|
|
|
@@ -112,4 +141,93 @@ function uninstall (settingsPath) {
|
|
|
112
141
|
return { wasInstalled, settingsPath }
|
|
113
142
|
}
|
|
114
143
|
|
|
115
|
-
|
|
144
|
+
function installCodex (hooksPath) {
|
|
145
|
+
hooksPath = hooksPath || getCodexHooksPath()
|
|
146
|
+
const hooksConfig = loadJson(hooksPath) || {}
|
|
147
|
+
|
|
148
|
+
if (!hooksConfig.hooks) hooksConfig.hooks = {}
|
|
149
|
+
|
|
150
|
+
if (isCodexFullyInstalled(hooksConfig)) {
|
|
151
|
+
return { alreadyInstalled: true, hooksPath }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const k of Object.keys(hooksConfig.hooks)) {
|
|
155
|
+
hooksConfig.hooks[k] = removeTurbocommitHooks(hooksConfig.hooks[k])
|
|
156
|
+
if (Array.isArray(hooksConfig.hooks[k]) && hooksConfig.hooks[k].length === 0) {
|
|
157
|
+
delete hooksConfig.hooks[k]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const [event, def] of Object.entries(CODEX_HOOK_DEFS)) {
|
|
162
|
+
if (!hooksConfig.hooks[event]) hooksConfig.hooks[event] = []
|
|
163
|
+
const group = { hooks: def.hooks }
|
|
164
|
+
if (def.matcher) group.matcher = def.matcher
|
|
165
|
+
hooksConfig.hooks[event].push(group)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
writeJson(hooksPath, hooksConfig)
|
|
169
|
+
return { alreadyInstalled: false, hooksPath }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function uninstallCodex (hooksPath) {
|
|
173
|
+
hooksPath = hooksPath || getCodexHooksPath()
|
|
174
|
+
const hooksConfig = loadJson(hooksPath)
|
|
175
|
+
|
|
176
|
+
if (!hooksConfig || !hooksConfig.hooks) {
|
|
177
|
+
return { wasInstalled: false, hooksPath }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const wasInstalled = Object.keys(hooksConfig.hooks).some(event =>
|
|
181
|
+
hasTurbocommit(hooksConfig.hooks[event])
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
for (const k of Object.keys(hooksConfig.hooks)) {
|
|
185
|
+
hooksConfig.hooks[k] = removeTurbocommitHooks(hooksConfig.hooks[k])
|
|
186
|
+
if (Array.isArray(hooksConfig.hooks[k]) && hooksConfig.hooks[k].length === 0) {
|
|
187
|
+
delete hooksConfig.hooks[k]
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
writeJson(hooksPath, hooksConfig)
|
|
192
|
+
return { wasInstalled, hooksPath }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function install (settingsPathOrOptions) {
|
|
196
|
+
if (typeof settingsPathOrOptions === 'string') return installClaude(settingsPathOrOptions)
|
|
197
|
+
const harness = settingsPathOrOptions?.harness || null
|
|
198
|
+
const harnesses = selectedHarnesses(harness, { create: Boolean(harness) })
|
|
199
|
+
const results = harnesses.map(h =>
|
|
200
|
+
h === 'codex'
|
|
201
|
+
? { harness: h, ...installCodex(settingsPathOrOptions?.codexHooksPath) }
|
|
202
|
+
: { harness: h, ...installClaude(settingsPathOrOptions?.claudeSettingsPath) }
|
|
203
|
+
)
|
|
204
|
+
return { results, installedHarnesses: harnesses }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function uninstall (settingsPathOrOptions) {
|
|
208
|
+
if (typeof settingsPathOrOptions === 'string') return uninstallClaude(settingsPathOrOptions)
|
|
209
|
+
const harness = settingsPathOrOptions?.harness || null
|
|
210
|
+
const harnesses = selectedHarnesses(harness)
|
|
211
|
+
const results = harnesses.map(h =>
|
|
212
|
+
h === 'codex'
|
|
213
|
+
? { harness: h, ...uninstallCodex(settingsPathOrOptions?.codexHooksPath) }
|
|
214
|
+
: { harness: h, ...uninstallClaude(settingsPathOrOptions?.claudeSettingsPath) }
|
|
215
|
+
)
|
|
216
|
+
return { results, uninstalledHarnesses: harnesses }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
install,
|
|
221
|
+
uninstall,
|
|
222
|
+
installClaude,
|
|
223
|
+
uninstallClaude,
|
|
224
|
+
installCodex,
|
|
225
|
+
uninstallCodex,
|
|
226
|
+
hasTurbocommit,
|
|
227
|
+
isFullyInstalled,
|
|
228
|
+
isCodexFullyInstalled,
|
|
229
|
+
getSettingsPath,
|
|
230
|
+
getCodexHooksPath,
|
|
231
|
+
HOOK_DEFS,
|
|
232
|
+
CODEX_HOOK_DEFS
|
|
233
|
+
}
|
package/lib/log.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const path = require('path')
|
|
3
3
|
const os = require('os')
|
|
4
|
+
const { codexHome } = require('./harness')
|
|
4
5
|
|
|
5
|
-
function logPath () {
|
|
6
|
-
|
|
6
|
+
function logPath (harness = 'claude') {
|
|
7
|
+
const home = harness === 'codex' ? codexHome() : path.join(os.homedir(), '.claude')
|
|
8
|
+
return path.join(home, 'turbocommit', 'monitor.jsonl')
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
function logEvent (event, meta = {}) {
|
|
10
12
|
try {
|
|
11
13
|
const entry = { event, ...meta, title: meta.title || null, at: Date.now() }
|
|
12
|
-
const p = logPath()
|
|
14
|
+
const p = logPath(meta.harness || 'claude')
|
|
13
15
|
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
14
16
|
fs.appendFileSync(p, JSON.stringify(entry) + '\n')
|
|
15
17
|
} catch {}
|
package/lib/monitor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const { logPath } = require('./log')
|
|
3
|
+
const { existingHarnesses } = require('./harness')
|
|
3
4
|
|
|
4
5
|
const COLORS = {
|
|
5
6
|
start: '\x1b[36m',
|
|
@@ -48,9 +49,11 @@ function readEntries (filePath) {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
function monitor () {
|
|
52
|
+
function monitor (harness) {
|
|
53
|
+
harness = resolveHarness(harness)
|
|
54
|
+
if (!harness) return false
|
|
52
55
|
const cols = process.stdout.columns || 80
|
|
53
|
-
const lp = logPath()
|
|
56
|
+
const lp = logPath(harness)
|
|
54
57
|
const entries = readEntries(lp)
|
|
55
58
|
for (const entry of entries) {
|
|
56
59
|
process.stdout.write(formatEntry(entry, cols) + '\n')
|
|
@@ -87,6 +90,16 @@ function monitor () {
|
|
|
87
90
|
fs.unwatchFile(lp)
|
|
88
91
|
process.exit(0)
|
|
89
92
|
})
|
|
93
|
+
return true
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
|
|
96
|
+
function resolveHarness (harness) {
|
|
97
|
+
if (harness) return harness
|
|
98
|
+
const harnesses = existingHarnesses()
|
|
99
|
+
if (harnesses.length === 1) return harnesses[0]
|
|
100
|
+
if (harnesses.length === 0) return 'claude'
|
|
101
|
+
process.stderr.write('Multiple harnesses installed. Run: turbocommit monitor --harness claude|codex\n')
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { monitor, formatEntry, readEntries, formatSize, formatTime, resolveHarness }
|
package/lib/run.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const os = require('os')
|
|
3
3
|
const path = require('path')
|
|
4
|
-
const { loadJson
|
|
5
|
-
const { parseTranscript, formatBody, formatTitleTranscript, extractHeadline, extractModel } = require('./transcript')
|
|
4
|
+
const { loadJson } = require('./io')
|
|
5
|
+
const { parseTranscript, parseCodexTranscript, formatBody, formatTitleTranscript, extractHeadline, extractModel, extractCodexModel } = require('./transcript')
|
|
6
6
|
const { runTitleAgent, runBodyAgent } = require('./agent')
|
|
7
7
|
const { gitRoot, hasChanges, addAndCommit, hasCommits, currentBranch, pushClean } = require('./git')
|
|
8
8
|
const { logEvent } = require('./log')
|
|
9
9
|
const { wrapText } = require('./wrap')
|
|
10
10
|
const { hasTrackedModifications, cleanupTracking } = require('./track')
|
|
11
11
|
const { redact, buildRedactions } = require('./redact')
|
|
12
|
-
const { getAncestors, savePending, collectPending, cleanupConsumed, cleanupStale, readWatermark, saveWatermark, resolveParentCommit } = require('./session')
|
|
12
|
+
const { handleSessionEnd, getAncestors, savePending, collectPending, cleanupConsumed, cleanupStale, readWatermark, saveWatermark, resolveParentCommit } = require('./session')
|
|
13
|
+
const { activeConfig } = require('./config')
|
|
14
|
+
const { normalizeHookInput } = require('./harness')
|
|
15
|
+
const { resolveCodexTranscriptPath } = require('./codex')
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* Map a model ID like "claude-opus-4-6" to a friendly name like "Claude Opus 4.6".
|
|
@@ -54,7 +57,7 @@ function readClaudeAttribution (root) {
|
|
|
54
57
|
* Tier 2: Claude Code attribution.commit setting (when running under Claude)
|
|
55
58
|
* Tier 3: auto-detect model from transcript
|
|
56
59
|
*/
|
|
57
|
-
function resolveCoauthor (config, transcriptPath, root) {
|
|
60
|
+
function resolveCoauthor (config, transcriptPath, root, opts = {}) {
|
|
58
61
|
// Tier 1: explicit turbocommit config
|
|
59
62
|
if (config.coauthor === false) return null
|
|
60
63
|
|
|
@@ -63,16 +66,19 @@ function resolveCoauthor (config, transcriptPath, root) {
|
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
// Tier 2: Claude Code attribution setting
|
|
66
|
-
const claudeAttr = readClaudeAttribution(root)
|
|
69
|
+
const claudeAttr = opts.harness === 'codex' ? undefined : readClaudeAttribution(root)
|
|
67
70
|
if (claudeAttr !== undefined) {
|
|
68
71
|
return claudeAttr === '' ? null : claudeAttr
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
// Tier 3: auto-detect from transcript
|
|
72
|
-
const model =
|
|
75
|
+
const model = opts.harness === 'codex'
|
|
76
|
+
? extractCodexModel(transcriptPath, opts.model)
|
|
77
|
+
: extractModel(transcriptPath)
|
|
73
78
|
if (!model) return null
|
|
74
79
|
const name = formatModelName(model)
|
|
75
|
-
|
|
80
|
+
const email = opts.harness === 'codex' ? 'noreply@openai.com' : 'noreply@anthropic.com'
|
|
81
|
+
return `Co-Authored-By: ${name} <${email}>`
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
/**
|
|
@@ -84,46 +90,56 @@ function resolveCoauthor (config, transcriptPath, root) {
|
|
|
84
90
|
* - If tracking file exists with entries → this agent modified files → commit
|
|
85
91
|
* - If tracking file missing/empty → skip, buffer transcript for later pickup
|
|
86
92
|
*/
|
|
87
|
-
function run (input) {
|
|
93
|
+
function run (input, opts = {}) {
|
|
88
94
|
if (process.env.TURBOCOMMIT_DISABLED) return
|
|
89
95
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
+
const hookInput = typeof input === 'string'
|
|
97
|
+
? normalizeHookInput(input, 'stop', opts.harness)
|
|
98
|
+
: input
|
|
99
|
+
if (!hookInput) return
|
|
96
100
|
|
|
97
101
|
// Find git root
|
|
98
|
-
const root = gitRoot()
|
|
102
|
+
const root = gitRoot(hookInput.cwd || process.cwd())
|
|
99
103
|
if (!root) return
|
|
100
104
|
|
|
101
105
|
// Merge global + project config (project wins)
|
|
102
|
-
const
|
|
103
|
-
const projectCfg = loadJson(`${root}/.claude/turbocommit.json`)
|
|
104
|
-
const config = mergeConfig(globalCfg || {}, projectCfg || {})
|
|
106
|
+
const { config } = activeConfig(root)
|
|
105
107
|
if (config.enabled !== true) return
|
|
108
|
+
recordCodexStop(hookInput, root)
|
|
106
109
|
|
|
107
110
|
// Parse transcript
|
|
108
|
-
const
|
|
111
|
+
const transcriptPath = resolveTranscriptPath(hookInput)
|
|
112
|
+
const pairs = hookInput.harness === 'codex'
|
|
113
|
+
? parseCodexTranscript(transcriptPath)
|
|
114
|
+
: parseTranscript(transcriptPath)
|
|
109
115
|
|
|
110
116
|
// Gather monitor metadata
|
|
111
117
|
const project = path.basename(root)
|
|
112
118
|
const branch = currentBranch(root)
|
|
113
119
|
let context = 0
|
|
114
|
-
try { context = fs.statSync(
|
|
120
|
+
try { context = fs.statSync(transcriptPath).size } catch {}
|
|
115
121
|
|
|
116
|
-
const sessionId = hookInput.
|
|
122
|
+
const sessionId = hookInput.sessionId
|
|
117
123
|
|
|
118
124
|
// Watermark slicing: only include new pairs since last commit in this session
|
|
119
125
|
const watermark = sessionId ? readWatermark(root, sessionId) : null
|
|
120
|
-
const
|
|
121
|
-
const
|
|
126
|
+
const watermarkPairCount = watermark && Number.isInteger(watermark.pairs) ? watermark.pairs : 0
|
|
127
|
+
const newPairs = watermark ? pairs.slice(watermarkPairCount) : pairs
|
|
128
|
+
const precompactWatermark = watermark && watermark.source === 'precompact'
|
|
129
|
+
const selfPrecompactPending = sessionId ? collectPending(root, [sessionId], { source: 'precompact' }) : []
|
|
130
|
+
let effectivePairs = newPairs.length > 0 ? newPairs : pairs
|
|
131
|
+
if (precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0) {
|
|
132
|
+
const basePairs = Number.isInteger(watermark.basePairs) ? watermark.basePairs : 0
|
|
133
|
+
const bufferedPairs = pairs.slice(basePairs, watermarkPairCount)
|
|
134
|
+
effectivePairs = bufferedPairs.length > 0 ? bufferedPairs : pairs
|
|
135
|
+
}
|
|
122
136
|
|
|
123
137
|
// Skip decision: if PreToolUse never fired for this session, skip commit
|
|
124
138
|
if (sessionId && !hasTrackedModifications(root, sessionId)) {
|
|
125
|
-
|
|
126
|
-
|
|
139
|
+
if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
|
|
140
|
+
savePending(root, sessionId, formatBody(effectivePairs))
|
|
141
|
+
}
|
|
142
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context })
|
|
127
143
|
cleanupStale(root)
|
|
128
144
|
return
|
|
129
145
|
}
|
|
@@ -132,15 +148,17 @@ function run (input) {
|
|
|
132
148
|
// Early exit: tracking fired but all changes were reverted
|
|
133
149
|
if (hasCommits(root) && !hasChanges(root)) {
|
|
134
150
|
if (sessionId) {
|
|
135
|
-
|
|
151
|
+
if (!(precompactWatermark && newPairs.length === 0 && selfPrecompactPending.length > 0)) {
|
|
152
|
+
savePending(root, sessionId, formatBody(effectivePairs))
|
|
153
|
+
}
|
|
136
154
|
cleanupTracking(root, sessionId)
|
|
137
155
|
}
|
|
138
|
-
logEvent('skip', { project, branch, context })
|
|
156
|
+
logEvent('skip', { harness: hookInput.harness, project, branch, context })
|
|
139
157
|
cleanupStale(root)
|
|
140
158
|
return
|
|
141
159
|
}
|
|
142
160
|
|
|
143
|
-
logEvent('start', { project, branch, context })
|
|
161
|
+
logEvent('start', { harness: hookInput.harness, project, branch, context })
|
|
144
162
|
|
|
145
163
|
const formattedTranscript = formatBody(effectivePairs)
|
|
146
164
|
|
|
@@ -148,14 +166,14 @@ function run (input) {
|
|
|
148
166
|
let headline
|
|
149
167
|
if (config.title?.type !== 'transcript') {
|
|
150
168
|
const titleTranscript = formatTitleTranscript(effectivePairs)
|
|
151
|
-
headline = runTitleAgent(root, config.title || {}, titleTranscript)
|
|
169
|
+
headline = runTitleAgent(root, config.title || {}, titleTranscript, hookInput.harness)
|
|
152
170
|
}
|
|
153
171
|
headline = headline || extractHeadline(effectivePairs)
|
|
154
172
|
|
|
155
173
|
// Body: transcript by default, agent if opted in
|
|
156
174
|
let body
|
|
157
175
|
if (config.body?.type === 'agent') {
|
|
158
|
-
body = runBodyAgent(root, config.body, formattedTranscript)
|
|
176
|
+
body = runBodyAgent(root, config.body, formattedTranscript, hookInput.harness)
|
|
159
177
|
}
|
|
160
178
|
body = body || formattedTranscript
|
|
161
179
|
|
|
@@ -168,10 +186,12 @@ function run (input) {
|
|
|
168
186
|
: ''
|
|
169
187
|
|
|
170
188
|
const ancestors = getAncestors(root, sessionId)
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
// it would duplicate content between Planning and Implementation.
|
|
189
|
+
// Normal self-pending is already covered by effectivePairs. Precompact
|
|
190
|
+
// self-pending is different because PreCompact advances the watermark.
|
|
174
191
|
const pending = collectPending(root, [...ancestors].reverse())
|
|
192
|
+
if (precompactWatermark && newPairs.length > 0 && selfPrecompactPending.length > 0) {
|
|
193
|
+
pending.push(...selfPrecompactPending)
|
|
194
|
+
}
|
|
175
195
|
|
|
176
196
|
if (pending.length > 0) {
|
|
177
197
|
combinedBody = continuation + '## Planning\n\n' + pending.join('\n\n---\n\n') +
|
|
@@ -185,7 +205,10 @@ function run (input) {
|
|
|
185
205
|
const wrappedBody = wrapText(combinedBody, config.body?.maxLineLength)
|
|
186
206
|
|
|
187
207
|
// Resolve coauthor trailer
|
|
188
|
-
const coauthor = resolveCoauthor(config,
|
|
208
|
+
const coauthor = resolveCoauthor(config, transcriptPath, root, {
|
|
209
|
+
harness: hookInput.harness,
|
|
210
|
+
model: hookInput.model
|
|
211
|
+
})
|
|
189
212
|
const tag = coauthor ? '\n\n' + coauthor : ''
|
|
190
213
|
|
|
191
214
|
const redactions = buildRedactions()
|
|
@@ -193,28 +216,70 @@ function run (input) {
|
|
|
193
216
|
const safeBody = redact(wrappedBody + tag, redactions)
|
|
194
217
|
const sha = addAndCommit(root, safeHeadline, safeBody)
|
|
195
218
|
|
|
196
|
-
logEvent('success', { project, branch, context, title: safeHeadline })
|
|
219
|
+
logEvent('success', { harness: hookInput.harness, project, branch, context, title: safeHeadline })
|
|
197
220
|
|
|
198
221
|
if (config.push === true) {
|
|
199
222
|
if (pushClean(root)) {
|
|
200
|
-
logEvent('push', { project, branch })
|
|
223
|
+
logEvent('push', { harness: hookInput.harness, project, branch })
|
|
201
224
|
} else {
|
|
202
|
-
logEvent('push-fail', { project, branch })
|
|
225
|
+
logEvent('push-fail', { harness: hookInput.harness, project, branch })
|
|
203
226
|
}
|
|
204
227
|
}
|
|
205
228
|
|
|
206
229
|
// Post-commit: save watermark and cleanup
|
|
207
230
|
if (sessionId) {
|
|
208
|
-
saveWatermark(root, sessionId, pairs.length, sha)
|
|
231
|
+
saveWatermark(root, sessionId, pairs.length, sha, { source: 'commit' })
|
|
209
232
|
const ancestors = getAncestors(root, sessionId)
|
|
210
233
|
cleanupConsumed(root, [...ancestors, sessionId])
|
|
211
234
|
cleanupTracking(root, sessionId)
|
|
212
235
|
}
|
|
213
236
|
cleanupStale(root)
|
|
214
237
|
} catch (err) {
|
|
215
|
-
logEvent('fail', { project, branch, context })
|
|
238
|
+
logEvent('fail', { harness: hookInput.harness, project, branch, context })
|
|
216
239
|
throw err
|
|
217
240
|
}
|
|
218
241
|
}
|
|
219
242
|
|
|
220
|
-
|
|
243
|
+
function runPreCompact (input, opts = {}) {
|
|
244
|
+
if (process.env.TURBOCOMMIT_DISABLED) return
|
|
245
|
+
const hookInput = typeof input === 'string'
|
|
246
|
+
? normalizeHookInput(input, 'pre-compact', opts.harness)
|
|
247
|
+
: input
|
|
248
|
+
if (!hookInput || !hookInput.sessionId) return
|
|
249
|
+
|
|
250
|
+
const root = gitRoot(hookInput.cwd || process.cwd())
|
|
251
|
+
if (!root) return
|
|
252
|
+
|
|
253
|
+
const transcriptPath = resolveTranscriptPath(hookInput)
|
|
254
|
+
const pairs = hookInput.harness === 'codex'
|
|
255
|
+
? parseCodexTranscript(transcriptPath)
|
|
256
|
+
: parseTranscript(transcriptPath)
|
|
257
|
+
if (pairs.length === 0) return
|
|
258
|
+
|
|
259
|
+
const existing = readWatermark(root, hookInput.sessionId)
|
|
260
|
+
const existingPairCount = existing && Number.isInteger(existing.pairs) ? existing.pairs : 0
|
|
261
|
+
const baseline = existingPairCount <= pairs.length ? existingPairCount : 0
|
|
262
|
+
const pendingPairs = pairs.slice(baseline)
|
|
263
|
+
if (pendingPairs.length > 0) {
|
|
264
|
+
savePending(root, hookInput.sessionId, formatBody(pendingPairs), { source: 'precompact' })
|
|
265
|
+
}
|
|
266
|
+
const basePairs = existing && existing.source === 'precompact' && Number.isInteger(existing.basePairs)
|
|
267
|
+
? existing.basePairs
|
|
268
|
+
: baseline
|
|
269
|
+
saveWatermark(root, hookInput.sessionId, pairs.length, existing ? existing.commit : undefined, {
|
|
270
|
+
source: 'precompact',
|
|
271
|
+
basePairs
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function resolveTranscriptPath (hookInput) {
|
|
276
|
+
return hookInput.harness === 'codex'
|
|
277
|
+
? resolveCodexTranscriptPath(hookInput)
|
|
278
|
+
: hookInput.transcriptPath
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function recordCodexStop (hookInput, root) {
|
|
282
|
+
if (hookInput.harness === 'codex' && hookInput.event === 'Stop') handleSessionEnd(hookInput, root)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = { run, runPreCompact, formatModelName, resolveCoauthor, readClaudeAttribution, resolveTranscriptPath, recordCodexStop }
|