[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

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 2 comments · opened Jul 28, 2026

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:

  1. 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.
  2. 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_message present: upstream deletes the state file, the patched copy keeps it and advances to iteration: 2.
  • End to end, with the patch applied in a local build: max_iterations: 3 yields num_turns = 3 in claude -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 -rs fail). Sourcing the text from last_assistant_message removes 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.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗