[BUG] ralph-loop: the expected completion promise is not whitespace-normalized, so a promise containing a double space can never match

Status Open
Reported on v2.1.220
Maintainer reply ✓ Yes — bcherny
Activity 4 comments · opened Jul 28, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code (2.1.220)

Plugin

ralph-loop 1.0.0 (claude-plugins-official marketplace)

Summary

A completion promise containing a double space (or leading/trailing whitespace) can never match, so the loop cannot be stopped even when the model truthfully emits the exact phrase it was given.

hooks/stop-hook.sh normalizes only the observed side:

PROMISE_TEXT=$(echo "$LAST_OUTPUT" | perl -0777 -pe 's/.*?<promise>(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g')

while the expected side is read from the state file with only surrounding quotes stripped:

COMPLETION_PROMISE=$(echo "$FRONTMATTER" | grep '^completion_promise:' | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/')

and the two are compared literally. setup-ralph-loop.sh stores --completion-promise verbatim (COMPLETION_PROMISE="$2"), so 'ALL DONE' is expected as ALL DONE but can only ever be observed as ALL DONE.

Because the promise is the intended way to end the loop, and the loop has no manual stop in claude -p, an unbounded loop configured this way can only be ended by killing the process.

Reproduction

PLUGIN=~/.claude/plugins/marketplaces/claude-plugins-official/plugins/ralph-loop
cd "$(mktemp -d)"; mkdir .claude
printf -- '---\niteration: 1\nsession_id:\nmax_iterations: 20\ncompletion_promise: "ALL  DONE"\n---\n\ndo the task\n' > .claude/ralph-loop.local.md
printf '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"<promise>ALL  DONE</promise>"}]}}\n' > t.jsonl
echo "{\"session_id\":\"s1\",\"transcript_path\":\"$PWD/t.jsonl\"}" | bash "$PLUGIN/hooks/stop-hook.sh" >/dev/null
grep '^iteration' .claude/ralph-loop.local.md 2>/dev/null || echo "STATE FILE DELETED"

Observed: iteration: 2 — the promise was honoured exactly as configured and the loop kept going.

Control — the same run with a single space in both places prints STATE FILE DELETED, so the harness above discriminates.

Suggested fix

Normalize the expected side the same way the observed side is normalized, immediately before the comparison:

COMPLETION_PROMISE_NORM=$(printf '%s' "$COMPLETION_PROMISE" | perl -0777 -pe 's/^\s+|\s+$//g; s/\s+/ /g' 2>/dev/null || printf '%s' "$COMPLETION_PROMISE")

if [[ -n "$PROMISE_TEXT" ]] && [[ "$PROMISE_TEXT" = "$COMPLETION_PROMISE_NORM" ]]; then

(Keeping the original $COMPLETION_PROMISE for the user-facing messages, so what is echoed still matches what the user typed.)

Verified in isolation, on a copy of the shipped script with only this hunk changed, using the reproduction above: upstream leaves the state file at iteration: 2, the patched copy removes it and ends the loop.

One incidental note on the comparison line: the existing comment says

# Use = for literal string comparison (not pattern matching)
# == in [[ ]] does glob pattern matching which breaks with *, ?, [ characters

The reasoning is inaccurate — in bash [[ a = b ]] and [[ a == b ]] behave identically, and what actually disables glob matching here is that the right-hand operand is quoted. The code is safe as written (verified: a promise of DONE* does not match an observed DONEXYZ), but the comment would mislead anyone who later removes the quotes.

Related

Currently unreachable in normal use because the loop never runs at all — see #81825.

View original on GitHub ↗

4 Comments

bcherny collaborator · 1 month ago

Reproduced on Linux with Claude Code 2.1.233 and ralph-loop 1.0.0 installed from the claude-plugins-official marketplace.

Running your harness verbatim: with completion_promise: "ALL DONE" (double space) and a transcript whose last assistant message contains <promise>ALL DONE</promise> — the exact phrase as configured — the loop does not stop and the state file advances to iteration: 2. The single-space control run deletes the state file and ends the loop, confirming the harness discriminates.

The mechanism checks out in the shipped plugin: the promise extracted from the model's output is whitespace-normalized (trimmed, internal runs of whitespace collapsed to a single space) before comparison, while the expected promise from the state file is compared as-written. Since the setup script stores the user's promise verbatim, any promise containing a double space or stray leading/trailing whitespace is unmatchable from the start — and if no iteration cap was set, the loop can only be ended by killing the process. Normalizing both sides the same way before comparing, as you suggest, is the right fix. Your side note about the =/== comment is also correct — the quoting of the right-hand operand is what prevents glob matching there.

🤖 Generated with Claude Code

marcindulak · 18 days ago

For your information: the label reproduced on this issue does not prevent it from becoming stale and eventually auto-closed. See https://github.com/anthropics/claude-code/issues/87647 for details, and consider upvoting that for visibility.

sahiljagtap08 · 4 days ago

Hi, I'm picking this up. I'll normalize the expected completion promise in stop-hook.sh the same way the observed <promise> text is (trim, collapse whitespace runs) right before the comparison, keeping the raw value for user-facing messages. — Sahil Jagtap

sahiljagtap08 · 4 days ago

Fix is up: https://github.com/sahiljagtap08/claude-code/pull/4 (branch sahiljagtap/ralph-wiggum-normalize-completion-promise, one commit on current main). Fork PRs are rejected on this repo right now, so a maintainer can cherry-pick:

git fetch https://github.com/sahiljagtap08/claude-code.git sahiljagtap/ralph-wiggum-normalize-completion-promise
git cherry-pick FETCH_HEAD

The expected promise is now trimmed and whitespace-collapsed right before the comparison, matching how the observed <promise> text is normalized; the raw value still drives the user-facing messages. Also corrected the misleading = vs == comment. Verified with your repro: 'ALL DONE' now stops the loop, and the mismatch / glob-character / no-tag controls still continue. — Sahil Jagtap