Notices when the same test command fails twice in a row and tells the model to ask for a second opinion.

Notices when Claude runs the same test command and it fails twice in a row, and tells Claude to stop and ask for a second opinion.
After every Bash command, it checks whether the command ran a test suite: pytest, npm, pnpm, yarn or bun test, vitest, jest, go test, cargo test, rspec, phpunit, dotnet test, make test and a few more. It finds the test command anywhere in a chain, so cd app && npm test counts and grep pytest notes.txt doesn't.
On the second failure in a row of the same command, it does two things:
A pass resets the count. The main session and each subagent keep separate counts.
The note is the note option in .claude-plugin/plugin.json. The default asks Claude to brief the second-opinion and second-opinion-sonnet agents, which are custom agent types. If you don't have agents with those names, change the default to whatever you want Claude to do.
It isn't on a marketplace. Clone the repo and load the folder:
git clone https://github.com/cprentice9/ccmods.git
claude --plugin-dir ccmods/stuck-detector
The ccmods README explains how to load it in every session, including desktop app sessions.
From the ccmods folder:
claude plugin validate stuck-detector
claude plugin test stuck-detectorhooks/register.ts 67 lines1import type { Register } from 'claude-code'
2
3// Commands that run a test suite. Each is matched at the start of a shell
4// segment, so `cd app && npm test` counts and `grep pytest notes.txt` does not.
5const TEST_COMMANDS = [
6 'pytest',
7 'python -m pytest',
8 'python3 -m pytest',
9 'uv run pytest',
10 'npm test',
11 'npm run test',
12 'pnpm test',
13 'pnpm run test',
14 'yarn test',
15 'yarn run test',
16 'bun test',
17 'bun run test',
18 'npx vitest',
19 'npx jest',
20 'vitest',
21 'jest',
22 'go test',
23 'cargo test',
24 'mix test',
25 'rspec',
26 'bundle exec rspec',
27 'phpunit',
28 'vendor/bin/phpunit',
29 'dotnet test',
30 'make test',
31 'claude plugin test',
32]
33
34const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
35
36const TEST_PATTERN = new RegExp(
37 `(?:^|[;&|(]\\s*)(?:\\w+=\\S*\\s+)*(?:${TEST_COMMANDS.map(c =>
38 c.split(' ').map(escape).join('\\s+'),
39 ).join('|')})(?=$|[\\s;&|)])`,
40)
41
42export const register: Register = (on, options) => {
43 const failures = new Map<string, number>()
44
45 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
46 const ran = await next(e)
47 const command = String(e.command).trim().replace(/\s+/g, ' ')
48 if (ran.deny !== undefined || !TEST_PATTERN.test(command)) return ran
49
50 const key = `${e.agentId ?? 'main'}\n${command}`
51 if (ran.isError !== true) {
52 failures.delete(key)
53 return ran
54 }
55
56 const count = (failures.get(key) ?? 0) + 1
57 failures.set(key, count)
58 if (count !== 2) return ran
59
60 // Name the test command itself, not a `cd somewhere &&` in front of it.
61 const shown = command.slice(command.search(TEST_PATTERN)).replace(/^[;&|(\s]+/, '')
62 const short = shown.length > 50 ? `${shown.slice(0, 49)}…` : shown
63 $.ui.toast(`Failed twice in a row: ${short}`)
64 return { ...ran, context: [...(ran.context ?? []), String(options.note)] }
65 })
66}
67