[BUG] ralph-loop never loops on 2.1.x: Stop hook reads the transcript before the current turn's assistant message is written, then deletes its own state file
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). Also reproduced under the Desktop app's bundled runtime 2.1.219.
Summary
/ralph-loop never iterates. The first Stop hook invocation deletes the loop's own state file and allows the exit, so the session ends after a single turn. Nothing is surfaced in the TUI — the observable symptom is "nothing happened".
hooks/stop-hook.sh derives the last assistant message by parsing the transcript JSONL. At the moment the Stop hook fires, the transcript does not yet contain the current turn's assistant message. On iteration 1 the file therefore contains zero "role":"assistant" lines, which the script treats as fatal:
if ! grep -q '"role":"assistant"' "$TRANSCRIPT_PATH"; then
echo "⚠️ Ralph loop: No assistant messages found in transcript" >&2
...
rm "$RALPH_STATE_FILE"
exit 0
fi
Measurement — what the harness actually presents at Stop time
A pass-through Stop hook (observes only; blocks twice so that several rounds are visible), in a scratch directory, on 2.1.220:
Stop #1 | assistant lines in transcript = 0 | .last_assistant_message = "ITERMARK"
Stop #2 | assistant lines in transcript = 1 | .last_assistant_message = "ITERMARK"
Stop #3 | assistant lines in transcript = 2 | .last_assistant_message = "ITERMARK"
Two consequences:
- The transcript lags by exactly one turn. On iteration 1 there is nothing to find; from iteration 2 onward the script reads the previous turn's text, so promise detection is also off by one.
- The hook input already carries the text as
last_assistant_message. The plugin never reads it.
Deterministic reproduction (no API calls)
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: null\n---\n\ndo the task\n' > .claude/ralph-loop.local.md
printf '{"type":"user","message":{"role":"user","content":"hi"}}\n' > t.jsonl
echo "{\"session_id\":\"s1\",\"transcript_path\":\"$PWD/t.jsonl\"}" | bash "$PLUGIN/hooks/stop-hook.sh"
ls -a .claude/
Observed:
⚠️ Ralph loop: No assistant messages found in transcript
Transcript: /var/folders/.../t.jsonl
This is unusual and may indicate a transcript format issue
Ralph loop is stopping.
…and .claude/ralph-loop.local.md is gone.
End to end with the plugin enabled and max_iterations: 3: claude -p reports num_turns = 1.
Suggested fix
Read the harness-provided field; fall back to the transcript only for builds that do not send it; and stop treating "no assistant text this turn" as fatal — the script's own comments already acknowledge that a turn ending in tool calls legitimately has no text block.
LAST_OUTPUT=$(echo "$HOOK_INPUT" | jq -r '.last_assistant_message // ""')
if [[ -z "$LAST_OUTPUT" ]]; then
# Fallback for builds that do not send the field. An empty result here is NOT an
# error: it just means "no <promise> this turn", so the loop simply continues.
TRANSCRIPT_PATH=$(echo "$HOOK_INPUT" | jq -r '.transcript_path // ""')
if [[ -n "$TRANSCRIPT_PATH" ]] && [[ -f "$TRANSCRIPT_PATH" ]]; then
LAST_LINES=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | tail -n 100 || true)
if [[ -n "$LAST_LINES" ]]; then
set +e
PARSED=$(echo "$LAST_LINES" | jq -rs 'map(.message.content[]? | select(.type == "text") | .text) | last // ""' 2>/dev/null)
JQ_EXIT=$?
set -e
if [[ $JQ_EXIT -eq 0 ]]; then LAST_OUTPUT="$PARSED"; fi
fi
fi
fi
Verified two ways:
- In isolation, on a copy of the shipped script with only this hunk changed, fed a transcript with zero assistant lines plus
last_assistant_messagepresent: upstream deletes the state file, the patched copy keeps it and advances toiteration: 2. - End to end, with the patch applied in a local build:
max_iterations: 3yieldsnum_turns = 3inclaude -p, in the interactive TUI, and under the Desktop app's bundled 2.1.219 runtime. (That build also carries fixes for the four downstream defects mentioned below; none of them is reachable in this scenario — no completion promise is set, the cap is a plain decimal, and the counter is written with a space.)
Related
- #68665 (closed as not planned) reports a different trigger for a failure in the same transcript-slurp code path (a raw control character making
jq -rsfail). Sourcing the text fromlast_assistant_messageremoves that failure class as well. - Once this is fixed, four further defects in the same file become reachable for the first time — they are currently masked because the loop never runs. Filed separately, per the single-bug-per-report policy.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗