😤 Remembers your corrections: offers to hold a lasting preference, reminds Claude of it every session, and fixes commands it can swap safely.

Remembers your corrections, so you only have to say "no, use pnpm" once.

/plugin install grudge@claude-desk-neighbours
When you send a short prompt that sounds like a lasting preference ("no, use pnpm", "don't use default exports", "always use the logger, not console"), the band offers to remember it while Claude carries on working:
😤 Hold a grudge? "use pnpm, not npm" 4: Hold 5: Nah
Nothing gets remembered unless you press Hold, either by typing 4 into an empty prompt or by clicking it. One-off fixes ("that's the wrong file") don't get an offer at all, and if you ignore an offer it goes away when you send your next prompt.
Every rule you hold is passed to Claude in every session in that repo. Rules about personal style, like spelling or tone, apply in every repo.
When a rule is an exact command swap, Grudge fixes the command before it runs and adds a note under the tool call: 😤 grudge #4: npm → pnpm. It only swaps whole commands (it never touches .npmrc), and only where the arguments mean the same thing:
| From | To | After |
|---|---|---|
npm | pnpm | install, i, run, test, start |
npm | yarn | run, test, start |
npm | bun | install, i, run |
pnpm | npm | install, i, run, test, start |
yarn | npm | run, test, start |
yarn | pnpm | install, run, test, start |
pip | pip3 | anything |
python | python3 | anything |
Here Claude was asked to run npm test anyway, and it ran as pnpm test:

Click 😤 1 grudge in the status row, or run /grudges. Rules for this repo are listed first, then the ones that apply everywhere:

Forgive a rule and it's gone straight away.
If a prompt is under 400 characters and has something in it that looks like a correction, it goes to Haiku to decide whether it's a lasting preference. Any other prompt costs nothing. Rules are kept in the plugin's store.
hooks/register.tsx 424 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderNode } from 'claude-code'
3
4import type { Grudge, GrudgeList, GrudgeOffer, GrudgeScope, GrudgeSwap } from '../types'
5
6const OFFER = { plugin: 'grudge', key: 'offer' } as const
7const LIST = { plugin: 'grudge', key: 'list' } as const
8const NOTES = { plugin: 'grudge', key: 'notes' } as const
9
10const offer = atom(OFFER, null)
11const list = atom(LIST, { repoName: '', repo: [], global: [] })
12const notes = atom(NOTES, '')
13
14const PANE = 'grudges'
15const MAX_PROMPT = 400
16
17// Cheap gate: only short prompts that read like a standing correction reach the model.
18const CORRECTION =
19 /^\s*(no|nope|nah|don'?t|do not|never|always|stop|please (don'?t|do not|always|never|stop)|from now on|we (use|don'?t)|i (prefer|want you to always|said))\b|\b(from now on|going forward|in future|instead of|rather than|always use|never use|stop using|use \S+,? not)\b/i
20
21// The only swaps held to be exact: same arguments, same meaning, after these subcommands.
22const SWAPS: readonly { from: string; to: string; sub: readonly string[] | null }[] = [
23 { from: 'npm', to: 'pnpm', sub: ['install', 'i', 'run', 'test', 'start'] },
24 { from: 'npm', to: 'yarn', sub: ['run', 'test', 'start'] },
25 { from: 'npm', to: 'bun', sub: ['install', 'i', 'run'] },
26 { from: 'pnpm', to: 'npm', sub: ['install', 'i', 'run', 'test', 'start'] },
27 { from: 'yarn', to: 'npm', sub: ['run', 'test', 'start'] },
28 { from: 'yarn', to: 'pnpm', sub: ['install', 'run', 'test', 'start'] },
29 { from: 'pip', to: 'pip3', sub: null },
30 { from: 'python', to: 'python3', sub: null },
31]
32
33const TOOLS = [...new Set(SWAPS.flatMap(s => [s.from, s.to]))].join('|')
34const PREFER_FIRST = new RegExp(
35 String.raw`\b(?:use|prefer|always use|stick to|with)\s+(${TOOLS})\b[^.!?]*?\b(?:not|instead of|rather than|over)\s+(${TOOLS})\b`,
36 'i',
37)
38const REJECT_FIRST = new RegExp(
39 String.raw`\b(?:no|not|never|never use|don'?t use|do not use|stop using|avoid|instead of)\s+(${TOOLS})\b[^.!?]*?\b(?:use|prefer|stick to)\s+(${TOOLS})\b`,
40 'i',
41)
42const ARROW = new RegExp(String.raw`\b(${TOOLS})\s*(?:->|→|=>)\s*(${TOOLS})\b`, 'i')
43
44const JUDGE_SYSTEM = [
45 'You read one message a developer sent to their coding assistant and decide whether it states a',
46 'LASTING preference about how the assistant should work from now on (tools, conventions, style),',
47 'as opposed to a one-off correction about the current task ("that\'s the wrong file").',
48 'Reply with JSON only: {"lasting": boolean, "rule": string, "scope": "repo" | "global"}.',
49 '"rule" is the preference as a terse imperative of at most 60 characters, e.g. "use pnpm, not npm".',
50 '"scope" is "global" only when it is clearly personal style that would hold in any project',
51 '(spelling, tone, comment style); otherwise "repo".',
52].join(' ')
53
54const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
55
56export function extractSwap(text: string): GrudgeSwap | null {
57 const prefer = PREFER_FIRST.exec(text)
58 const reject = REJECT_FIRST.exec(text)
59 const arrow = ARROW.exec(text)
60 const pair = prefer
61 ? { to: prefer[1], from: prefer[2] }
62 : reject
63 ? { from: reject[1], to: reject[2] }
64 : arrow
65 ? { from: arrow[1], to: arrow[2] }
66 : null
67 if (pair === null || pair.from === undefined || pair.to === undefined) return null
68 const from = pair.from.toLowerCase()
69 const to = pair.to.toLowerCase()
70
71 return SWAPS.some(s => s.from === from && s.to === to) ? { from, to } : null
72}
73
74function escape(text: string): string {
75 return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
76}
77
78/** Swaps `from` for `to` where it stands as a command, never inside a word (`npmrc`). */
79export function applySwap(command: string, swap: GrudgeSwap): string {
80 const rule = SWAPS.find(s => s.from === swap.from && s.to === swap.to)
81 if (rule === undefined) return command
82 const after = rule.sub ? String.raw`(?=\s+(?:${rule.sub.join('|')})(?:\s|$))` : String.raw`(?=\s|$)`
83 const pattern = new RegExp(String.raw`(^|&&|\|\||[;|\n(]|\$\()(\s*)${escape(rule.from)}${after}`, 'g')
84
85 return command.replace(pattern, `$1$2${rule.to}`)
86}
87
88function parseJson(text: string): Record<string, unknown> | null {
89 const start = text.indexOf('{')
90 const end = text.lastIndexOf('}')
91 if (start < 0 || end <= start) return null
92 try {
93 const value: unknown = JSON.parse(text.slice(start, end + 1))
94 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
95 } catch {
96 return null
97 }
98}
99
100function isGrudge(value: unknown): value is Grudge {
101 if (typeof value !== 'object' || value === null) return false
102 const g = value as Record<string, unknown>
103 return typeof g.id === 'number' && typeof g.rule === 'string' && (g.scope === 'repo' || g.scope === 'global')
104}
105
106function day(ms: number): string {
107 const d = new Date(ms)
108 return `${d.getDate()} ${MONTHS[d.getMonth()]}`
109}
110
111function basename(path: string): string {
112 return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? path
113}
114
115function sameRule(a: string, b: string): boolean {
116 const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
117 return norm(a) === norm(b)
118}
119
120let repoRoot: string | null = null
121// Bumped on every prompt, so a verdict that lands after the next one is dropped.
122let generation = 0
123
124async function repoHere($: EngineInterface): Promise<string> {
125 if (repoRoot === null) {
126 repoRoot = (await $.session.repo())?.root ?? (await $.session.root())
127 }
128 return repoRoot
129}
130
131async function loadAll($: EngineInterface): Promise<Grudge[]> {
132 const stored = await $.store.get('grudges')
133 return Array.isArray(stored) ? stored.filter(isGrudge) : []
134}
135
136async function rulesHere($: EngineInterface): Promise<Grudge[]> {
137 const root = await repoHere($)
138 return (await loadAll($)).filter(g => g.scope === 'global' || g.repo === root)
139}
140
141async function refreshList($: EngineInterface): Promise<void> {
142 const root = await repoHere($)
143 const all = await loadAll($)
144 const next: GrudgeList = {
145 repoName: basename(root),
146 repo: all.filter(g => g.scope === 'repo' && g.repo === root),
147 global: all.filter(g => g.scope === 'global'),
148 }
149 await $.state.set(LIST, next)
150}
151
152async function hold($: EngineInterface): Promise<void> {
153 const held = await read($, offer)
154 if (held === null) return
155 const root = await repoHere($)
156 const all = await loadAll($)
157 const id = Math.max(0, ...all.map(g => g.id)) + 1
158 const grudge: Grudge = {
159 id,
160 rule: held.rule,
161 scope: held.scope,
162 repo: held.scope === 'repo' ? root : null,
163 heldAt: await $.clock.now(),
164 hits: 0,
165 swap: held.swap,
166 }
167 await $.store.set('grudges', [...all, grudge])
168 await $.state.set(OFFER, null)
169 await refreshList($)
170}
171
172async function forgive($: EngineInterface, id: number): Promise<void> {
173 await $.store.set('grudges', (await loadAll($)).filter(g => g.id !== id))
174 await refreshList($)
175}
176
177async function countHits($: EngineInterface, ids: readonly number[]): Promise<void> {
178 const all = await loadAll($)
179 await $.store.set('grudges', all.map(g => (ids.includes(g.id) ? { ...g, hits: g.hits + 1 } : g)))
180}
181
182async function openGrudges($: EngineInterface): Promise<void> {
183 await refreshList($)
184 await togglePane($, PANE, '😤 Grudges')
185}
186
187async function judge($: EngineInterface, text: string, asked: number): Promise<void> {
188 const reply = await $.model.complete({
189 model: 'haiku',
190 effort: 'low',
191 maxTokens: 200,
192 timeoutMs: 15000,
193 system: JUDGE_SYSTEM,
194 prompt: `Message:\n"""\n${text}\n"""`,
195 })
196 if (!reply.isAnswered || asked !== generation) return
197 const verdict = parseJson(reply.text)
198 if (verdict === null || verdict.lasting !== true || typeof verdict.rule !== 'string') return
199 const rule = verdict.rule.replace(/\s+/g, ' ').trim().replace(/[.]$/, '').slice(0, 70)
200 if (rule === '') return
201 if ((await rulesHere($)).some(g => sameRule(g.rule, rule))) return
202 const scope: GrudgeScope = verdict.scope === 'global' ? 'global' : 'repo'
203 const next: GrudgeOffer = { rule, scope, swap: extractSwap(`${rule}. ${text}`) }
204 if (asked === generation) await $.state.set(OFFER, next)
205}
206
207// The hint line under the prompt is shared. Each Desk Neighbours mod adds one clickable item to
208// a row keyed `desk-hint` after the engine's own hint; items are kept in order by their keys
209// (`desk-1-…` to `desk-5-…`), so the row reads the same alone or together, in any load order.
210const DESK = 'desk-hint'
211
212// The engine draws the band's collapse mark `[-]` over the right end of its first row without
213// narrowing `bodyColumns`, so band rows stop this many cells short of the edge.
214const MARKER = 4
215
216function keyOf(node: RenderNode | undefined): string {
217 if (typeof node !== 'object' || node === null || !('props' in node)) return ''
218 const key = (node.props as Record<string, unknown> | undefined)?.key
219 return typeof key === 'string' ? key : ''
220}
221
222export function joinDesk(below: RenderElement, mine: RenderElement, wrap: (children: RenderNode[]) => RenderElement): RenderElement {
223 if (below.type !== 'Box' || keyOf(below) !== DESK) return wrap([below, mine])
224 const [line, ...items] = below.children ?? []
225 const sorted = [...items.filter(n => keyOf(n) !== keyOf(mine)), mine].sort((a, b) => keyOf(a).localeCompare(keyOf(b)))
226 return { ...below, children: line === undefined ? sorted : [line, ...sorted] }
227}
228
229/** Opens this mod's pane, or closes it when it is the one showing. */
230async function togglePane($: EngineInterface, id: string, title: string): Promise<void> {
231 const pane = (await $.ui.panes()).find(p => p.id === id)
232 if (pane?.isShown === true) {
233 await $.ui.close({ id })
234 return
235 }
236 await $.ui.open({ id, title, focus: true, closeOnEscape: true })
237}
238
239/** "10:42" in local time. */
240function clockTime(ms: number): string {
241 const d = new Date(ms)
242 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
243}
244
245export const register: Register = on => {
246 on('session.start', async ($, e, next) => {
247 await $.command.register({ name: 'grudges', description: '😤 List the rules Grudge holds, and forgive some' })
248 void refreshList($).catch(() => undefined)
249 return next(e)
250 })
251
252 // `/clear` and `/resume` go on under a new session with empty state and fire no `session.start`;
253 // their classic SessionStart (source `clear` or `resume`) is the one place to load the rules list again.
254 on('classic.SessionStart', ($, e, next) => {
255 if (e.source === 'clear' || e.source === 'resume') void refreshList($).catch(() => undefined)
256 return next(e)
257 })
258
259 on('session.end', async ($, e, next) => {
260 if (e.reason === 'clear' || e.reason === 'resume') {
261 generation += 1
262 await $.state.set(OFFER, null).catch(() => undefined)
263 }
264 return next(e)
265 })
266
267 on('prompt.submit', ($, e, next) => {
268 generation += 1
269 const asked = generation
270 void read($, offer)
271 .then(shown => (shown === null ? undefined : $.state.set(OFFER, null)))
272 .catch(() => undefined)
273 const isCandidate = e.origin.kind === 'composer' && e.text.length < MAX_PROMPT && CORRECTION.test(e.text)
274 if (isCandidate) void judge($, e.text, asked).catch(() => undefined)
275 return next(e)
276 })
277
278 // Held rules ride every request as a standing preference.
279 on('prompt.compose', async ($, e, next) => {
280 const composed = await next(e)
281 const rules = await rulesHere($)
282 if (rules.length === 0) return composed
283 const text = [
284 'Standing preferences the user has stated (follow them unless told otherwise in this conversation):',
285 ...rules.map(g => `- ${g.rule}`),
286 ].join('\n')
287 return { sections: [...composed.sections, { id: 'grudge:rules', text, scope: 'session' as const }] }
288 })
289
290 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
291 const held = (await rulesHere($)).filter(g => g.swap !== null)
292 if (held.length === 0) return next(e)
293 let command = e.command
294 const fired: Grudge[] = []
295 for (const grudge of held) {
296 if (grudge.swap === null) continue
297 const swapped = applySwap(command, grudge.swap)
298 if (swapped !== command) {
299 command = swapped
300 fired.push(grudge)
301 }
302 }
303 if (fired.length === 0) return next(e)
304 const note = fired.map(g => `😤 grudge #${g.id}: ${g.swap?.from} → ${g.swap?.to}`).join(' · ')
305 void $.state.set({ ...NOTES, id: e.tool_use_id }, note).catch(() => undefined)
306 void countHits(
307 $,
308 fired.map(g => g.id),
309 ).catch(() => undefined)
310 return next({ ...e, command })
311 })
312
313 on('ui.render', { component: 'ToolUse', props: { tool: 'Bash' } }, async ($, e, next) => {
314 const note = await read($, memberOf(notes, e))
315 if (note === '') return next(e)
316 const { Box, Text } = $.ui.resolve(e)
317 return (
318 <Box flexDirection="column">
319 {await next(e)}
320 <Text dimColor>
321 {' '}
322 {note}
323 </Text>
324 </Box>
325 )
326 })
327
328 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
329 if (e.props.hasSurvey) return next(e)
330 const shown = await read($, offer)
331 if (shown === null) return next(e)
332 const below = await next(e)
333 const { Box, Text, Button } = $.ui.resolve(e)
334 const where = shown.scope === 'global' ? ' (everywhere)' : ''
335 return (
336 <Box flexDirection="column">
337 {below}
338 {/* Wraps rather than truncates, so the rule and its buttons always show whole. */}
339 <Box flexDirection="row" flexWrap="wrap" columnGap={2} width={e.props.bodyColumns - MARKER}>
340 <Box flexShrink={1}>
341 <Text>
342 😤 Hold a grudge? "{shown.rule}"{where}
343 </Text>
344 </Box>
345 {/* Digits press from an empty prompt; the Desk Neighbours split them so none clash (Grudge 4–5). */}
346 <Box flexShrink={0} gap={1}>
347 <Button key="grudge-hold" hotkey="4" plain label="Hold" onPress={() => void hold($)} />
348 <Button key="grudge-nah" hotkey="5" plain label="Nah" onPress={() => void update($, offer, () => null)} />
349 </Box>
350 </Box>
351 </Box>
352 )
353 })
354
355 on('command.run', { command: 'grudges' }, async $ => {
356 await refreshList($)
357 await $.ui.open({ id: PANE, title: '😤 Grudges', focus: true, closeOnEscape: true })
358 return { text: '😤 Grudges open. Esc closes.' }
359 })
360
361 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
362 const held = await read($, list)
363 const n = held.repo.length + held.global.length
364 const label = n === 0 ? '😤 no grudges' : `😤 ${n} grudge${n === 1 ? '' : 's'}`
365 const below = await next(e)
366 const { Box, Button } = $.ui.resolve(e)
367 const mine = <Button key="desk-1-grudge" plain dimColor label={label} onPress={() => void openGrudges($)} />
368 return joinDesk(below, mine, children => (
369 <Box key={DESK} flexDirection="row" columnGap={2}>
370 {children}
371 </Box>
372 ))
373 })
374
375 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
376 const { Box, Text, Button } = $.ui.resolve(e)
377 const held = await read($, list)
378 const columns = e.props.bodyColumns
379
380 const row = (g: Grudge) => {
381 const tally = g.swap === null ? 'reminded' : `enforced ${g.hits}×`
382 return (
383 <Box key={`row-${g.id}`} flexDirection="row" width={columns}>
384 <Box width={5} flexShrink={0}>
385 <Text dimColor>#{String(g.id)}</Text>
386 </Box>
387 <Box flexGrow={1} flexShrink={1}>
388 <Text>{g.rule}</Text>
389 </Box>
390 <Box flexShrink={0} marginLeft={2}>
391 <Text dimColor>
392 held {day(g.heldAt)} · {tally}
393 </Text>
394 </Box>
395 <Box flexShrink={0} marginLeft={2}>
396 <Button key={`forgive-${g.id}`} label="Forgive" dimColor onPress={() => void forgive($, g.id)} />
397 </Box>
398 </Box>
399 )
400 }
401
402 const isEmpty = held.repo.length === 0 && held.global.length === 0
403 return (
404 <Box flexDirection="column">
405 <Box flexDirection="row" marginBottom={1}>
406 <Text bold>😤 Grudges</Text>
407 <Text dimColor> · {held.repoName}</Text>
408 </Box>
409 {isEmpty && <Text dimColor>None held. Yet.</Text>}
410 {held.repo.map(row)}
411 {held.global.length > 0 && (
412 <Box marginTop={held.repo.length > 0 ? 1 : 0}>
413 <Text dimColor>everywhere</Text>
414 </Box>
415 )}
416 {held.global.map(row)}
417 <Box marginTop={1}>
418 <Text dimColor>Correct Claude in a prompt to start one · esc closes</Text>
419 </Box>
420 </Box>
421 )
422 })
423}
424types/index.d.ts 35 lines1export type GrudgeScope = 'repo' | 'global'
2
3/** A whole-word command swap a rule enforces: `npm install` → `pnpm install`. */
4export type GrudgeSwap = { from: string; to: string }
5
6/** One held rule, as kept in the store. */
7export type Grudge = {
8 id: number
9 rule: string
10 scope: GrudgeScope
11 /** The repo root a `repo` rule belongs to; null for a global one. */
12 repo: string | null
13 heldAt: number
14 /** Commands it fixed. */
15 hits: number
16 swap: GrudgeSwap | null
17}
18
19/** A rule offered in the band, not yet held. */
20export type GrudgeOffer = { rule: string; scope: GrudgeScope; swap: GrudgeSwap | null }
21
22/** What the /grudges pane draws. */
23export type GrudgeList = { repoName: string; repo: Grudge[]; global: Grudge[] }
24
25declare module 'claude-code' {
26 interface PluginState {
27 grudge: {
28 offer: GrudgeOffer | null
29 list: GrudgeList
30 /** The note under a Bash row a rule rewrote, by tool_use_id. */
31 notes: StateFamily<string>
32 }
33 }
34}
35