SLOPSHOPPER

correction-band

Draws Log it / Not a correction buttons above the prompt when a submitted prompt looks like a correction, and a Review button when recall memory candidates are…

newbandpromptprocess
A shopper browsing a rack in a slop shop
README

correction-band

A Claude Code mod that draws a band above the prompt in two cases.

  1. The prompt you just submitted has a correction shape ("why did you build...", "we already have a tool for..."). The band asks whether to record it.
  2. The recall context plane has memory candidates pending review. The band keeps the count in view instead of one scrolled-away SessionStart line.

The prompt always reaches the model unchanged. The mod never drops or rewrites it.

Rows and buttons

Correction? "why did you build"   [ Log it ] [ Not a correction ]
recall sync stale (10h ago)  Recall: 33 memory candidates pending review  [ Review ]
KeyHotkeyWhat it does
log1Submits an instruction, as the mod, quoting the stored correction text and telling the agent to run correction-log. The row clears at the next operator prompt. Clears the row.
dismiss2Clears the row. Submits nothing.
review3Runs /recall review through $.command.run. If that rejects, fills the prompt box with /recall review (the host refuses a $.prompt.submit text that starts with /). Hides the row for the session.

The stale prefix shows when the heartbeat is missing a last_success, unparsable there, or older than 6 hours, the rule recall-review-nudge.py uses. A stale heartbeat with zero candidates shows the stale line alone. Nothing pending draws nothing. The band never exceeds two rows or maxRows.

What it reads and runs

  • ~/.claude/recall-context-heartbeat.json, read with $.fs.read at session.start.
  • python3 ~/.claude/hooks/correction-nudge.py --match, with {"prompt": "<text>"} on stdin and a 1.5 s timeout. The matcher stays owned by that script; this mod holds no pattern.
  • Prompts starting with /, <, [Request interrupted, This session is being continued, or Caveat: The messages below, and prompts over 1200 characters, are never sent to the matcher.

claude plugin validate --strict reports:

hooks: session.start, prompt.submit, ui.render{component=AbovePrompt}
calls: $.command.run, $.env.get, $.fs.read, $.process.run, $.prompt.fill, $.prompt.submit, $.state.get, $.state.set, $.ui.invalidate, $.ui.resolve

A submitted prompt waits for the matcher up to 1.5 s (measured about 30 ms) and goes through unchanged if it times out. Any error, timeout, non-zero exit, or unparsable output fails open: the prompt flows and no band is drawn.

The --match contract

correction-nudge.py --match reads the hook's stdin JSON, prints one JSON line {"matched": "<pattern>" | null, "phrase": "<text>" | null}, exits 0, writes no marker files, and ignores the per-session claim. The transport is stdin, which $.process.run supports through init.stdin, so no --payload-file flag is needed.

Load it

claude --plugin-dir /path/to/agentic-ways-of-working/mods/correction-band
# or
CLAUDE_CODE_PLUGIN_DIRS=/path/to/agentic-ways-of-working/mods/correction-band claude

Off switch: CORRECTION_BAND_OFF=1 skips the matcher; recall rows still draw.

Tests

claude plugin test mods/correction-band

Twenty drawing and flow tests, in both the terminal and desktop surfaces for the rows and buttons. The test kit does not honor a plugin origin on $.prompt.submit, so the own-submission guard (next.origin.plugin) is not unit tested. The engine skips the calling plugin's own hooks for $.prompt.submit, and the canary covers the flow.

evals/does-not-block-prompts checks that a correction-shaped prompt still reaches the model with the mod loaded (claude plugin eval). The band itself is covered by the drawing tests, since an eval cannot press a button.

Tested on Claude Code 2.1.288. Canary:

claude -p "why did you build a new script for this, we already have a tool for it. Reply with exactly the word ACK." --plugin-dir mods/correction-band --max-turns 2

Headless sessions draw nothing, so the canary proves prompt flow only.

Source 2 files
hooks/register.ts 194 lines
1// correction-band: a band above the prompt for corrections and pending recall reviews.
2//
3// prompt.submit: asks ~/.claude/hooks/correction-nudge.py --match whether the
4//   prompt has a correction shape. The matcher is owned by that script; this
5//   file holds no pattern. A match is kept in $.state and drawn as a row with
6//   Log it / Not a correction buttons. The prompt itself always passes on
7//   unchanged: this module never drops or rewrites a prompt.
8// session.start: reads ~/.claude/recall-context-heartbeat.json and keeps the
9//   pending candidate count (stale after 6 h, the rule recall-review-nudge.py uses).
10// ui.render (AbovePrompt): up to two rows, one per pending item, each a Text
11//   line plus buttons. Nothing pending means next(e): the band is not drawn.
12//
13// Reads: the heartbeat file. Runs: python3 correction-nudge.py --match with the
14// prompt on stdin. Submits: the log-it instruction, as the mod, never as the user.
15// Review runs /recall review through $.command.run, else fills the prompt box with it.
16// Fail open: any error, timeout, non-zero exit, or unparsable output leaves the
17// prompt flowing and the band absent. CORRECTION_BAND_OFF=1 turns the mod off.
18//
19// The host reads on(...) and $.noun.method(...) from source, so they are
20// spelled literally, and helpers that take $ are top-level functions.
21import { atom, read, update } from 'claude-code'
22
23const CORRECTION = atom({ plugin: 'correction-band', key: 'correction' }, null)
24const RECALL = atom({ plugin: 'correction-band', key: 'recall' }, null)
25
26const NAME = 'correction-band'
27const MAX_PROMPT_CHARS = 1200
28const STALE_MS = 6 * 3600 * 1000
29// Prompts that are not the operator's own words; the matcher is never asked.
30const SKIP_PREFIXES = ['/', '<', '[Request interrupted', 'This session is being continued', 'Caveat: The messages below']
31
32function logItText(text) {
33  return `Record this correction I gave with correction-log: "${text}". Run ~/.local/bin/correction-log --scope <this repo or *> --job … --not … --use … --why … --source …, point --use at the rule's owner and write the owner first if there is none. Then continue the task.`
34}
35
36export function register(on) {
37  on('session.start', async ($, e, next) => {
38    const result = await next(e)
39    await loadRecall($)
40    return result
41  })
42
43  on('prompt.submit', async ($, e, next) => {
44    try {
45      if ((await $.env.get('CORRECTION_BAND_OFF')) === '1') return next(e)
46      if (next.origin?.plugin === NAME) return next(e)
47      if (!isOperatorText(e.text)) return next(e)
48      // The row never outlives the prompt that matched.
49      await update($, CORRECTION, () => null)
50      await checkPrompt($, e.text)
51    } catch {
52      // Fail open: the prompt goes on as typed.
53    }
54    return next(e)
55  })
56
57  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
58    const correction = await read($, CORRECTION)
59    const recall = await read($, RECALL)
60    const rows = []
61    if (correction) rows.push(correctionRow($, e, correction))
62    const recallRow = recall ? recallLine($, e, recall) : null
63    if (recallRow) rows.push(recallRow)
64    const room = Math.min(2, e.props?.maxRows ?? 2)
65    if (rows.length === 0 || room < 1) return next(e)
66    const { Box } = $.ui.resolve(e)
67    return Box({ flexDirection: 'column', paddingX: 1, children: rows.slice(0, room) })
68  })
69}
70
71function isOperatorText(text) {
72  if (typeof text !== 'string' || text.length === 0 || text.length > MAX_PROMPT_CHARS) return false
73  return !SKIP_PREFIXES.some((p) => text.startsWith(p))
74}
75
76async function checkPrompt($, text) {
77  const home = await $.env.get('HOME')
78  if (!home) return
79  const r = await $.process.run(['python3', `${home}/.claude/hooks/correction-nudge.py`, '--match'], {
80    timeoutMs: 1500,
81    stdin: JSON.stringify({ prompt: text }),
82  })
83  if (r.exitCode !== 0) return
84  const out = JSON.parse(r.stdout.trim().split('\n').pop() ?? '')
85  if (!out || typeof out.matched !== 'string' || !out.matched) return
86  await update($, CORRECTION, () => ({
87    text: text.slice(0, 160),
88    pattern: out.matched,
89    phrase: String(out.phrase ?? ''),
90    at: Date.now(),
91  }))
92  $.ui.invalidate('ui.render')
93}
94
95async function loadRecall($) {
96  try {
97    const home = await $.env.get('HOME')
98    const raw = await $.fs.read(`${home}/.claude/recall-context-heartbeat.json`)
99    const hb = JSON.parse(raw)
100    const at = typeof hb.last_success === 'string' ? Date.parse(hb.last_success) : NaN
101    const stale = Number.isNaN(at) || Date.now() - at > STALE_MS
102    const candidates = Number.isFinite(hb.candidates) ? Number(hb.candidates) : 0
103    await update($, RECALL, () => ({
104      candidates,
105      lastSuccess: Number.isNaN(at) ? null : String(hb.last_success),
106      stale,
107      error: typeof hb.error === 'string' ? hb.error : null,
108      reviewedAt: null,
109    }))
110  } catch {
111    await update($, RECALL, () => null)
112  }
113  $.ui.invalidate('ui.render')
114}
115
116function ageText(lastSuccess) {
117  const at = lastSuccess ? Date.parse(lastSuccess) : NaN
118  if (Number.isNaN(at)) return 'never'
119  const hours = (Date.now() - at) / 3600000
120  return hours < 48 ? `${Math.round(hours)}h ago` : `${Math.round(hours / 24)}d ago`
121}
122
123// Cut text to fit `room` cells, ending in an ellipsis; single-width characters only.
124function fit(text, room) {
125  const flat = text.replace(/\s+/g, ' ').trim()
126  if (room < 4) return ''
127  return flat.length <= room ? flat : `${flat.slice(0, room - 1)}…`
128}
129
130function correctionRow($, e, correction) {
131  const { Box, Text, Button } = $.ui.resolve(e)
132  const columns = e.props?.bodyColumns ?? 80
133  const buttons = 20 + 10 // "[ Not a correction ]" + "[ Log it ]" with spacing
134  const phrase = fit(correction.phrase, columns - buttons - 16)
135  return Box({
136    flexDirection: 'row',
137    children: [
138      Text({ children: `Correction? "${phrase}"  ` }),
139      Button({
140        key: 'log',
141        label: 'Log it',
142        hotkey: '1',
143        onPress: async () => {
144          await $.prompt.submit({ text: logItText(correction.text) })
145          await update($, CORRECTION, () => null)
146          $.ui.invalidate('ui.render')
147        },
148      }),
149      Button({
150        key: 'dismiss',
151        label: 'Not a correction',
152        hotkey: '2',
153        onPress: async () => {
154          await update($, CORRECTION, () => null)
155          $.ui.invalidate('ui.render')
156        },
157      }),
158    ],
159  })
160}
161
162function recallLine($, e, recall) {
163  if (recall.reviewedAt) return null
164  const showCount = recall.candidates > 0
165  if (!showCount && !recall.stale) return null
166  const { Box, Text, Button } = $.ui.resolve(e)
167  const children = []
168  if (recall.stale) {
169    children.push(Text({ dimColor: true, children: `recall sync stale (${ageText(recall.lastSuccess)})  ` }))
170  }
171  if (showCount) {
172    const n = recall.candidates
173    children.push(Text({ children: `Recall: ${n} memory candidate${n === 1 ? '' : 's'} pending review  ` }))
174    children.push(
175      Button({
176        key: 'review',
177        label: 'Review',
178        hotkey: '3',
179        onPress: async () => {
180          try {
181            await $.command.run({ command: 'recall', args: 'review' })
182          } catch {
183            // The host refuses a $.prompt.submit text that begins with /; fill the box for the person to send.
184            await $.prompt.fill({ text: '/recall review', mode: 'replace' })
185          }
186          await update($, RECALL, (r) => (r ? { ...r, reviewedAt: Date.now() } : r))
187          $.ui.invalidate('ui.render')
188        },
189      }),
190    )
191  }
192  return Box({ flexDirection: 'row', children })
193}
194
types/index.d.ts 15 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'correction-band': {
4      correction: { text: string; pattern: string; phrase: string; at: number } | null
5      recall: {
6        candidates: number
7        lastSuccess: string | null
8        stale: boolean
9        error: string | null
10        reviewedAt: number | null
11      } | null
12    }
13  }
14}
15