A short card that explains what an agent changed on this branch: a headline, a few one-line changes, where to look first and what was not checked

My Claude Code mods, one folder per mod. Needs Claude Code 2.1.287 or newer.
| Mod | What it does |
|---|---|
| usage-band | A band above the prompt showing context fill, tokens, cost and rate limits: stat tiles in the desktop app, a coloured row in the terminal |
| plan-progress | Reads a phased plan into a live progress tree: phases, steps, elapsed time and an estimate of what is left |
| test-user | A Haiku test user that clicks through your locally running app and reports UI/UX issues: its task list, findings and failures in a live pane |
| change-card | A short card explaining what an agent changed on this branch: a headline, a few one-line changes, where to look first and what was not checked |
All four mods share one look: dark violet cards with a lilac accent. In the desktop app's Code tab they draw as images (cards, tiles, gradients); in the terminal they use the same colours as text.

At the prompt of a terminal Claude Code session:
/plugin install usage-band --marketplace WeaponizedLego/claude-code-mods
/plugin install plan-progress --marketplace WeaponizedLego/claude-code-mods
Answer y to add the marketplace, then pick the user scope. Or from a shell:
claude plugin marketplace add WeaponizedLego/claude-code-mods
claude plugin install usage-band@claude-code-mods
claude plugin install plan-progress@claude-code-mods
Update later with claude plugin marketplace update claude-code-mods, then claude plugin update usage-band@claude-code-mods.

When a plan with several phases is approved in plan mode, the mod reads its phases and steps (## Phase 1: ... headings, other work headings, or a nested list) into a tree and opens it in a pane. In the terminal it looks like this:
Move auth to sessions 20m in · ~40m left
▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 33% 2/6 steps · phase 2/3
✓ 1 Setup 20m
├ ✓ Add the sessions table 10m
└ ✓ Write the session store 10m
● 2 Switch over
├ ● Replace token checks in the middleware
├ ○ Update the login endpoint
└ ○ Remove the old token helpers
○ 3 Verify
└ ○ Run the auth test suite
The status line carries the short form (▰▰▱▱ 2/6 · phase 2/3: Switch over · 20m in · ~40m left). Steps move when Claude calls the mod's plan_mark tool, or by themselves when Claude's tasks or todos are named like a step. Without plan mode, ask Claude to lay the work out in phases and it calls plan_set. The estimate is the pace so far times the steps left.
During a run of small steps Claude can forget to mark them. When the tree hasn't moved for a while (at least 6 tool calls, and 3 minutes or 1.5× the pace so far, whichever is longer), the next tool result reminds Claude which step the tree shows. Claude can then catch up in one call: plan_mark with through: true closes every earlier open step and splits the elapsed time evenly between them.
/plan-progress opens the tree (it also works mid-turn); /plan-progress clear stops tracking.

A test user for the app you are running locally. Ask Claude to "test the app" (or a feature, a page, a flow) and it hands the job to test-user:tester, a subagent on Haiku that drives the app in the browser the way a person would: it works out what to test, finds the app (the URL you gave, .claude/launch.json, package.json), plans a handful of user tasks, works through them and reports each problem it sees. It never edits code, stays on localhost and only uses test data.
When you just say "test the app", Claude points it at the feature most recently built in the session; with nothing built, the tester looks at git diff and git log for a recent feature, and failing that tests the whole application.
The pane shows the run as it goes: status, tasks passed out of total, a count per severity (blocker, major, minor, polish), the task list with a note on each failure, a card per finding (worst first, with where and how to reproduce), a red card when it could not test at all, and earlier runs folded at the bottom. The status line carries the short form (test-user ● 3/6 tasks · 2 findings · 4m · Pay with the test card) and a toast says how it ended.
The session gets the full record, not just the tester's closing message: it rides back on the Agent result when the tester ran in the foreground, and on the next prompt (or the background task's notification) otherwise; test_report reads it any time. A tester whose turn ends early and is resumed carries on with the same run.
Claude gets the same reporting tools (test_plan, test_task, test_finding, test_finish), so a test it runs by hand shows in the pane too, and it can amend the latest tester run with what it re-checked. If the tester stops without a verdict, the record decides it; a "passed" never hides a failed task.
/test-user opens the pane; /test-user run [what to test] starts a run; /test-user clear forgets the runs.

/changes opens a Changes pane and asks Claude to explain what changed on the branch since it forked from origin/HEAD (or main/master), uncommitted and untracked files included. Run from a folder that is not itself a repository but holds checkouts (a task folder with one worktree per repo), it reads each of them and prefixes their paths with the folder's name. Claude answers by filling in the mod's change_card tool, which keeps the card short by refusing anything over its limits: a 70-character headline, a one-sentence gist, at most 5 one-line changes (with an optional before → after), at most 3 places to look first and 3 things nobody checked. Every changed file has to sit under one of the changes (a path ending in / covers a folder), so nothing hides.
When the code moves on after the card was written, the pane says which files and /changes asks Claude to update just what they affect. After a turn that leaves a sizeable diff (over 5 files or 200 lines) a toast suggests /changes, once per card; nothing is written unasked.
/changes <worktree or branch> explains that one instead of the session's own checkout: a worktree by its path, folder name, branch or any unique part of those (/changes PLAT-421), or a local branch at its tip. When the session's checkout has nothing changed (say, main with the work in .claude/worktrees/), /changes lists the worktrees that do. /changes <ref> compares against another ref; /changes clear forgets the card.
Put it in its own folder (<mod>/.claude-plugin/plugin.json, <mod>/hooks/...) and add an entry to .claude-plugin/marketplace.json. Check it with claude plugin validate ..
hooks/register.tsx 405 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Facts } from '../types'
5import { FORK_REFS, factsOf, KIND_GLYPH, KINDS, LIMIT, cardText, changedFiles, check, factsText, isBig, movedSince, plural, shortPath, signature } from './card'
6import type { CardInput, RepoDiff } from './card'
7import { C, KIND_COLOR, cardSvg, emptySvg, statLine } from './look'
8
9const PANE = 'change-card'
10const TITLE = 'Changes'
11const TOOL = 'mcp__change-card__change_card'
12// Roughly one cell of the desktop's code font, in CSS pixels.
13const CELL_PX = 8
14
15const cardAtom = atom({ plugin: 'change-card', key: 'card' } as const, null)
16const factsAtom = atom({ plugin: 'change-card', key: 'facts' } as const, null)
17const baseAtom = atom({ plugin: 'change-card', key: 'baseRef' } as const, null)
18const focusAtom = atom({ plugin: 'change-card', key: 'focus' } as const, null)
19const nudgedAtom = atom({ plugin: 'change-card', key: 'nudged' } as const, null)
20
21async function git($: EngineInterface, args: string[], cwd?: string) {
22 const r = await $.process.run(['git', ...args], cwd ? { cwd } : undefined).catch(() => null)
23 return r && r.exitCode === 0 ? r.stdout : null
24}
25
26/** The commit a repository is compared against: the ref asked for, else where `tip` forked. */
27async function baseOf($: EngineInterface, dir: string, baseRef: string | null, tip = 'HEAD'): Promise<{ base: string; how: string } | null> {
28 if (baseRef) {
29 const base = (await git($, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`], dir))?.trim()
30 return base ? { base, how: baseRef } : null
31 }
32 for (const ref of FORK_REFS) {
33 const base = (await git($, ['merge-base', tip, ref], dir))?.trim()
34 if (base) return { base, how: `fork point from ${ref}` }
35 }
36 const head = (await git($, ['rev-parse', tip], dir))?.trim()
37 return head ? { base: head, how: 'uncommitted work only' } : null
38}
39
40const baseName = (path: string) => path.replace(/\/$/, '').split('/').pop() ?? path
41
42type Checkout = { path: string; branch: string }
43
44/** Every worktree of the repository at `dir`, its own checkout included. */
45async function worktreesOf($: EngineInterface, dir: string): Promise<Checkout[]> {
46 const out = (await git($, ['worktree', 'list', '--porcelain'], dir)) ?? ''
47 return out
48 .split('\n\n')
49 .map(block => {
50 const path = /^worktree (.+)$/m.exec(block)?.[1] ?? ''
51 const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1] ?? ''
52 return { path, branch }
53 })
54 .filter(w => w.path)
55}
56
57type Target = { name: string; dir: string; tip?: string; label?: string }
58
59/** A worktree (by path, folder or branch, or a unique part of one) or a local branch of the session's repository. */
60async function targetOf($: EngineInterface, top: string, focus: string): Promise<Target | null> {
61 const all = await worktreesOf($, top)
62 const want = focus.toLowerCase()
63 const exact = all.find(w => w.path === focus || baseName(w.path) === focus || w.branch === focus)
64 const loose = all.filter(w => `${w.branch} ${baseName(w.path)}`.toLowerCase().includes(want))
65 const hit = exact ?? (loose.length === 1 ? loose[0] : undefined)
66 if (hit) return { name: '', dir: hit.path, ...(hit.path !== top && { label: `worktree ${hit.branch || baseName(hit.path)}` }) }
67 if (await git($, ['rev-parse', '--verify', '--quiet', `refs/heads/${focus}`], top)) return { name: '', dir: top, tip: focus, label: `branch ${focus}` }
68 return null
69}
70
71/** The session's repository; else, in a folder of checkouts, each child with a .git (a clone or a worktree). */
72async function reposHere($: EngineInterface): Promise<Target[]> {
73 const top = (await git($, ['rev-parse', '--show-toplevel']))?.trim()
74 if (top) return [{ name: '', dir: top }]
75 const cwd = await $.session.cwd()
76 const children = (await $.fs.list(cwd).catch(() => [])).filter(c => c.kind === 'dir' && !c.name.startsWith('.'))
77 const found: Target[] = []
78 for (const c of children) {
79 const dir = `${cwd.replace(/\/$/, '')}/${c.name}`
80 const inside = await $.fs.list(dir).catch(() => [])
81 if (inside.some(e => e.name === '.git')) found.push({ name: c.name, dir })
82 }
83 return found
84}
85
86async function diffOf($: EngineInterface, t: Target, baseRef: string | null): Promise<RepoDiff | null> {
87 const b = await baseOf($, t.dir, baseRef, t.tip)
88 if (!b) return null
89 const numstat = (await git($, ['diff', '--numstat', '--no-renames', b.base, ...(t.tip ? [t.tip] : [])], t.dir)) ?? ''
90 // A branch away from its checkout has no working files to add.
91 const untracked = t.tip ? '' : ((await git($, ['ls-files', '--others', '--exclude-standard'], t.dir)) ?? '')
92 return { ...t, ...b, numstat, untracked }
93}
94
95/** What changed since the base, read from git; null when no repository is here, or the focus or ref matches nothing. */
96async function collect($: EngineInterface, baseRef: string | null, focus: string | null): Promise<Facts | null> {
97 let targets: Target[]
98 if (focus) {
99 const top = (await git($, ['rev-parse', '--show-toplevel']))?.trim()
100 const t = top ? await targetOf($, top, focus) : null
101 if (!t) return null
102 targets = [t]
103 } else targets = await reposHere($)
104 const diffs: RepoDiff[] = []
105 for (const t of targets) {
106 const d = await diffOf($, t, baseRef)
107 if (d) diffs.push(d)
108 }
109 return diffs.length ? factsOf(diffs) : null
110}
111
112/** The other worktrees of the session's repository that have changes, for when its own checkout has none. */
113async function busyWorktrees($: EngineInterface): Promise<string[]> {
114 const top = (await git($, ['rev-parse', '--show-toplevel']))?.trim()
115 if (!top) return []
116 const lines: string[] = []
117 for (const w of await worktreesOf($, top)) {
118 if (w.path === top) continue
119 const d = await diffOf($, { name: '', dir: w.path }, null)
120 const n = d ? Object.keys(factsOf([d]).files).length : 0
121 if (n) lines.push(` ${baseName(w.path)}${w.branch && w.branch !== baseName(w.path) ? ` (${w.branch})` : ''}: ${plural(n, 'file')}`)
122 }
123 return lines
124}
125
126async function refresh($: EngineInterface): Promise<Facts | null> {
127 const facts = await collect($, await read($, baseAtom), await read($, focusAtom))
128 await update($, factsAtom, () => facts)
129 return facts
130}
131
132// A pane that cannot open (refused, or no surface to draw on) never costs the card.
133async function openPane($: EngineInterface) {
134 await $.ui.open({ id: PANE, title: TITLE }).catch(() => ({ isPlaced: false }))
135}
136
137const HOW_TO_WRITE =
138 'Read the diffs that matter (biggest churn and public surfaces first) and use what you know from this session about why. ' +
139 'Group by intent, not by file: one change is one thing a person would say out loud. Lead with what matters most. ' +
140 'Every changed file must sit under a change; a path ending in "/" covers a folder, and small leftovers go in one "chore". ' +
141 'look: at most 3 places a reviewer must check first (data that could be lost, a trust boundary, a contract that changed, anything you were unsure of); none is fine. ' +
142 'unchecked: what nobody ran or tested, plainly. The tool refuses long text: say less, not more. ' +
143 'After the card is shown, reply with one line pointing at the Changes pane; do not repeat the card in chat.'
144
145export const register: Register = on => {
146 on('session.start', async ($, e, next) => {
147 const result = await next(e)
148
149 await $.tool.register({
150 name: 'change_card',
151 description:
152 'Show the developer a short card explaining what changed on this branch: a headline, one-line changes grouped by intent, where to look first, and what was not checked. ' +
153 'Use it when they run /changes or ask what you (or another agent) changed. ' +
154 HOW_TO_WRITE,
155 inputSchema: {
156 type: 'object',
157 properties: {
158 title: { type: 'string', maxLength: LIMIT.title, description: 'The whole change as a headline' },
159 gist: { type: 'string', maxLength: LIMIT.gist, description: 'One plain sentence: what is different for someone using or maintaining this' },
160 changes: {
161 type: 'array',
162 minItems: 1,
163 maxItems: LIMIT.changes,
164 items: {
165 type: 'object',
166 properties: {
167 kind: { type: 'string', enum: KINDS },
168 title: { type: 'string', maxLength: LIMIT.changeTitle, description: 'What changed, as you would say it out loud' },
169 before: { type: 'string', maxLength: LIMIT.side, description: 'Optional: how it was, in a few words' },
170 after: { type: 'string', maxLength: LIMIT.side, description: 'Optional: how it is now, in a few words' },
171 files: { type: 'array', minItems: 1, items: { type: 'string' }, description: 'Paths from the repository root as git prints them; "dir/" covers a folder' },
172 },
173 required: ['kind', 'title', 'files'],
174 },
175 },
176 look: {
177 type: 'array',
178 maxItems: LIMIT.look,
179 items: {
180 type: 'object',
181 properties: {
182 file: { type: 'string' },
183 line: { type: 'integer', minimum: 1 },
184 why: { type: 'string', maxLength: LIMIT.why, description: 'What to check there' },
185 },
186 required: ['file', 'why'],
187 },
188 },
189 unchecked: { type: 'array', maxItems: LIMIT.unchecked, items: { type: 'string', maxLength: LIMIT.uncheckedItem } },
190 },
191 required: ['title', 'gist', 'changes'],
192 },
193 isDeferred: false,
194 })
195 await $.command.register({
196 name: 'changes',
197 description: 'Show the change card for this branch; Claude writes or updates it when needed ([worktree | branch]: explain that one; [ref]: compare against it; clear: forget it)',
198 argumentHint: '[worktree | branch | ref | clear]',
199 immediate: true,
200 })
201
202 await refresh($).catch(() => null)
203 return result
204 })
205
206 on('tool.call', { tool: TOOL }, async ($, e) => {
207 const facts = await refresh($)
208 if (!facts) return { deny: 'No git repository here or in the folders directly under it, or the base ref is gone: there is nothing to compare against.' }
209 if (Object.keys(facts.files).length === 0) return { deny: `Nothing changed since ${facts.since}.` }
210 const checked = check(e as unknown as CardInput, facts, await $.clock.now())
211 if ('errors' in checked) return { deny: `The card was not shown. Fix all of these and call ${TOOL} again:\n- ${checked.errors.join('\n- ')}` }
212 await update($, cardAtom, () => checked.card)
213 await update($, nudgedAtom, () => null)
214 await openPane($)
215 return { result: `Shown to the developer in the Changes pane:\n\n${cardText(checked.card)}\n\nReply with one line pointing there; do not repeat the card.` }
216 })
217
218 on('command.run', { command: 'changes' }, async ($, e) => {
219 const arg = e.args.trim()
220 if (arg === 'clear') {
221 await update($, cardAtom, () => null)
222 await update($, baseAtom, () => null)
223 await update($, focusAtom, () => null)
224 await $.ui.close({ id: PANE }).catch(() => {})
225 return { text: 'Forgot the change card.' }
226 }
227
228 // An argument names a worktree or branch to explain, else a commit to compare against.
229 let facts: Facts | null
230 if (arg) {
231 const before = { base: await read($, baseAtom), focus: await read($, focusAtom) }
232 await update($, baseAtom, () => null)
233 await update($, focusAtom, () => arg)
234 facts = await refresh($)
235 if (!facts) {
236 await update($, focusAtom, () => null)
237 await update($, baseAtom, () => arg)
238 facts = await refresh($)
239 }
240 if (!facts) {
241 await update($, baseAtom, () => before.base)
242 await update($, focusAtom, () => before.focus)
243 await refresh($)
244 return { text: `No worktree, branch or commit here matches "${arg}".` }
245 }
246 // A new target is a new card.
247 await update($, cardAtom, () => null)
248 } else {
249 facts = await refresh($)
250 if (!facts) return { text: 'No git repository here, nor in the folders directly under it.' }
251 }
252 await openPane($)
253
254 if (Object.keys(facts.files).length === 0) {
255 const busy = arg ? [] : await busyWorktrees($)
256 return {
257 text: busy.length
258 ? `Nothing changed in this checkout since ${facts.since}. Worktrees with changes:\n${busy.join('\n')}\nRun /changes <name> to explain one.`
259 : `Nothing changed since ${facts.since}.`,
260 }
261 }
262
263 const card = await read($, cardAtom)
264 const moved = card ? movedSince(card, facts) : []
265 if (card && !arg && moved.length === 0) return { text: 'The change card is up to date.' }
266
267 const ask =
268 card && !arg
269 ? `Update the change card with ${TOOL}. ${plural(moved.length, 'file')} changed since it was written: ${moved.join(', ')}. ` +
270 `Read what moved and rewrite only what it affects; keep what is still true.\n\nThe card now:\n${cardText(card)}`
271 : `Write a change card with ${TOOL} for ${facts.repos.length === 1 && facts.repos[0]!.label ? `the ${facts.repos[0]!.label}` : 'this branch'}.`
272 // Submitted once this command has returned: a prompt from inside it would wait on itself.
273 const text = `${ask}\n\n${factsText(facts)}`
274 $.clock.after(0, () => void $.prompt.submit({ text }).catch(() => $.ui.toast('change-card: could not ask Claude; ask for a change card yourself')))
275 return { text: card && !arg ? 'Asking Claude to update the change card.' : 'Asking Claude for a change card.' }
276 })
277
278 // Past a sizeable change, suggest a card once, until one is written.
279 on('turn.complete', async ($, e, next) => {
280 const result = await next(e)
281 if (e.agentId || e.reason !== 'answer') return result
282 const before = await read($, factsAtom)
283 const facts = await refresh($).catch(() => null)
284 if (!facts || !isBig(facts) || (before && signature(before) === signature(facts))) return result
285 if (await read($, nudgedAtom)) return result
286 const card = await read($, cardAtom)
287 if (card && movedSince(card, facts).length === 0) return result
288 await update($, nudgedAtom, () => signature(facts))
289 $.ui.toast(`${plural(Object.keys(facts.files).length, 'file')} changed · /changes ${card ? 'updates the card' : 'explains them'}`)
290 return result
291 }).catch(($, e, next) => next(e))
292
293 on('session.end', async ($, e, next) => {
294 if (e.reason === 'clear') {
295 await update($, cardAtom, () => null)
296 await update($, nudgedAtom, () => null)
297 }
298 return next(e)
299 })
300
301 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
302 const card = await read($, cardAtom)
303 const facts = await read($, factsAtom)
304 const moved = card && facts ? movedSince(card, facts) : []
305
306 if (e.surface !== 'terminal') {
307 const { Svg } = $.ui.resolve(e)
308 const width = Math.max(300, e.props.bodyColumns * CELL_PX)
309 if (!card) {
310 const empty = emptySvg(facts, width)
311 return <Svg source={empty.source} alt="No change card yet" width={width} height={empty.height} />
312 }
313 const drawn = cardSvg(card, facts, moved, width)
314 return <Svg source={drawn.source} alt={drawn.alt} width={width} height={drawn.height} />
315 }
316
317 const { Box, Text } = $.ui.resolve(e)
318 if (!card) {
319 return (
320 <Box flexDirection="column" borderStyle="round" borderColor={C.stroke} paddingX={1}>
321 <Text bold color={C.text}>
322 No change card yet
323 </Text>
324 <Text color={C.muted} wrap="wrap">
325 Run /changes to have Claude explain what changed on this branch.
326 </Text>
327 {facts && <Text color={C.dim} wrap="truncate">{statLine(facts)}</Text>}
328 </Box>
329 )
330 }
331
332 return (
333 <Box flexDirection="column" key="change-card">
334 {moved.length > 0 && (
335 <Box flexDirection="column" borderStyle="round" borderColor={C.warn} paddingX={1} key="moved">
336 <Text bold color={C.warn}>{`${plural(moved.length, 'file')} changed since this card`}</Text>
337 <Text color={C.muted} wrap="truncate">{`${moved.slice(0, 4).map(shortPath).join(', ')}${moved.length > 4 ? ', …' : ''} · /changes to update`}</Text>
338 </Box>
339 )}
340
341 <Text bold color={C.text} wrap="wrap">
342 {card.title}
343 </Text>
344 <Text color={C.soft} wrap="wrap">
345 {card.gist}
346 </Text>
347 {facts && <Text color={C.muted} wrap="truncate">{statLine(facts)}</Text>}
348
349 <Text> </Text>
350 {card.changes.flatMap((c, i) => {
351 const n = plural(changedFiles(c, card.facts).length, 'file')
352 const row = (
353 <Box flexDirection="row" justifyContent="space-between" key={`change-${i}`}>
354 <Box flexDirection="row" flexShrink={1}>
355 <Text color={KIND_COLOR[c.kind]} bold>{`[${KIND_GLYPH[c.kind]}] `}</Text>
356 <Text color={C.text} wrap="truncate">
357 {c.title}
358 </Text>
359 </Box>
360 <Text color={C.muted}>{` ${n}`}</Text>
361 </Box>
362 )
363 if (!c.before && !c.after) return [row]
364 return [
365 row,
366 <Box flexDirection="row" key={`shift-${i}`}>
367 <Text color={C.muted} wrap="truncate">{` ${c.before ?? '…'}`}</Text>
368 <Text color={C.accent}>{' → '}</Text>
369 <Text color={C.soft} wrap="truncate">
370 {c.after ?? '…'}
371 </Text>
372 </Box>,
373 ]
374 })}
375
376 {card.look.length > 0 && (
377 <Box flexDirection="column" key="look">
378 <Text> </Text>
379 <Text bold color={C.text}>
380 Look here first
381 </Text>
382 {card.look.flatMap((s, i) => [
383 <Text color={C.glow} wrap="truncate" key={`look-${i}`}>{` ${shortPath(s.file)}${s.line ? `:${s.line}` : ''}`}</Text>,
384 <Text color={C.soft} wrap="wrap" key={`why-${i}`}>{` ${s.why}`}</Text>,
385 ])}
386 </Box>
387 )}
388
389 {card.unchecked.length > 0 && (
390 <Box flexDirection="column" key="unchecked">
391 <Text> </Text>
392 <Text bold color={C.warn}>
393 Not checked
394 </Text>
395 {card.unchecked.map((u, i) => (
396 <Text color={C.soft} wrap="wrap" key={`u-${i}`}>{` • ${u}`}</Text>
397 ))}
398 </Box>
399 )}
400 </Box>
401 )
402 })
403}
404
405hooks/card.ts 173 lines1import type { Card, Change, Facts, Kind, Repo, Spot } from '../types'
2
3// The card stays short because the tool refuses anything longer: these are the caps.
4export const LIMIT = {
5 title: 70,
6 gist: 160,
7 changes: 5,
8 changeTitle: 60,
9 side: 60,
10 look: 3,
11 why: 80,
12 unchecked: 3,
13 uncheckedItem: 70,
14} as const
15
16export const KINDS: Kind[] = ['new', 'fix', 'change', 'removal', 'chore']
17export const KIND_LABEL: Record<Kind, string> = { new: 'NEW', fix: 'FIX', change: 'CHANGED', removal: 'REMOVED', chore: 'CHORE' }
18export const KIND_GLYPH: Record<Kind, string> = { new: '+', fix: '!', change: '~', removal: '-', chore: '·' }
19
20// A nudge is worth it past about this much change.
21export const BIG_FILES = 5
22export const BIG_LINES = 200
23
24const MAX_LISTED = 200
25
26// The fork point from the default branch, else the branch itself (uncommitted work only).
27export const FORK_REFS = ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master']
28
29export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
30export const oneLine = (t: unknown) => (typeof t === 'string' ? t.replace(/\s+/g, ' ').trim() : '')
31
32// ---- the diff
33
34export type RepoDiff = Repo & { numstat: string; untracked: string }
35
36/** Facts from each repository's `git diff --numstat` and untracked files; paths prefixed by the repository's name. */
37export function factsOf(diffs: RepoDiff[]): Facts {
38 const files: Record<string, string> = {}
39 let added = 0
40 let deleted = 0
41 for (const r of diffs) {
42 const at = (path: string) => (r.name ? `${r.name}/${path}` : path)
43 for (const line of r.numstat.split('\n')) {
44 const [a, d, ...rest] = line.split('\t')
45 const path = rest.join('\t')
46 if (!path) continue
47 if (a === '-') {
48 files[at(path)] = 'binary'
49 continue
50 }
51 added += Number(a) || 0
52 deleted += Number(d) || 0
53 files[at(path)] = `+${a} -${d}`
54 }
55 for (const path of r.untracked.split('\n').map(p => p.trim())) {
56 if (path && !files[at(path)]) files[at(path)] = 'new'
57 }
58 }
59 const repos = diffs.map(({ numstat: _n, untracked: _u, ...r }) => ({ ...r, base: r.base.slice(0, 7) }))
60 const one = repos[0]
61 const since =
62 repos.length === 1 && one && !one.name
63 ? `${one.base} (${one.how})${one.label ? ` · ${one.label}` : ''}`
64 : repos.map(r => `${r.name} ${r.base}`).join(', ')
65 return { repos, since, files, added, deleted }
66}
67
68export const fileCount = (f: Facts) => Object.keys(f.files).length
69export const isBig = (f: Facts) => fileCount(f) > BIG_FILES || f.added + f.deleted > BIG_LINES
70export const signature = (f: Facts) => JSON.stringify(f.files)
71
72export const covers = (entry: string, path: string) => entry === path || (entry.endsWith('/') && path.startsWith(entry))
73
74/** Files whose diff moved since the card was written. */
75export function movedSince(card: Card, facts: Facts): string[] {
76 const paths = new Set([...Object.keys(card.facts), ...Object.keys(facts.files)])
77 return [...paths].filter(p => card.facts[p] !== facts.files[p]).sort()
78}
79
80export const changedFiles = (c: Change, facts: Record<string, string>) => Object.keys(facts).filter(p => c.files.some(e => covers(e, p)))
81
82/** The file list Claude writes the card from, and how to read each diff. */
83export function factsText(f: Facts): string {
84 const paths = Object.keys(f.files).sort()
85 const listed = paths.slice(0, MAX_LISTED).map(p => ` ${f.files[p]}\t${p}`)
86 if (paths.length > MAX_LISTED) listed.push(` … and ${paths.length - MAX_LISTED} more`)
87 const range = (r: Repo) => (r.tip ? `${r.base} ${r.tip}` : r.base)
88 const read = f.repos.map(r =>
89 r.name ? ` ${r.name}/…: git -C ${r.dir} diff ${range(r)} -- <path inside ${r.name}>` : ` git -C ${r.dir} diff ${range(r)} -- <path>`,
90 )
91 return `${plural(paths.length, 'file')} changed since ${f.since}, +${f.added} -${f.deleted}:\n${listed.join('\n')}\n\nRead a file's diff with:\n${read.join('\n')}`
92}
93
94// ---- the card, from the change_card tool
95
96type ChangeInput = { kind?: unknown; title?: unknown; before?: unknown; after?: unknown; files?: unknown }
97type SpotInput = { file?: unknown; line?: unknown; why?: unknown }
98export type CardInput = { title?: unknown; gist?: unknown; changes?: unknown; look?: unknown; unchecked?: unknown }
99
100const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
101
102/** The card, or every reason it was refused, so one retry can fix them all. */
103export function check(input: CardInput, facts: Facts, now: number): { card: Card } | { errors: string[] } {
104 const errors: string[] = []
105 const cap = (name: string, v: unknown, max: number, required = true) => {
106 const t = oneLine(v)
107 if (!t && required) errors.push(`${name} is missing.`)
108 else if (t.length > max) errors.push(`${name} is ${t.length} characters; the limit is ${max}. Say less: "${t.slice(0, 40)}…"`)
109 return t
110 }
111
112 const title = cap('title', input.title, LIMIT.title)
113 const gist = cap('gist', input.gist, LIMIT.gist)
114
115 const rawChanges = list(input.changes)
116 if (rawChanges.length === 0) errors.push('changes needs at least one entry.')
117 if (rawChanges.length > LIMIT.changes) errors.push(`${rawChanges.length} changes; the limit is ${LIMIT.changes}. Merge related ones, and sweep small leftovers into one "chore".`)
118 const changes: Change[] = rawChanges.slice(0, LIMIT.changes).map((raw, i) => {
119 const c = (raw ?? {}) as ChangeInput
120 const at = `changes[${i + 1}]`
121 const kind = KINDS.includes(c.kind as Kind) ? (c.kind as Kind) : 'change'
122 const files = list(c.files).map(oneLine).filter(Boolean)
123 if (files.length === 0) errors.push(`${at} names no files.`)
124 const before = cap(`${at}.before`, c.before, LIMIT.side, false)
125 const after = cap(`${at}.after`, c.after, LIMIT.side, false)
126 return { kind, title: cap(`${at}.title`, c.title, LIMIT.changeTitle), ...(before && { before }), ...(after && { after }), files }
127 })
128
129 const rawLook = list(input.look)
130 if (rawLook.length > LIMIT.look) errors.push(`${rawLook.length} look entries; the limit is ${LIMIT.look}. Keep the ones a reviewer must check first.`)
131 const look: Spot[] = rawLook.slice(0, LIMIT.look).map((raw, i) => {
132 const s = (raw ?? {}) as SpotInput
133 const file = oneLine(s.file)
134 if (!file) errors.push(`look[${i + 1}].file is missing.`)
135 const line = Number(s.line)
136 return { file, ...(Number.isInteger(line) && line > 0 && { line }), why: cap(`look[${i + 1}].why`, s.why, LIMIT.why) }
137 })
138
139 const rawUnchecked = list(input.unchecked)
140 if (rawUnchecked.length > LIMIT.unchecked) errors.push(`${rawUnchecked.length} unchecked entries; the limit is ${LIMIT.unchecked}.`)
141 const unchecked = rawUnchecked.slice(0, LIMIT.unchecked).map((u, i) => cap(`unchecked[${i + 1}]`, u, LIMIT.uncheckedItem))
142
143 // Nothing hides: every changed file sits under a change, and every named file changed.
144 const named = changes.flatMap(c => c.files)
145 const loose = Object.keys(facts.files).filter(p => !named.some(e => covers(e, p)))
146 if (loose.length) {
147 errors.push(`No change covers ${plural(loose.length, 'file')}: ${loose.slice(0, 20).join(', ')}${loose.length > 20 ? ', …' : ''}. Add them to a change (a path ending in "/" covers a folder).`)
148 }
149 const unknown = named.filter(e => !Object.keys(facts.files).some(p => covers(e, p)))
150 if (unknown.length) errors.push(`Not among the changed files: ${unknown.join(', ')}. Use the paths exactly as listed.`)
151
152 if (errors.length) return { errors }
153 return { card: { title, gist, changes, look, unchecked, writtenAt: now, facts: { ...facts.files } } }
154}
155
156/** The card as plain text, for the model and the command. */
157export function cardText(card: Card): string {
158 const lines = [card.title, card.gist, '']
159 for (const c of card.changes) {
160 lines.push(`[${KIND_GLYPH[c.kind]}] ${c.title} (${plural(changedFiles(c, card.facts).length, 'file')})`)
161 if (c.before || c.after) lines.push(` ${c.before ?? '…'} → ${c.after ?? '…'}`)
162 }
163 if (card.look.length) lines.push('', 'Look here first:', ...card.look.map(s => ` ${s.file}${s.line ? `:${s.line}` : ''} ${s.why}`))
164 if (card.unchecked.length) lines.push('', `Not checked: ${card.unchecked.join('; ')}`)
165 return lines.join('\n')
166}
167
168/** The last two parts of a path: enough to recognise it. */
169export const shortPath = (p: string) => {
170 const parts = p.replace(/\/$/, '').split('/')
171 return (parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : parts.join('/')) + (p.endsWith('/') ? '/' : '')
172}
173hooks/look.ts 170 lines1import type { Card, Facts, Kind } from '../types'
2import { KIND_LABEL, changedFiles, plural, shortPath } from './card'
3
4// The palette the other mods share: dark violet cards with a lilac accent,
5// orange and red for what needs a look. An SVG is drawn as an image, so it
6// takes real colours, not theme keys; the terminal uses the same.
7export const C = {
8 card: '#23222b',
9 raised: '#2a2933',
10 stroke: '#363541',
11 text: '#ececf1',
12 soft: '#c9c7d3',
13 muted: '#8e8c9a',
14 dim: '#5f5d6b',
15 accent: '#a98bff',
16 deep: '#7d68c9',
17 glow: '#c4b2ff',
18 track: '#3b3650',
19 warn: '#ff8a4c',
20 hot: '#ff5c7a',
21} as const
22
23export const KIND_COLOR: Record<Kind, string> = { new: C.accent, fix: C.warn, change: C.glow, removal: C.hot, chore: C.muted }
24
25const FONT = `font-family="Inter, 'Segoe UI', system-ui, -apple-system, sans-serif"`
26const MONO = `font-family="'JetBrains Mono', 'SF Mono', Menlo, monospace"`
27
28export const esc = (t: string) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
29
30// Text in an image cannot wrap or truncate itself: cut it to an estimated width.
31const chars = (px: number, size: number) => Math.max(3, Math.floor(px / (size * 0.52)))
32const fit = (t: string, px: number, size: number) => {
33 const max = chars(px, size)
34 return t.length > max ? `${t.slice(0, max - 1)}…` : t
35}
36function wrap(t: string, px: number, size: number, maxLines: number): string[] {
37 const max = chars(px, size)
38 const lines: string[] = []
39 let line = ''
40 for (const word of t.split(' ')) {
41 if (!line) line = word
42 else if (line.length + 1 + word.length <= max) line += ` ${word}`
43 else {
44 lines.push(line)
45 line = word
46 }
47 }
48 if (line) lines.push(line)
49 if (lines.length > maxLines) {
50 const kept = lines.slice(0, maxLines)
51 kept[maxLines - 1] = fit(`${kept[maxLines - 1]} ${lines[maxLines]}`, px - size, size).replace(/…?$/, '…')
52 return kept.map(l => fit(l, px, size))
53 }
54 return lines.map(l => fit(l, px, size))
55}
56
57const text = (x: number, y: number, size: number, fill: string, body: string, extra = '') =>
58 `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" ${extra}>${body}</text>`
59
60const svg = (W: number, H: number, body: string) =>
61 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" ${FONT}>${body}</svg>`
62
63const num = (n: number) => String(n).replace(/\B(?=(\d{3})+$)/g, ',')
64export const statLine = (f: Facts) => `${plural(Object.keys(f.files).length, 'file')} · +${num(f.added)} −${num(f.deleted)} · since ${f.since}`
65
66const GAP = 10
67const ROW = 22
68
69/** The whole card as one SVG: headline, changes, where to look, what was not checked. */
70export function cardSvg(card: Card, facts: Facts | null, moved: string[], width: number): { source: string; height: number; alt: string } {
71 const W = Math.max(300, Math.round(width))
72 const parts: string[] = []
73 let y = 0
74
75 // ---- the code moved on since the card was written
76 if (moved.length) {
77 const h = 54
78 parts.push(`<rect x="0.5" y="0.5" width="${W - 1}" height="${h - 1}" rx="14" fill="${C.raised}" stroke="${C.warn}"/>`)
79 parts.push(text(18, 23, 12.5, C.warn, esc(`${plural(moved.length, 'file')} changed since this card`), 'font-weight="600"'))
80 parts.push(text(18, 41, 11, C.muted, esc(fit(`${moved.slice(0, 4).map(shortPath).join(', ')}${moved.length > 4 ? ', …' : ''} · /changes to update`, W - 36, 11))))
81 y += h + GAP
82 }
83
84 // ---- headline and gist
85 const titleLines = wrap(card.title, W - 36, 15, 2)
86 const gistLines = wrap(card.gist, W - 36, 12.5, 3)
87 const headH = 30 + titleLines.length * 20 + gistLines.length * 18 + (facts ? 26 : 6)
88 parts.push(`<rect x="0.5" y="${y + 0.5}" width="${W - 1}" height="${headH - 1}" rx="16" fill="${C.card}" stroke="${C.stroke}"/>`)
89 let ty = y + 30
90 for (const l of titleLines) {
91 parts.push(text(18, ty, 15, C.text, esc(l), 'font-weight="600"'))
92 ty += 20
93 }
94 ty += 2
95 for (const l of gistLines) {
96 parts.push(text(18, ty, 12.5, C.soft, esc(l)))
97 ty += 18
98 }
99 if (facts) parts.push(text(18, ty + 6, 10.5, C.muted, esc(fit(statLine(facts), W - 36, 10.5))))
100 y += headH + GAP
101
102 // ---- one row per change, a second line for before → after
103 const rows = card.changes.map(c => (c.before || c.after ? 2 : 1))
104 const listH = 44 + rows.reduce((a, b) => a + b, 0) * ROW - 4
105 parts.push(`<rect x="0.5" y="${y + 0.5}" width="${W - 1}" height="${listH - 1}" rx="14" fill="${C.raised}" stroke="${C.stroke}"/>`)
106 parts.push(text(16, y + 25, 13.5, C.text, 'What changed', 'font-weight="600"'))
107 ty = y + 52
108 card.changes.forEach((c, i) => {
109 const color = KIND_COLOR[c.kind]
110 const tag = KIND_LABEL[c.kind]
111 const tagW = Math.round(tag.length * 6.2 + 14)
112 parts.push(`<rect x="16" y="${ty - 13}" width="${tagW}" height="17" rx="8.5" fill="none" stroke="${color}"/>`)
113 parts.push(text(16 + tagW / 2, ty - 1.5, 9, color, tag, 'text-anchor="middle" font-weight="600" letter-spacing="0.6"'))
114 const n = plural(changedFiles(c, card.facts).length, 'file')
115 parts.push(text(W - 18, ty, 11, C.muted, n, 'text-anchor="end"'))
116 parts.push(text(24 + tagW, ty, 12.5, C.text, esc(fit(c.title, W - 60 - tagW - n.length * 6, 12.5))))
117 if (rows[i] === 2) {
118 ty += ROW - 2
119 parts.push(
120 `<text x="${24 + tagW}" y="${ty}" font-size="11.5"><tspan fill="${C.muted}">${esc(fit(c.before ?? '…', (W - 60 - tagW) / 2, 11.5))}</tspan>` +
121 `<tspan fill="${C.accent}"> → </tspan><tspan fill="${C.soft}">${esc(fit(c.after ?? '…', (W - 60 - tagW) / 2, 11.5))}</tspan></text>`,
122 )
123 ty += ROW + 2
124 } else ty += ROW
125 })
126 y += listH + GAP
127
128 // ---- where a reviewer looks first
129 if (card.look.length) {
130 const h = 44 + card.look.length * 38 - 6
131 parts.push(`<rect x="0.5" y="${y + 0.5}" width="${W - 1}" height="${h - 1}" rx="14" fill="${C.raised}" stroke="${C.stroke}"/>`)
132 parts.push(text(16, y + 25, 13.5, C.text, 'Look here first', 'font-weight="600"'))
133 card.look.forEach((s, i) => {
134 const sy = y + 50 + i * 38
135 parts.push(`<rect x="16" y="${sy - 11}" width="3" height="30" rx="1.5" fill="${C.deep}"/>`)
136 parts.push(text(26, sy, 11.5, C.glow, esc(fit(`${shortPath(s.file)}${s.line ? `:${s.line}` : ''}`, W - 50, 11.5)), MONO))
137 parts.push(text(26, sy + 16, 11.5, C.soft, esc(fit(s.why, W - 50, 11.5))))
138 })
139 y += h + GAP
140 }
141
142 // ---- what nobody checked
143 if (card.unchecked.length) {
144 const h = 40 + card.unchecked.length * 18
145 parts.push(`<rect x="0.5" y="${y + 0.5}" width="${W - 1}" height="${h - 1}" rx="14" fill="${C.card}" stroke="${C.warn}" stroke-opacity="0.6" stroke-dasharray="4 4"/>`)
146 parts.push(text(16, y + 25, 13.5, C.warn, 'Not checked', 'font-weight="600"'))
147 card.unchecked.forEach((u, i) => {
148 const uy = y + 46 + i * 18
149 parts.push(`<circle cx="21" cy="${uy - 4}" r="2.5" fill="${C.warn}"/>`)
150 parts.push(text(30, uy, 11.5, C.soft, esc(fit(u, W - 48, 11.5))))
151 })
152 y += h + GAP
153 }
154
155 const height = Math.round(y - GAP)
156 const alt = `${card.title}: ${card.gist} ${plural(card.changes.length, 'change')}${moved.length ? `; ${plural(moved.length, 'file')} changed since` : ''}`
157 return { source: svg(W, height, parts.join('')), height, alt }
158}
159
160export function emptySvg(facts: Facts | null, width: number): { source: string; height: number } {
161 const W = Math.max(300, Math.round(width))
162 const H = facts ? 110 : 92
163 const body =
164 `<rect x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" rx="16" fill="${C.card}" stroke="${C.stroke}" stroke-dasharray="4 4"/>` +
165 text(20, 38, 14, C.text, 'No change card yet', 'font-weight="600"') +
166 text(20, 60, 11.5, C.muted, esc(fit('Run /changes to have Claude explain what changed on this branch.', W - 40, 11.5))) +
167 (facts ? text(20, 84, 10.5, C.dim, esc(fit(statLine(facts), W - 40, 10.5))) : '')
168 return { source: svg(W, H, body), height: H }
169}
170types/index.d.ts 65 lines1export type Kind = 'new' | 'fix' | 'change' | 'removal' | 'chore'
2
3export type Change = {
4 kind: Kind
5 title: string
6 // One line each: how it behaved before, and after. Both optional.
7 before?: string
8 after?: string
9 // Paths as git prints them; one ending in "/" covers everything under it.
10 files: string[]
11}
12
13export type Spot = { file: string; line?: number; why: string }
14
15export type Card = {
16 title: string
17 gist: string
18 changes: Change[]
19 look: Spot[]
20 unchecked: string[]
21 writtenAt: number
22 // The diff the card was written against: path -> "+added -deleted".
23 facts: Record<string, string>
24}
25
26// One repository the diff reads: the session's own, or each one in a folder of
27// checkouts (a task folder holding one worktree per repo).
28export type Repo = {
29 // '' for the session's own repository; else the folder's name, which prefixes its paths.
30 name: string
31 dir: string
32 // The commit compared against, and how it was found.
33 base: string
34 how: string
35 // A branch compared at its tip (committed work only) instead of the checkout's files.
36 tip?: string
37 // "worktree feature/x" or "branch x" when /changes picked one; absent for the session's own checkout.
38 label?: string
39}
40
41export type Facts = {
42 repos: Repo[]
43 // "58e1986 (fork point from origin/HEAD)", or one "<repo> <base>" per repository.
44 since: string
45 files: Record<string, string>
46 added: number
47 deleted: number
48}
49
50declare module 'claude-code' {
51 interface PluginState {
52 'change-card': {
53 card: Card | null
54 // The diff now, refreshed when a turn ends and when /changes runs.
55 facts: Facts | null
56 // A ref /changes was given, used in place of the fork point.
57 baseRef: string | null
58 // A worktree or branch /changes was pointed at, explained instead of the session's checkout.
59 focus: string | null
60 // The diff a nudge was last shown for, so it is shown once.
61 nudged: string | null
62 }
63 }
64}
65