SLOPSHOPPER

review-ledger

Keeps review findings (H1, M3, L2...) per repo across sessions and ticks them off when their PR merges

newpaneguardcommandtoastprompt
v0.1.0MITupdated 2026-10-04ipartington/claude-mods/review-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-ledger
│ ┃ Review items ✕ › fix the failing auth test and add an audit log call │ ┃ No review items yet. Ask Claude for a │ ┃ review. ⏺ 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 │ │ › /review │ ⎿ review-ledger: 0 review item(s) for this repo. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Review items
No review items yet. Ask Claude for a review.
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 179 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ReviewItem } from '../types'
5
6// A per-repo ledger of review findings (H1, M3, L2...) that survives /clear.
7// Claude writes to it through a tool; merged PRs tick items off; a pane and
8// /review show it; prompts that name an item get the ledger as context.
9
10const items = atom({ plugin: 'review-ledger', key: 'items' } as const, [])
11
12const PANE = 'review-ledger'
13const TOOL = 'items'
14const TOOL_ID = `mcp__review-ledger__${TOOL}`
15const POLL_MS = 5 * 60_000
16const ITEM_REF = /\b[HMLhml]\d{1,2}\b/
17const ORDER = { H: 0, M: 1, L: 2 } as Record<string, number>
18
19let repoKey = ''
20
21async function repoRoot($: EngineInterface): Promise<string> {
22  const r = await $.process.run(['git', 'rev-parse', '--show-toplevel']).catch(() => undefined)
23  return r?.exitCode === 0 ? r.stdout.trim() : await $.session.cwd()
24}
25
26async function save($: EngineInterface, list: ReviewItem[]) {
27  const sorted = [...list].sort(
28    (a, b) => (ORDER[a.id[0]?.toUpperCase() ?? ''] ?? 9) - (ORDER[b.id[0]?.toUpperCase() ?? ''] ?? 9)
29      || Number(a.id.slice(1)) - Number(b.id.slice(1)),
30  )
31  await update($, items, () => sorted)
32  await $.store.set(repoKey, sorted)
33}
34
35function summary(list: ReviewItem[], only?: Set<string>): string {
36  const rows = list
37    .filter(i => !only || only.has(i.id) || i.status === 'open' || i.status === 'in-progress')
38    .map(i => `- ${i.id} [${i.status}${i.pr ? ` #${i.pr}` : ''}] ${i.title}${i.note ? ` (${i.note})` : ''}`)
39  const done = list.filter(i => i.status === 'done').length
40  const wont = list.filter(i => i.status === 'wontdo').length
41  return `[review-ledger] Review items for this repo (${done} done, ${wont} won't do, ${list.length - done - wont} left):\n${rows.join('\n')}\n` +
42    `Update it with the ${TOOL_ID} tool as items change.`
43}
44
45async function checkMerged($: EngineInterface) {
46  const list = await read($, items)
47  const waiting = list.filter(i => i.status === 'in-progress' && i.pr > 0)
48  if (waiting.length === 0) return
49  let changed = false
50  const next = [...list]
51  for (const item of waiting) {
52    const r = await $.process.run(['gh', 'pr', 'view', String(item.pr), '--json', 'state']).catch(() => undefined)
53    if (!r || r.exitCode !== 0) continue
54    const state = (JSON.parse(r.stdout) as { state: string }).state
55    if (state === 'MERGED') {
56      const at = next.findIndex(i => i.id === item.id)
57      next[at] = { ...item, status: 'done' }
58      changed = true
59      $.ui.toast(`${item.id} done: #${item.pr} merged`)
60    }
61  }
62  if (changed) await save($, next)
63}
64
65export const register: Register = on => {
66  on('session.start', async ($, e, next) => {
67    repoKey = `ledger:${await repoRoot($)}`
68    const saved = ((await $.store.get(repoKey)) as ReviewItem[] | undefined) ?? []
69    await update($, items, () => saved)
70
71    await $.command.register({
72      name: 'review',
73      description: 'Open the review-items pane for this repo',
74      argumentHint: '[list]',
75    })
76    await $.tool.register({
77      name: TOOL,
78      description:
79        'The persistent ledger of code/security review findings for this repo (ids like H1, M3, L2 by severity). ' +
80        'Call it with action "upsert" to record findings when you finish a review, and whenever an item\'s state ' +
81        'changes: "in-progress" with the PR number once a fix PR is open (it is marked done automatically when that PR merges), ' +
82        '"wontdo" with a note when the user declines one, "done" when fixed without a PR. Action "list" returns the ledger.',
83      inputSchema: {
84        type: 'object',
85        properties: {
86          action: { type: 'string', enum: ['list', 'upsert', 'remove'] },
87          items: {
88            type: 'array',
89            items: {
90              type: 'object',
91              properties: {
92                id: { type: 'string', description: 'Severity letter and number, e.g. H4' },
93                title: { type: 'string' },
94                status: { type: 'string', enum: ['open', 'in-progress', 'done', 'wontdo'] },
95                pr: { type: 'number' },
96                note: { type: 'string' },
97              },
98              required: ['id'],
99            },
100          },
101        },
102        required: ['action'],
103      },
104    })
105    $.clock.every(POLL_MS, () => void checkMerged($))
106    void checkMerged($)
107
108    return next(e)
109  })
110
111  on('tool.call', { tool: /^mcp__review-ledger__items$/ }, async ($, e) => {
112    const input = e as unknown as { action: string; items?: Partial<ReviewItem>[] }
113    const list = await read($, items)
114
115    if (input.action === 'upsert' || input.action === 'remove') {
116      const byId = new Map(list.map(i => [i.id, i]))
117      for (const raw of input.items ?? []) {
118        const id = String(raw.id ?? '').toUpperCase()
119        if (!id) continue
120        if (input.action === 'remove') {
121          byId.delete(id)
122          continue
123        }
124        const old = byId.get(id)
125        byId.set(id, {
126          id,
127          title: raw.title ?? old?.title ?? '',
128          status: raw.status ?? old?.status ?? 'open',
129          pr: raw.pr ?? old?.pr ?? 0,
130          note: raw.note ?? old?.note ?? '',
131        })
132      }
133      await save($, [...byId.values()])
134    }
135
136    return { result: summary(await read($, items)) }
137  })
138
139  on('command.run', { command: 'review' }, async ($, e) => {
140    const list = await read($, items)
141    if (e.args.trim() === 'list') return { text: list.length ? summary(list) : 'No review items for this repo yet.' }
142    await $.ui.open({ id: PANE, title: 'Review items' })
143    return { text: `${list.length} review item(s) for this repo.` }
144  })
145
146  on('prompt.submit', async ($, e, next) => {
147    const list = await read($, items)
148    if (list.length === 0 || e.origin.kind !== 'composer') return next(e)
149    const named = new Set([...e.text.matchAll(new RegExp(ITEM_REF, 'g'))].map(m => m[0].toUpperCase()))
150    if (named.size === 0 && !/\breview\b/i.test(e.text)) return next(e)
151    return next({ ...e, context: [...(e.context ?? []), summary(list, named)] })
152  })
153
154  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
155    const { Box, Text } = $.ui.resolve(e)
156    const list = await read($, items)
157    const mark = { open: '○', 'in-progress': '◐', done: '●', wontdo: '–' } as const
158    const color = { open: 'yellow', 'in-progress': 'cyan', done: 'green', wontdo: 'gray' } as const
159    const left = list.filter(i => i.status === 'open' || i.status === 'in-progress').length
160
161    return (
162      <Box flexDirection="column">
163        {list.length === 0 && <Text dimColor>No review items yet. Ask Claude for a review.</Text>}
164        {list.length > 0 && <Text bold>{left} left of {list.length}</Text>}
165        {list.map(i => (
166          <Box key={i.id}>
167            <Text color={color[i.status]}>{mark[i.status]} </Text>
168            <Text bold>{i.id.padEnd(4)}</Text>
169            <Text dimColor={i.status === 'done' || i.status === 'wontdo'} wrap="truncate">
170              {i.title}
171              {i.pr ? ` #${i.pr}` : ''}
172            </Text>
173          </Box>
174        ))}
175      </Box>
176    )
177  })
178}
179
types/index.d.ts 16 lines
1export type ReviewItem = {
2  id: string
3  title: string
4  status: 'open' | 'in-progress' | 'done' | 'wontdo'
5  pr: number
6  note: string
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'review-ledger': {
12      items: ReviewItem[]
13    }
14  }
15}
16