stop-hook.sh 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. #!/bin/bash
  2. # Ralph Loop Stop Hook
  3. # Prevents session exit when a ralph-loop is active
  4. # Feeds Claude's output back as input to continue the loop
  5. set -euo pipefail
  6. # Read hook input from stdin (advanced stop hook API)
  7. HOOK_INPUT=$(cat)
  8. # Check if ralph-loop is active
  9. RALPH_STATE_FILE=".claude/ralph-loop.local.md"
  10. if [[ ! -f "$RALPH_STATE_FILE" ]]; then
  11. # No active loop - allow exit
  12. exit 0
  13. fi
  14. # Parse markdown frontmatter (YAML between ---) and extract values
  15. FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$RALPH_STATE_FILE")
  16. ITERATION=$(echo "$FRONTMATTER" | grep '^iteration:' | sed 's/iteration: *//')
  17. MAX_ITERATIONS=$(echo "$FRONTMATTER" | grep '^max_iterations:' | sed 's/max_iterations: *//')
  18. # Extract completion_promise and strip surrounding quotes if present
  19. COMPLETION_PROMISE=$(echo "$FRONTMATTER" | grep '^completion_promise:' | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/')
  20. # Validate numeric fields before arithmetic operations
  21. if [[ ! "$ITERATION" =~ ^[0-9]+$ ]]; then
  22. echo "⚠️ Ralph loop: State file corrupted" >&2
  23. echo " File: $RALPH_STATE_FILE" >&2
  24. echo " Problem: 'iteration' field is not a valid number (got: '$ITERATION')" >&2
  25. echo "" >&2
  26. echo " This usually means the state file was manually edited or corrupted." >&2
  27. echo " Ralph loop is stopping. Run /ralph-loop again to start fresh." >&2
  28. rm "$RALPH_STATE_FILE"
  29. exit 0
  30. fi
  31. if [[ ! "$MAX_ITERATIONS" =~ ^[0-9]+$ ]]; then
  32. echo "⚠️ Ralph loop: State file corrupted" >&2
  33. echo " File: $RALPH_STATE_FILE" >&2
  34. echo " Problem: 'max_iterations' field is not a valid number (got: '$MAX_ITERATIONS')" >&2
  35. echo "" >&2
  36. echo " This usually means the state file was manually edited or corrupted." >&2
  37. echo " Ralph loop is stopping. Run /ralph-loop again to start fresh." >&2
  38. rm "$RALPH_STATE_FILE"
  39. exit 0
  40. fi
  41. # Check if max iterations reached
  42. if [[ $MAX_ITERATIONS -gt 0 ]] && [[ $ITERATION -ge $MAX_ITERATIONS ]]; then
  43. echo "🛑 Ralph loop: Max iterations ($MAX_ITERATIONS) reached."
  44. rm "$RALPH_STATE_FILE"
  45. exit 0
  46. fi
  47. # Get transcript path from hook input
  48. TRANSCRIPT_PATH=$(echo "$HOOK_INPUT" | jq -r '.transcript_path')
  49. if [[ ! -f "$TRANSCRIPT_PATH" ]]; then
  50. echo "⚠️ Ralph loop: Transcript file not found" >&2
  51. echo " Expected: $TRANSCRIPT_PATH" >&2
  52. echo " This is unusual and may indicate a Claude Code internal issue." >&2
  53. echo " Ralph loop is stopping." >&2
  54. rm "$RALPH_STATE_FILE"
  55. exit 0
  56. fi
  57. # Read last assistant message from transcript (JSONL format - one JSON per line)
  58. # First check if there are any assistant messages
  59. if ! grep -q '"role":"assistant"' "$TRANSCRIPT_PATH"; then
  60. echo "⚠️ Ralph loop: No assistant messages found in transcript" >&2
  61. echo " Transcript: $TRANSCRIPT_PATH" >&2
  62. echo " This is unusual and may indicate a transcript format issue" >&2
  63. echo " Ralph loop is stopping." >&2
  64. rm "$RALPH_STATE_FILE"
  65. exit 0
  66. fi
  67. # Extract the most recent assistant text block.
  68. #
  69. # Claude Code writes each content block (text/tool_use/thinking) as its own
  70. # JSONL line, all with role=assistant. `tail -1` alone would often grab a
  71. # tool_use or thinking block, leaving no text to check. Instead, slurp all
  72. # assistant lines, flatten to text blocks only, and take the last one.
  73. #
  74. # `last // ""` yields empty string when no text blocks exist (e.g. a turn
  75. # that is all tool calls). That's fine: empty text means no <promise> tag,
  76. # so the loop simply continues.
  77. LAST_OUTPUT=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | jq -rs '
  78. map(.message.content[]? | select(.type == "text") | .text) | last // ""
  79. ' 2>/dev/null) || LAST_OUTPUT=""
  80. # Check for completion promise (only if set)
  81. if [[ "$COMPLETION_PROMISE" != "null" ]] && [[ -n "$COMPLETION_PROMISE" ]]; then
  82. # Extract text from <promise> tags using Perl for multiline support
  83. # -0777 slurps entire input, s flag makes . match newlines
  84. # .*? is non-greedy (takes FIRST tag), whitespace normalized
  85. PROMISE_TEXT=$(echo "$LAST_OUTPUT" | perl -0777 -pe 's/.*?<promise>(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g' 2>/dev/null || echo "")
  86. # Use = for literal string comparison (not pattern matching)
  87. # == in [[ ]] does glob pattern matching which breaks with *, ?, [ characters
  88. if [[ -n "$PROMISE_TEXT" ]] && [[ "$PROMISE_TEXT" = "$COMPLETION_PROMISE" ]]; then
  89. echo "✅ Ralph loop: Detected <promise>$COMPLETION_PROMISE</promise>"
  90. rm "$RALPH_STATE_FILE"
  91. exit 0
  92. fi
  93. fi
  94. # Not complete - continue loop with SAME PROMPT
  95. NEXT_ITERATION=$((ITERATION + 1))
  96. # Extract prompt (everything after the closing ---)
  97. # Skip first --- line, skip until second --- line, then print everything after
  98. # Use i>=2 instead of i==2 to handle --- in prompt content
  99. PROMPT_TEXT=$(awk '/^---$/{i++; next} i>=2' "$RALPH_STATE_FILE")
  100. if [[ -z "$PROMPT_TEXT" ]]; then
  101. echo "⚠️ Ralph loop: State file corrupted or incomplete" >&2
  102. echo " File: $RALPH_STATE_FILE" >&2
  103. echo " Problem: No prompt text found" >&2
  104. echo "" >&2
  105. echo " This usually means:" >&2
  106. echo " • State file was manually edited" >&2
  107. echo " • File was corrupted during writing" >&2
  108. echo "" >&2
  109. echo " Ralph loop is stopping. Run /ralph-loop again to start fresh." >&2
  110. rm "$RALPH_STATE_FILE"
  111. exit 0
  112. fi
  113. # Update iteration in frontmatter (portable across macOS and Linux)
  114. # Create temp file, then atomically replace
  115. TEMP_FILE="${RALPH_STATE_FILE}.tmp.$$"
  116. sed "s/^iteration: .*/iteration: $NEXT_ITERATION/" "$RALPH_STATE_FILE" > "$TEMP_FILE"
  117. mv "$TEMP_FILE" "$RALPH_STATE_FILE"
  118. # Build system message with iteration count and completion promise info
  119. if [[ "$COMPLETION_PROMISE" != "null" ]] && [[ -n "$COMPLETION_PROMISE" ]]; then
  120. SYSTEM_MSG="🔄 Ralph iteration $NEXT_ITERATION | To stop: output <promise>$COMPLETION_PROMISE</promise> (ONLY when statement is TRUE - do not lie to exit!)"
  121. else
  122. SYSTEM_MSG="🔄 Ralph iteration $NEXT_ITERATION | No completion promise set - loop runs infinitely"
  123. fi
  124. # Output JSON to block the stop and feed prompt back
  125. # The "reason" field contains the prompt that will be sent back to Claude
  126. jq -n \
  127. --arg prompt "$PROMPT_TEXT" \
  128. --arg msg "$SYSTEM_MSG" \
  129. '{
  130. "decision": "block",
  131. "reason": $prompt,
  132. "systemMessage": $msg
  133. }'
  134. # Exit 0 for successful hook execution
  135. exit 0