SLOPSHOPPER

grudge

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

newpanebandspinnerrowsguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · grudge
│ ┃ 😤 Grudges ✕ › fix the failing auth test and add an audit log call │ ┃ 😤 Grudges · app │ ┃ ⏺ Read(src/auth.ts) │ ┃ None held. Yet. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Correct Claude in a prompt to start one · ⎿ Added 2 lines, removed 1 line │ ┃ esc closes ⏺ 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 │ │ › /grudges │ ⎿ grudge: 😤 Grudges open. Esc closes. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ 😤 no grudges

Draws

Pane · 😤 Grudges
😤 Grudges · app None held. Yet. Correct Claude in a prompt to start one · esc closes
Prompt hint
⟨Claude Code's own drawing⟩ 😤 no grudges
README

😤 Grudge

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

Saying "no, use pnpm, not npm": Grudge offers to hold it while Claude works, and 4 holds it

/plugin install grudge@claude-desk-neighbours

How it works

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:

FromToAfter
npmpnpminstall, i, run, test, start
npmyarnrun, test, start
npmbuninstall, i, run
pnpmnpminstall, i, run, test, start
yarnnpmrun, test, start
yarnpnpminstall, run, test, start
pippip3anything
pythonpython3anything

Here Claude was asked to run npm test anyway, and it ran as pnpm test:

A Bash call with the note "grudge #1: npm → pnpm" under it

The pane

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

The Grudges pane listing "use pnpm, not npm", held 3 Oct, enforced 1×, with a Forgive button

Forgive a rule and it's gone straight away.

In the background

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.

Source 2 files
hooks/register.tsx 424 lines
1import { 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}
424
types/index.d.ts 35 lines
1export 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