@searls/turbocommit 0.10.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/install.js ADDED
@@ -0,0 +1,115 @@
1
+ const os = require('os')
2
+ const path = require('path')
3
+ const { loadJson, writeJson } = require('./io')
4
+
5
+ /**
6
+ * Hook definitions for each Claude Code event turbocommit uses.
7
+ */
8
+ const HOOK_DEFS = {
9
+ PreToolUse: {
10
+ matcher: 'Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*',
11
+ hooks: [{ type: 'command', command: 'turbocommit hook pre-tool-use' }]
12
+ },
13
+ SessionStart: {
14
+ hooks: [{ type: 'command', command: 'turbocommit hook session-start' }]
15
+ },
16
+ SessionEnd: {
17
+ hooks: [{ type: 'command', command: 'turbocommit hook session-end' }]
18
+ },
19
+ Stop: {
20
+ hooks: [{ type: 'command', command: 'turbocommit hook stop' }]
21
+ }
22
+ }
23
+
24
+ function getSettingsPath () {
25
+ return path.join(os.homedir(), '.claude', 'settings.json')
26
+ }
27
+
28
+ function hasTurbocommit (groups) {
29
+ if (!Array.isArray(groups)) return false
30
+ return groups.some(g => {
31
+ const hooks = g && g.hooks ? g.hooks : []
32
+ return hooks.some(h => h.command && h.command.includes('turbocommit'))
33
+ })
34
+ }
35
+
36
+ /**
37
+ * Check if turbocommit hooks are installed across all expected events.
38
+ */
39
+ function isFullyInstalled (settings) {
40
+ if (!settings || !settings.hooks) return false
41
+ return Object.keys(HOOK_DEFS).every(event =>
42
+ hasTurbocommit(settings.hooks[event])
43
+ )
44
+ }
45
+
46
+ function removeTurbocommitHooks (groups) {
47
+ if (!Array.isArray(groups)) return groups
48
+ return groups.map(g => {
49
+ if (!g || !Array.isArray(g.hooks)) return g
50
+ const filtered = g.hooks.filter(h => !h.command || !h.command.includes('turbocommit'))
51
+ return { ...g, hooks: filtered }
52
+ }).filter(g => g.hooks && g.hooks.length > 0)
53
+ }
54
+
55
+ /**
56
+ * Install turbocommit hooks for all 4 events (PreToolUse, SessionStart,
57
+ * SessionEnd, Stop). Each event gets its own group at the end.
58
+ * Cleans up stale entries (including old `turbocommit run`) on install.
59
+ */
60
+ function install (settingsPath) {
61
+ settingsPath = settingsPath || getSettingsPath()
62
+ const settings = loadJson(settingsPath) || {}
63
+
64
+ if (!settings.hooks) settings.hooks = {}
65
+
66
+ // Check if already fully installed
67
+ if (isFullyInstalled(settings)) {
68
+ return { alreadyInstalled: true, settingsPath }
69
+ }
70
+
71
+ // Clean all stale turbocommit entries first (including old `turbocommit run`)
72
+ for (const k of Object.keys(settings.hooks)) {
73
+ settings.hooks[k] = removeTurbocommitHooks(settings.hooks[k])
74
+ if (Array.isArray(settings.hooks[k]) && settings.hooks[k].length === 0) {
75
+ delete settings.hooks[k]
76
+ }
77
+ }
78
+
79
+ // Install each hook event in its own group at the end
80
+ for (const [event, def] of Object.entries(HOOK_DEFS)) {
81
+ if (!settings.hooks[event]) settings.hooks[event] = []
82
+ const group = { hooks: def.hooks }
83
+ if (def.matcher) group.matcher = def.matcher
84
+ settings.hooks[event].push(group)
85
+ }
86
+
87
+ writeJson(settingsPath, settings)
88
+ return { alreadyInstalled: false, settingsPath }
89
+ }
90
+
91
+ function uninstall (settingsPath) {
92
+ settingsPath = settingsPath || getSettingsPath()
93
+ const settings = loadJson(settingsPath)
94
+
95
+ if (!settings || !settings.hooks) {
96
+ return { wasInstalled: false, settingsPath }
97
+ }
98
+
99
+ // Check if any turbocommit hook exists in any event
100
+ const wasInstalled = Object.keys(settings.hooks).some(event =>
101
+ hasTurbocommit(settings.hooks[event])
102
+ )
103
+
104
+ for (const k of Object.keys(settings.hooks)) {
105
+ settings.hooks[k] = removeTurbocommitHooks(settings.hooks[k])
106
+ if (Array.isArray(settings.hooks[k]) && settings.hooks[k].length === 0) {
107
+ delete settings.hooks[k]
108
+ }
109
+ }
110
+
111
+ writeJson(settingsPath, settings)
112
+ return { wasInstalled, settingsPath }
113
+ }
114
+
115
+ module.exports = { install, uninstall, hasTurbocommit, isFullyInstalled, getSettingsPath, HOOK_DEFS }
package/lib/io.js ADDED
@@ -0,0 +1,58 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { spawnSync } = require('child_process')
4
+
5
+ function readStdin () {
6
+ return fs.readFileSync(0, 'utf8')
7
+ }
8
+
9
+ function loadJson (p) {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(p, 'utf8'))
12
+ } catch {
13
+ return null
14
+ }
15
+ }
16
+
17
+ function writeJson (p, obj) {
18
+ ensureDir(path.dirname(p))
19
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf8')
20
+ }
21
+
22
+ function ensureDir (p) {
23
+ fs.mkdirSync(p, { recursive: true })
24
+ }
25
+
26
+ function tryRun (cmd, opts) {
27
+ const { input, ...rest } = opts || {}
28
+ let r
29
+ try {
30
+ r = spawnSync(cmd, {
31
+ ...rest,
32
+ ...(input != null ? { input } : {}),
33
+ shell: true,
34
+ encoding: 'utf8',
35
+ maxBuffer: 50 * 1024 * 1024
36
+ })
37
+ } catch {
38
+ return { code: 1, signal: null, stdout: '', stderr: '' }
39
+ }
40
+ return { code: r.status ?? (r.signal || r.error ? 1 : 0), signal: r.signal || null, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
41
+ }
42
+
43
+ function mergeConfig (global, project) {
44
+ const result = { ...global }
45
+ for (const key of Object.keys(project)) {
46
+ if (
47
+ typeof result[key] === 'object' && result[key] !== null && !Array.isArray(result[key]) &&
48
+ typeof project[key] === 'object' && project[key] !== null && !Array.isArray(project[key])
49
+ ) {
50
+ result[key] = { ...result[key], ...project[key] }
51
+ } else {
52
+ result[key] = project[key]
53
+ }
54
+ }
55
+ return result
56
+ }
57
+
58
+ module.exports = { readStdin, loadJson, writeJson, ensureDir, tryRun, mergeConfig }
package/lib/log.js ADDED
@@ -0,0 +1,18 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const os = require('os')
4
+
5
+ function logPath () {
6
+ return path.join(os.homedir(), '.claude', 'turbocommit', 'monitor.jsonl')
7
+ }
8
+
9
+ function logEvent (event, meta = {}) {
10
+ try {
11
+ const entry = { event, ...meta, title: meta.title || null, at: Date.now() }
12
+ const p = logPath()
13
+ fs.mkdirSync(path.dirname(p), { recursive: true })
14
+ fs.appendFileSync(p, JSON.stringify(entry) + '\n')
15
+ } catch {}
16
+ }
17
+
18
+ module.exports = { logEvent, logPath }
package/lib/monitor.js ADDED
@@ -0,0 +1,92 @@
1
+ const fs = require('fs')
2
+ const { logPath } = require('./log')
3
+
4
+ const COLORS = {
5
+ start: '\x1b[36m',
6
+ success: '\x1b[32m',
7
+ fail: '\x1b[31m',
8
+ skip: '\x1b[33m',
9
+ reset: '\x1b[0m',
10
+ dim: '\x1b[2m'
11
+ }
12
+
13
+ function formatSize (bytes) {
14
+ if (bytes < 1024) return bytes + 'B'
15
+ return Math.round(bytes / 1024) + 'KB'
16
+ }
17
+
18
+ function formatTime (ts) {
19
+ const d = new Date(ts)
20
+ return d.toLocaleTimeString('en-GB', { hour12: false })
21
+ }
22
+
23
+ function formatEntry (entry, cols) {
24
+ const time = formatTime(entry.at)
25
+ const event = entry.event.padEnd(7)
26
+ const project = (entry.project || '').padEnd(12)
27
+ const branch = (entry.branch || '').padEnd(10)
28
+ const size = formatSize(entry.context || 0).padEnd(6)
29
+ const color = COLORS[entry.event] || ''
30
+
31
+ // Fixed columns: time(8) + 2 + event(7) + 2 + project(12) + 2 + branch(10) + 2 + size(6) = 51
32
+ const fixedWidth = 51
33
+ const maxTitle = Math.max(0, (cols || 80) - fixedWidth)
34
+ let title = entry.title || ''
35
+ if (title.length > maxTitle) title = title.slice(0, maxTitle - 1) + '\u2026'
36
+
37
+ return `${COLORS.dim}${time}${COLORS.reset} ${color}${event}${COLORS.reset} ${project} ${branch} ${size}${title}`
38
+ }
39
+
40
+ function readEntries (filePath) {
41
+ try {
42
+ const data = fs.readFileSync(filePath, 'utf8')
43
+ return data.split('\n').filter(Boolean).map(line => {
44
+ try { return JSON.parse(line) } catch { return null }
45
+ }).filter(Boolean)
46
+ } catch {
47
+ return []
48
+ }
49
+ }
50
+
51
+ function monitor () {
52
+ const cols = process.stdout.columns || 80
53
+ const lp = logPath()
54
+ const entries = readEntries(lp)
55
+ for (const entry of entries) {
56
+ process.stdout.write(formatEntry(entry, cols) + '\n')
57
+ }
58
+
59
+ let offset = 0
60
+ try { offset = fs.statSync(lp).size } catch {}
61
+
62
+ fs.watchFile(lp, { interval: 500 }, () => {
63
+ let data
64
+ try {
65
+ const stat = fs.statSync(lp)
66
+ if (stat.size < offset) offset = 0 // File was truncated/recreated
67
+ if (stat.size === offset) return
68
+ const fd = fs.openSync(lp, 'r')
69
+ const buf = Buffer.alloc(stat.size - offset)
70
+ fs.readSync(fd, buf, 0, buf.length, offset)
71
+ fs.closeSync(fd)
72
+ offset = stat.size
73
+ data = buf.toString('utf8')
74
+ } catch {
75
+ return
76
+ }
77
+ const lines = data.split('\n').filter(Boolean)
78
+ for (const line of lines) {
79
+ try {
80
+ const entry = JSON.parse(line)
81
+ process.stdout.write(formatEntry(entry, process.stdout.columns || cols) + '\n')
82
+ } catch {}
83
+ }
84
+ })
85
+
86
+ process.on('SIGINT', () => {
87
+ fs.unwatchFile(lp)
88
+ process.exit(0)
89
+ })
90
+ }
91
+
92
+ module.exports = { monitor, formatEntry, readEntries, formatSize, formatTime }
package/lib/run.js ADDED
@@ -0,0 +1,208 @@
1
+ const fs = require('fs')
2
+ const os = require('os')
3
+ const path = require('path')
4
+ const { loadJson, mergeConfig } = require('./io')
5
+ const { parseTranscript, formatBody, formatTitleTranscript, extractHeadline, extractModel } = require('./transcript')
6
+ const { runTitleAgent, runBodyAgent } = require('./agent')
7
+ const { gitRoot, hasChanges, addAndCommit, hasCommits, currentBranch } = require('./git')
8
+ const { logEvent } = require('./log')
9
+ const { wrapText } = require('./wrap')
10
+ const { hasTrackedModifications, cleanupTracking } = require('./track')
11
+ const { getAncestors, savePending, collectPending, cleanupConsumed, cleanupStale, readWatermark, saveWatermark, resolveParentCommit } = require('./session')
12
+
13
+ /**
14
+ * Map a model ID like "claude-opus-4-6" to a friendly name like "Claude Opus 4.6".
15
+ * Handles both new (claude-opus-4-6) and old (claude-3-5-sonnet-20241022) formats
16
+ * by separating parts into alphabetic (tier) and numeric (version) groups.
17
+ */
18
+ function formatModelName (modelId) {
19
+ if (!modelId) return null
20
+ const stripped = modelId.replace(/^claude-/, '')
21
+ if (stripped === modelId) return modelId // not a claude model, use as-is
22
+ const withoutDate = stripped.replace(/-\d{8}$/, '')
23
+ const parts = withoutDate.split('-')
24
+ const alpha = parts.filter(p => /^[a-z]+$/i.test(p))
25
+ const numeric = parts.filter(p => /^\d+$/.test(p))
26
+ if (alpha.length === 0 || numeric.length === 0) return modelId
27
+ const tier = alpha.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
28
+ const version = numeric.join('.')
29
+ return `Claude ${tier} ${version}`
30
+ }
31
+
32
+ /**
33
+ * Read Claude Code's attribution.commit setting.
34
+ * Returns the string value (including empty string for explicit opt-out),
35
+ * or undefined when the setting is absent / not running under Claude Code.
36
+ */
37
+ function readClaudeAttribution (root) {
38
+ if (!process.env.CLAUDECODE) return undefined
39
+ const globalSettings = loadJson(path.join(os.homedir(), '.claude', 'settings.json'))
40
+ const projectSettings = root ? loadJson(path.join(root, '.claude', 'settings.json')) : null
41
+ const projectVal = projectSettings?.attribution?.commit
42
+ const globalVal = globalSettings?.attribution?.commit
43
+ if (projectVal !== undefined) return projectVal
44
+ if (globalVal !== undefined) return globalVal
45
+ return undefined
46
+ }
47
+
48
+ /**
49
+ * Resolve the Co-Authored-By trailer value.
50
+ * Returns the full trailer line or null.
51
+ *
52
+ * Tier 1: turbocommit config (coauthor: false → null, string → use it)
53
+ * Tier 2: Claude Code attribution.commit setting (when running under Claude)
54
+ * Tier 3: auto-detect model from transcript
55
+ */
56
+ function resolveCoauthor (config, transcriptPath, root) {
57
+ // Tier 1: explicit turbocommit config
58
+ if (config.coauthor === false) return null
59
+
60
+ if (typeof config.coauthor === 'string') {
61
+ return `Co-Authored-By: ${config.coauthor}`
62
+ }
63
+
64
+ // Tier 2: Claude Code attribution setting
65
+ const claudeAttr = readClaudeAttribution(root)
66
+ if (claudeAttr !== undefined) {
67
+ return claudeAttr === '' ? null : claudeAttr
68
+ }
69
+
70
+ // Tier 3: auto-detect from transcript
71
+ const model = extractModel(transcriptPath)
72
+ if (!model) return null
73
+ const name = formatModelName(model)
74
+ return `Co-Authored-By: ${name} <noreply@anthropic.com>`
75
+ }
76
+
77
+ /**
78
+ * Core auto-commit logic. Called by `turbocommit hook stop`.
79
+ * Reads hook input, checks bail conditions, and commits.
80
+ * Always exits 0 — never blocks Claude, never outputs to stdout.
81
+ *
82
+ * Skip/commit decision is based on PreToolUse tracking:
83
+ * - If tracking file exists with entries → this agent modified files → commit
84
+ * - If tracking file missing/empty → skip, buffer transcript for later pickup
85
+ */
86
+ function run (input) {
87
+ if (process.env.TURBOCOMMIT_DISABLED) return
88
+
89
+ let hookInput
90
+ try {
91
+ hookInput = JSON.parse(input)
92
+ } catch {
93
+ return
94
+ }
95
+
96
+ // Find git root
97
+ const root = gitRoot()
98
+ if (!root) return
99
+
100
+ // Merge global + project config (project wins)
101
+ const globalCfg = loadJson(path.join(os.homedir(), '.claude', 'turbocommit.json'))
102
+ const projectCfg = loadJson(`${root}/.claude/turbocommit.json`)
103
+ const config = mergeConfig(globalCfg || {}, projectCfg || {})
104
+ if (config.enabled !== true) return
105
+
106
+ // Parse transcript
107
+ const pairs = parseTranscript(hookInput.transcript_path)
108
+
109
+ // Gather monitor metadata
110
+ const project = path.basename(root)
111
+ const branch = currentBranch(root)
112
+ let context = 0
113
+ try { context = fs.statSync(hookInput.transcript_path).size } catch {}
114
+
115
+ const sessionId = hookInput.session_id
116
+
117
+ // Watermark slicing: only include new pairs since last commit in this session
118
+ const watermark = sessionId ? readWatermark(root, sessionId) : null
119
+ const newPairs = watermark ? pairs.slice(watermark.pairs) : pairs
120
+ const effectivePairs = newPairs.length > 0 ? newPairs : pairs
121
+
122
+ // Skip decision: if PreToolUse never fired for this session, skip commit
123
+ if (sessionId && !hasTrackedModifications(root, sessionId)) {
124
+ savePending(root, sessionId, formatBody(effectivePairs))
125
+ logEvent('skip', { project, branch, context })
126
+ cleanupStale(root)
127
+ return
128
+ }
129
+
130
+ try {
131
+ // Early exit: tracking fired but all changes were reverted
132
+ if (hasCommits(root) && !hasChanges(root)) {
133
+ if (sessionId) {
134
+ savePending(root, sessionId, formatBody(effectivePairs))
135
+ cleanupTracking(root, sessionId)
136
+ }
137
+ logEvent('skip', { project, branch, context })
138
+ cleanupStale(root)
139
+ return
140
+ }
141
+
142
+ logEvent('start', { project, branch, context })
143
+
144
+ const formattedTranscript = formatBody(effectivePairs)
145
+
146
+ // Title: agent by default, transcript if opted out
147
+ let headline
148
+ if (config.title?.type !== 'transcript') {
149
+ const titleTranscript = formatTitleTranscript(effectivePairs)
150
+ headline = runTitleAgent(root, config.title || {}, titleTranscript)
151
+ }
152
+ headline = headline || extractHeadline(effectivePairs)
153
+
154
+ // Body: transcript by default, agent if opted in
155
+ let body
156
+ if (config.body?.type === 'agent') {
157
+ body = runBodyAgent(root, config.body, formattedTranscript)
158
+ }
159
+ body = body || formattedTranscript
160
+
161
+ // Continuation reference + pending transcripts from ancestor sessions
162
+ let combinedBody = body
163
+ if (sessionId) {
164
+ const parentCommit = resolveParentCommit(root, sessionId)
165
+ const continuation = parentCommit
166
+ ? `Continuation of ${parentCommit.slice(0, 7)}\n\n`
167
+ : ''
168
+
169
+ const ancestors = getAncestors(root, sessionId)
170
+ // Collect pending from ancestors only — self-pending is already
171
+ // covered by effectivePairs (same watermark baseline) so including
172
+ // it would duplicate content between Planning and Implementation.
173
+ const pending = collectPending(root, [...ancestors].reverse())
174
+
175
+ if (pending.length > 0) {
176
+ combinedBody = continuation + '## Planning\n\n' + pending.join('\n\n---\n\n') +
177
+ '\n\n## Implementation\n\n' + body
178
+ } else {
179
+ combinedBody = continuation + body
180
+ }
181
+ }
182
+
183
+ // Wrap body lines if configured
184
+ const wrappedBody = wrapText(combinedBody, config.body?.maxLineLength)
185
+
186
+ // Resolve coauthor trailer
187
+ const coauthor = resolveCoauthor(config, hookInput.transcript_path, root)
188
+ const tag = coauthor ? '\n\n' + coauthor : ''
189
+
190
+ const sha = addAndCommit(root, headline, wrappedBody + tag)
191
+
192
+ logEvent('success', { project, branch, context, title: headline })
193
+
194
+ // Post-commit: save watermark and cleanup
195
+ if (sessionId) {
196
+ saveWatermark(root, sessionId, pairs.length, sha)
197
+ const ancestors = getAncestors(root, sessionId)
198
+ cleanupConsumed(root, [...ancestors, sessionId])
199
+ cleanupTracking(root, sessionId)
200
+ }
201
+ cleanupStale(root)
202
+ } catch (err) {
203
+ logEvent('fail', { project, branch, context })
204
+ throw err
205
+ }
206
+ }
207
+
208
+ module.exports = { run, formatModelName, resolveCoauthor, readClaudeAttribution }