[BUG] ralph-loop: Stop hook fails to detect a correct <promise> when the transcript window contains a control character

Open 💬 1 comment Opened Jun 15, 2026 by aaronpdoherty

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

What's Wrong?

Plugin

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

Summary

A ralph loop can fail to terminate even when the worker emits the exact, correct
<promise>…</promise> completion token. The Stop hook keeps re-injecting the
prompt. I hit this on a finished work package whose PR was open and CI green — it
looped 6 times before I cancelled it manually.

Root cause

hooks/stop-hook.sh detects completion by slurping the last 100 assistant
transcript lines through jq and reading the final text block:

LAST_LINES=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | tail -n 100)
LAST_OUTPUT=$(echo "$LAST_LINES" | jq -rs 'map(.message.content[]? | select(.type=="text") | .text) | last // ""')

The jq -rs slurp parses the whole window as one stream, so a single
transcript line containing a raw control character (U+0000–U+001F) — e.g. an
embedded newline from a large tool-output block or a multi-line heredoc command —
makes the entire slurp fail:

jq: parse error: Invalid string: control characters from U+0000 through U+001F must be escaped

When that happens the extracted promise is empty, never matches
$COMPLETION_PROMISE, and the loop continues. (Ironically, iterating while
debugging the loop adds more such tool output, keeping the window unparseable.)

Reproduce

  1. Start a ralph loop with a completion promise.
  2. In a turn, emit a large tool output / multi-line command whose transcript

record contains an unescaped control char.

  1. Emit the correct <promise>TOKEN</promise>. Observe the loop re-inject the

prompt instead of stopping.
You can confirm the parse failure directly:
grep '"role":"assistant"' <transcript>.jsonl | tail -100 | jq -rs '.'

Suggested fix

  • Parse the window line-by-line and skip unparseable lines instead of slurping,

e.g. jq -R 'fromjson? // empty' per line, then take the last text block — so
one bad line can't nuke detection.

  • Verify the JQ_EXIT != 0 graceful-stop branch actually triggers on a slurp

parse error (in the observed case the loop continued rather than stopping, so
either jq returned empty-with-exit-0 or that branch isn't reached).

Workaround

Keep the promise-emitting turn text-only (no large tool output), and use
/ralph-loop:cancel-ralph if a loop won't stop despite genuine completion.

What Should Happen?

Describe the expected behavior

When the worker's final message contains <promise>$COMPLETION_PROMISE</promise>
matching the configured promise, the Stop hook should detect it and end the loop
(remove the state file, allow exit) — regardless of any control characters
elsewhere in the transcript window. Promise detection should be robust to
individual unparseable/garbled transcript lines (skip them) rather than failing
closed and silently continuing. And if jq genuinely cannot parse the window, the
documented JQ_EXIT != 0 fallback should stop the loop rather than re-injecting
the prompt (in the observed case it continued instead).

Actual behavior

The loop ignored the correct promise and re-injected the prompt for 6 consecutive
iterations on a work package that was complete (PR open, CI green). It only
stopped via manual /ralph-loop:cancel-ralph.

Error Messages/Logs

Steps to Reproduce

  1. Start a ralph loop with a completion promise, e.g.

/ralph-loop:ralph-loop <task> --completion-promise MY-TOKEN --max-iterations 15.

  1. During the loop, run a turn that emits large or multi-line tool output — e.g.

gh run watch, a multi-line git commit/gh pr create heredoc, or any
command whose captured output contains an unescaped control character
(U+0000–U+001F). This gets written into the session .jsonl transcript.

  1. Complete the task and emit the exact promise <promise>MY-TOKEN</promise> as

your final message.

  1. Observe: the Stop hook re-injects the same prompt instead of ending the loop,

even though the token exactly matches --completion-promise.

Direct confirmation of the underlying parse failure (same slurp the hook uses):

grep '"role":"assistant"' <session>.jsonl | tail -100 | jq -rs '.'
# => jq: parse error: Invalid string: control characters from U+0000 through U+001F must be escaped

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.141 (Claude Code)

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

VS Code integrated terminal

Additional Information

_No response_

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗