SLOPSHOPPER

remote-gate

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 &&…

newguardprocess
v0.1.1no licenseupdated 2026-10-08oleksandrkodua/qa-mods/remote-gate
A shopper browsing a rack in a slop shop
README

qa-mods

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.

Install

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.

Plugins

PluginWhat it does
sandbox-guardDenies writes to protected Claude config paths and hands back a Terminal command
remote-gateAsks before git push and wrangler --remote, showing folder, branch, remote and the commits that will go out
blast-radiusShows what a risky Bash command will touch before it runs
retry-analyzerDetects repeated identical failing tool calls and tells Claude to change strategy
secret-redactorMasks tokens and keys in tool output
evidence-saverSaves screenshots from tool results to a folder you pick
replay-theater/replay: step through the session's file edits one by one
verification-guardChecks claims like "tests pass" against tool calls that actually ran
handoffOffers /handoff as the context fills up; fills the prompt box with a HANDOFF.md prompt
notifyA toast and a macOS notification when a long command or turn finishes
quick-actionsCompact and Clear buttons, each asks to confirm first
hudContext 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-ukFork of next-steps (MIT, Thariq Shihipar): suggested next prompts are always in Ukrainian. Install instead of next-steps
plan-progressFork of plan-progress 0.7.6 (zycck, MIT): live progress bars above the prompt. Install instead of plan-progress@zycck-mods

Tests

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.

License

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

Source 2 files
hooks/register.ts 60 lines
1import 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}
60
hooks/gate.ts 63 lines
1export 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