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…

A Claude Code mod that draws a band above the prompt in two cases.
The prompt always reaches the model unchanged. The mod never drops or rewrites it.
Correction? "why did you build" [ Log it ] [ Not a correction ]
recall sync stale (10h ago) Recall: 33 memory candidates pending review [ Review ]
| Key | Hotkey | What it does |
|---|---|---|
log | 1 | Submits 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. |
dismiss | 2 | Clears the row. Submits nothing. |
review | 3 | Runs /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.
~/.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./, <, [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.
--match contractcorrection-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.
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.
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.
hooks/register.ts 194 lines1// 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}
194types/index.d.ts 15 lines1declare 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