SLOPSHOPPER

merge-followthrough

Watches your PRs, and when one merges it pulls main and deletes the branch, then tells Claude

newbandcommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · merge-followthrough
› 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 › /prs ⎿ merge-followthrough: No open PRs. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Five Claude Code mods that remove the routine I kept doing by hand. Each one comes from an audit of my last 30 Claude Code sessions. A mod is a plugin of function hooks: it runs inside Claude Code and can draw a band above the prompt, a pane, a toast or a status line, and can step into prompts, tool calls and the session's start and end.

ModThe habit it removesWhat you seeUsage cost
merge-followthroughTyping "616 merged" (61 times in 30 sessions), then Claude spending a turn on git checkout main && git pull and deleting the branch; ! git pull by hand at session startBand of your open PRs with CI ✓ ✗ …; toast when a merge has been pulled; /prsNone
handoff-on-clear"give me a prompt to restart", "I've lost your feedback, can you redisplay" after /clearToast on save; band ↺ Handoff from last session [Load] [Dismiss]; /handoffOne cached fork per /clear (option summarize, on by default)
bg-task-band"how are we looking", "any updates", "when's the estimated end time?"Band: ⏳ Wait for cp-22 recap 12m/25m · TASK [drain]; toast on finishNone
review-ledger"what's left to do from the review?", updating the H/M/L list by handPane of items by severity (/review); toast when an item's PR mergesNone
tts-liteA Stop hook starting a full claude -p per reply to speak a summary, which left 200 stray transcriptsAudio onlyShort replies none; longer ones one bare Haiku completion

How each one works

merge-followthrough: polls gh pr list --author @me every 60 s. When a PR it saw open is merged, it checks out the default branch, pull --ff-onlys, deletes the local branch and prunes, as long as the working tree is clean. On your next prompt it tells Claude what it already did, so Claude doesn't redo it. Typing 616 merged runs the same steps at once. At session start it fast-forwards the default branch if you're on it with no changes.

handoff-on-clear: on session.end (a /clear or an exit) it saves the last answer, your recent asks, PR numbers and artifact links mentioned, the branch, and (with summarize) a 200-word handoff from one $.model.fork, which the API serves mostly from the prompt cache. It's kept per directory for three days. It goes to Claude when you press Load, run /handoff, or ask something like "redisplay" or "prompt to restart".

bg-task-band: records background Bash, Monitor and Agent calls as they start, reads each one's output file every 10 s for the last line, and marks it done from the task notification that arrives when it ends. A TaskStop marks it stopped. Finished tasks stay on the band for 90 s.

review-ledger: registers the tool mcp__review-ledger__items, which Claude calls to record findings (H1, M3, L2) and their state: open, in-progress with a PR number, done, or won't do. It's stored per repository root, so it survives /clear and new sessions. Every 5 minutes it checks in-progress PRs and marks an item done when its PR merges. A prompt that says "review" or names an item gets the ledger as context.

tts-lite: on turn.complete it reads replies of 20 words or fewer out as they are, and asks Haiku for one sentence about anything longer. It plays them through Piper and paplay, or espeak. It reads the same CLAUDE_TTS, CLAUDE_TTS_SPEED, CLAUDE_TTS_VOICE and CLAUDE_TTS_MODEL variables as the shell hook it replaces, and stays silent while that hook (tts-speak.sh) is still in ~/.claude/settings.json, so you never hear double.

Install

Requirements: Claude Code 2.1.288 or later (function-hook mods are early access and the API moves between releases), plus git and an authenticated gh for merge-followthrough and review-ledger. tts-lite needs paplay and Piper or espeak.

Load them for every session by listing the folders in CLAUDE_CODE_PLUGIN_DIRS (colon-separated) in the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/code/claude-mods/merge-followthrough:~/code/claude-mods/handoff-on-clear:~/code/claude-mods/bg-task-band:~/code/claude-mods/review-ledger:~/code/claude-mods/tts-lite"
  }
}

For one session only:

claude --plugin-dir ./merge-followthrough --plugin-dir ./bg-task-band

Or copy them into a session's hot-reload folder:

scripts/sync.sh ~/.claude/dev-mods/<session-id>

Layout

<mod>/
  .claude-plugin/plugin.json   manifest (name, version, options, state contract)
  hooks/hooks.json             { "modules": ["./register.tsx"] }
  hooks/register.tsx           the hooks module
  types/index.d.ts             $.state contract (mods that keep state)
  tests/*.test.ts              claude plugin test
scripts/
  check-mods.sh                validate / test the mods given files touch
  typecheck.sh                 tsc against Claude Code's generated API types
  sync.sh                      copy mods into a load folder

Development

pip install pre-commit && pre-commit install
claude plugin validate <mod>
claude plugin test <mod>
scripts/typecheck.sh            # all mods

The pre-commit hooks run standard hygiene, gitleaks, shellcheck, and for the mods a commit touches: claude plugin validate, a TypeScript check and claude plugin test. The type-check needs the API declarations Claude Code writes into .claude-plugin/types/ when it loads a mod. Those are generated per build, git-ignored, and found automatically in any ~/.claude/dev-mods copy. Without them the type-check is skipped with a note.

See AGENTS.md for the conventions an agent (or a person) should follow when changing a mod.

License

MIT

Source 2 files
hooks/register.tsx 223 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PrRow } from '../types'
5
6// Watches the PRs you authored in this repo. When one merges it does the
7// routine Claude used to spend a turn on (checkout main, pull --ff-only,
8// delete the merged local branch) and tells Claude on your next prompt.
9
10const prs = atom({ plugin: 'merge-followthrough', key: 'prs' } as const, [])
11const merged = atom({ plugin: 'merge-followthrough', key: 'merged' } as const, [])
12
13const POLL_MS = 60_000
14const MERGED_PROMPT = /^\s*(#?\d+[\s,]*(and\s+)?)*(merged|megerd|mered|,erged)\b/i
15
16type GhPr = {
17  number: number
18  title: string
19  headRefName: string
20  state: 'OPEN' | 'MERGED' | 'CLOSED'
21  isDraft: boolean
22  mergeable: string
23  statusCheckRollup?: { status?: string; conclusion?: string; state?: string }[]
24}
25
26function checksOf(rollup: GhPr['statusCheckRollup']): PrRow['checks'] {
27  if (!rollup || rollup.length === 0) return 'none'
28  const states = rollup.map(c => (c.conclusion || c.state || c.status || '').toUpperCase())
29  if (states.some(s => ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED'].includes(s))) return 'fail'
30  if (states.some(s => ['', 'PENDING', 'QUEUED', 'IN_PROGRESS', 'EXPECTED'].includes(s))) return 'pending'
31  return 'pass'
32}
33
34async function git($: EngineInterface, args: string[]) {
35  return $.process.run(['git', ...args], { timeoutMs: 60_000 })
36}
37
38async function defaultBranch($: EngineInterface): Promise<string> {
39  const r = await git($, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
40  return r.exitCode === 0 ? r.stdout.trim().replace(/^origin\//, '') : 'main'
41}
42
43async function isClean($: EngineInterface): Promise<boolean> {
44  const r = await git($, ['status', '--porcelain', '--untracked-files=no'])
45  return r.exitCode === 0 && r.stdout.trim() === ''
46}
47
48// The routine: fast-forward the default branch, drop the merged branch.
49async function followThrough($: EngineInterface, pr: { number: number; branch: string }): Promise<string> {
50  const main = await defaultBranch($)
51  const parts: string[] = []
52  const current = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
53
54  if (!(await isClean($))) {
55    return `#${pr.number} merged. Working tree has uncommitted changes on ${current}, so I left git alone.`
56  }
57  if (current !== main) {
58    const co = await git($, ['checkout', '-q', main])
59    if (co.exitCode !== 0) return `#${pr.number} merged. Could not check out ${main}: ${co.stderr.trim().slice(0, 200)}`
60    parts.push(`checked out ${main}`)
61  }
62  const pull = await git($, ['pull', '--ff-only', '-q'])
63  if (pull.exitCode !== 0) {
64    parts.push(`pull failed: ${pull.stderr.trim().slice(0, 200)}`)
65  } else {
66    const head = (await git($, ['log', '--oneline', '-1'])).stdout.trim()
67    parts.push(`${main} is at ${head}`)
68  }
69  if (pr.branch && pr.branch !== main) {
70    const exists = await git($, ['rev-parse', '--verify', '-q', `refs/heads/${pr.branch}`])
71    if (exists.exitCode === 0) {
72      // -D is safe here: GitHub says the PR merged, and squash merges defeat -d.
73      const del = await git($, ['branch', '-D', pr.branch])
74      parts.push(del.exitCode === 0 ? `deleted local branch ${pr.branch}` : `kept ${pr.branch} (${del.stderr.trim()})`)
75    }
76  }
77  await git($, ['fetch', '-q', '--prune'])
78
79  return `#${pr.number} merged: ${parts.join('; ')}.`
80}
81
82async function fetchPrs($: EngineInterface): Promise<GhPr[] | undefined> {
83  const r = await $.process.run(
84    ['gh', 'pr', 'list', '--author', '@me', '--state', 'all', '--limit', '20',
85      '--json', 'number,title,headRefName,state,isDraft,mergeable,statusCheckRollup'],
86    { timeoutMs: 30_000 },
87  ).catch(() => undefined)
88  if (!r || r.exitCode !== 0) return undefined
89  try {
90    return JSON.parse(r.stdout) as GhPr[]
91  } catch {
92    return undefined
93  }
94}
95
96// Numbers seen open this session, so a merge is noticed once.
97const seenOpen = new Set<number>()
98const handled = new Set<number>()
99let isRepo = false
100
101async function poll($: EngineInterface) {
102  const list = await fetchPrs($)
103  if (!list) return
104  const open = list.filter(p => p.state === 'OPEN')
105  await update($, prs, () =>
106    open.map(p => ({
107      number: p.number,
108      title: p.title,
109      branch: p.headRefName,
110      state: p.state,
111      isDraft: p.isDraft,
112      checks: checksOf(p.statusCheckRollup),
113      mergeable: p.mergeable,
114    })),
115  )
116  for (const p of list) {
117    if (p.state === 'OPEN') seenOpen.add(p.number)
118    if (p.state === 'MERGED' && seenOpen.has(p.number) && !handled.has(p.number)) {
119      handled.add(p.number)
120      const note = await followThrough($, { number: p.number, branch: p.headRefName })
121      await update($, merged, list => [...list, { number: p.number, note }])
122      $.ui.toast(note, { timeoutMs: 8000 })
123    }
124  }
125}
126
127export const register: Register = on => {
128  on('session.start', async ($, e, next) => {
129    const inside = await git($, ['rev-parse', '--is-inside-work-tree']).catch(() => undefined)
130    isRepo = inside?.exitCode === 0
131    if (!isRepo) return next(e)
132
133    await $.command.register({ name: 'prs', description: 'Refresh the PR band and pull main if PRs merged' })
134
135    // The hand-typed `! git checkout main; git pull` at the start of a session.
136    const main = await defaultBranch($)
137    const current = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
138    if (current === main && (await isClean($))) {
139      const before = (await git($, ['rev-parse', 'HEAD'])).stdout.trim()
140      const pull = await git($, ['pull', '--ff-only', '-q'])
141      const after = (await git($, ['rev-parse', 'HEAD'])).stdout.trim()
142      if (pull.exitCode === 0 && before !== after) {
143        const count = (await git($, ['rev-list', '--count', `${before}..${after}`])).stdout.trim()
144        $.ui.toast(`${main} fast-forwarded ${count} commit(s)`)
145      }
146    }
147
148    void poll($)
149    $.clock.every(POLL_MS, () => void poll($))
150
151    return next(e)
152  })
153
154  on('command.run', { command: 'prs' }, async $ => {
155    await poll($)
156    const open = await read($, prs)
157    return { text: open.length === 0 ? 'No open PRs.' : `${open.length} open PR(s); see the band above the prompt.` }
158  })
159
160  on('prompt.submit', async ($, e, next) => {
161    if (!isRepo) return next(e)
162    const context = [...(e.context ?? [])]
163
164    // You typed "616 merged": make sure the routine ran before Claude starts.
165    if (e.origin.kind === 'composer' && MERGED_PROMPT.test(e.text)) {
166      await poll($)
167      const numbers = [...e.text.matchAll(/\d+/g)].map(m => Number(m[0]))
168      for (const n of numbers) {
169        if (handled.has(n)) continue
170        const view = await $.process.run(['gh', 'pr', 'view', String(n), '--json', 'state,headRefName'])
171          .catch(() => undefined)
172        if (!view || view.exitCode !== 0) continue
173        const pr = JSON.parse(view.stdout) as { state: string; headRefName: string }
174        if (pr.state !== 'MERGED') continue
175        handled.add(n)
176        const note = await followThrough($, { number: n, branch: pr.headRefName })
177        await update($, merged, list => [...list, { number: n, note }])
178      }
179      if (numbers.length === 0) {
180        // Plain "merged": pull anyway.
181        const note = await followThrough($, { number: 0, branch: '' })
182        await update($, merged, list => [...list, { number: 0, note: note.replace('#0 merged', 'merged') }])
183      }
184    }
185
186    const notes = await read($, merged)
187    if (notes.length > 0) {
188      context.push(
189        `[merge-followthrough] Already done, no need to repeat:\n${notes.map(n => `- ${n.note}`).join('\n')}`,
190      )
191      await update($, merged, () => [])
192    }
193
194    return next(context.length > 0 ? { ...e, context } : e)
195  })
196
197  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
198    const open = await read($, prs)
199    if (!isRepo || open.length === 0 || e.props.hasSurvey) return next(e)
200
201    const { Box, Text } = $.ui.resolve(e)
202    const mark = { pass: '✓', fail: '✗', pending: '…', none: '·' } as const
203    const color = { pass: 'green', fail: 'red', pending: 'yellow', none: 'gray' } as const
204
205    return (
206      <Box flexDirection="column">
207        {open.slice(0, 4).map(p => (
208          <Box key={String(p.number)}>
209            <Text color={color[p.checks]}>{mark[p.checks]} </Text>
210            <Text bold>#{p.number} </Text>
211            <Text dimColor wrap="truncate">
212              {p.isDraft ? 'draft · ' : ''}
213              {p.mergeable === 'CONFLICTING' ? 'conflicts · ' : ''}
214              {p.title}
215            </Text>
216          </Box>
217        ))}
218        {open.length > 4 && <Text dimColor>+{open.length - 4} more open PRs</Text>}
219      </Box>
220    )
221  })
222}
223
types/index.d.ts 19 lines
1export type PrRow = {
2  number: number
3  title: string
4  branch: string
5  state: 'OPEN' | 'MERGED' | 'CLOSED'
6  isDraft: boolean
7  checks: 'pass' | 'fail' | 'pending' | 'none'
8  mergeable: string
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'merge-followthrough': {
14      prs: PrRow[]
15      merged: { number: number; note: string }[]
16    }
17  }
18}
19