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…

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 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.PATH, to copy a screenshot out of the session's transcript.In Claude Code:
/plugin marketplace add hacksurvivor/claude-mods
/plugin install grudge@hacksurvivor
/reload-plugins
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.~/.claude/grudge/.See PRIVACY.md.
Apache-2.0
hooks/register.tsx 291 lines1import { 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}
291hooks/grudge.ts 139 lines1import 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
139types/index.d.ts 32 lines1// 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