Boris Cherny a310cc1ae6
ralph-loop: fix false completion on bare word, whitespace-asymmetric promise match, and frozen iteration counter
Three stop-hook bugs that break loop termination:

- A bare assistant message equal to the promise (e.g. just "DONE") ended
  the loop even without <promise> tags: the perl substitution passed the
  input through unchanged when the tag was absent. Now the promise text
  is only extracted when a <promise>...</promise> tag is present.
- The observed promise text was whitespace-normalized but the expected
  promise was not, so a promise containing consecutive spaces
  ("ALL  DONE") could never match and the loop never ended. Both sides
  are now normalized identically (trim + collapse runs of whitespace).
- The iteration reader accepted "iteration:1" (no space after the colon)
  but the writer's sed only matched "iteration: " with a space, so the
  counter froze and the loop ran past max_iterations. The writer now
  matches with or without spaces, like the reader.

Adds tests/stop-hook.test.sh covering all three regressions plus
controls; the three bug cases fail on the previous script and pass now.

Fixes anthropics/claude-code#81827
Fixes anthropics/claude-code#81828
Fixes anthropics/claude-code#81829

No-Verification-Needed: shell-script plugin in claude-plugins-official; no claude-cli-internal runtime surface

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 20:19:46 -07:00

198 lines
7.9 KiB
Bash
Executable File

#!/bin/bash
# Ralph Loop Stop Hook
# Prevents session exit when a ralph-loop is active
# Feeds Claude's output back as input to continue the loop
set -euo pipefail
# Read hook input from stdin (advanced stop hook API)
HOOK_INPUT=$(cat)
# Check if ralph-loop is active
RALPH_STATE_FILE=".claude/ralph-loop.local.md"
if [[ ! -f "$RALPH_STATE_FILE" ]]; then
# No active loop - allow exit
exit 0
fi
# Parse markdown frontmatter (YAML between ---) and extract values
FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$RALPH_STATE_FILE")
ITERATION=$(echo "$FRONTMATTER" | grep '^iteration:' | sed 's/iteration: *//')
MAX_ITERATIONS=$(echo "$FRONTMATTER" | grep '^max_iterations:' | sed 's/max_iterations: *//')
# Extract completion_promise, strip surrounding quotes if present, then
# normalize whitespace (trim + collapse runs to a single space) — the observed
# <promise> text is normalized the same way below, so both sides must match.
COMPLETION_PROMISE=$(echo "$FRONTMATTER" | grep '^completion_promise:' | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/' | perl -0777 -pe 's/^\s+|\s+$//g; s/\s+/ /g')
# Session isolation: the state file is project-scoped, but the Stop hook
# fires in every Claude Code session in that project. If another session
# started the loop, this session must not block (or touch the state file).
# Legacy state files without session_id fall through (preserves old behavior).
STATE_SESSION=$(echo "$FRONTMATTER" | grep '^session_id:' | sed 's/session_id: *//' || true)
HOOK_SESSION=$(echo "$HOOK_INPUT" | jq -r '.session_id // ""')
if [[ -n "$STATE_SESSION" ]] && [[ "$STATE_SESSION" != "$HOOK_SESSION" ]]; then
exit 0
fi
# Validate numeric fields before arithmetic operations
if [[ ! "$ITERATION" =~ ^[0-9]+$ ]]; then
echo "⚠️ Ralph loop: State file corrupted" >&2
echo " File: $RALPH_STATE_FILE" >&2
echo " Problem: 'iteration' field is not a valid number (got: '$ITERATION')" >&2
echo "" >&2
echo " This usually means the state file was manually edited or corrupted." >&2
echo " Ralph loop is stopping. Run /ralph-loop again to start fresh." >&2
rm "$RALPH_STATE_FILE"
exit 0
fi
if [[ ! "$MAX_ITERATIONS" =~ ^[0-9]+$ ]]; then
echo "⚠️ Ralph loop: State file corrupted" >&2
echo " File: $RALPH_STATE_FILE" >&2
echo " Problem: 'max_iterations' field is not a valid number (got: '$MAX_ITERATIONS')" >&2
echo "" >&2
echo " This usually means the state file was manually edited or corrupted." >&2
echo " Ralph loop is stopping. Run /ralph-loop again to start fresh." >&2
rm "$RALPH_STATE_FILE"
exit 0
fi
# Check if max iterations reached
if [[ $MAX_ITERATIONS -gt 0 ]] && [[ $ITERATION -ge $MAX_ITERATIONS ]]; then
echo "🛑 Ralph loop: Max iterations ($MAX_ITERATIONS) reached."
rm "$RALPH_STATE_FILE"
exit 0
fi
# Get transcript path from hook input
TRANSCRIPT_PATH=$(echo "$HOOK_INPUT" | jq -r '.transcript_path')
if [[ ! -f "$TRANSCRIPT_PATH" ]]; then
echo "⚠️ Ralph loop: Transcript file not found" >&2
echo " Expected: $TRANSCRIPT_PATH" >&2
echo " This is unusual and may indicate a Claude Code internal issue." >&2
echo " Ralph loop is stopping." >&2
rm "$RALPH_STATE_FILE"
exit 0
fi
# Read last assistant message from transcript (JSONL format - one JSON per line)
# First check if there are any assistant messages
if ! grep -q '"role":"assistant"' "$TRANSCRIPT_PATH"; then
echo "⚠️ Ralph loop: No assistant messages found in transcript" >&2
echo " Transcript: $TRANSCRIPT_PATH" >&2
echo " This is unusual and may indicate a transcript format issue" >&2
echo " Ralph loop is stopping." >&2
rm "$RALPH_STATE_FILE"
exit 0
fi
# Extract the most recent assistant text block.
#
# Claude Code writes each content block (text/tool_use/thinking) as its own
# JSONL line, all with role=assistant. So slurp the last N assistant lines,
# flatten to text blocks only, and take the last one.
#
# Capped at the last 100 assistant lines to keep jq's slurp input bounded
# for long-running sessions.
LAST_LINES=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | tail -n 100)
if [[ -z "$LAST_LINES" ]]; then
echo "⚠️ Ralph loop: Failed to extract assistant messages" >&2
echo " Ralph loop is stopping." >&2
rm "$RALPH_STATE_FILE"
exit 0
fi
# Parse the recent lines and pull out the final text block.
# `last // ""` yields empty string when no text blocks exist (e.g. a turn
# that is all tool calls). That's fine: empty text means no <promise> tag,
# so the loop simply continues.
# (Briefly disable errexit so a jq failure can be caught by the $? check.)
set +e
LAST_OUTPUT=$(echo "$LAST_LINES" | jq -rs '
map(.message.content[]? | select(.type == "text") | .text) | last // ""
' 2>&1)
JQ_EXIT=$?
set -e
# Check if jq succeeded
if [[ $JQ_EXIT -ne 0 ]]; then
echo "⚠️ Ralph loop: Failed to parse assistant message JSON" >&2
echo " Error: $LAST_OUTPUT" >&2
echo " This may indicate a transcript format issue." >&2
echo " Ralph loop is stopping." >&2
rm "$RALPH_STATE_FILE"
exit 0
fi
# Check for completion promise (only if set)
if [[ "$COMPLETION_PROMISE" != "null" ]] && [[ -n "$COMPLETION_PROMISE" ]]; then
# Extract text from <promise> tags using Perl for multiline support
# -0777 slurps entire input, s flag makes . match newlines
# .*? is non-greedy (takes FIRST tag), whitespace normalized
# Prints ONLY when a <promise> tag is present — output without the tag
# (e.g. a bare "DONE") must never count as the promise.
PROMISE_TEXT=$(echo "$LAST_OUTPUT" | perl -0777 -ne 'if (/<promise>(.*?)<\/promise>/s) { my $t = $1; $t =~ s/^\s+|\s+$//g; $t =~ s/\s+/ /g; print $t; }' 2>/dev/null || echo "")
# Use = for literal string comparison (not pattern matching)
# == in [[ ]] does glob pattern matching which breaks with *, ?, [ characters
if [[ -n "$PROMISE_TEXT" ]] && [[ "$PROMISE_TEXT" = "$COMPLETION_PROMISE" ]]; then
echo "✅ Ralph loop: Detected <promise>$COMPLETION_PROMISE</promise>"
rm "$RALPH_STATE_FILE"
exit 0
fi
fi
# Not complete - continue loop with SAME PROMPT
NEXT_ITERATION=$((ITERATION + 1))
# Extract prompt (everything after the closing ---)
# Skip first --- line, skip until second --- line, then print everything after
# Use i>=2 instead of i==2 to handle --- in prompt content
PROMPT_TEXT=$(awk '/^---$/{i++; next} i>=2' "$RALPH_STATE_FILE")
if [[ -z "$PROMPT_TEXT" ]]; then
echo "⚠️ Ralph loop: State file corrupted or incomplete" >&2
echo " File: $RALPH_STATE_FILE" >&2
echo " Problem: No prompt text found" >&2
echo "" >&2
echo " This usually means:" >&2
echo " • State file was manually edited" >&2
echo " • File was corrupted during writing" >&2
echo "" >&2
echo " Ralph loop is stopping. Run /ralph-loop again to start fresh." >&2
rm "$RALPH_STATE_FILE"
exit 0
fi
# Update iteration in frontmatter (portable across macOS and Linux)
# Create temp file, then atomically replace
TEMP_FILE="${RALPH_STATE_FILE}.tmp.$$"
# Match "iteration:" with or without spaces after the colon, like the reader
# above — a stricter pattern here would silently no-op and freeze the counter.
sed "s/^iteration:.*/iteration: $NEXT_ITERATION/" "$RALPH_STATE_FILE" > "$TEMP_FILE"
mv "$TEMP_FILE" "$RALPH_STATE_FILE"
# Build system message with iteration count and completion promise info
if [[ "$COMPLETION_PROMISE" != "null" ]] && [[ -n "$COMPLETION_PROMISE" ]]; then
SYSTEM_MSG="🔄 Ralph iteration $NEXT_ITERATION | To stop: output <promise>$COMPLETION_PROMISE</promise> (ONLY when statement is TRUE - do not lie to exit!)"
else
SYSTEM_MSG="🔄 Ralph iteration $NEXT_ITERATION | No completion promise set - loop runs infinitely"
fi
# Output JSON to block the stop and feed prompt back
# The "reason" field contains the prompt that will be sent back to Claude
jq -n \
--arg prompt "$PROMPT_TEXT" \
--arg msg "$SYSTEM_MSG" \
'{
"decision": "block",
"reason": $prompt,
"systemMessage": $msg
}'
# Exit 0 for successful hook execution
exit 0