mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-03-17 10:33:08 +00:00
The state file lives at .claude/ralph-loop.local.md — project-scoped, not session-scoped. The plugin's Stop hook fires in every Claude Code session open in that project directory. So if session A starts a loop, session B's Stop events also find the state file and block, feeding A's prompt into B and consuming A's iteration budget. This was masked by the transcript-parsing bug fixed in the previous commit: that bug deleted the state file on the first Stop in any session, so neither session looped. Fixing it exposed the leak. Fix: setup writes CLAUDE_CODE_SESSION_ID into the frontmatter; the hook compares against .session_id from its stdin JSON and exits silently on mismatch. State files without session_id (written by old setup scripts) fall through to preserve existing behavior.
168 lines
6.7 KiB
Bash
Executable File
168 lines
6.7 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 and strip surrounding quotes if present
|
|
COMPLETION_PROMISE=$(echo "$FRONTMATTER" | grep '^completion_promise:' | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/')
|
|
|
|
# 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. `tail -1` alone would often grab a
|
|
# tool_use or thinking block, leaving no text to check. Instead, slurp all
|
|
# assistant lines, flatten to text blocks only, and take the last one.
|
|
#
|
|
# `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.
|
|
LAST_OUTPUT=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | jq -rs '
|
|
map(.message.content[]? | select(.type == "text") | .text) | last // ""
|
|
' 2>/dev/null) || LAST_OUTPUT=""
|
|
|
|
# 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
|
|
PROMISE_TEXT=$(echo "$LAST_OUTPUT" | perl -0777 -pe 's/.*?<promise>(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g' 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.$$"
|
|
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
|