After a turn that changed code, shows the non-standard changes worth a developer's attention and separates them from pattern reuse

A mod for Claude Code, written as a plugin of function hooks: after a turn that changed code, it shows only the changes worth a developer's attention — the ones that do something new or non-obvious — and skips the ones that just reuse an existing pattern.
At the prompt of a Claude Code session:
/plugin install change-highlights --marketplace cherniaiev04/change-highlights
Answer y to add the marketplace, then pick a scope (user scope = every session).
The function-hooks plugin API is early access and changes between Claude Code releases. This mod was built and tested on Claude Code 2.1.293.
When a turn ends with at least 3 changed lines of code (Edit / Write / NotebookEdit; .md, .txt, .canvas, .base files are skipped), a reviewer sorts the changes into:
Only the specific ones are shown, in a pane, at most three, each as path:line, the key lines of code, and — when the code doesn't speak for itself — a sentence on what it gains. When everything is routine you get a toast instead.
/highlights open the pane (Older / Newer to browse the last 20)
/highlights sonnet reviewer: Sonnet with the changes + full text of the changed files (default, cheap)
/highlights fork reviewer: the session's own model over the whole conversation (sees patterns
in other files; more accurate, costs more)
The review runs in the background after the turn and costs one extra model call per turn that changed code.
Run the mod from its folder instead of installing it, so edits reload while the session runs:
claude --plugin-dir ./change-highlights
or, for sessions the desktop app starts, list the folder in ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/path/change-highlights/change-highlights" } }
Check and test:
claude plugin validate ./change-highlights
claude plugin test ./change-highlights
MIT
hooks/register.tsx 237 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Highlight } from '../types'
5
6const PANE = 'change-highlights'
7const TITLE = 'Change highlights'
8const history = atom({ plugin: 'change-highlights', key: 'history' } as const, [])
9const cursor = atom({ plugin: 'change-highlights', key: 'cursor' } as const, -1)
10const isBusy = atom({ plugin: 'change-highlights', key: 'isBusy' } as const, false)
11
12type Change = { file: string; before: string; after: string }
13
14// Notes and prose are not code: the vault, memory files, docs.
15const SKIP = /\.(md|mdx|txt|canvas|base)$|\/Obsidian Vault\//i
16const MIN_LINES = 3
17const PER_CHANGE = 1500
18const ALL_CHANGES = 24000
19
20const lines = (s: string) => (s === '' ? 0 : s.split('\n').length)
21const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}\n…(cut)` : s)
22
23const describe = (changes: Change[]) => {
24 let out = ''
25 for (const c of changes) {
26 const block =
27 `### ${c.file}\n` +
28 (c.before ? `--- before\n${clip(c.before, PER_CHANGE)}\n` : '(new file)\n') +
29 `+++ after\n${clip(c.after, PER_CHANGE)}\n\n`
30 if (out.length + block.length > ALL_CHANGES) {
31 out += `…and ${changes.length - changes.indexOf(c)} more changes left out.\n`
32 break
33 }
34 out += block
35 }
36 return out
37}
38
39type Mode = 'sonnet' | 'fork'
40const MODES: readonly Mode[] = ['sonnet', 'fork']
41const MODE_KEY = 'mode'
42const PER_FILE = 30000
43const ALL_FILES = 90000
44
45const RULES = `Sort every change into one of two kinds:
46- PATTERN: reuses something that already existed in this codebase (an existing controller/service/DTO/component shape, a copied test layout, config wiring, renames, mechanical follow-ups).
47- SPECIFIC: does something the codebase did not do before, or does it in a non-obvious way: a new algorithm or rule, a tricky edge case, a workaround, a concurrency/security/caching decision, a surprising API use, a deliberate deviation from the existing pattern.
48
49Write markdown, nothing before it, and keep it SHORT: the code carries the message, not prose.
50
51For each SPECIFIC change, most important first (at most 3):
52
53\`path:line\`
54a fenced code block with only the interesting lines (at most ~10)
55then, only if the code does not speak for itself, one or two plain sentences: what it does and what it gains (faster, safer, more reliable, simpler, ...). Leave the sentence out when the code is self-explanatory. Only in rare cases, when there is a real risk or a non-obvious trade-off, write a longer explanation (one short paragraph at most).
56
57No headings, no titles, no bold labels, no summary, no list of the routine changes. Separate entries with a blank line.
58
59If there is no SPECIFIC change, write only: "Nothing non-standard: every change follows an existing pattern."`
60
61// Fork: the whole conversation is the context, so patterns from other files count too.
62const FORK_PROMPT = (changes: string) => `You are reviewing the code changes made in the turn that just ended, for the developer who owns this code. Do not continue the task; only write this review. You have seen the codebase in this conversation; use it to judge what already existed.
63
64${RULES} Use the language the developer writes in this conversation.
65
66The changes (as given to the edit tools):
67
68${changes}`
69
70// Sonnet: no conversation, so the changed files themselves stand in for the codebase.
71const SONNET_PROMPT = (task: string, changes: string, files: string) => `You are reviewing code changes an AI assistant just made, for the developer who owns this code. You see the developer's request, each change, and the full current text of the changed files. Judge PATTERN by what the rest of these files already did; when a change looks copied from code you cannot see, say "probably follows an existing pattern" rather than guessing.
72
73${RULES} Write in the language of the developer's request.
74
75<request>
76${task || '(no text)'}
77</request>
78
79<changes>
80${changes}
81</changes>
82
83<files>
84${files}
85</files>`
86
87async function readFiles($: EngineInterface, done: Change[]) {
88 let out = ''
89 for (const file of [...new Set(done.map(c => c.file))]) {
90 const text = await $.fs.read(file).catch(() => undefined)
91 if (typeof text !== 'string') continue
92 const block = `### ${file}\n${clip(text, PER_FILE)}\n\n`
93 if (out.length + block.length > ALL_FILES) break
94 out += block
95 }
96 return out
97}
98
99async function analyse($: EngineInterface, done: Change[], task: string) {
100 await update($, isBusy, () => true)
101 $.ui.status('change-highlights: reviewing changes…')
102 try {
103 const mode = ((await $.store.get(MODE_KEY)) as Mode | undefined) ?? 'sonnet'
104 const changes = describe(done)
105 let reply = mode === 'fork' ? await $.model.fork({ prompt: FORK_PROMPT(changes) }) : undefined
106 if (!reply?.isAnswered) {
107 const prompt = SONNET_PROMPT(task, changes, await readFiles($, done))
108 reply = await $.model.complete({ model: 'sonnet', prompt, maxTokens: 2500, timeoutMs: 120000 })
109 }
110 if (!reply.isAnswered) {
111 $.ui.toast(`change-highlights: no review (${reply.reason})`)
112 return
113 }
114
115 const files = [...new Set(done.map(c => c.file.split('/').pop() ?? c.file))]
116 const entry: Highlight = { at: await $.clock.now(), files, text: reply.text.trim() }
117 await update($, history, list => [...list, entry].slice(-20))
118 await update($, cursor, () => -1)
119
120 const isRoutine = /Nothing non-standard/i.test(entry.text)
121 if (isRoutine) {
122 $.ui.toast('change-highlights: all changes follow existing patterns (/highlights)')
123 } else {
124 const opened = await $.ui.open({ id: PANE, title: TITLE })
125 $.ui.toast(
126 opened.isPlaced
127 ? 'change-highlights: non-standard changes found'
128 : 'change-highlights: non-standard changes found — /highlights to view',
129 )
130 }
131 } catch (err) {
132 $.ui.toast(`change-highlights failed: ${String(err)}`)
133 } finally {
134 $.ui.status(undefined)
135 await update($, isBusy, () => false)
136 }
137}
138
139export const register: Register = on => {
140 let changes: Change[] = []
141 let task = ''
142
143 on('session.start', async ($, e, next) => {
144 await $.command.register({
145 name: 'highlights',
146 description: 'Show the non-standard code changes of recent turns; "sonnet" or "fork" picks the reviewer',
147 argumentHint: '[sonnet|fork]',
148 })
149 return next(e)
150 })
151
152 on('command.run', { command: 'highlights' }, async ($, e) => {
153 const asked = e.args.trim().toLowerCase()
154 if (asked) {
155 if (!MODES.includes(asked as Mode)) return { text: 'Usage: /highlights [sonnet|fork]' }
156 await $.store.set(MODE_KEY, asked)
157 return {
158 text:
159 asked === 'fork'
160 ? 'Reviews now run on the session model over the whole conversation (more accurate, costlier).'
161 : 'Reviews now run on Sonnet with the changed files only (cheap).',
162 }
163 }
164 await update($, cursor, () => -1)
165 await $.ui.open({ id: PANE, title: TITLE })
166 const list = await read($, history)
167 return { text: list.length ? 'Change highlights opened.' : 'No code changes analysed yet in this session.' }
168 })
169
170 on('turn.start', ($, e, next) => {
171 changes = []
172 task = e.text
173 return next(e)
174 })
175
176 on('tool.call', async ($, e, next) => {
177 const ran = await next(e)
178 if (ran.deny !== undefined || ran.isError) return ran
179
180 let change: Change | undefined
181 if (e.tool === 'Edit') change = { file: e.file_path, before: e.old_string, after: e.new_string }
182 else if (e.tool === 'Write') change = { file: e.file_path, before: '', after: e.content }
183 else if (e.tool === 'NotebookEdit') change = { file: e.notebook_path, before: '', after: e.new_source }
184
185 if (change && !SKIP.test(change.file)) changes.push(change)
186 return ran
187 })
188
189 on('turn.complete', async ($, e, next) => {
190 const result = await next(e)
191 if (e.agentId !== undefined || e.reason !== 'answer') return result
192
193 const done = changes
194 changes = []
195 const size = done.reduce((n, c) => n + Math.max(lines(c.before), lines(c.after)), 0)
196 if (done.length === 0 || size < MIN_LINES) return result
197
198 void analyse($, done, task)
199 return result
200 })
201
202 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
203 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
204 const list = await read($, history)
205 const busy = await read($, isBusy)
206 const at = await read($, cursor)
207 const i = at < 0 || at >= list.length ? list.length - 1 : at
208 const shown = list[i]
209
210 if (!shown) {
211 return (
212 <Box flexDirection="column">
213 <Text dimColor>{busy ? 'Reviewing the changes…' : 'No code changes analysed yet.'}</Text>
214 </Box>
215 )
216 }
217
218 return (
219 <Box flexDirection="column">
220 <Box flexDirection="row" gap={1}>
221 <Text dimColor>
222 {i + 1}/{list.length} · {shown.files.slice(0, 4).join(', ')}
223 {shown.files.length > 4 ? ` +${shown.files.length - 4}` : ''}
224 {busy ? ' · reviewing new changes…' : ''}
225 </Text>
226 {i > 0 && <Button key="older" label="Older" onPress={() => update($, cursor, () => i - 1)} />}
227 {i < list.length - 1 && (
228 <Button key="newer" label="Newer" onPress={() => update($, cursor, () => i + 1)} />
229 )}
230 <Button key="close" label="Close" onPress={() => $.ui.close({ id: PANE })} />
231 </Box>
232 <Markdown text={shown.text} />
233 </Box>
234 )
235 })
236}
237types/index.d.ts 20 lines1export type Highlight = {
2 /** When the turn ended, ms since epoch. */
3 at: number
4 /** Files the turn changed. */
5 files: string[]
6 /** The model's write-up, markdown. */
7 text: string
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'change-highlights': {
13 history: Highlight[]
14 /** Index into history being shown; -1 = latest. */
15 cursor: number
16 isBusy: boolean
17 }
18 }
19}
20