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

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.
/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.
:line:col and path prefixes. If no line looks like an error, it uses the last output line.Spiral: same error after 3 fixes: <signature> [Dismiss].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.
/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.
MIT
hooks/register.tsx 107 lines1import { 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}
107hooks/spiral.ts 136 lines1export 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}
136types/index.d.ts 13 lines1export 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