After each successful git commit, toasts a 5-7-5 haiku about it (written by a small model, or from templates when the model is unavailable); /haiku-commit…

Every commit deserves a poem: after each successful git commit, a three-line haiku about it pops up as a toast.
git commit, including behind rtk, env, VAR=value, cd dir && and git -C dir.HEAD before the command and again after it: a haiku is written only when HEAD moved, so failed, empty or no-op commits are ignored without reading git's (possibly localised) messages.git show --numstat --format=%H%n%s HEAD (a machine format, the same in every locale): subject, file count, insertions, deletions and the main file extension.$.model.complete), so it asks haiku for a 5-7-5 haiku from the subject and stats (low effort, 120 tokens max, 8 s timeout). The reply is kept only if it is three short lines, each within two syllables of 5-7-5 by an approximate English counter.feat, fix, docs, ...), the file count, the insertions/deletions balance and the main language, with variants chosen by hashing the commit hash, so the same commit always gets the same poem. The fragments are built to 5-7-5 (file phrases and language names carry explicit syllable counts, matched by tails of the right length) and the tests check every pool with the syllable counter.Example (template):
A new branch unfolds
two files moved by patient hands
the garden widens
| Command | Effect |
|---|---|
/haiku-commit | Shows the last haiku again, with the commit and whether a model or the templates wrote it |
/haiku-commit off | Stops the haikus (kept across sessions) |
/haiku-commit on | Resumes them |
claude --plugin-dir /path/to/ModsTools/mods/haiku-commit
$.clock.after), so commits are not slowed by the model; only the HEAD read before the command (a local git rev-parse) runs inline. The toast can arrive up to about 8 s after the commit when the model is slow.cd / -C in the same command; a folder written with ~ is skipped.git commit after resolving conflicts has no --numstat lines, so it gets a "zero files" haiku.claude plugin validate mods/haiku-commit
claude plugin test mods/haiku-commit # 19 testshooks/register.ts 94 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { asHaiku, commitDir, modelPrompt, parseShow, resolveDir, templateHaiku } from './haiku'
4import type { Commit } from './haiku'
5
6const OFF = 'off'
7const LAST = 'last'
8const MODEL_TIMEOUT_MS = 8000
9
10type Last = { hash: string; subject: string; lines: string[]; source: 'model' | 'template' }
11
12async function git($: EngineInterface, dir: string, args: string[]): Promise<string | null> {
13 try {
14 const ran = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: 5000 })
15
16 return ran.exitCode === 0 ? ran.stdout : null
17 } catch {
18 return null
19 }
20}
21
22async function headOf($: EngineInterface, dir: string): Promise<string | null> {
23 return (await git($, dir, ['rev-parse', '--verify', '-q', 'HEAD']))?.trim() ?? null
24}
25
26async function compose($: EngineInterface, c: Commit): Promise<Last> {
27 try {
28 const r = await $.model.complete({ model: 'haiku', prompt: modelPrompt(c), maxTokens: 120, effort: 'low', timeoutMs: MODEL_TIMEOUT_MS })
29 const lines = r.isAnswered ? asHaiku(r.text) : null
30 if (lines !== null) return { hash: c.hash, subject: c.subject, lines, source: 'model' }
31 } catch {
32 // The engine refused to send the request (model blocked): fall back.
33 }
34
35 return { hash: c.hash, subject: c.subject, lines: templateHaiku(c), source: 'template' }
36}
37
38// Reads the new commit and toasts its haiku; a commit happened only if HEAD moved
39// (no reliance on git's localised messages). Never throws: it runs detached.
40async function celebrate($: EngineInterface, path: string, before: string | null): Promise<void> {
41 try {
42 const show = await git($, path, ['show', '--numstat', '--format=%H%n%s', 'HEAD'])
43 const commit = show === null ? null : parseShow(show)
44 if (commit === null || commit.hash === before) return
45 const last = await compose($, commit)
46 await $.store.set(LAST, last)
47 $.ui.toast(last.lines.join('\n'), { timeoutMs: 8000 })
48 } catch {
49 // Store or toast refused: nothing to show.
50 }
51}
52
53function isLast(v: unknown): v is Last {
54 const l = v as Last | null
55 return l !== null && typeof l === 'object' && typeof l.hash === 'string' && Array.isArray(l.lines) && l.lines.every(x => typeof x === 'string')
56}
57
58export const register: Register = on => {
59 on('session.start', async ($, e, next) => {
60 await $.command.register({ name: 'haiku-commit', description: 'Show the last commit haiku: [on|off]' })
61
62 return next(e)
63 })
64
65 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
66 if (e.agentId !== undefined || !/\bcommit\b/.test(e.command)) return next(e)
67 const dir = commitDir(e.command)
68 if (dir === undefined || dir.startsWith('~') || (await $.store.get(OFF)) === true) return next(e)
69 const path = resolveDir(dir, await $.session.cwd())
70 const before = await headOf($, path)
71 const ran = await next(e)
72 if (ran.deny !== undefined || ran.isError === true) return ran
73 // The haiku (git show, a model call of up to 8 s) is written after the tool result is back.
74 $.clock.after(0, () => void celebrate($, path, before))
75
76 return ran
77 })
78
79 on('command.run', { command: 'haiku-commit' }, async ($, e) => {
80 const arg = e.args.trim().toLowerCase()
81 if (arg === 'off' || arg === 'on') {
82 await $.store.set(OFF, arg === 'off')
83
84 return { text: `haiku-commit is ${arg === 'off' ? 'OFF' : 'ON'}.` }
85 }
86 if (arg !== '') return { text: 'Usage: /haiku-commit [on|off]' }
87 const last = await $.store.get(LAST)
88 const off = (await $.store.get(OFF)) === true ? '\n(haiku-commit is OFF)' : ''
89 if (!isLast(last)) return { text: `No commit haiku yet.${off}` }
90
91 return { text: `${last.lines.join('\n')}\n\n— ${last.hash.slice(0, 7)} ${last.subject} (${last.source === 'model' ? 'written by a model' : 'from templates'})${off}` }
92 })
93}
94hooks/haiku.ts 292 lines1// ---------- Finding `git commit` in a shell line ----------
2
3/** The words of each simple command of a shell line, quotes removed. */
4export function shellWords(command: string): string[][] {
5 const statements: string[][] = []
6 let words: string[] = []
7 let word = ''
8 let inWord = false
9 const endWord = () => {
10 if (inWord) words.push(word)
11 word = ''
12 inWord = false
13 }
14 const endStatement = () => {
15 endWord()
16 if (words.length > 0) statements.push(words)
17 words = []
18 }
19
20 for (let i = 0; i < command.length; i++) {
21 const c = command[i]!
22 if (c === "'") {
23 const end = command.indexOf("'", i + 1)
24 word += command.slice(i + 1, end === -1 ? undefined : end)
25 inWord = true
26 i = end === -1 ? command.length : end
27 } else if (c === '"') {
28 inWord = true
29 for (i++; i < command.length && command[i] !== '"'; i++) {
30 if (command[i] === '\\' && '"\\$`\n'.includes(command[i + 1] ?? 'x')) i++
31 word += command[i] ?? ''
32 }
33 } else if (c === '\\') {
34 if (command[i + 1] !== '\n') {
35 word += command[i + 1] ?? ''
36 inWord = true
37 }
38 i++
39 } else if (c === ';' || c === '&' || c === '|' || c === '\n' || c === '(' || c === ')') endStatement()
40 else if (c === ' ' || c === '\t') endWord()
41 else {
42 word += c
43 inWord = true
44 }
45 }
46 endStatement()
47
48 return statements
49}
50
51const WRAPPERS = new Set(['rtk', 'env', 'command', 'time', 'nice', 'sudo'])
52
53/**
54 * The folder of the last `git commit` of the line, relative or absolute as written:
55 * its `git -C` folder, else the last `cd` before it, else '' (the session folder).
56 * Undefined when the line runs no `git commit`.
57 */
58export function commitDir(command: string): string | undefined {
59 let cd = ''
60 let found: string | undefined
61 for (const words of shellWords(command)) {
62 let i = 0
63 for (;;) {
64 while (words[i] !== undefined && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!)) i++
65 if (words[i] !== undefined && WRAPPERS.has(words[i]!)) i++
66 else break
67 }
68 if (words[i] === 'cd') {
69 const to = words[i + 1]
70 if (to !== undefined && to !== '-') cd = joinDir(cd, to)
71 continue
72 }
73 if (words[i] !== 'git') continue
74 i++
75 let dir = cd
76 while (words[i]?.startsWith('-')) {
77 if (words[i] === '-C' && words[i + 1] !== undefined) dir = joinDir(dir, words[i + 1]!)
78 i += words[i] === '-C' || words[i] === '-c' ? 2 : 1
79 }
80 if (words[i] === 'commit') found = dir
81 }
82
83 return found
84}
85
86function joinDir(base: string, to: string): string {
87 if (to.startsWith('/') || to.startsWith('~') || base === '') return to
88
89 return `${base.replace(/\/+$/, '')}/${to}`
90}
91
92/** `dir` resolved against the session folder; `~` left to the caller. */
93export function resolveDir(dir: string, cwd: string): string {
94 if (dir === '') return cwd
95 if (dir.startsWith('/')) return dir
96
97 return `${cwd.replace(/\/+$/, '')}/${dir}`
98}
99
100// ---------- Reading `git show --numstat --format=%H%n%s HEAD` ----------
101
102export type Commit = { hash: string; subject: string; files: number; insertions: number; deletions: number; ext: string }
103
104/** Parses `git show --numstat --format=%H%n%s` output (machine format, the same in every locale). */
105export function parseShow(out: string): Commit | null {
106 const lines = out.split('\n')
107 const hash = (lines[0] ?? '').trim()
108 if (!/^[0-9a-f]{7,64}$/.test(hash)) return null
109 const subject = (lines[1] ?? '').trim()
110 let files = 0
111 let insertions = 0
112 let deletions = 0
113 const weight = new Map<string, number>()
114 for (const line of lines.slice(2)) {
115 const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line)
116 if (m === null) continue
117 files++
118 const add = m[1] === '-' ? 0 : Number(m[1])
119 const del = m[2] === '-' ? 0 : Number(m[2])
120 insertions += add
121 deletions += del
122 const ext = extensionOf(m[3]!)
123 weight.set(ext, (weight.get(ext) ?? 0) + add + del + 1)
124 }
125 let ext = ''
126 let best = -1
127 for (const [e, w] of weight) {
128 if (w > best) {
129 ext = e
130 best = w
131 }
132 }
133
134 return { hash, subject, files, insertions, deletions, ext }
135}
136
137/** Lower-case extension of a numstat path (renames `a => b` and `{a => b}` take the new name). */
138export function extensionOf(path: string): string {
139 const target = path.includes('=>') ? path.replace(/\{[^{}]*=> ([^{}]*)\}/, '$1').split(' => ').pop()! : path
140 const base = target.split('/').pop() ?? ''
141 const dot = base.lastIndexOf('.')
142
143 return dot > 0 ? base.slice(dot + 1).toLowerCase() : ''
144}
145
146export function commitType(subject: string): string {
147 const m = /^([a-z]+)(\([^()]*\))?!?:/i.exec(subject.trim())
148
149 return m === null ? 'other' : m[1]!.toLowerCase()
150}
151
152// ---------- Syllables ----------
153
154/** Approximate English syllables of a word: vowel groups, minus silent endings, plus a few hiatuses. */
155export function wordSyllables(word: string): number {
156 let w = word.toLowerCase().replace(/[^a-z]/g, '')
157 if (w === '') return 0
158 if (w.length <= 3) return 1
159 if (/[^aeiouy]es$/.test(w) && !/(?:[sxzcg]|ch|sh)es$/.test(w)) w = w.slice(0, -2) // files, rules (not pages)
160 else if (/[^aeiouytd]ed$/.test(w)) w = w.slice(0, -2) // changed (not mended)
161 else if (/[^aeiouy]e$/.test(w)) w = w.slice(0, -1) // shape, single
162 w = w.replace(/^y/, '')
163 let n = w.match(/[aeiouy]+/g)?.length ?? 0
164 if (/[^aeiouyl]l$/.test(w)) n++ // single, settles
165 if (/iet|ia(?!n)|uou/.test(w)) n++ // quiet, dial
166
167 return Math.max(1, n)
168}
169
170export const lineSyllables = (line: string) => line.split(/[\s-]+/).reduce((s, w) => s + wordSyllables(w), 0)
171
172/**
173 * A model's reply as three haiku lines, or null when it is not one: three short
174 * non-empty lines, each within two syllables of 5-7-5 by the approximate count.
175 */
176export function asHaiku(reply: string): string[] | null {
177 const lines = reply
178 .split('\n')
179 .map(l => l.trim().replace(/^["'`*_>\-\s]+|["'`*_\s]+$/g, ''))
180 .filter(l => l !== '')
181 if (lines.length !== 3) return null
182 const target = [5, 7, 5]
183 const ok = lines.every((l, i) => l.length <= 48 && Math.abs(lineSyllables(l) - target[i]!) <= 2)
184
185 return ok ? lines : null
186}
187
188// ---------- Template haiku ----------
189
190/** A fragment and its syllable count (stated, so proper nouns count right). */
191type Frag = [text: string, syllables: number]
192
193export const LINE1: Record<string, string[]> = {
194 feat: ['a new branch unfolds', 'something new takes root', 'fresh code greets the dawn'],
195 fix: ['a crack is mended', 'the bug flies away', 'one less thorn tonight'],
196 docs: ['words fall like soft rain', 'ink dries on the page', 'the readme grows wise'],
197 style: ['the spaces align', 'neat rows, combed and clean', 'the lint sighs in peace'],
198 refactor: ['old walls rearranged', 'same house, with new rooms', 'the shape shifts, still whole'],
199 perf: ['the loop runs swifter', 'less waiting today', 'speed hums in the wire'],
200 test: ['a new test stands guard', 'green lights in a row', 'the checks keep watch now'],
201 build: ['the scaffold is changed', 'the build learns new steps', 'gears turn in the night'],
202 ci: ['the pipeline wakes up', 'robots check the gate', 'the green lights wait now'],
203 chore: ['small chores, done with care', 'sweeping the workshop', 'the dust is cleared off'],
204 revert: ['footsteps walked backward', 'undone, as before', 'time flows in reverse'],
205 other: ['a commit is made', 'the tree grows a ring', 'one more step taken'],
206}
207
208const NUMBERS: Frag[] = [
209 ['zero', 2], ['one', 1], ['two', 1], ['three', 1], ['four', 1], ['five', 1],
210 ['six', 1], ['seven', 2], ['eight', 1], ['nine', 1], ['ten', 1],
211]
212
213/** "a single file", "three files", "many files": the line-2 subject. */
214export function filesPhrase(files: number): Frag {
215 if (files === 1) return ['a single file', 4]
216 const n = NUMBERS[files]
217 if (n !== undefined) return [`${n[0]} files`, n[1] + 1]
218
219 return ['so many files', 4]
220}
221
222export const LINE2_TAIL: Record<number, string[]> = {
223 3: ['shift and sigh', 'drift and turn', 'wake and stretch'],
224 4: ['drift in the wind', 'stir in the dusk', 'change in the dark'],
225 5: ['changed in quiet light', 'moved by patient hands', 'stirred by morning wind'],
226}
227
228/** Line-2 tails after "a single file" (singular verbs, 3 syllables). */
229export const LINE2_TAIL_ONE = ['shifts and sighs', 'drifts and turns', 'stirs and wakes']
230
231export const LANGUAGES: Record<string, Frag> = {
232 ts: ['TypeScript', 2], tsx: ['TypeScript', 2], js: ['JavaScript', 3], jsx: ['JavaScript', 3], mjs: ['JavaScript', 3],
233 swift: ['Swift', 1], py: ['Python', 2], md: ['markdown', 2], json: ['JSON', 2], rs: ['Rust', 1], go: ['Go', 1],
234 sh: ['shell', 1], zsh: ['shell', 1], css: ['styles', 1], html: ['markup', 2], yml: ['YAML', 2], yaml: ['YAML', 2],
235 java: ['Java', 2], kt: ['Kotlin', 2], rb: ['Ruby', 2], c: ['C', 1], h: ['C', 1], cpp: ['C plus plus', 3], sql: ['SQL', 3],
236}
237
238export const LINE3_TAIL: Record<number, string[]> = {
239 2: ['at rest', 'sleeps now', 'holds firm'],
240 3: ['sleeps at dusk', 'rests at last', 'settles down'],
241 4: ['rests in the dusk', 'sleeps under stars', 'hums in the dark'],
242}
243
244export const BALANCE: Record<'grow' | 'shrink' | 'even', string[]> = {
245 grow: ['the code grows longer', 'new lines bloom like moss', 'the garden widens'],
246 shrink: ['less is more today', 'dead leaves swept away', 'lighter than before'],
247 even: ['lines come, lines depart', 'give and take, in turn', 'the scales hold steady'],
248}
249
250/** FNV-1a over the commit hash: stable variant choice per commit. */
251export function hashOf(text: string): number {
252 let h = 0x811c9dc5
253 for (let i = 0; i < text.length; i++) {
254 h ^= text.charCodeAt(i)
255 h = Math.imul(h, 0x01000193) >>> 0
256 }
257
258 return h
259}
260
261const pick = <T>(list: readonly T[], seed: number): T => list[seed % list.length]!
262
263/** A 5-7-5 haiku from the commit's type, file count, insertions/deletions and main extension. */
264export function templateHaiku(c: Commit): string[] {
265 const h = hashOf(c.hash)
266 const line1 = pick(LINE1[commitType(c.subject)] ?? LINE1.other!, h)
267 const [files, fs] = filesPhrase(c.files)
268 const line2 = `${files} ${pick(c.files === 1 ? LINE2_TAIL_ONE : LINE2_TAIL[7 - fs]!, h >>> 3)}`
269 const lang = LANGUAGES[c.ext]
270 let line3: string
271 if (lang !== undefined && ((h >>> 6) & 1) === 0) {
272 line3 = `${lang[0]} ${pick(LINE3_TAIL[5 - lang[1]]!, h >>> 7)}`
273 } else {
274 const kind = c.insertions > c.deletions * 2 ? 'grow' : c.deletions > c.insertions * 2 ? 'shrink' : 'even'
275 line3 = pick(BALANCE[kind], h >>> 9)
276 }
277
278 return [capitalize(line1), line2, line3]
279}
280
281const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
282
283export function modelPrompt(c: Commit): string {
284 return [
285 'Write one haiku in English about this git commit: exactly three lines of 5, 7 and 5 syllables.',
286 'Reply with the three lines only: no title, no quotes, no commentary.',
287 '',
288 `Subject: ${c.subject.slice(0, 200)}`,
289 `Files changed: ${c.files}, +${c.insertions} -${c.deletions}${c.ext === '' ? '' : `, mostly .${c.ext}`}`,
290 ].join('\n')
291}
292