SLOPSHOPPER

stuck-detector

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

newguardtoast
v0.1.0no licenseupdated 2026-10-02cprentice9/ccmods/stuck-detector
A shopper browsing a rack in a slop shop
README

stuck-detector

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.

What it does

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:

  • Shows a toast naming the command.
  • Adds a note to the command's result telling Claude to stop fixing and ask for a second opinion.

A pass resets the count. The main session and each subagent keep separate counts.

The note

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.

Loading it

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.

Checking it

From the ccmods folder:

claude plugin validate stuck-detector
claude plugin test stuck-detector
Source 1 files
hooks/register.ts 67 lines
1import 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