SLOPSHOPPER

spiral-stop

Stops fix loops: when the same error returns after 3 fix attempts, tells you and Claude to stop and rethink

newbandguardcommandtoastprompt
v0.1.0MITupdated 2026-10-020xGondarxyz/claude-code-mods/spiral-stop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spiral-stop
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /spiral ⎿ spiral-stop: 1x error: expected , got | fail ⎿ spiral-stop: Band: down ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

spiral-stop

A Claude Code mod that stops fix loops. Claude runs a test, edits code, runs it again, gets the same error, and repeats. This mod notices. After 3 fix attempts with the same error, it tells you and tells Claude to stop and name the assumption that might be wrong.

Install

/plugin marketplace add 0xGondarxyz/claude-code-mods
/plugin install spiral-stop@claude-code-mods

Or try it without installing:

git clone https://github.com/0xGondarxyz/claude-code-mods
claude --plugin-dir claude-code-mods/spiral-stop

Mods are not sandboxed. They run with the same access as Claude Code. Read the source before you install any mod, including this one.

What it does

  1. Every failed Bash call gets an error signature. The mod keeps the first 2 error-looking lines and removes numbers, hex ids, durations, timestamps, :line:col and path prefixes. If no line looks like an error, it uses the last output line.
  2. A fix attempt is a Write, Edit, MultiEdit or NotebookEdit call, or a new prompt from you. The same signature counts again only when a fix attempt came in between. Running the same failing command twice with no edit counts once.
  3. At count 3 the mod:
  4. adds a note for Claude: stop changing code, name the wrong assumption in 1 or 2 sentences, ask you one diagnostic question.
  5. shows a band above the prompt: Spiral: same error after 3 fixes: <signature> [Dismiss].
  6. shows one toast.
  7. A second trigger: 3 prompts in a row from you that say it is still broken ("still not working", "same error", "didn't fix it"). Any other prompt resets this counter.
  8. When the same command later succeeds, its signature is dropped (and the band, if it was for that signature).

The note reaches Claude as context after the failing tool result (or after your prompt for the prompt trigger). It never adds a conversation row.

Commands

  • /spiral prints the top 5 tracked signatures with counts, and whether the band is up.
  • /spiral reset clears all signatures and the band.

The band has a Dismiss button.

Limits

  • The counters live in memory. A hot reload or a new session starts them at zero.
  • Failure means the Bash tool reported an error (non-zero exit). A command that prints errors and exits 0 is not tracked.
  • The signature is a heuristic. Two different errors with the same first lines look the same.
  • Fixed values: threshold 3, top 5, 200 characters. No options.

License

MIT

Source 3 files
hooks/register.tsx 107 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Band } from '../types'
5import {
6  TOAST,
7  bandText,
8  errorInstruction,
9  newTracker,
10  noteFailure,
11  noteFix,
12  noteSuccess,
13  notePrompt,
14  promptInstruction,
15  reset,
16  statusText,
17} from './spiral'
18
19const band = atom({ plugin: 'spiral-stop', key: 'band' } as const, null as Band | null)
20
21const FIX_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
22
23async function toast($: EngineInterface): Promise<void> {
24  try {
25    await $.ui.toast(TOAST)
26  } catch {}
27}
28
29export const register: Register = on => {
30  const tracker = newTracker()
31
32  on('session.start', async ($, e, next) => {
33    try {
34      await $.command.register({ name: 'spiral', description: 'Show tracked error loops. "/spiral reset" clears them.' })
35    } catch {}
36    return next(e)
37  })
38
39  on('command.run', { command: 'spiral' }, async ($, e) => {
40    if (e.args.trim() === 'reset') {
41      reset(tracker)
42      await update($, band, () => null)
43      return { text: 'spiral-stop: cleared.' }
44    }
45    return { text: statusText(tracker, (await read($, band)) !== null) }
46  })
47
48  on('prompt.submit', async ($, e, next) => {
49    try {
50      if (!notePrompt(tracker, e.text)) return next(e)
51      await update($, band, () => ({ kind: 'prompt' as const, signature: '' }))
52      await toast($)
53      return next({ ...e, context: [...(e.context ?? []), promptInstruction] })
54    } catch {
55      return next(e)
56    }
57  })
58
59  on('tool.call', async ($, e, next) => {
60    if (FIX_TOOLS.has(e.tool)) {
61      noteFix(tracker)
62      return next(e)
63    }
64    if (e.tool !== 'Bash') return next(e)
65    const ran = await next(e)
66    try {
67      if (ran.deny !== undefined) return ran
68      if (ran.isError !== true) {
69        const dropped = noteSuccess(tracker, e.command)
70        const current = await read($, band)
71        if (current !== null && dropped.includes(current.signature)) await update($, band, () => null)
72        return ran
73      }
74      const f = noteFailure(tracker, e.command, typeof ran.result === 'string' ? ran.result : (ran.text ?? ''))
75      if (!f.trigger) return ran
76      const text = errorInstruction(f.signature)
77      await update($, band, () => ({ kind: 'error' as const, signature: f.signature }))
78      await toast($)
79      return { ...ran, context: [...(ran.context ?? []), text] }
80    } catch {
81      return ran
82    }
83  })
84
85  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
86    const inner = await next(e)
87    try {
88      if (e.props.hasSurvey) return inner
89      const b = await read($, band)
90      if (b === null) return inner
91
92      const { Box, Button, Text } = $.ui.resolve(e)
93      return (
94        <Box flexDirection="column">
95          <Box>
96            <Text color="yellow">{bandText(b.kind, b.signature, e.props.bodyColumns)} </Text>
97            <Button key="spiral-dismiss" label="Dismiss" onPress={() => update($, band, () => null)} />
98          </Box>
99          {inner.type !== 'engine' ? inner : null}
100        </Box>
101      )
102    } catch {
103      return inner
104    }
105  })
106}
107
hooks/spiral.ts 136 lines
1export const TRIGGER_AT = 3
2
3export type Entry = { count: number; seq: number; cmd: string; triggered: boolean }
4
5export type Tracker = {
6  sigs: Map<string, Entry>
7  /** Bumped by every fix attempt (an edit tool call or a new user prompt). */
8  seq: number
9  promptStreak: number
10}
11
12export const newTracker = (): Tracker => ({ sigs: new Map(), seq: 0, promptStreak: 0 })
13
14const ERROR_LINE = /error|fail|exception|traceback|panic|assert|expected/i
15
16const WRAPPER_LINE = /^(error:\s*)?exit code:?\s*\d*$/i
17
18const STILL_BROKEN: RegExp[] = [
19  /\b(still|again)\b[^.!?\n]{0,40}\b(broken|failing|fails|failed|errors?|not working|doesn'?t work|same)\b/i,
20  /\b(broken|failing|fails|failed|errors?|not working|doesn'?t work)\b[^.!?\n]{0,30}\b(still|again)\b/i,
21  /\bsame error\b/i,
22  /\bdidn'?t fix\b/i,
23  /\bnot fixed\b/i,
24]
25
26export const normalizeCommand = (cmd: string): string => cmd.trim().replace(/\s+/g, ' ')
27
28export function normalizeLine(line: string): string {
29  return line
30    .replace(/\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?/g, '')
31    .replace(/\b\d{1,2}:\d{2}:\d{2}(\.\d+)?/g, '')
32    .replace(/\b\d+(\.\d+)?\s?(ms|us|µs|ns|sec|secs|seconds?|s|min|mins|minutes?)\b/gi, '')
33    .replace(/\b0x[0-9a-f]+\b|\b[0-9a-f]{8,}\b/gi, '')
34    .replace(/:\d+(:\d+)?/g, '')
35    .replace(/(?:\/[\w.@+~-]+)*\/(?=[\w.@+~-])/g, '')
36    .replace(/\d+/g, '')
37    .replace(/\s+/g, ' ')
38    .trim()
39}
40
41/** The first 2 error-looking lines (or the last non-empty line), normalized, max 200 chars. */
42export function signature(output: string): string {
43  const lines = output.split('\n').map(l => l.trim()).filter(l => l !== '' && !WRAPPER_LINE.test(l))
44  const errors = lines.filter(l => ERROR_LINE.test(l)).map(normalizeLine).filter(l => l !== '')
45  const picked = errors.length > 0 ? errors.slice(0, 2) : [normalizeLine(lines[lines.length - 1] ?? '')]
46  return picked.join(' | ').slice(0, 200)
47}
48
49export function isStillBroken(prompt: string): boolean {
50  return STILL_BROKEN.some(re => re.test(prompt))
51}
52
53export function noteFix(t: Tracker): void {
54  t.seq += 1
55}
56
57export type Failure = { signature: string; count: number; trigger: boolean }
58
59export function noteFailure(t: Tracker, command: string, output: string): Failure {
60  const cmd = normalizeCommand(command)
61  const code = /exit code:?\s*(\d+)/i.exec(output)?.[1] ?? '?'
62  const sig = signature(output) || `${cmd} exit ${code}`.slice(0, 200)
63  const entry = t.sigs.get(sig)
64  if (entry === undefined) {
65    t.sigs.set(sig, { count: 1, seq: t.seq, cmd, triggered: false })
66    return { signature: sig, count: 1, trigger: false }
67  }
68  if (t.seq > entry.seq) entry.count += 1
69  entry.seq = t.seq
70  entry.cmd = cmd
71  const trigger = entry.count >= TRIGGER_AT && !entry.triggered
72  if (trigger) entry.triggered = true
73  return { signature: sig, count: entry.count, trigger }
74}
75
76/** A success of the command that produced a signature drops that signature. Returns the dropped ones. */
77export function noteSuccess(t: Tracker, command: string): string[] {
78  const cmd = normalizeCommand(command)
79  const dropped: string[] = []
80  for (const [sig, entry] of t.sigs) {
81    if (entry.cmd === cmd) {
82      t.sigs.delete(sig)
83      dropped.push(sig)
84    }
85  }
86  return dropped
87}
88
89/** A prompt is a fix attempt. Returns true when it is the 3rd "still broken" prompt in a row. */
90export function notePrompt(t: Tracker, text: string): boolean {
91  noteFix(t)
92  if (!isStillBroken(text)) {
93    t.promptStreak = 0
94    return false
95  }
96  t.promptStreak += 1
97  if (t.promptStreak < TRIGGER_AT) return false
98  t.promptStreak = 0
99  return true
100}
101
102export function reset(t: Tracker): void {
103  t.sigs.clear()
104  t.promptStreak = 0
105}
106
107export function top(t: Tracker, n: number): { signature: string; count: number }[] {
108  return [...t.sigs]
109    .map(([signature, e]) => ({ signature, count: e.count }))
110    .sort((a, b) => b.count - a.count)
111    .slice(0, n)
112}
113
114const INSTRUCTION =
115  'Stop changing code. In 1 or 2 sentences, name the assumption that might be wrong, then ask the user one diagnostic question.'
116
117export const errorInstruction = (sig: string): string =>
118  `spiral-stop: the same error came back after ${TRIGGER_AT} fix attempts: "${sig}". ${INSTRUCTION}`
119
120export const promptInstruction = `spiral-stop: the user said it is still broken ${TRIGGER_AT} times in a row. ${INSTRUCTION}`
121
122export const TOAST = 'spiral-stop: same error 3 times. Claude was told to stop and rethink.'
123
124export function bandText(kind: 'error' | 'prompt', sig: string, columns: number): string {
125  if (kind === 'prompt') return 'Spiral: still broken 3 times in a row'
126  const prefix = 'Spiral: same error after 3 fixes: '
127  const room = Math.max(8, columns - prefix.length - ' [Dismiss]'.length - 1)
128  return prefix + (sig.length > room ? `${sig.slice(0, room - 1)}…` : sig)
129}
130
131export function statusText(t: Tracker, isBandUp: boolean): string {
132  const rows = top(t, 5)
133  const lines = rows.length === 0 ? ['No failing signatures tracked.'] : rows.map(r => `${r.count}x ${r.signature}`)
134  return [...lines, `Band: ${isBandUp ? 'up' : 'down'}`].join('\n')
135}
136
types/index.d.ts 13 lines
1export type Band = {
2  /** What tripped the mod: the same error again, or three "still broken" prompts. */
3  kind: 'error' | 'prompt'
4  /** The error signature; empty for the prompt kind. */
5  signature: string
6}
7
8declare module 'claude-code' {
9  interface PluginState {
10    'spiral-stop': { band: Band | null }
11  }
12}
13