SLOPSHOPPER

change-card

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

newpaneguardcommandtoasttool
★ 1v0.3.0no licenseupdated 2026-10-12WeaponizedLego/claude-code-mods/change-card
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · change-card
│ ┃ Changes ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ No change card yet │ ⏺ Read(src/auth.ts) │ ┃ │ Run /changes to have Claude explain what │ ⎿ Read 6 lines │ ┃ │ changed on this branch. │ ⏺ Update(src/auth.ts) │ ┃ │ 2 files · +14 −3 · since a1b2c3d (uncom… │ ⎿ 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 │ │ › /changes │ ⎿ change-card: Asking Claude for a change card. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes
╭──────────────────────────────────────────────────────────╮ │ No change card yet │ │ Run /changes to have Claude explain what changed on this │ │ branch. │ │ 2 files · +14 −3 · since a1b2c3d (uncommitted work only) │ ╰──────────────────────────────────────────────────────────╯
README

claude-code-mods

My Claude Code mods, one folder per mod. Needs Claude Code 2.1.287 or newer.

ModWhat it does
usage-bandA 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-progressReads a phased plan into a live progress tree: phases, steps, elapsed time and an estimate of what is left
test-userA 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-cardA 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.

usage-band in the desktop app

Install

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.

plan-progress

plan-progress in the desktop app

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.

test-user

test-user in the desktop app

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.

change-card

change-card in the desktop app

/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.

Adding a mod

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 ..

Source 4 files
hooks/register.tsx 405 lines
1import { 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
405
hooks/card.ts 173 lines
1import 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}
173
hooks/look.ts 170 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
170
types/index.d.ts 65 lines
1export 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