Writes a retro handoff doc from this session and opens the review in a tmux claude session

Managed using GNU Stow
Structure: "name-of-package"/path-to/destination stow "package-to-install" to set up symlinks stow -D "package-to-uninstall" to remove symlinks
Install both packages with stow omp omarchy, then apply an Omarchy theme to generate the OMP palette. Restart OMP once after setup so it starts with the custom theme's file watcher. Subsequent Omarchy theme switches update OMP live. Changing the theme through Appearance settings alone does not start the watcher in the current OMP runtime.
Install the packages and browser UI modification from the repository root as your desktop user:
make install-vivaldi
The target runs Stow, prompts for sudo to patch Vivaldi's system resources, and reapplies the current Omarchy theme. Stow alone is not sufficient. Restart Vivaldi once after it completes. Subsequent theme switches update its interface within about a second, without a debugging port or local server. The integration selects its own Omarchy theme and disables Vivaldi's theme scheduling.
Run make install-vivaldi again after Vivaldi updates, then restart the browser. The modification uses Vivaldi's internal API and may need changes when that API changes. The installation supports one desktop user and shares that user's palette with every Vivaldi profile using this installation.
To stop syncing, remove the omarchy-theme.js script tag from /opt/vivaldi/resources/vivaldi/window.html, restart Vivaldi, and select your preferred theme and scheduling settings.
hooks/register.ts 166 lines1import type { EngineInterface, Register, SessionMessage } from 'claude-code'
2
3const TMUX_SESSION = 'retros'
4const DOTFILES = 'dotfiles'
5const RETROS_DIR = 'retros'
6const FALLBACK_MODEL = 'sonnet'
7const FALLBACK_MESSAGES = 80
8const REVIEW_MODEL = 'opus'
9const REVIEW_INSTRUCTIONS = '~/.claude/skills/retro/instructions/retro-agent.md'
10
11type Facts = {
12 slug: string
13 task: string
14 happened: string
15 correction: string
16}
17
18type Handoff = Facts & {
19 incident: string
20 date: string
21 cwd: string
22 logPath: string
23}
24
25const FACTS_SHAPE = `Answer with one JSON object and nothing else, keys:
26"slug": 2 to 4 lowercase hyphen-joined words naming the failure, not the task
27"task": what the user originally asked for, quoted from their message
28"happened": the approach the assistant took around the incident, with file:line refs where edits were made
29"correction": the user's correction messages quoted verbatim, and how the corrected version differed`
30
31function factsPrompt(incident: string): string {
32 const framing = incident === ''
33 ? 'Identify where in this session the user materially corrected the assistant.'
34 : `The user names the incident as: ${incident}`
35 return `${framing}\nFacts only, no root cause.\n${FACTS_SHAPE}`
36}
37
38function transcriptText(messages: readonly SessionMessage[]): string {
39 return messages
40 .slice(-FALLBACK_MESSAGES)
41 .map(message => `${message.role}: ${message.text}`)
42 .join('\n\n')
43}
44
45type Extracted = Facts | { failure: string }
46
47function parseFacts(reply: string): Extracted {
48 const start = reply.indexOf('{')
49 const end = reply.lastIndexOf('}')
50 if (start === -1 || end <= start) return { failure: 'reply holds no JSON object' }
51 let parsed: unknown
52 try {
53 parsed = JSON.parse(reply.slice(start, end + 1))
54 } catch {
55 return { failure: 'reply is not valid JSON' }
56 }
57 if (typeof parsed !== 'object' || parsed === null) return { failure: 'reply is not a JSON object' }
58 const { slug, task, happened, correction } = parsed as Record<string, unknown>
59 if (typeof slug !== 'string' || typeof task !== 'string' || typeof happened !== 'string' || typeof correction !== 'string') {
60 return { failure: 'reply misses one of slug, task, happened, correction' }
61 }
62 const safeSlug = slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
63 return safeSlug === '' ? { failure: 'reply has an empty slug' } : { slug: safeSlug, task, happened, correction }
64}
65
66async function extractFacts($: EngineInterface, incident: string): Promise<Extracted> {
67 const prompt = factsPrompt(incident)
68 const forked = await $.model.fork({ prompt })
69 if (forked.isAnswered) return parseFacts(forked.text)
70 if (forked.reason !== 'nothing-to-fork') return { failure: `fork ${forked.reason}` }
71
72 const completed = await $.model.complete({
73 model: FALLBACK_MODEL,
74 system: prompt,
75 prompt: transcriptText(await $.session.messages()),
76 })
77 return completed.isAnswered ? parseFacts(completed.text) : { failure: `completion ${completed.reason}` }
78}
79
80async function sessionLogPath($: EngineInterface, home: string, cwd: string, sessionId: string): Promise<string> {
81 const projectDir = `${home}/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}`
82 const ownLog = `${projectDir}/${sessionId}.jsonl`
83 if (await $.fs.exists(ownLog)) return ownLog
84 const newest = (await $.fs.list(projectDir).catch(() => []))
85 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.jsonl'))
86 .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
87 return newest === undefined ? `unverified, ${ownLog} is missing` : `${projectDir}/${newest.name} (newest, own log missing)`
88}
89
90function localDate(epochMs: number): string {
91 const date = new Date(epochMs)
92 const pad = (part: number): string => String(part).padStart(2, '0')
93 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
94}
95
96function handoffDoc(handoff: Handoff): string {
97 return `# Retro: ${handoff.slug}
98
99${handoff.date} · ${handoff.cwd} · session log: ${handoff.logPath}
100
101## Incident (user)
102
103${handoff.incident === '' ? 'not stated, see Correction' : handoff.incident}
104
105## Task
106
107${handoff.task}
108
109## What happened
110
111${handoff.happened}
112
113## Correction
114
115${handoff.correction}
116`
117}
118
119async function openReview($: EngineInterface, dotfilesDir: string, docPath: string, slug: string): Promise<string> {
120 const command = `claude --model ${REVIEW_MODEL} 'Follow ${REVIEW_INSTRUCTIONS} for ${docPath}'`
121 const windowArgs = ['-n', slug, '-c', dotfilesDir, command]
122 try {
123 const existing = await $.process.run(['tmux', 'has-session', '-t', `=${TMUX_SESSION}`])
124 const opened = existing.exitCode === 0
125 ? await $.process.run(['tmux', 'new-window', '-t', `=${TMUX_SESSION}`, ...windowArgs])
126 : await $.process.run(['tmux', 'new-session', '-d', '-s', TMUX_SESSION, ...windowArgs])
127 return opened.exitCode === 0 ? `session at ${TMUX_SESSION}:${slug}` : `tmux failed: ${opened.stderr.trim()}`
128 } catch (error) {
129 return `tmux not started: ${error instanceof Error ? error.message : String(error)}`
130 }
131}
132
133export const register: Register = on => {
134 on('session.start', async ($, e, next) => {
135 await $.command.register({
136 name: 'retro',
137 description: 'Write a retro handoff doc from this session and open the review in tmux',
138 argumentHint: '[one line naming the failure]',
139 })
140 return next(e)
141 })
142
143 on('command.run', { command: 'retro' }, async ($, e) => {
144 const incident = e.args.trim()
145 const facts = await extractFacts($, incident)
146 if ('failure' in facts) return { text: `retro: ${facts.failure}, nothing written` }
147
148 const home = await $.env.get('HOME')
149 if (home === undefined) return { text: 'retro: HOME is unset, nothing written' }
150 const dotfilesDir = `${home}/${DOTFILES}`
151 const date = localDate(await $.clock.now())
152 const docPath = `${RETROS_DIR}/${date}-${facts.slug}.md`
153 const cwd = await $.session.cwd()
154 await $.fs.write(`${dotfilesDir}/${docPath}`, handoffDoc({
155 ...facts,
156 incident,
157 date,
158 cwd,
159 logPath: await sessionLogPath($, home, cwd, await $.session.id()),
160 }))
161
162 const review = await openReview($, dotfilesDir, docPath, facts.slug)
163 return { text: `Retro doc: ${docPath}, ${review}` }
164 }).catch(() => ({ text: 'retro: failed, see the debug log' }))
165}
166