Detects Claude repeating the same failing tool call (3+ identical failures, or 4+ with the same error) and tells it to change strategy and tell the user. Never…

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 32 lines1import type { Register } from 'claude-code'
2
3import { errorKey, nudge, signature, Streaks } from './loop'
4
5/**
6 * Retry Analyzer: spots Claude hammering a failing call and tells it to change strategy.
7 * The note rides in `context` (or the deny text) so the model relays it; nothing is blocked.
8 */
9export const register: Register = on => {
10 const streaks = new Streaks()
11
12 on('prompt.submit', ($, e, next) => {
13 streaks.reset() // the user intervened: start counting afresh
14
15 return next(e)
16 })
17
18 on('tool.call', async ($, e, next) => {
19 const ran = await next(e)
20 const denied = ran.deny !== undefined
21 const failed = denied || ran.isError === true
22 const err = denied ? `deny|${String(ran.deny).slice(0, 80)}` : errorKey(String(e.tool), ran.isError === true ? ran.text : '')
23 const hit = streaks.record(`${e.tool}|${signature(e as Record<string, unknown>)}`, err, failed)
24
25 if (!hit) return ran
26
27 return denied
28 ? { deny: `${ran.deny}\n\n${nudge(hit)}` }
29 : { ...ran, context: [...(ran.context ?? []), nudge(hit)] }
30 })
31}
32hooks/loop.ts 47 lines1export const SAME_CALL = 3
2export const SAME_ERROR = 4
3
4/** Stable key of a call: tool plus its arguments, minus the per-call ids and the free-text `description` (it changes between identical retries). */
5export function signature(e: Record<string, unknown>): string {
6 const rest = Object.fromEntries(Object.entries(e).filter(([k]) => k !== 'tool_use_id' && k !== 'agentId' && k !== 'description'))
7
8 return JSON.stringify(rest).replace(/\s+/g, ' ').slice(0, 400)
9}
10
11export const errorKey = (tool: string, text: unknown) =>
12 `${tool}|${String(text ?? '').replace(/\d+/g, '#').replace(/\s+/g, ' ').slice(0, 80)}`
13
14export class Streaks {
15 private calls = new Map<string, number>()
16 private errors = new Map<string, number>()
17
18 /** Record one outcome; returns the count that tripped a threshold, or 0. */
19 record(sig: string, err: string, failed: boolean): { count: number; why: 'call' | 'error' } | null {
20 if (!failed) {
21 this.calls.delete(sig)
22 return null
23 }
24
25 const c = (this.calls.get(sig) ?? 0) + 1
26 const x = (this.errors.get(err) ?? 0) + 1
27
28 this.calls.set(sig, c)
29 this.errors.set(err, x)
30
31 if (c >= SAME_CALL) return { count: c, why: 'call' }
32 if (x >= SAME_ERROR) return { count: x, why: 'error' }
33
34 return null
35 }
36
37 reset() {
38 this.calls.clear()
39 this.errors.clear()
40 }
41}
42
43export const nudge = (hit: { count: number; why: 'call' | 'error' }) =>
44 `retry-analyzer: ${
45 hit.why === 'call' ? `this exact call has now failed ${hit.count} times in a row` : `the same error has now appeared ${hit.count} times`
46 }. Stop repeating it: re-read the error, change the approach (different command or inputs, or investigate the cause first), and tell the user in one line what you are changing.`
47