SLOPSHOPPER

nudges

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.

newguardprompt
v0.1.0MITupdated 2026-10-19daldaman/claude-nudges
A shopper browsing a rack in a slop shop
README

nudges

A Claude Code mod that gives Claude two one-line nudges at the moment they help. It makes no model calls of its own.

  • The same failure twice. When a tool fails twice in a row for the same reason, the second result carries a line telling Claude to find the cause and change approach instead of retrying as is, or to wait and retry once if the error looks temporary. Before calling it a dead end, it's told to run /unstuck.
  • The same refusal twice. When a permission rule, the auto-mode classifier or a guard such as guardrails refuses the same thing twice, even from a different shell, Claude is told not to retry it or reach the same result another way, and to ask you or hand you a one-line ! command instead.
  • "Done" with no check. When Claude says the work is done but no test or check has run since its last code edit this turn, its stop is sent back once, asking it to run the check that fits and report the result, or to say plainly what is unverified. You see a line in the transcript when that happens.

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.

What counts

  • A code edit: a successful Edit, Write, MultiEdit or NotebookEdit by the main conversation. Prose files (.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.
  • A check: a Bash or PowerShell command with a word like 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.
  • Saying it's done: "All done", "Done." opening a line, "fixed it", "it's now fixed", "should work now" and the like, in the first 200 or last 400 characters of the reply. A reply that asks a question, or says what wasn't tested ("not yet verified", "couldn't run the tests", "2 tests failed"), is left alone.
  • The same failure: the error text with paths, numbers and spacing removed. Your stopping or rejecting a call isn't a failure. A success clears the streak, and so does any message you send.

Install

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.

Settings

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
SettingValuesDefault
repeatedFailureon, offon
doneCheckon, offon

What it costs

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.

Limits

  • It reads words, not meaning. A check it doesn't know by name (Rscript analysis.R) doesn't count, and a done claim worded unusually gets through.
  • It doesn't see file edits made through the shell (Set-Content, sed -i).
  • Its record lives in memory, so a reload forgets the current streak or turn. That costs at most one nudge.
  • The done-check is a Stop hook. Where an organization manages Claude Code, Stop hooks only run for plugins the organization lists, so there only the failure nudge works.

Develop

claude plugin validate .
claude plugin test .

License

MIT, with the original's copyright kept. See LICENSE.

Source 2 files
hooks/register.ts 74 lines
1// 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}
74
hooks/lib/checks.ts 184 lines
1// 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