SLOPSHOPPER

haiku-commit

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…

newguardcommandtoastmodelprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · haiku-commit
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /haiku-commit ⎿ haiku-commit: No commit haiku yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

haiku-commit

Every commit deserves a poem: after each successful git commit, a three-line haiku about it pops up as a toast.

What it does

  • Watches the main agent's Bash calls for git commit, including behind rtk, env, VAR=value, cd dir && and git -C dir.
  • Reads 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.
  • Reads the new commit with 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.
  • Writes the haiku:
  • With a model first. Mods can call a model ($.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.
  • From templates otherwise (timeout, API error, refused model, a reply that is not a haiku): a deterministic haiku built from the commit type (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.
  • Toasts the three lines for 8 seconds and keeps the last haiku across sessions.

Example (template):

A new branch unfolds
two files moved by patient hands
the garden widens

Commands

CommandEffect
/haiku-commitShows the last haiku again, with the commit and whether a model or the templates wrote it
/haiku-commit offStops the haikus (kept across sessions)
/haiku-commit onResumes them

Install

claude --plugin-dir /path/to/ModsTools/mods/haiku-commit

Limits

  • The haiku is written after the Bash result is returned (deferred with $.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.
  • The model call is billed to the session's account like any other small request.
  • Only commits run by the main agent's Bash tool are seen, not commits made in your own terminal or by subagents.
  • Folders are resolved from the session folder plus cd / -C in the same command; a folder written with ~ is skipped.
  • Syllable counts are approximate (English heuristics).
  • A merge commit made with git commit after resolving conflicts has no --numstat lines, so it gets a "zero files" haiku.
  • Where the transcript is printed into scrollback (no toast overlay), Claude Code shows a toast as one line on the notification bar, so the three lines may appear flattened there.

Develop

claude plugin validate mods/haiku-commit
claude plugin test mods/haiku-commit   # 19 tests
Source 2 files
hooks/register.ts 94 lines
1import 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}
94
hooks/haiku.ts 292 lines
1// ---------- 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