@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/session.js
CHANGED
|
@@ -1,28 +1,36 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const path = require('path')
|
|
3
3
|
const { ensureDir } = require('./io')
|
|
4
|
+
const { gitCommonDir } = require('./git')
|
|
4
5
|
|
|
5
6
|
const BREADCRUMB_THRESHOLD_MS = 2000
|
|
6
7
|
const STALE_TTL_MS = 24 * 60 * 60 * 1000
|
|
7
8
|
|
|
8
9
|
function turbocommitDir (root) {
|
|
9
|
-
|
|
10
|
+
if (!root) return null
|
|
11
|
+
const gitDir = gitCommonDir(root)
|
|
12
|
+
if (!gitDir) return null
|
|
13
|
+
return path.join(gitDir, 'turbocommit')
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
function breadcrumbDir (root) {
|
|
13
|
-
|
|
17
|
+
const base = turbocommitDir(root)
|
|
18
|
+
return base && path.join(base, 'breadcrumbs')
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
function chainDir (root) {
|
|
17
|
-
|
|
22
|
+
const base = turbocommitDir(root)
|
|
23
|
+
return base && path.join(base, 'chains')
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
function pendingDir (root) {
|
|
21
|
-
|
|
27
|
+
const base = turbocommitDir(root)
|
|
28
|
+
return base && path.join(base, 'pending')
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
function watermarkDir (root) {
|
|
25
|
-
|
|
32
|
+
const base = turbocommitDir(root)
|
|
33
|
+
return base && path.join(base, 'watermarks')
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
/**
|
|
@@ -31,17 +39,14 @@ function watermarkDir (root) {
|
|
|
31
39
|
function handleSessionEnd (input, root) {
|
|
32
40
|
if (!root) return
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
hookInput = JSON.parse(input)
|
|
37
|
-
} catch {
|
|
38
|
-
return
|
|
39
|
-
}
|
|
42
|
+
const hookInput = typeof input === 'string' ? parseInput(input) : input
|
|
43
|
+
if (!hookInput) return
|
|
40
44
|
|
|
41
|
-
const sessionId = hookInput.session_id
|
|
45
|
+
const sessionId = hookInput.sessionId || hookInput.session_id
|
|
42
46
|
if (!sessionId) return
|
|
43
47
|
|
|
44
48
|
const dir = breadcrumbDir(root)
|
|
49
|
+
if (!dir) return
|
|
45
50
|
ensureDir(dir)
|
|
46
51
|
const data = { session_id: sessionId, timestamp: Date.now() }
|
|
47
52
|
fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify(data) + '\n')
|
|
@@ -53,21 +58,17 @@ function handleSessionEnd (input, root) {
|
|
|
53
58
|
function handleSessionStart (input, root) {
|
|
54
59
|
if (!root) return
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
hookInput = JSON.parse(input)
|
|
59
|
-
} catch {
|
|
60
|
-
return
|
|
61
|
-
}
|
|
61
|
+
const hookInput = typeof input === 'string' ? parseInput(input) : input
|
|
62
|
+
if (!hookInput) return
|
|
62
63
|
|
|
63
|
-
const sessionId = hookInput.session_id
|
|
64
|
+
const sessionId = hookInput.sessionId || hookInput.session_id
|
|
64
65
|
if (!sessionId) return
|
|
65
66
|
|
|
66
67
|
const source = hookInput.source
|
|
67
68
|
if (source !== 'clear' && source !== 'resume') return
|
|
68
69
|
|
|
69
70
|
const dir = breadcrumbDir(root)
|
|
70
|
-
if (!fs.existsSync(dir)) return
|
|
71
|
+
if (!dir || !fs.existsSync(dir)) return
|
|
71
72
|
|
|
72
73
|
// Scan breadcrumbs for closest match
|
|
73
74
|
const now = Date.now()
|
|
@@ -108,11 +109,20 @@ function handleSessionStart (input, root) {
|
|
|
108
109
|
|
|
109
110
|
// Write chain for this session
|
|
110
111
|
const cDir = chainDir(root)
|
|
112
|
+
if (!cDir) return
|
|
111
113
|
ensureDir(cDir)
|
|
112
114
|
const chain = { parent: best.session_id, ancestors }
|
|
113
115
|
fs.writeFileSync(path.join(cDir, sessionId + '.json'), JSON.stringify(chain) + '\n')
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
function parseInput (input) {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(input)
|
|
121
|
+
} catch {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
function readChain (root, sessionId) {
|
|
117
127
|
try {
|
|
118
128
|
return JSON.parse(fs.readFileSync(path.join(chainDir(root), sessionId + '.json'), 'utf8'))
|
|
@@ -133,21 +143,27 @@ function getAncestors (root, sessionId) {
|
|
|
133
143
|
* Save formatted transcript to pending directory for later pickup.
|
|
134
144
|
*/
|
|
135
145
|
let pendingSeq = 0
|
|
136
|
-
function savePending (root, sessionId, transcript) {
|
|
137
|
-
const
|
|
146
|
+
function savePending (root, sessionId, transcript, opts = {}) {
|
|
147
|
+
const base = pendingDir(root)
|
|
148
|
+
if (!base) return
|
|
149
|
+
const dir = path.join(base, sessionId)
|
|
138
150
|
ensureDir(dir)
|
|
139
151
|
const timestamp = String(Date.now()) + '-' + String(pendingSeq++).padStart(4, '0')
|
|
140
|
-
|
|
152
|
+
const source = opts.source ? '.' + String(opts.source).replace(/[^a-z0-9-]/gi, '-') : ''
|
|
153
|
+
fs.writeFileSync(path.join(dir, timestamp + source + '.txt'), transcript)
|
|
141
154
|
}
|
|
142
155
|
|
|
143
156
|
/**
|
|
144
157
|
* Collect pending transcripts for a list of session IDs, in order
|
|
145
158
|
* (oldest ancestor first). Returns array of strings.
|
|
146
159
|
*/
|
|
147
|
-
function collectPending (root, sessionIds) {
|
|
160
|
+
function collectPending (root, sessionIds, opts = {}) {
|
|
148
161
|
const results = []
|
|
162
|
+
const base = pendingDir(root)
|
|
163
|
+
if (!base) return results
|
|
164
|
+
const sourceSuffix = opts.source ? '.' + opts.source + '.txt' : null
|
|
149
165
|
for (const sid of sessionIds) {
|
|
150
|
-
const dir = path.join(
|
|
166
|
+
const dir = path.join(base, sid)
|
|
151
167
|
let files
|
|
152
168
|
try {
|
|
153
169
|
files = fs.readdirSync(dir).sort()
|
|
@@ -156,6 +172,7 @@ function collectPending (root, sessionIds) {
|
|
|
156
172
|
}
|
|
157
173
|
for (const file of files) {
|
|
158
174
|
if (!file.endsWith('.txt')) continue
|
|
175
|
+
if (sourceSuffix && !file.endsWith(sourceSuffix)) continue
|
|
159
176
|
try {
|
|
160
177
|
const content = fs.readFileSync(path.join(dir, file), 'utf8')
|
|
161
178
|
if (content.trim()) results.push(content)
|
|
@@ -181,10 +198,14 @@ function readWatermark (root, sessionId) {
|
|
|
181
198
|
/**
|
|
182
199
|
* Save watermark after a commit: pair count and commit SHA.
|
|
183
200
|
*/
|
|
184
|
-
function saveWatermark (root, sessionId, pairs, commit) {
|
|
201
|
+
function saveWatermark (root, sessionId, pairs, commit, meta = {}) {
|
|
185
202
|
const dir = watermarkDir(root)
|
|
203
|
+
if (!dir) return
|
|
186
204
|
ensureDir(dir)
|
|
187
|
-
|
|
205
|
+
const data = { pairs }
|
|
206
|
+
if (commit) data.commit = commit
|
|
207
|
+
Object.assign(data, meta)
|
|
208
|
+
fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify(data) + '\n')
|
|
188
209
|
}
|
|
189
210
|
|
|
190
211
|
/**
|
|
@@ -194,13 +215,13 @@ function saveWatermark (root, sessionId, pairs, commit) {
|
|
|
194
215
|
function resolveParentCommit (root, sessionId) {
|
|
195
216
|
// Own watermark takes priority (same-session previous commit)
|
|
196
217
|
const own = readWatermark(root, sessionId)
|
|
197
|
-
if (own) return own.commit
|
|
218
|
+
if (own && own.commit) return own.commit
|
|
198
219
|
|
|
199
220
|
// Walk chain ancestors nearest-first
|
|
200
221
|
const ancestors = getAncestors(root, sessionId)
|
|
201
222
|
for (const aid of ancestors) {
|
|
202
223
|
const wm = readWatermark(root, aid)
|
|
203
|
-
if (wm) return wm.commit
|
|
224
|
+
if (wm && wm.commit) return wm.commit
|
|
204
225
|
}
|
|
205
226
|
return null
|
|
206
227
|
}
|
|
@@ -209,9 +230,11 @@ function resolveParentCommit (root, sessionId) {
|
|
|
209
230
|
* Delete consumed pending + chain files after commit.
|
|
210
231
|
*/
|
|
211
232
|
function cleanupConsumed (root, sessionIds) {
|
|
233
|
+
const base = pendingDir(root)
|
|
234
|
+
if (!base) return
|
|
212
235
|
for (const sid of sessionIds) {
|
|
213
236
|
// Remove pending dir
|
|
214
|
-
const dir = path.join(
|
|
237
|
+
const dir = path.join(base, sid)
|
|
215
238
|
try {
|
|
216
239
|
const files = fs.readdirSync(dir)
|
|
217
240
|
for (const file of files) {
|
|
@@ -232,6 +255,7 @@ function cleanupStale (root, maxAgeMs) {
|
|
|
232
255
|
const ttl = maxAgeMs != null ? maxAgeMs : STALE_TTL_MS
|
|
233
256
|
const now = Date.now()
|
|
234
257
|
const base = turbocommitDir(root)
|
|
258
|
+
if (!base) return
|
|
235
259
|
|
|
236
260
|
for (const sub of ['breadcrumbs', 'chains', 'tracking', 'watermarks']) {
|
|
237
261
|
const dir = path.join(base, sub)
|
package/lib/track.js
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const path = require('path')
|
|
3
3
|
const { ensureDir } = require('./io')
|
|
4
|
+
const { turbocommitDir } = require('./session')
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Directory under
|
|
7
|
+
* Directory under the git common dir where turbocommit stores tracking state.
|
|
8
|
+
* Returns null when the root isn't resolvable to a git repo (including when
|
|
9
|
+
* run outside a repo, which is not expected during normal operation).
|
|
7
10
|
*/
|
|
8
11
|
function trackingDir (root) {
|
|
9
|
-
|
|
12
|
+
const base = turbocommitDir(root)
|
|
13
|
+
return base && path.join(base, 'tracking')
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
function trackingPath (root, sessionId) {
|
|
13
|
-
|
|
17
|
+
const dir = trackingDir(root)
|
|
18
|
+
return dir && path.join(dir, sessionId + '.jsonl')
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
/**
|
|
@@ -36,22 +41,17 @@ function extractFilePath (toolInput) {
|
|
|
36
41
|
* Always exits 0 (never blocks tool execution).
|
|
37
42
|
*/
|
|
38
43
|
function handleTrack (input, root) {
|
|
39
|
-
|
|
44
|
+
const hookInput = typeof input === 'string' ? parseInput(input) : input
|
|
45
|
+
root = root || hookInput?.root
|
|
46
|
+
if (!root || !hookInput) return
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
try {
|
|
43
|
-
hookInput = JSON.parse(input)
|
|
44
|
-
} catch {
|
|
45
|
-
return
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const sessionId = hookInput.session_id
|
|
48
|
+
const sessionId = hookInput.sessionId || hookInput.session_id
|
|
49
49
|
if (!sessionId) return
|
|
50
50
|
|
|
51
|
-
const toolName = hookInput.tool_name
|
|
51
|
+
const toolName = hookInput.toolName || hookInput.tool_name
|
|
52
52
|
if (!toolName) return
|
|
53
53
|
|
|
54
|
-
const toolInput = hookInput.tool_input || {}
|
|
54
|
+
const toolInput = hookInput.toolInput || hookInput.tool_input || {}
|
|
55
55
|
|
|
56
56
|
const entry = { tool: toolName, t: Date.now() }
|
|
57
57
|
|
|
@@ -72,10 +72,19 @@ function handleTrack (input, root) {
|
|
|
72
72
|
if (toolName === 'Bash' && typeof toolInput.command !== 'string') return
|
|
73
73
|
|
|
74
74
|
const file = trackingPath(root, sessionId)
|
|
75
|
+
if (!file) return
|
|
75
76
|
ensureDir(path.dirname(file))
|
|
76
77
|
fs.appendFileSync(file, JSON.stringify(entry) + '\n')
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
function parseInput (input) {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(input)
|
|
83
|
+
} catch {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
/**
|
|
80
89
|
* Check whether a session has tracked any file-modifying tool calls.
|
|
81
90
|
* Bash entries alone don't count — Bash is too noisy (ls, git status, etc.)
|
package/lib/transcript.js
CHANGED
|
@@ -42,6 +42,58 @@ function parseTranscript (filePath) {
|
|
|
42
42
|
return state.pairs
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function parseCodexTranscript (filePath) {
|
|
46
|
+
if (!filePath || !fs.existsSync(filePath)) return []
|
|
47
|
+
|
|
48
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n')
|
|
49
|
+
const state = { pairs: [], prompt: null, response: '' }
|
|
50
|
+
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
if (!line.trim()) continue
|
|
53
|
+
let entry
|
|
54
|
+
try {
|
|
55
|
+
entry = JSON.parse(line)
|
|
56
|
+
} catch {
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const payload = entry.payload
|
|
61
|
+
if (entry.type !== 'response_item' || payload?.type !== 'message') continue
|
|
62
|
+
|
|
63
|
+
if (payload.role === 'user') {
|
|
64
|
+
const prompt = visibleText(payload.content)
|
|
65
|
+
if (isCodexSystemStatus(prompt)) continue
|
|
66
|
+
if (state.prompt !== null) {
|
|
67
|
+
state.pairs.push({ prompt: state.prompt, response: state.response })
|
|
68
|
+
}
|
|
69
|
+
state.prompt = prompt
|
|
70
|
+
state.response = ''
|
|
71
|
+
} else if (payload.role === 'assistant' && state.prompt !== null) {
|
|
72
|
+
state.response += visibleText(payload.content)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (state.prompt !== null) {
|
|
77
|
+
state.pairs.push({ prompt: state.prompt, response: state.response })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return state.pairs.filter(p => p.prompt)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function visibleText (content) {
|
|
84
|
+
if (typeof content === 'string') return content
|
|
85
|
+
if (!Array.isArray(content)) return ''
|
|
86
|
+
return content
|
|
87
|
+
.filter(block => block.type === 'input_text' || block.type === 'output_text' || block.type === 'text')
|
|
88
|
+
.map(block => block.text)
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.join('')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isCodexSystemStatus (text) {
|
|
94
|
+
return text.startsWith('== System Status ==\n') && text.includes('[automatic message added by system]')
|
|
95
|
+
}
|
|
96
|
+
|
|
45
97
|
/**
|
|
46
98
|
* Format a single prompt/response pair.
|
|
47
99
|
*/
|
|
@@ -130,4 +182,35 @@ function extractModel (filePath) {
|
|
|
130
182
|
return null
|
|
131
183
|
}
|
|
132
184
|
|
|
133
|
-
|
|
185
|
+
function extractCodexModel (filePath, hookModel) {
|
|
186
|
+
if (hookModel) return hookModel
|
|
187
|
+
if (!filePath || !fs.existsSync(filePath)) return null
|
|
188
|
+
|
|
189
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n')
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
if (!line.trim()) continue
|
|
192
|
+
let entry
|
|
193
|
+
try {
|
|
194
|
+
entry = JSON.parse(line)
|
|
195
|
+
} catch {
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (entry.type === 'session_meta' && entry.payload?.model) return entry.payload.model
|
|
199
|
+
if (entry.type === 'session_meta' && entry.payload?.model_slug) return entry.payload.model_slug
|
|
200
|
+
}
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
parseTranscript,
|
|
206
|
+
parseCodexTranscript,
|
|
207
|
+
formatPair,
|
|
208
|
+
formatBody,
|
|
209
|
+
formatTitleTranscript,
|
|
210
|
+
extractHeadline,
|
|
211
|
+
fallbackHeadline,
|
|
212
|
+
extractModel,
|
|
213
|
+
extractCodexModel,
|
|
214
|
+
visibleText,
|
|
215
|
+
isCodexSystemStatus
|
|
216
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@searls/turbocommit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Auto-commit after every
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "Auto-commit after every AI coding agent turn",
|
|
5
5
|
"bin": {
|
|
6
6
|
"turbocommit": "./cli.js"
|
|
7
7
|
},
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
"keywords": [
|
|
20
20
|
"claude",
|
|
21
21
|
"claude-code",
|
|
22
|
+
"codex",
|
|
23
|
+
"openai-codex",
|
|
24
|
+
"ai-agent",
|
|
22
25
|
"git",
|
|
23
26
|
"auto-commit"
|
|
24
27
|
],
|