@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/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 path.join(root, '.claude', 'turbocommit.json')
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 p = configPath(root)
17
- if (fs.existsSync(p)) {
18
- return { ok: true, alreadyExists: true, path: p }
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(p))
22
- writeJson(p, { enabled: true })
23
- return { ok: true, alreadyExists: false, path: p }
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 p = configPath(root)
33
- if (!fs.existsSync(p)) {
34
- return { ok: true, existed: false, path: p }
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: p }
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(os.homedir(), '.claude', 'settings.json')
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 install (settingsPath) {
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 uninstall (settingsPath) {
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
- module.exports = { install, uninstall, hasTurbocommit, isFullyInstalled, getSettingsPath, HOOK_DEFS }
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
- return path.join(os.homedir(), '.claude', 'turbocommit', 'monitor.jsonl')
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
- module.exports = { monitor, formatEntry, readEntries, formatSize, formatTime }
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, mergeConfig } = require('./io')
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 = extractModel(transcriptPath)
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
- return `Co-Authored-By: ${name} <noreply@anthropic.com>`
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
- let hookInput
91
- try {
92
- hookInput = JSON.parse(input)
93
- } catch {
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 globalCfg = loadJson(path.join(os.homedir(), '.claude', 'turbocommit.json'))
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 pairs = parseTranscript(hookInput.transcript_path)
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(hookInput.transcript_path).size } catch {}
120
+ try { context = fs.statSync(transcriptPath).size } catch {}
115
121
 
116
- const sessionId = hookInput.session_id
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 newPairs = watermark ? pairs.slice(watermark.pairs) : pairs
121
- const effectivePairs = newPairs.length > 0 ? newPairs : pairs
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
- savePending(root, sessionId, formatBody(effectivePairs))
126
- logEvent('skip', { project, branch, context })
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
- savePending(root, sessionId, formatBody(effectivePairs))
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
- // Collect pending from ancestors only self-pending is already
172
- // covered by effectivePairs (same watermark baseline) so including
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, hookInput.transcript_path, root)
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
- module.exports = { run, formatModelName, resolveCoauthor, readClaudeAttribution }
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 }