When a reply ends by announcing work ("I'll…", "Let me…", "je vais…") and no tool ran in the turn, Claude is sent back once to actually do it.

A reply that ends with "I'll update the README", "Let me fix that", "Next: deploy" or "je lance la migration", in a turn where no tool ran, is a promise left hanging. jr-no-promise sends Claude back, once, to do the work now:
Your last message announces work but no tool ran in this turn. Do that work now with the tools, in this turn, then report what was done in the past tense. If you cannot do it, say so plainly and name the blocker instead of announcing it.
I'll…, I will…, I'm going to…, Let me…, Next:, je vais…, je lance…, je m'occupe…, je continue…, prochaine étape :…Plan heading or Plan:), a request for approval ("shall I…", "tu veux…"), an explanation ("let me explain…"), and any turn where a tool ran./config)| Option | Default | |
|---|---|---|
| Send Claude back | on | Turn the re-prompt off without uninstalling |
/plugin install jr-no-promise --marketplace jrlprost/claude-code-mods
Counts tool calls in each turn and reads Claude's last message when it stops. Never blocks a tool. Runs nothing, sends nothing over the network.
hooks/register.ts 31 lines1import type { Register } from 'claude-code'
2
3import { isBrokenPromise, REASON } from './promise'
4
5// Tool calls made since the main loop's turn started.
6const turn = { tools: 0 }
7
8export const register: Register = (on, options) => {
9 const isEnabled = options.enabled !== false
10
11 on('turn.start', async ($, e, next) => {
12 if (e.agentId === undefined) turn.tools = 0
13 return next(e)
14 })
15
16 on('tool.call', async ($, e, next) => {
17 turn.tools++
18 return next(e)
19 }).catch(($, e, next) => next(e))
20
21 // When Claude is about to stop, a reply that only announces work sends it back to do it.
22 // Once per turn: a Stop that is already the answer to a block (stop_hook_active) passes.
23 on('classic.Stop', async ($, e, next) => {
24 const result = await next(e)
25 if (!isEnabled || e.stop_hook_active || result.block || turn.tools > 0) return result
26 if (!isBrokenPromise(e.last_assistant_message ?? '')) return result
27 $.ui.toast('jr no-promise · the reply announced work without doing it: sent back once')
28 return { ...result, block: REASON }
29 }).catch(($, e, next) => next(e))
30}
31hooks/promise.ts 31 lines1// An announcement of work about to be done, in English or French.
2const ANNOUNCE = [
3 /\b(I'll|I will|I'm going to|I am going to|let me|now I'll|I'm now)\s+(?!explain|clarify|summari[sz]e|recap|know|be (clear|honest|brief)|answer|walk you|show you why)\w+/i,
4 /\bI'm (launching|starting|continuing|running|tackling|fixing|building|writing|deploying|working on)\b/i,
5 /^\s*(next|next step|then)\s*:/im,
6 /\bje (vais|lance|m'occupe|continue|m'attaque|passe à|démarre|enchaîne|corrige|construis|déploie|relance)\b/i,
7 /^\s*(prochaine étape|ensuite|étape suivante)\s*:/im,
8]
9
10// A plan or a question waits for the person on purpose: never a broken promise.
11const WAITS = [
12 /\?\s*$/,
13 /^#{1,4}\s*(plan|proposal|proposition|options?)\b/im,
14 /^\s*\**(plan|proposal|proposition)\**\s*:/im,
15 /\b(shall I|should I|do you want|want me to|let me know|tell me|veux-tu|tu veux|tu préfères|dis-moi|je lance \?|on y va \?)/i,
16]
17
18const lastLines = (text: string) => text.trim().split('\n').filter(l => l.trim()).slice(-6).join('\n')
19
20/** True when the reply ends by announcing work, without being a plan or a question. */
21export const isBrokenPromise = (text: string) => {
22 const tail = lastLines(text)
23 if (!tail || WAITS.some(w => w.test(tail))) return false
24 return ANNOUNCE.some(a => a.test(tail))
25}
26
27export const REASON =
28 'Your last message announces work ("I\'ll…", "Let me…", "Next:", "je vais…", "je lance…") but no tool ran in this turn. ' +
29 'Do that work now with the tools, in this turn, then report what was done in the past tense. ' +
30 'If you cannot do it, say so plainly and name the blocker instead of announcing it.'
31