SLOPSHOPPER

Grudge

Every look you reject becomes a rule for that project, with your screenshot, so Claude never brings it back; it also says when Claude changed UI without a…

newrowsguardcommandtoastprompt
v0.1.1Apache-2.0updated 2026-10-04hacksurvivor/claude-mods/mods/grudge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · grudge
› fix the failing auth test and add an audit log call ⏺ 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 › /grudge ⎿ grudge: Grudge holds nothing against app yet. Reject or approve a look and it lands here; /grudge add <rule> writes on ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Grudge

A Claude Code mod that holds a grudge against every look you reject, so Claude never brings it back.

You reply "this is slop" with a screenshot, or "again the old top bar". Grudge asks Claude to turn that into one rule for the project, written after it has looked at your screenshot, and keeps the screenshot with it. A line under Claude's reply shows what was noted, with Undo:

Noted for shop-app: keep the R4 top bar in every theme · Undo

The next time Claude edits a UI file in that project, it is handed every rule first, along with two habits: look for real references before inventing UI, and show a preview before a new look. When Claude changes UI files without a preview you approved in the session, the reply says so:

Changed SettingsView.swift without a preview you approved

Approvals count too. "Looks right" or picking variant "A" after a preview keeps that look as a rule, so later changes don't quietly undo it.

Grudge in the Claude desktop app: a rejection with a screenshot, the rule it left under Claude's reply, and the warning after a UI change with no approved preview

  • /grudge lists the project's rules, numbered.
  • /grudge forget 2 drops one; /grudge add <rule> writes one yourself.
  • /grudge off pauses it for the session, /grudge on resumes it.

Requirements

  • Claude Code 2.1.286 or later (mods). The notes are drawn in the Claude desktop app and in the terminal.
  • Python 3 on your PATH, to copy a screenshot out of the session's transcript.

Install

In Claude Code:

/plugin marketplace add hacksurvivor/claude-mods
/plugin install grudge@hacksurvivor
/reload-plugins

What it runs, reads and sends

  • Reads what you type, to tell a rejection or an approval from other messages, and the paths of files Claude edits, to tell UI files from the rest.
  • Runs bin/shot.py with Python when you reject a look with a screenshot. It reads this session's transcript in ~/.claude/projects and copies that message's images into ~/.claude/grudge/<project>/. Nothing else.
  • Sends nothing anywhere itself. The rules (and the screenshots' file paths) go to Claude as part of your conversation, under your Claude account, like any message.
  • Keeps the rules in Claude Code's plugin store on your computer, and the screenshots in ~/.claude/grudge/.

See PRIVACY.md.

License

Apache-2.0

Source 3 files
hooks/register.tsx 291 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry, Notice } from '../types'
5import {
6  NOTE_SCHEMA,
7  NOTE_TOOL,
8  NOTE_TOOL_ID,
9  anchorOf,
10  isPreviewTool,
11  isUiFile,
12  isUnder,
13  grudgeBrief,
14  listing,
15  nameOf,
16  noteAsk,
17  unapprovedLine,
18  verdictOf,
19} from './grudge'
20
21// Every look the person turns down or signs off on becomes a rule for that
22// project. Claude writes the rule itself through the note tool, having seen
23// their words and screenshot; the screenshot is copied out of the transcript.
24// The first time a turn edits a UI file, Claude is handed the project's rules,
25// and a reply that changed UI with no approved preview says so under it.
26
27// The store key predates the name; Mods reads it under this key too.
28const STORE_KEY = 'ledger'
29const KEPT = 400
30const NOTICES_KEPT = 30
31
32const notices = atom({ plugin: 'grudge', key: 'notices' } as const, [] as Notice[])
33const version = atom({ plugin: 'grudge', key: 'version' } as const, 0)
34const isPaused = atom({ plugin: 'grudge', key: 'isPaused' } as const, false)
35
36// This session: whether a look is on the table, and whether one was approved.
37let isLookShown = false
38let isApproved = false
39let didLastTurnTouchUi = false
40// This turn: the verdict given, the rules noted, the UI files changed.
41let pending: { verdict: Entry['verdict']; quote: string; hasImage: boolean } | undefined
42let noted: string[] = []
43let uiFiles: string[] = []
44let isBriefed = false
45
46async function projectOf($: EngineInterface): Promise<string> {
47  const repo = await $.session.repo()
48  const cwd = await $.session.cwd()
49
50  return repo?.root ?? cwd
51}
52
53async function grudges($: EngineInterface): Promise<Entry[]> {
54  const kept = await $.store.get(STORE_KEY)
55
56  return Array.isArray(kept) ? (kept as Entry[]) : []
57}
58
59async function save($: EngineInterface, entries: Entry[]): Promise<void> {
60  await $.store.set(STORE_KEY, entries.slice(-KEPT))
61  await update($, version, n => n + 1)
62}
63
64// The screenshots of the person's last message, copied next to their grudges.
65async function screenshots($: EngineInterface, project: string, id: string): Promise<string[]> {
66  try {
67    const home = await $.env.get('HOME')
68    const folder = `${home ?? '~'}/.claude/grudge/${nameOf(project).replace(/[^\w.-]+/g, '-')}`
69    const ran = await $.process.run(['/bin/zsh', '-lc', '"$@"', 'grudge', 'python3', `${$.plugin.root}/bin/shot.py`, await $.session.id(), folder, id], {
70      timeoutMs: 20_000,
71    })
72    const paths = JSON.parse(ran.stdout.trim() || '[]') as unknown
73
74    return Array.isArray(paths) ? paths.filter((path): path is string => typeof path === 'string') : []
75  } catch {
76    return []
77  }
78}
79
80async function note($: EngineInterface, screen: string, rule: string, verdict: Entry['verdict'] | undefined): Promise<Entry> {
81  const project = await projectOf($)
82  const at = await $.clock.now()
83  const id = `grudge-${at.toString(36)}`
84  const given = verdict ?? pending?.verdict ?? 'rejected'
85  const images = pending?.hasImage === true ? await screenshots($, project, id) : []
86  const entry: Entry = { id, at, project, verdict: given, screen: screen.trim(), rule: rule.trim(), quote: pending?.quote ?? '', images }
87  await save($, [...(await grudges($)), entry])
88  noted.push(id)
89
90  return entry
91}
92
93async function forget($: EngineInterface, id: string): Promise<void> {
94  await save($, (await grudges($)).filter(entry => entry.id !== id))
95  $.ui.toast('Grudge dropped.', { timeoutMs: 5_000 })
96}
97
98export const register: Register = on => {
99  on('session.start', async ($, e, next) => {
100    await $.command.register({
101      name: 'grudge',
102      description: "Show what Grudge holds against this project's looks, or forget, add, pause a rule",
103      argumentHint: '[forget <n> | add <rule> | off | on]',
104      immediate: true,
105    })
106    await $.tool.register({
107      name: NOTE_TOOL,
108      description:
109        "Records what the person just rejected or approved in the product's look, as one rule for this project in Grudge, their list of looks they won't see again. Call it once when Grudge asks you to.",
110      inputSchema: NOTE_SCHEMA,
111    })
112
113    return next(e)
114  })
115
116  on('command.run', { command: 'grudge' }, async ($, e) => {
117    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
118    const project = await projectOf($)
119    const entries = await grudges($)
120
121    if (verb === 'off' || verb === 'on') {
122      await update($, isPaused, () => verb === 'off')
123
124      return { text: verb === 'off' ? 'Grudge is paused for this session. /grudge on resumes it.' : 'Grudge is on.' }
125    }
126
127    if (verb === 'forget') {
128      const mine = entries.filter(entry => entry.project === project)
129      const target = mine[Number(rest[0]) - 1]
130
131      if (target === undefined) {
132        return { text: `No rule ${rest[0] ?? ''} here. /grudge lists them with their numbers.` }
133      }
134
135      await save($, entries.filter(entry => entry.id !== target.id))
136
137      return { text: `Forgot: ${target.screen}: ${target.rule}` }
138    }
139
140    if (verb === 'add' && rest.length > 0) {
141      const at = await $.clock.now()
142      const entry: Entry = { id: `grudge-${at.toString(36)}`, at, project, verdict: 'rejected', screen: 'General', rule: rest.join(' '), quote: '', images: [] }
143      await save($, [...entries, entry])
144
145      return { text: `Added for ${nameOf(project)}: ${entry.rule}` }
146    }
147
148    return { text: listing(project, entries) }
149  })
150
151  on('prompt.submit', async ($, e, next) => {
152    const isPerson = e.origin === undefined || ['composer', 'bridge', 'sdk'].includes(e.origin.kind)
153
154    if (!isPerson || e.turnId !== undefined) {
155      return next(e)
156    }
157
158    noted = []
159    uiFiles = []
160    isBriefed = false
161    pending = undefined
162
163    if (await read($, isPaused)) {
164      return next(e)
165    }
166
167    const hasImage = e.attachments?.some(item => item.type === 'image') ?? false
168    const verdict = verdictOf(e.text, hasImage, isLookShown || didLastTurnTouchUi)
169
170    if (verdict === undefined) {
171      return next(e)
172    }
173
174    pending = { verdict, quote: e.text.trim().slice(0, 300), hasImage }
175    isApproved = verdict === 'approved'
176
177    return next({ ...e, context: [...(e.context ?? []), noteAsk(verdict, hasImage)] })
178  })
179
180  on('tool.call', { tool: NOTE_TOOL_ID }, async ($, e) => {
181    const input = e as unknown as { screen?: unknown; rule?: unknown; verdict?: unknown }
182    const screen = typeof input.screen === 'string' ? input.screen : ''
183    const rule = typeof input.rule === 'string' ? input.rule : ''
184
185    if (screen.trim() === '' || rule.trim() === '') {
186      return { deny: 'Give the screen and the rule.' }
187    }
188
189    const verdict = input.verdict === 'approved' || input.verdict === 'rejected' ? input.verdict : undefined
190    const entry = await note($, screen, rule, verdict)
191
192    return { result: [{ type: 'text', text: `Recorded for ${nameOf(entry.project)}. Carry on with the person's request.` }] }
193  })
194
195  on('tool.call', async ($, e, next) => {
196    const path = (e as unknown as { file_path?: unknown }).file_path
197    const file = typeof path === 'string' ? path : undefined
198
199    if (isPreviewTool(e.tool, file)) {
200      isLookShown = true
201      isApproved = false
202
203      return next(e)
204    }
205
206    if (!/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(e.tool) || file === undefined || !isUiFile(file) || (await read($, isPaused))) {
207      return next(e)
208    }
209
210    const done = await next(e)
211
212    if ('deny' in done && done.deny !== undefined) {
213      return done
214    }
215
216    if ('isError' in done && done.isError === true) {
217      return done
218    }
219
220    uiFiles.push(file)
221
222    if (isBriefed) {
223      return done
224    }
225
226    isBriefed = true
227    const brief = grudgeBrief(await projectOf($), await grudges($), isApproved)
228
229    return { ...done, context: [...(done.context ?? []), brief] }
230  })
231
232  on('turn.complete', async ($, e, next) => {
233    const done = await next(e)
234
235    if (e.agentId !== undefined || e.reason !== 'answer') {
236      return done
237    }
238
239    const unapproved = isApproved ? [] : uiFiles
240    didLastTurnTouchUi = uiFiles.length > 0
241    isLookShown = isLookShown || didLastTurnTouchUi
242
243    if (noted.length > 0 || unapproved.length > 0) {
244      const notice: Notice = { anchor: anchorOf(e.answer), ids: [...noted], unapproved: [...unapproved] }
245      await update($, notices, list => [...list, notice].slice(-NOTICES_KEPT))
246    }
247
248    pending = undefined
249
250    return done
251  })
252
253  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
254    const mine = (await read($, notices)).filter(notice => isUnder(notice.anchor, e.props.text))
255
256    if (mine.length === 0) {
257      return next(e)
258    }
259
260    await read($, version)
261    const entries = await grudges($)
262    const drawn = await next(e)
263    const { Box, Button, Text } = $.ui.resolve(e)
264
265    return (
266      <Box flexDirection="column">
267        {drawn}
268        {mine.flatMap((notice, at) => [
269          ...notice.ids
270            .map(id => entries.find(entry => entry.id === id))
271            .filter((entry): entry is Entry => entry !== undefined)
272            .map(entry => (
273              <Box key={`noted-${entry.id}`} flexDirection="row" columnGap={1} marginTop={1} marginLeft={2}>
274                <Text dimColor>{`Noted for ${nameOf(entry.project)}: ${entry.rule}`}</Text>
275                <Text dimColor>·</Text>
276                <Button key={`undo-${entry.id}`} label="Undo" plain onPress={() => forget($, entry.id)} />
277              </Box>
278            )),
279          ...(notice.unapproved.length > 0
280            ? [
281                <Box key={`unapproved-${at}`} marginTop={1} marginLeft={2}>
282                  <Text color="warning">{unapprovedLine(notice.unapproved)}</Text>
283                </Box>,
284              ]
285            : []),
286        ])}
287      </Box>
288    )
289  })
290}
291
hooks/grudge.ts 139 lines
1import type { Entry } from '../types'
2
3export const NOTE_TOOL = 'note'
4export const NOTE_TOOL_ID = 'mcp__grudge__note'
5export const RULES_SHOWN = 25
6
7const REJECTED =
8  /\bslop|\bugly\b|\blooks? (bad|off|wrong|weird|cheap|awful|terrible|shit|sloppy|old)\b|\bdon'?t like\b|\bnot (right|correct|good enough)\b|\bdoesn'?t look\b|\bregress|\bold (design|look|style)\b|\bwtf\b|не нравится|некрасив|убог|криво|выглядит (плохо|странно|дёшево|дешево|старо)|хуйн|говно/i
9const APPROVED =
10  /^(looks (good|great|right|perfect|dope|clean)|love it|i like (it|this|that)|i approve|approved?|perfect|dope|nice|that'?s it|exactly|ship it|go with [a-d]|[a-d])\b/i
11const UI_WORDS =
12  /\b(ui|ux|design|layout|button|icon|logo|colou?r|font|spacing|padding|screen|page|bar|card|modal|sheet|panel|theme|style|look|animation|onboarding)\b|дизайн|кнопк|иконк|экран|цвет|шрифт/i
13
14// Whether the prompt turns down or signs off on a look. A rejection needs a
15// screenshot, a look on the table, or words about the UI; an approval needs a
16// look on the table and a short reply.
17export function verdictOf(text: string, hasImage: boolean, isLookShown: boolean): Entry['verdict'] | undefined {
18  const said = text.trim()
19
20  if (REJECTED.test(said) && (hasImage || isLookShown || UI_WORDS.test(said))) {
21    return 'rejected'
22  }
23
24  if (isLookShown && said.length <= 80 && APPROVED.test(said)) {
25    return 'approved'
26  }
27
28  return undefined
29}
30
31// The files whose edits change what the person sees.
32const UI_FILE = /\.(tsx|jsx|vue|svelte|css|scss|sass|less|html|xib|storyboard)$/i
33const SWIFT_UI = /(View|Screen|Theme|Style|Sheet|Panel|Bar|Button|Cell|Row|Card|Window|Menu|Toolbar|UI)[^/]*\.swift$/
34const NOT_UI = /(^|\/)(tests?|__tests__|spec|fixtures?|node_modules|\.build|dist|build)\/|\.(test|spec)\.|\/(tmp|private\/tmp|scratchpad)\//i
35
36export function isUiFile(path: string): boolean {
37  return !NOT_UI.test(path) && (UI_FILE.test(path) || SWIFT_UI.test(path))
38}
39
40// Tools that put a look in front of the person.
41export function isPreviewTool(tool: string, path: string | undefined): boolean {
42  return /show_widget$|^Artifact$|preview_start$/.test(tool) || (/^(Write|Edit)$/.test(tool) && path !== undefined && /\.html?$/i.test(path))
43}
44
45export const nameOf = (project: string): string => project.split('/').filter(part => part !== '').at(-1) ?? project
46const fileName = (path: string): string => path.split('/').at(-1) ?? path
47
48// What Claude is asked to do the moment the person gives a verdict.
49export function noteAsk(verdict: Entry['verdict'], hasImage: boolean): string {
50  const what =
51    verdict === 'rejected'
52      ? `The person just rejected what you showed them${hasImage ? ', with a screenshot' : ''}.`
53      : 'The person just approved what you showed them.'
54  const rule =
55    verdict === 'rejected'
56      ? 'one specific imperative line that keeps this from happening again, judged from their words and screenshot'
57      : 'one specific imperative line saying what to keep, so later changes don\'t undo it'
58
59  return [
60    `Grudge: ${what}`,
61    `Before anything else, call the ${NOTE_TOOL} tool of grudge once, with screen (the part of the product, 1 to 4 words) and rule (${rule}).`,
62    'Write the rule in English. Never mention Grudge or the tool in your reply.',
63  ].join(' ')
64}
65
66const shortDate = (at: number): string => new Date(at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
67
68// What Claude reads when it first touches a UI file in a turn.
69export function grudgeBrief(project: string, entries: Entry[], isApproved: boolean): string {
70  const mine = entries.filter(entry => entry.project === project).slice(-RULES_SHOWN)
71  const line = (entry: Entry) =>
72    `- ${entry.screen}: ${entry.rule} (${shortDate(entry.at)})${entry.images.length > 0 ? ` [screenshot: ${entry.images.join(', ')}]` : ''}`
73  const rejected = mine.filter(entry => entry.verdict === 'rejected').map(line)
74  const approved = mine.filter(entry => entry.verdict === 'approved').map(line)
75
76  return [
77    mine.length > 0
78      ? `Grudge for ${nameOf(project)}: what the person turned down and signed off on before. Follow it.`
79      : `Grudge for ${nameOf(project)}: nothing recorded yet.`,
80    ...(rejected.length > 0 ? ['Rejected:', ...rejected] : []),
81    ...(approved.length > 0 ? ['Approved:', ...approved] : []),
82    'Before inventing new UI, look for references from real, shipped products. For a new look or a change of direction, show a preview and get the person\'s approval first; a faithful fix to an approved design needs none.',
83    isApproved ? '' : 'No preview has been approved in this session yet.',
84  ]
85    .filter(text => text !== '')
86    .join('\n')
87}
88
89export function unapprovedLine(files: string[]): string {
90  const names = [...new Set(files.map(fileName))]
91  const shown = names.length <= 2 ? names.join(' and ') : `${names[0]} and ${names.length - 1} more`
92
93  return `Changed ${shown} without a preview you approved`
94}
95
96// /grudge: the project's rules, numbered for /grudge forget.
97export function listing(project: string, entries: Entry[]): string {
98  const mine = entries.filter(entry => entry.project === project)
99
100  if (mine.length === 0) {
101    return `Grudge holds nothing against ${nameOf(project)} yet. Reject or approve a look and it lands here; /grudge add <rule> writes one yourself.`
102  }
103
104  const numbered = mine.map((entry, at) => ({ entry, n: at + 1 }))
105  const block = (verdict: Entry['verdict'], title: string) => {
106    const rows = numbered.filter(({ entry }) => entry.verdict === verdict)
107
108    return rows.length === 0 ? [] : [title, ...rows.map(({ entry, n }) => `  ${n}. ${entry.screen}: ${entry.rule} · ${shortDate(entry.at)}`)]
109  }
110
111  return [
112    `${nameOf(project)} · ${mine.length} ${mine.length === 1 ? 'rule' : 'rules'}`,
113    ...block('rejected', 'Rejected'),
114    ...block('approved', 'Approved'),
115    '/grudge forget <n> · /grudge add <rule>',
116  ].join('\n')
117}
118
119export function anchorOf(text: string): string {
120  return text.trim().slice(-160)
121}
122
123export function isUnder(anchor: string, blockText: string): boolean {
124  const block = anchorOf(blockText)
125
126  return block.length >= 12 && anchor.endsWith(block.slice(-Math.min(block.length, 80)))
127}
128
129// What Claude passes to the note tool.
130export const NOTE_SCHEMA = {
131  type: 'object',
132  properties: {
133    screen: { type: 'string', description: 'The part of the product, in 1 to 4 words' },
134    rule: { type: 'string', description: 'One specific imperative line to follow next time' },
135    verdict: { type: 'string', enum: ['rejected', 'approved'], description: 'Left out: the verdict the person just gave' },
136  },
137  required: ['screen', 'rule'],
138} as const
139
types/index.d.ts 32 lines
1// One look the person turned down or signed off on, and the rule it left.
2export type Entry = {
3  id: string
4  at: number
5  // The repository's root, or the folder when there is no repository.
6  project: string
7  verdict: 'rejected' | 'approved'
8  // The part of the product, in a few words: "Top bar", "Settings sheet".
9  screen: string
10  // One imperative line Claude follows next time.
11  rule: string
12  // What the person wrote.
13  quote: string
14  // The screenshots they sent with it, copied out of the transcript.
15  images: string[]
16}
17
18// What goes under one reply: the rules it noted, and UI files it changed
19// with no approved preview.
20export type Notice = { anchor: string; ids: string[]; unapproved: string[] }
21
22declare module 'claude-code' {
23  interface PluginState {
24    'grudge': {
25      notices: Notice[]
26      // Bumped when a grudge is added or dropped, so notices draw again.
27      version: number
28      isPaused: boolean
29    }
30  }
31}
32