Asks before git push and wrangler --remote, showing the folder, the branch, the remote and the commits that will go out. Understands git -C dir and cd dir &&…

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.ts 60 lines1import type { Register } from 'claude-code'
2
3import { classify, repoDir } from './gate'
4
5/**
6 * Remote Gate: asks before `git push` and `wrangler ... --remote`, showing the remote, the branch
7 * and the commits that will go out. The repository shown is the one the command works in
8 * (`git -C dir push`, `cd dir && git push`), not only the session's own.
9 * Fail closed: a dismissed or unavailable dialog refuses the command.
10 */
11export const register: Register = on => {
12 let home = ''
13
14 on('session.start', async ($, e, next) => {
15 home = String((await $.env.get('HOME')) ?? '')
16
17 return next(e)
18 })
19
20 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
21 const command = String(e.command ?? '')
22 const kind = classify(command)
23
24 if (kind === undefined) return next(e)
25
26 let approved = false
27
28 try {
29 let info = ''
30
31 if (kind !== 'remote-db') {
32 const dir = repoDir(command, home)
33 const where = dir ? ['-C', dir] : []
34 const remote = await $.process.run(['git', ...where, 'remote', '-v'])
35 const branch = await $.process.run(['git', ...where, 'rev-parse', '--abbrev-ref', 'HEAD'])
36 const out = await $.process.run(['git', ...where, 'log', '--oneline', '@{u}..HEAD'])
37 const urls = remote.stdout.split('\n').filter(l => l.includes('(push)')).join('; ')
38 const commits = out.exitCode === 0 ? out.stdout.trim() || 'немає нових комітів' : 'upstream не задано'
39
40 // the dialog collapses line breaks, so the parts are labelled sentences
41 info = ` Тека: ${dir ?? 'тека сесії'}. Гілка: ${branch.stdout.trim() || '?'}. Remote: ${urls || '?'}. Піде: ${commits.split('\n').slice(0, 12).join(' | ')}.`
42 }
43
44 const label = kind === 'force-push' ? 'FORCE-PUSH' : kind === 'push' ? 'git push' : 'wrangler --remote (бойові дані)'
45 const answer = await $.ui.ask(`Remote Gate: ${label}. Команда: ${command.slice(0, 200)}.${info} Виконати?`, {
46 options: ['Виконати', 'Скасувати'],
47 header: 'Remote Gate',
48 })
49
50 approved = answer === 'Виконати'
51 } catch {
52 // dismissed or headless: fail closed
53 }
54
55 if (!approved) return { deny: 'remote-gate: не підтверджено. Не повторюй команду без нової згоди користувача.' }
56
57 return next(e)
58 })
59}
60hooks/gate.ts 63 lines1export type Kind = 'push' | 'force-push' | 'remote-db'
2
3/** Drop heredoc bodies (documents/code fed to another program) unless a shell reads them. */
4function stripHeredocs(cmd: string): string {
5 const out: string[] = []
6 let end: string | null = null
7 let keep = false
8
9 for (const line of cmd.split('\n')) {
10 if (end !== null) {
11 if (line.trim() === end) end = null
12 else if (keep) out.push(line)
13
14 continue
15 }
16
17 out.push(line)
18
19 const m = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1/.exec(line)
20
21 if (m) {
22 end = m[2]!
23 keep = /\b(ba|z|da|)sh\s*(-\w+\s*)*(-\s*)?<</.test(line)
24 }
25 }
26
27 return out.join('\n')
28}
29
30/** Quoted strings emptied: `echo "git push"` and a commit message that mentions it are not a push. */
31const maskQuotes = (s: string) => s.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, '""')
32
33export function classify(command: string): Kind | undefined {
34 const cmd = maskQuotes(stripHeredocs(command))
35
36 if (/\bgit\b[^|;&]*\bpush\b/.test(cmd)) {
37 return /--force\b|--force-with-lease|--force-if-includes|(^|\s)-f(\s|$)|\+\S+:|\s\+\S+/.test(cmd) ? 'force-push' : 'push'
38 }
39
40 if (/\bwrangler\b/.test(cmd) && /--remote\b/.test(cmd)) return 'remote-db'
41
42 return undefined
43}
44
45const unquote = (s: string) => s.replace(/^["']|["']$/g, '')
46
47/**
48 * The folder the command works in: `git -C <dir> push` or a leading `cd <dir> &&`.
49 * Without it the gate would describe the session's own repository, not the one being pushed.
50 */
51export function repoDir(command: string, home: string): string | undefined {
52 const cmd = stripHeredocs(command)
53 const c = /\bgit\s+(?:--?[\w-]+(?:=\S+)?\s+)*-C\s+("[^"]+"|'[^']+'|[^\s"']+)/.exec(cmd)
54 const cd = /^\s*cd\s+("[^"]+"|'[^']+'|[^\s;&"']+)\s*(?:&&|;)/.exec(cmd)
55 const raw = c?.[1] ?? cd?.[1]
56
57 if (!raw) return undefined
58
59 const dir = unquote(raw)
60
61 return dir.startsWith('~/') && home ? `${home}${dir.slice(1)}` : dir
62}
63