@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/LICENSE +21 -0
- package/README.md +179 -0
- package/cli.js +193 -0
- package/lib/agent.js +77 -0
- package/lib/doctor.js +101 -0
- package/lib/git.js +70 -0
- package/lib/init.js +41 -0
- package/lib/install.js +115 -0
- package/lib/io.js +58 -0
- package/lib/log.js +18 -0
- package/lib/monitor.js +92 -0
- package/lib/run.js +208 -0
- package/lib/session.js +298 -0
- package/lib/track.js +120 -0
- package/lib/transcript.js +133 -0
- package/lib/wrap.js +129 -0
- package/package.json +33 -0
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 }
|