Two one-line nudges to Claude, with no model calls: when the same failure or refusal happens twice, and when it says done with no check since its last edit.

A Claude Code mod that gives Claude two one-line nudges at the moment they help. It makes no model calls of its own.
/unstuck.! command instead.Both are ported from cablate/ctx-handoff-mod, without the rest of that toolkit, and adapted: English only, the refusal nudge, PowerShell commands, and R, LaTeX and Quarto checks.
.md, .txt, .rst, .adoc), LICENSE, .gitignore and the like, and anything in a scratchpad folder don't count, and neither do a subagent's edits.test, check, build, compile, lint, tsc, pytest, testthat, validate, verify, parse, py_compile, latexmk, quarto, render or tar_make in it (00d_validate_data.R counts), or the /verify skill. Commands that only look at files (ls, cat, git, gh, Get-Content, Select-String, …) never count. A check that fails doesn't count as passing.Needs Claude Code 2.1.302 or later with function hooks on (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).
At the prompt of a terminal session:
/plugin install nudges --marketplace daldaman/claude-nudges
From a shell:
claude plugin install nudges --marketplace daldaman/claude-nudges
In the desktop app, click + next to the prompt box, then Plugins. Choose Add marketplace with daldaman/claude-nudges, then Add plugin.
Each nudge turns off on its own, at the prompt with /plugin configure nudges@nudges, or from a shell:
echo '{"doneCheck":"off"}' | claude plugin configure nudges@nudges --values-stdin
| Setting | Values | Default |
|---|---|---|
repeatedFailure | on, off | on |
doneCheck | on, off | on |
Nothing on its own. A nudge is a line or two of text the model reads. A sent-back stop means one more model turn, at most once per turn.
Rscript analysis.R) doesn't count, and a done claim worded unusually gets through.Set-Content, sed -i).claude plugin validate .
claude plugin test .
MIT, with the original's copyright kept. See LICENSE.
hooks/register.ts 74 lines1// Two nudges for Claude, with no model calls of their own, ported from cablate/ctx-handoff-mod (MIT):
2// the same failure or refusal twice in a row, and "done" with no check since the last edit.
3// A nudge is text only the model reads; the person sees a line in the transcript when a stop is sent back.
4import type { Register, ToolCallResult } from 'claude-code'
5
6import { doneCheck, freshWork, noteCall, outcomeOf, trackOutcome } from './lib/checks.ts'
7import type { Streak } from './lib/checks.ts'
8
9// Module state: a reload starts both over, which costs at most one nudge.
10const streaks = new Map<string, Streak>()
11let work = freshWork()
12
13export const register: Register = (on, options) => {
14 const opts = (options ?? {}) as Record<string, unknown>
15 const isFailureOn = opts.repeatedFailure !== 'off'
16 const isDoneOn = opts.doneCheck !== 'off'
17
18 // After every tool call: note this turn's edits and checks, and add a nudge to the second same failure.
19 // Never throws and never changes whether the call ran: on any error of its own the result goes through as is.
20 on('tool.call', async ($, e, next) => {
21 const r = await next(e)
22 try {
23 const scope = (e as { agentId?: string }).agentId ?? ''
24 const isDeny = r.deny !== undefined
25 const isError = r.isError === true
26 // Subagents' edits and checks are theirs, not the main conversation's turn.
27 if (isDoneOn && scope === '' && !isDeny) noteCall(work, e.tool, e as Record<string, unknown>, isError)
28 if (!isFailureOn) return r
29 const text = r.deny ?? r.text ?? (typeof r.result === 'string' ? r.result : undefined)
30 const nudge = trackOutcome(streaks, scope, e.tool, outcomeOf(isError, isDeny, text), text)
31 if (!nudge) return r
32 // A deny carries no context, so the nudge rides in its text.
33 if (r.deny !== undefined) return { deny: `${r.deny}\n\n${nudge}` }
34 return { ...r, context: [...(r.context ?? []), nudge] } as ToolCallResult
35 } catch {
36 return r
37 }
38 }).catch(($, e, next) => next(e))
39 // Each .catch here lets the call through untouched: `next(e)` replays what the hook's own call
40 // settled to, so nothing runs twice. A nudge never stands in the way.
41
42 // Claude is about to stop: if it says it's done but nothing checked its last code edit, send it back once.
43 on('classic.Stop', async ($, e, next) => {
44 const out = await next(e)
45 // Another Stop hook already sent the turn back: decide at the stop that really ends it.
46 if (out.block !== undefined) return out
47 if ((e as { agent_id?: string }).agent_id !== undefined) return out
48 try {
49 const reason = isDoneOn ? doneCheck(work, e.last_assistant_message, e.stop_hook_active === true) : undefined
50 if (reason) {
51 $.ui.log('nudges: Claude said done with no check since its last edit, so it was asked to check first')
52 return { ...out, block: reason }
53 }
54 } catch {}
55 work = freshWork()
56 return out
57 }).catch(($, e, next) => next(e))
58
59 // A message from the person starts a new turn, and may have changed what fails or what is allowed.
60 on('prompt.submit', async ($, e, next) => {
61 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
62 work = freshWork()
63 streaks.clear()
64 }
65 return next(e)
66 }).catch(($, e, next) => next(e))
67
68 // An interrupted turn has no Stop: drop its record so it isn't counted into the next one.
69 on('turn.complete', async ($, e, next) => {
70 if (e.agentId === undefined && e.isAborted) work = freshWork()
71 return next(e)
72 })
73}
74hooks/lib/checks.ts 184 lines1// The two nudges' logic, kept apart from `$`: the same failure or refusal twice in a row,
2// and "done" with no check since the last edit. Ported from cablate/ctx-handoff-mod's loops.ts (MIT).
3// Their state is module state: a reload clears it, which only costs one nudge.
4
5export const TAG = '[nudges]'
6
7// --- A: the same failure, or the same refusal, twice in a row ---
8
9export type Streak = { sig: string; count: number; isNudged: boolean }
10
11const MAX_SIG = 200
12const MAX_STREAKS = 200
13// Built with the constructor so no control character sits in the pattern.
14const ANSI = new RegExp(String.raw`${String.fromCharCode(27)}\[[0-9;]*[A-Za-z]`, 'g')
15
16// The person stopped or rejected the call: not a failure, and nothing to say about it.
17const USER_STOP = /doesn't want to proceed|user rejected|tool use was rejected|interrupted by user|request interrupted|user denied/i
18// A permission rule, the auto-mode classifier or a guard refused the call: retrying it, or getting the
19// same result another way, is the wrong move, so these get their own nudge.
20const REFUSAL =
21 /has been denied|denied by your permission settings|denied by the Claude Code auto mode classifier|permission (?:for|to) [^.\n]{0,80}denied|\bblocked, because\b/i
22
23export type Outcome = 'ok' | 'stopped' | 'failed' | 'refused'
24
25export const outcomeOf = (isError: boolean, isDeny: boolean, text: string | undefined): Outcome => {
26 if (isDeny) return 'refused'
27 if (!isError) return 'ok'
28 if (USER_STOP.test(text ?? '')) return 'stopped'
29 return REFUSAL.test(text ?? '') ? 'refused' : 'failed'
30}
31
32// An error's signature: colour codes, paths, numbers (lines, times, ids) and spacing removed.
33export const failureSignature = (text: string | undefined): string =>
34 (text ?? '')
35 .replace(ANSI, '')
36 .replace(/<\/?tool_use_error>/g, '')
37 .replace(/[A-Za-z]:[\\/][^\s'"`)>\]]*/g, '<p>')
38 .replace(/(?:\.{0,2}\/)?(?:[\w.@~-]+\/)+[\w.@~-]*/g, '<p>')
39 .replace(/\d+/g, '#')
40 .replace(/\s+/g, ' ')
41 .trim()
42 .slice(0, MAX_SIG)
43
44// A refusal of `conda` in Bash and then in PowerShell is the same refusal: the shell's name is dropped.
45const refusalSignature = (text: string | undefined) => failureSignature((text ?? '').replace(/\b(?:Bash|PowerShell)\b/g, '<shell>'))
46
47// The first non-empty line, cut to 80 characters, for the model to recognise.
48const brief = (text: string | undefined) => {
49 const line = (text ?? '').replace(/<\/?tool_use_error>/g, '').split('\n').map(l => l.trim()).find(Boolean) ?? ''
50 return line.length > 80 ? `${line.slice(0, 80)}…` : line
51}
52
53export const failureNudge = (tool: string, text: string | undefined) =>
54 `${TAG} ${tool} failed twice in a row for the same reason (${brief(text) || 'no error text'}). ` +
55 'If the error looks temporary (a timeout, something still loading, a dropped connection), wait and retry once. ' +
56 'Otherwise find the cause and change approach instead of retrying as is; before calling it a dead end, run /unstuck.'
57
58export const refusalNudge = (text: string | undefined) =>
59 `${TAG} The same thing was refused twice (${brief(text) || 'no reason given'}). ` +
60 "A refusal by a permission rule, the auto-mode classifier or a guard isn't a bug to fix: don't retry it, " +
61 "and don't reach the same result another way. If the step is still needed, stop and ask the user, or give them a one-line `!` command to run themselves."
62
63// Notes one finished tool call; on the second failure (or refusal) in a row with the same signature,
64// returns the nudge to hand the model. Once per streak. A success clears that tool's failure streak.
65export function trackOutcome(
66 streaks: Map<string, Streak>, scope: string, tool: string, outcome: Outcome, text: string | undefined,
67): string | undefined {
68 const key = outcome === 'refused' ? `${scope}|refused` : `${scope}|${tool}`
69 if (outcome === 'ok' || outcome === 'stopped') {
70 streaks.delete(key)
71 return undefined
72 }
73 const sig = outcome === 'refused' ? refusalSignature(text) : failureSignature(text)
74 const prev = streaks.get(key)
75 if (!prev || prev.sig !== sig) {
76 // A new signature starts a new streak; the cap only stops the map growing without end.
77 if (!prev && streaks.size >= MAX_STREAKS) streaks.clear()
78 streaks.set(key, { sig, count: 1, isNudged: false })
79 return undefined
80 }
81 prev.count += 1
82 if (prev.isNudged) return undefined
83 prev.isNudged = true
84 return outcome === 'refused' ? refusalNudge(text) : failureNudge(tool, text)
85}
86
87// --- B: "done", with no check since the last edit ---
88
89// This turn's work. seq counts finished tool calls; lastEdit and lastCheck are the seq of the last
90// code edit and the last passing check (0 = none); isCheckFailed, whether the last check failed.
91export type Work = { seq: number; lastEdit: number; lastCheck: number; isCheckFailed: boolean; isReminded: boolean }
92export const freshWork = (): Work => ({ seq: 0, lastEdit: 0, lastCheck: 0, isCheckFailed: false, isReminded: false })
93
94const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
95// Prose, license and git housekeeping files, and scratch files, need no test run.
96const DOC_FILE = /(?:\.(?:md|mdx|txt|rst|adoc)|(?:^|[\\/])(?:LICENSE|NOTICE|COPYING|\.gitignore|\.gitattributes|\.editorconfig))$/i
97const SCRATCH_PATH = /(?:^|[\\/])scratchpad[\\/]/i
98// Commands that only look at or move things, whatever words follow them.
99const NOT_RUNNING = new Set([
100 'ls', 'cat', 'echo', 'grep', 'rg', 'find', 'head', 'tail', 'git', 'gh', 'cd', 'mkdir', 'rm', 'cp', 'mv', 'sed', 'awk',
101 'type', 'wc', 'diff', 'open', 'pwd', 'which', 'printf', 'code', 'start', 'explorer', 'robocopy',
102 'get-content', 'gc', 'select-string', 'sls', 'get-childitem', 'gci', 'dir', 'set-location', 'sl', 'test-path',
103 'write-output', 'write-host', 'copy-item', 'move-item', 'remove-item', 'new-item', 'get-item', 'set-content',
104])
105// What a check says it does: tests, type checks, linters, builds, validators, parse checks, LaTeX and
106// Quarto builds, {targets} runs. An underscore separates words here, so 00d_validate_data.R counts.
107const CHECK_WORDS =
108 /(?<![A-Za-z0-9])(tests?|pytest|unittest|testthat|jest|vitest|mocha|tsc|typecheck|lint|eslint|biome|ruff|mypy|pyright|flake8|clippy|check|build|compile|py_compile|parse|verify|verification|validate|vet|latexmk|pdflatex|xelatex|lualatex|quarto|render|knit|tar_make)(?![A-Za-z0-9])/i
109
110// The program a command segment starts: past PowerShell's `&`, quotes, folders and `.exe`.
111const programOf = (segment: string) => {
112 const words = segment.trim().split(/\s+/)
113 const first = words[0] === '&' ? words[1] : words[0]
114 return (first ?? '').replace(/^["']|["']$/g, '').replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase()
115}
116
117// Whether a Bash or PowerShell command looks like a test, check or build (any one segment will do).
118export const isCheckCommand = (command: string | undefined) =>
119 (command ?? '').split(/&&|\|\||;|\||\n/).some(segment => {
120 const program = programOf(segment)
121 return program !== '' && !NOT_RUNNING.has(program) && CHECK_WORDS.test(segment)
122 })
123
124type CallInput = Record<string, unknown>
125
126// Notes one finished tool call of the main conversation. A failed edit isn't an edit; a failed check
127// marks the work unchecked rather than checked.
128export function noteCall(work: Work, tool: string, input: CallInput, isFailed: boolean) {
129 work.seq += 1
130 if (EDIT_TOOLS.has(tool)) {
131 if (isFailed) return
132 const file = [input.file_path, input.notebook_path, input.path].find((p): p is string => typeof p === 'string')
133 if (file && (DOC_FILE.test(file) || SCRATCH_PATH.test(file))) return
134 work.lastEdit = work.seq
135 work.isCheckFailed = false
136 return
137 }
138 const isCheck =
139 ((tool === 'Bash' || tool === 'PowerShell') && typeof input.command === 'string' && isCheckCommand(input.command)) ||
140 (tool === 'Skill' && input.skill === 'verify')
141 if (!isCheck) return
142 if (isFailed) {
143 work.isCheckFailed = true
144 } else {
145 work.lastCheck = work.seq
146 work.isCheckFailed = false
147 }
148}
149
150// Saying it's done: only the start and end of the reply are read, and questions don't count.
151const CLAIM =
152 /\b(all done|all set|i(?:'ve| have) (?:finished|completed|fixed)|(?:is|are|now|it's|that's) (?:done|fixed|complete(?:d)?|finished)|fixed it|works now|now works|should (?:now )?work|everything (?:is )?(?:done|working|fixed|in place))\b/i
153// "Done." or "Fixed:" opening a line.
154const CLAIM_OPENING = /(?:^|\n)\W{0,3}(?:done|fixed)\b(?!-)/i
155// Already says what wasn't checked, or that a check failed: no nudge.
156const HONEST =
157 /\b(?:not (?:yet )?(?:been )?(?:tested|verified|checked|run)|untested|unverified|couldn't (?:run|verify|test|check)|could not (?:run|verify|test|check)|unable to (?:run|verify|test|check)|haven't (?:run|tested|verified|checked)|didn't (?:run|test|verify|check)|did not (?:run|test|verify|check)|(?:tests?|checks?|build|validation) (?:failed|fails)|failing tests?)\b/i
158
159export function claimsDone(message: string | undefined) {
160 const text = (message ?? '').trim()
161 if (!text || /\?$/.test(text)) return false
162 const ends = text.length <= 600 ? text : `${text.slice(0, 200)}\n${text.slice(-400)}`
163 return (CLAIM.test(ends) || CLAIM_OPENING.test(ends)) && !HONEST.test(ends)
164}
165
166export const doneNudge = (isCheckFailed: boolean) =>
167 isCheckFailed
168 ? `${TAG} You said it's done, but the last test or check after your edits this turn failed. ` +
169 'Fix it and run the check again, then report what ran and the result; ' +
170 "if you can't fix it or it's unrelated to this change, say so plainly instead of calling it done."
171 : `${TAG} You said it's done, but no test or check has run since your last code edit this turn. ` +
172 'Run the check that fits the change (tests, a build, a validator, a parse check, or the changed script itself) ' +
173 'and report what ran and the result; where nothing can check it, say what is unverified and why instead of calling it done.'
174
175// At a stop: the reason to send Claude back, once per turn. isStopHookActive means a Stop hook
176// already sent this turn back once.
177export function doneCheck(work: Work, message: string | undefined, isStopHookActive: boolean): string | undefined {
178 if (work.isReminded || isStopHookActive) return undefined
179 if (work.lastEdit === 0 || (work.lastCheck > work.lastEdit && !work.isCheckFailed)) return undefined
180 if (!claimsDone(message)) return undefined
181 work.isReminded = true
182 return doneNudge(work.isCheckFailed)
183}
184