[BUG] ralph-loop: a bare message equal to the completion promise ends the loop with no <promise> tag (perl -p prints unmatched input)
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
The loop terminates as "complete" on a message that never contained a <promise> tag, as long as the message text happens to equal the promise phrase.
hooks/stop-hook.sh extracts the promise with:
PROMISE_TEXT=$(echo "$LAST_OUTPUT" | perl -0777 -pe 's/.*?<promise>(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g')
perl -p prints $_ unchanged when the substitution does not match. So when there is no tag at all, the entire assistant message falls through into PROMISE_TEXT, gets whitespace-normalized, and is then compared to $COMPLETION_PROMISE. A terse turn whose whole final message is the bare promise word therefore ends the loop without the model ever having made the promise.
This is the failure direction that matters most for this plugin: the completion promise is the only signal that the work is finished, and the surrounding prompt text ("ONLY when statement is TRUE - do not lie to exit!") shows the intent is that the tag be a deliberate act.
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: "DONE"\n---\n\ndo the task\n' > .claude/ralph-loop.local.md
printf '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"DONE"}]}}\n' > t.jsonl
echo "{\"session_id\":\"s1\",\"transcript_path\":\"$PWD/t.jsonl\"}" | bash "$PLUGIN/hooks/stop-hook.sh"
ls -a .claude/
Observed — no <promise> tag anywhere, yet:
✅ Ralph loop: Detected <promise>DONE</promise>
…and the state file is deleted, ending the loop.
Control — same setup with the assistant text still working on it leaves the state file in place at iteration: 2, so the harness above discriminates.
Suggested fix
Require the tag to be present before comparing:
if [[ "$LAST_OUTPUT" == *"<promise>"* ]]; then
PROMISE_TEXT=$(echo "$LAST_OUTPUT" | perl -0777 -pe 's/.*?<promise>(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g' 2>/dev/null || echo "")
else
PROMISE_TEXT=""
fi
Verified two ways:
- In isolation, on a copy of the shipped script with only this hunk changed, using the reproduction above: upstream deletes the state file, the patched copy leaves it at
iteration: 2. A properly tagged<promise>DONE</promise>still stops the patched copy, so the tag path is unaffected. - End to end, with the patch applied in a local build:
completion_promise: ALLDONE,max_iterations: 3, and a prompt that makes the model reply with the bare wordALLDONE(no tag) →claude -preportsnum_turns = 3; a properly tagged reply still stops it atnum_turns = 1.
Related
Currently unreachable in normal use because the loop never runs at all — see #81825.
3 Comments
I’ve implemented and tested a fix for this issue here: sylvesterkaczmarek/claude-code@b5181d6. The repository currently restricts PR creation to collaborators. If you’d like me to submit this as a PR, could you please add me as an outside collaborator? Happy to maintain the change through review.
Reproduced on Linux with Claude Code 2.1.233 and ralph-loop 1.0.0 from the official marketplace.
Using the reproduction from the report: with
completion_promise: "DONE", a final assistant message consisting of just the bare wordDONE— no<promise>tag anywhere — makes the Stop hook print✅ Ralph loop: Detected <promise>DONE</promise>and delete the loop state file, ending the loop. The control case (still working on it) correctly continues the loop at iteration 2, and a properly tagged<promise>DONE</promise>still stops it, so the harness cleanly discriminates the failure.The analysis matches what we observed: when the tag substitution finds no match, the entire message text falls through into the extracted value, gets whitespace-normalized, and is compared against the promise phrase — so any terse turn whose text happens to equal the promise phrase ends the loop without the model ever emitting the tag. Requiring the tag to be present before comparing, as suggested, resolves it without affecting the tagged path.
This is a bug in the ralph-loop plugin's stop hook rather than in the Claude Code CLI itself; flagging it for the plugin maintainers.
🤖 Generated with Claude Code
@robinhou729 this is a really clean example of why the completion signal itself shouldn’t be trusted as proof of completion.
Here the loop ends because the harness mis-parses a bare DONE, even though the intended completion condition was never actually established.
I’m working on the opposite approach: treat the worker’s completion signal as a claim, then require independent evidence before the task can actually close.
Your repro would be a nice minimal test of that separation.