The mini Clippy beside the band above the prompt: calm / worried / panic by context fill, with random Ukrainian remarks; /handoff puts a HANDOFF.md/CONTEXT.md…

Fourteen Claude Code mods, packaged as one plugin marketplace (qa-mods). Made for a manual QA workflow: guard rails around risky commands, evidence for what was tested, and a status band above the prompt.
Run in your own Terminal (a sandboxed Claude session cannot write ~/.claude).
claude plugin marketplace add oleksandrkodua/qa-mods
claude plugin install hud@qa-mods
Replace hud with any plugin from the table.
To install everything from a local clone, or to set up a fresh machine, see INSTALL.md and install-pack.sh.
| Plugin | What it does |
|---|---|
sandbox-guard | Denies writes to protected Claude config paths and hands back a Terminal command |
remote-gate | Asks before git push and wrangler --remote, showing folder, branch, remote and the commits that will go out |
blast-radius | Shows what a risky Bash command will touch before it runs |
retry-analyzer | Detects repeated identical failing tool calls and tells Claude to change strategy |
secret-redactor | Masks tokens and keys in tool output |
evidence-saver | Saves screenshots from tool results to a folder you pick |
replay-theater | /replay: step through the session's file edits one by one |
verification-guard | Checks claims like "tests pass" against tool calls that actually ran |
handoff | Offers /handoff as the context fills up; fills the prompt box with a HANDOFF.md prompt |
notify | A toast and a macOS notification when a long command or turn finishes |
quick-actions | Compact and Clear buttons, each asks to confirm first |
hud | Context fill and rate-limit windows above the prompt, a кеш 59:48 chip (m:ss) while the prompt cache is warm, five buttons, and /hud with the figures as text |
next-steps-uk | Fork of next-steps (MIT, Thariq Shihipar): suggested next prompts are always in Ukrainian. Install instead of next-steps |
plan-progress | Fork of plan-progress 0.7.6 (zycck, MIT): live progress bars above the prompt. Install instead of plan-progress@zycck-mods |
claude plugin test hud # from the repo root
cd hud && node --test tests/format.test.mjs tests/handoff-prompt.test.mjs
A bare node --test also picks up register.test.ts, which only runs under claude plugin test.
No license file for the original mods yet. The two forks keep their upstream MIT notices: next-steps-uk/NOTICE.md and plan-progress/NOTICE.md (plus plan-progress/LICENSE.upstream).
hooks/register.tsx 154 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { clippyFace, clippyLine, clippyMood, clippySvg } from './clippy'
5import { HANDOFF_PROMPT, hintLevel, hintLine } from './logic'
6import { nextDelay, pickPhrase } from './phrases'
7
8const remark = atom({ plugin: 'handoff', key: 'remark' } as const, '')
9
10/**
11 * Handoff: the `/handoff` command, the mini Clippy with its mood and random remarks beside the band above the prompt
12 * (the context figure and the button are drawn by the hud band since 0.6.0), and the VS Code hint. The mood: calm below
13 * `warnPercent` (50), worried from it, panic from `handoffAtPercent` (80).
14 * Prototype: a mini Clippy beside the band: calm, worried from `warnPercent`, panic from
15 * `handoffAtPercent`, with a short line once it is worried. Desktop draws an SVG, the terminal a one-line face.
16 * From `handoffAtPercent` (default 80) VS Code, which draws no band, gets the hint after the final answer.
17 * VS Code draws no band: a hint line is added to the end of the final answer instead, once at the
18 * threshold and again for every further 5 points. `/handoff` works everywhere: it puts the prompt
19 * into the input box (nothing is sent), or, where the box cannot be filled, copies it and prints it.
20 */
21export const register: Register = (on, options) => {
22 const at = Number(options?.handoffAtPercent) > 0 ? Number(options.handoffAtPercent) : 80
23 const warn = Number(options?.warnPercent) > 0 ? Number(options.warnPercent) : 50
24 const everySeconds = options?.remarkEverySeconds === 0 ? 0 : Number(options?.remarkEverySeconds) > 0 ? Number(options.remarkEverySeconds) : 120
25 const SHOW_MS = 9000
26 let lastRemark = ''
27 let nextAt = 0
28 let timerOn = false
29 let percent = 0
30 let surface = ''
31 let hinted = 0
32
33 on('session.start', async ($, e, next) => {
34 surface = String(e.surface ?? '')
35
36 // a name already taken must not break the mod
37 await $.command.register({ name: 'handoff', description: 'Put a HANDOFF.md/CONTEXT.md prompt in the box (or copy it where the box cannot be filled)' }).catch(() => undefined)
38
39 return next(e)
40 })
41
42 on('session.measure', async ($, e, next) => {
43 percent = Math.round(e.context.percent ?? 0)
44
45 if (percent < at) hinted = 0
46
47 // random remarks of the Clippy: a check every 5 s, a remark every `remarkEverySeconds` (x0.5..1.5), shown for 9 s.
48 // Started here, not in session.start: that event may not reach the mod (a resumed session), a measure always does.
49 if (!timerOn && everySeconds > 0 && surface !== 'vscode') {
50 timerOn = true
51 nextAt = (await $.clock.now()) + nextDelay(everySeconds, Math.random)
52 $.clock.every(5000, () => {
53 void (async () => {
54 const now = await $.clock.now()
55
56 if (now < nextAt) return
57
58 lastRemark = pickPhrase(clippyMood(percent, warn, at), lastRemark, Math.random)
59 nextAt = now + nextDelay(everySeconds, Math.random)
60 await update($, remark, () => lastRemark)
61 $.clock.after(SHOW_MS, () => void update($, remark, () => ''))
62 })()
63 })
64 }
65
66 return next(e)
67 })
68
69 on('command.run', { command: 'handoff' }, async ($, e) => {
70 // the host puts the plugin's name before a toast and a reply, so the messages do not repeat it.
71 // A draft the person already typed is kept: the prompt goes after it instead of over it.
72 const draft = await $.prompt.read().catch(() => ({ text: '' }))
73 const hasDraft = String(draft?.text ?? '').trim() !== ''
74 const filled = await $.prompt
75 .fill({ text: hasDraft ? `\n\n${HANDOFF_PROMPT}` : HANDOFF_PROMPT, mode: hasDraft ? 'append' : 'replace' })
76 .catch(() => ({ isFilled: false }))
77
78 if (filled.isFilled) {
79 const note = hasDraft ? 'промпт додано після твого тексту в полі вводу. Перевір і натисни Enter.' : 'промпт у полі вводу. Перевір і натисни Enter.'
80
81 // pressed from the hud band: a toast is enough, the transcript stays clean
82 if (e.origin.kind === 'plugin') {
83 $.ui.toast(note[0]!.toUpperCase() + note.slice(1))
84
85 return {} // no text: an empty string left a blank "handoff:" reply line in the chat
86 }
87
88 return { text: note[0]!.toUpperCase() + note.slice(1) }
89 }
90
91 const copied = await $.ui.copy({ text: HANDOFF_PROMPT }).catch(() => ({ isCopied: false }))
92
93 return { text: `${copied.isCopied ? 'Промпт скопійовано, встав його в поле вводу.' : 'Поле вводу зараз не заповнити, скопіюй промпт нижче.'}\n\n${HANDOFF_PROMPT}` }
94 })
95
96 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
97 const below = await next(e)
98
99 if (e.props.hasSurvey) return below
100
101 const ui: any = $.ui.resolve(e)
102 const { Box, Text, Svg } = ui
103 const isDesktop = e.surface === 'desktop'
104 const mood = clippyMood(percent, warn, at)
105 const talking = await read($, remark)
106 const say = talking || clippyLine(mood, percent)
107 // prototype: a mini Clippy beside the band. Desktop draws an SVG; the terminal has no Svg, so a one-line face.
108 const pic = isDesktop ? <Svg source={clippySvg(mood)} alt={`Clippy: ${mood}`} width={46} height={58} /> : <Text>{clippyFace(mood)}</Text>
109
110 // the context line and the button live in the hud band now; here: the remark of the Clippy, then everything below
111 const band = (
112 <Box flexDirection="column">
113 {say ? <Text color={percent >= at ? '#FF1F1F' : '#E09A1E'}>{say}</Text> : null}
114 {below}
115 </Box>
116 )
117
118 return (
119 <Box flexDirection="row" gap={2}>
120 {pic}
121 {band}
122 </Box>
123 )
124 })
125
126 // VS Code draws no band: the hint rides on the final answer of the main loop
127 on('turn.step', async function* ($, e, next) {
128 const stream = next(e)
129 let last = -1
130 let sawTool = false
131
132 while (true) {
133 const { value, done } = await stream.next()
134
135 if (done) return value
136
137 if (value.kind === 'text') {
138 last = value.index
139 } else if (value.kind === 'tool') {
140 sawTool = true
141 } else if (value.kind === 'stop' && surface === 'vscode' && e.agentId === undefined && !sawTool && value.stopReason === 'end_turn' && last >= 0) {
142 const level = hintLevel(percent, at)
143
144 if (level > hinted) {
145 hinted = level
146 yield { kind: 'text', index: last, text: `\n\n${hintLine(percent)}` }
147 }
148 }
149
150 yield value
151 }
152 })
153}
154hooks/clippy.ts 34 lines1export type ClippyMood = 'calm' | 'worried' | 'panic'
2
3/** The mood follows the context fill: calm below `warn`, worried from it, panic from `at`. */
4export const clippyMood = (percent: number, warn: number, at: number): ClippyMood => (percent >= at ? 'panic' : percent >= warn ? 'worried' : 'calm')
5
6/** Terminal has no `Svg` element: a one-line face stands in for the picture. */
7export const clippyFace = (m: ClippyMood): string => (m === 'panic' ? '(°□°)' : m === 'worried' ? '(•_•)' : '(•‿•)')
8
9/** The speech-bubble line; empty while calm. Plain text, no emoji. */
10export const clippyLine = (m: ClippyMood, percent: number): string =>
11 m === 'panic' ? `Місце закінчується (${percent}%). Збери handoff, поки не пізно.` : m === 'worried' ? 'Контекст уже заповнюється. Подумай про handoff.' : ''
12
13const BODY = '<path d="M45 200 L45 20 Q45 -5 70 -5 Q95 -5 95 20 L95 175 Q95 195 75 195 Q55 195 55 175 L55 40 Q55 28 70 28 Q85 28 85 40 L85 160" fill="none" stroke="#9aa3ad" stroke-width="10" stroke-linecap="round"/>'
14
15const FACE: Record<ClippyMood, string> = {
16 calm:
17 '<circle cx="58" cy="40" r="10" fill="#fff" stroke="#111" stroke-width="3"/><circle cx="82" cy="40" r="10" fill="#fff" stroke="#111" stroke-width="3"/>' +
18 '<circle cx="60" cy="41" r="4" fill="#111"/><circle cx="84" cy="41" r="4" fill="#111"/>' +
19 '<path d="M60 64 Q70 72 80 64" fill="none" stroke="#111" stroke-width="3" stroke-linecap="round"/>',
20 worried:
21 '<circle cx="58" cy="40" r="11" fill="#fff" stroke="#111" stroke-width="3"/><circle cx="82" cy="40" r="11" fill="#fff" stroke="#111" stroke-width="3"/>' +
22 '<circle cx="55" cy="42" r="4" fill="#111"/><circle cx="79" cy="42" r="4" fill="#111"/>' +
23 '<path d="M50 24 L64 29 M88 24 L76 29" stroke="#111" stroke-width="3" stroke-linecap="round"/>' +
24 '<path d="M60 68 Q70 62 80 68" fill="none" stroke="#111" stroke-width="3" stroke-linecap="round"/>',
25 panic:
26 '<circle cx="58" cy="40" r="12" fill="#fff" stroke="#111" stroke-width="3"/><circle cx="82" cy="40" r="12" fill="#fff" stroke="#111" stroke-width="3"/>' +
27 '<circle cx="58" cy="40" r="3" fill="#111"/><circle cx="82" cy="40" r="3" fill="#111"/>' +
28 '<ellipse cx="70" cy="68" rx="7" ry="9" fill="#7f1d1d" stroke="#111" stroke-width="3"/>' +
29 '<path d="M100 20 Q106 30 100 36 Q94 30 100 20" fill="#378ADD"/>',
30}
31
32/** A small Clippy as an SVG document for the desktop `Svg` element. */
33export const clippySvg = (m: ClippyMood): string => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-30 -20 200 250">${BODY}${FACE[m]}</svg>`
34hooks/logic.ts 14 lines1export const HANDOFF_PROMPT = `Підготуй передачу контексту в новий чат. Нічого не коміть і не деплой.
2
31. Онови (або створи в корені проєкту) HANDOFF.md і CONTEXT.md:
4 - HANDOFF.md: що зроблено в цій сесії, що відкрито і що перевірити наступним кроком, що НЕ відкочувати.
5 - CONTEXT.md: рішення і причини, додай записи поруч з наявними, нічого не стирай.
6 - Не вигадуй: чого не підтвердив, познач як «не перевірено» і напиши, де шукав.
72. Покажи diff обох файлів.
83. Наприкінці дай короткий промпт для нового чату: «Спершу прочитай HANDOFF.md, потім CONTEXT.md», плюс один рядок, з якого кроку продовжити.`
9
10/** 0 below the threshold, then 1 at it and +1 for every further 5 points (80 → 1, 85 → 2, 90 → 3). */
11export const hintLevel = (percent: number, at: number): number => (percent >= at ? 1 + Math.floor((percent - at) / 5) : 0)
12
13export const hintLine = (percent: number): string => `⏱ Контекст ${percent}%: скоро не вистачить місця. Щоб передати роботу в новий чат, введи /handoff: підготую HANDOFF.md і CONTEXT.md.`
14hooks/phrases.ts 40 lines1import type { ClippyMood } from './clippy'
2
3/** Random remarks, by mood. Short plain Ukrainian, QA flavoured, no emoji, nobody targeted. Edit freely. */
4export const PHRASES: Record<ClippyMood, string[]> = {
5 calm: [
6 'Баг не вовк, у прод не втече.',
7 'Скриншот-доказ уже збережено?',
8 'Severity оцінюй за ТЗ, а не за гучністю бага.',
9 'Випий води. Тест-кейси почекають.',
10 'Автоматизація це результат ручного QA, а не його заміна.',
11 'Граничні значення перевірив? Мінус один, нуль, максимум.',
12 'Це баг чи фіча? Відкрий ТЗ.',
13 '«На моїй машині працює» не є кроками відтворення.',
14 'Один сценарій, одна перевірка. Не складай все в один тест.',
15 'Я лише скріпка, але глянь на цей крок ще раз.',
16 'Емулятор viewport не замінює справжній пристрій.',
17 ],
18 worried: [
19 'Контекст уже заповнюється. Може, час на handoff?',
20 'Тримайся: ще є місце, але вже не безмежно.',
21 'Закоміть зроблене, поки є запас контексту.',
22 ],
23 panic: [
24 'Місце майже скінчилось. Handoff, швидко!',
25 'Збери HANDOFF.md, поки не пізно.',
26 'Ще трохи і доведеться починати з нуля. Handoff!',
27 ],
28}
29
30/** A phrase for the mood, never the same as the last one (when the pool has more than one). */
31export function pickPhrase(mood: ClippyMood, last: string, rnd: () => number): string {
32 const pool = PHRASES[mood].filter(p => p !== last)
33 const from = pool.length > 0 ? pool : PHRASES[mood]
34
35 return from[Math.min(from.length - 1, Math.floor(rnd() * from.length))]
36}
37
38/** Milliseconds until the next remark: the base interval times a random 0.5..1.5. */
39export const nextDelay = (everySeconds: number, rnd: () => number): number => Math.round(everySeconds * 1000 * (0.5 + rnd()))
40types/index.d.ts 11 lines1export type Remark = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 handoff: {
6 // the Clippy's current random remark; empty while it is quiet
7 remark: string
8 }
9 }
10}
11