From a968baa575a817abdcdc27ca5f1adf4d14ed8c09 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Thu, 29 Oct 2026 17:46:54 +0300 Subject: [PATCH] fix(ralph-loop): a --- rule in the prompt body is not a second frontmatter Both hooks read the frontmatter with a sed range that reopens at every --- line, and the stop hook rebuilt the prompt by dropping every --- line. A prompt with Markdown rules lost those lines on every iteration, and a frontmatter-like block quoted in the body (iteration: not a number) made the stop hook declare the state file corrupted and delete it. Read the frontmatter as the block up to the first closing --- only, feed the body back verbatim, and rewrite iteration: inside the frontmatter alone. --- ralph-loop/hooks/capture-response.sh | 6 +- ralph-loop/hooks/stop-hook.sh | 20 +++-- ralph-loop/tests/body-separators.test.sh | 101 +++++++++++++++++++++++ 3 files changed, 120 insertions(+), 7 deletions(-) create mode 100755 ralph-loop/tests/body-separators.test.sh diff --git a/ralph-loop/hooks/capture-response.sh b/ralph-loop/hooks/capture-response.sh index d8da50a2a..b0f424875 100755 --- a/ralph-loop/hooks/capture-response.sh +++ b/ralph-loop/hooks/capture-response.sh @@ -20,8 +20,10 @@ if [[ ! -f "$STATE_FILE" ]]; then exit 0 fi -# Extract completion promise from state file frontmatter -FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE") +# Extract completion promise from the state file frontmatter (the block +# between the opening `---` on line 1 and the first closing `---`; a `---` +# rule in the prompt body must not reopen the range). +FRONTMATTER=$(awk 'NR == 1 && /^---$/ { in_fm = 1; next } in_fm && /^---$/ { exit } in_fm' "$STATE_FILE") COMPLETION_PROMISE=$(echo "$FRONTMATTER" | grep '^completion_promise:' | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/') # No promise configured, nothing to check diff --git a/ralph-loop/hooks/stop-hook.sh b/ralph-loop/hooks/stop-hook.sh index ba0ba3434..d37aea9b1 100755 --- a/ralph-loop/hooks/stop-hook.sh +++ b/ralph-loop/hooks/stop-hook.sh @@ -21,8 +21,10 @@ if [[ ! -f "$STATE_FILE" ]]; then exit 0 fi -# Parse state file frontmatter -FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE") +# Parse state file frontmatter: the block between the opening `---` on line 1 +# and the first closing `---`. A `---` later in the prompt body is a Markdown +# rule, not a second frontmatter block, so it must not reopen the range. +FRONTMATTER=$(awk 'NR == 1 && /^---$/ { in_fm = 1; next } in_fm && /^---$/ { exit } in_fm' "$STATE_FILE") ITERATION=$(echo "$FRONTMATTER" | grep '^iteration:' | sed 's/iteration: *//') MAX_ITERATIONS=$(echo "$FRONTMATTER" | grep '^max_iterations:' | sed 's/max_iterations: *//') COMPLETION_PROMISE=$(echo "$FRONTMATTER" | grep '^completion_promise:' | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/') @@ -54,8 +56,9 @@ if [[ $MAX_ITERATIONS -gt 0 ]] && [[ $ITERATION -ge $MAX_ITERATIONS ]]; then exit 0 fi -# Extract prompt text (everything after the closing --- in frontmatter) -PROMPT_TEXT=$(awk '/^---$/{i++; next} i>=2' "$STATE_FILE") +# Extract prompt text: everything after the closing --- of the frontmatter, +# verbatim, including any --- rules the prompt itself contains. +PROMPT_TEXT=$(awk 'in_body { print; next } NR == 1 && /^---$/ { in_fm = 1; next } in_fm && /^---$/ { in_body = 1 }' "$STATE_FILE") if [[ -z "$PROMPT_TEXT" ]]; then echo "Ralph loop: no prompt text found in state file. Stopping." >&2 @@ -66,7 +69,14 @@ fi # Increment iteration NEXT_ITERATION=$((ITERATION + 1)) TEMP_FILE="${STATE_FILE}.tmp.$$" -sed "s/^iteration: .*/iteration: $NEXT_ITERATION/" "$STATE_FILE" > "$TEMP_FILE" +# Only the frontmatter line is rewritten; an `iteration:` line in the prompt +# body is prose and stays as it is. +awk -v next_iteration="$NEXT_ITERATION" ' + NR == 1 && /^---$/ { in_fm = 1; print; next } + in_fm && /^---$/ { in_fm = 0 } + in_fm && /^iteration:/ { print "iteration: " next_iteration; next } + { print } +' "$STATE_FILE" > "$TEMP_FILE" mv "$TEMP_FILE" "$STATE_FILE" # Build the followup message: iteration context + original prompt diff --git a/ralph-loop/tests/body-separators.test.sh b/ralph-loop/tests/body-separators.test.sh new file mode 100755 index 000000000..a4ba2f3ff --- /dev/null +++ b/ralph-loop/tests/body-separators.test.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +# Tests for prompts that contain `---` lines (Markdown rules) in the body of +# .cursor/ralph/scratchpad.md. +# Standalone (no test runner): bash ralph-loop/tests/body-separators.test.sh +# +# The hooks must read the frontmatter as the block between the opening `---` +# on line 1 and the first closing `---` only, feed the prompt body back +# verbatim, and rewrite the `iteration:` line in the frontmatter alone. + +set -uo pipefail + +HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/hooks" +FAILURES=0 + +check() { + local name="$1" ok="$2" + if [[ "$ok" == "1" ]]; then + echo "PASS: $name" + else + echo "FAIL: $name" + FAILURES=$((FAILURES + 1)) + fi +} + +make_project() { + local dir + dir="$(mktemp -d)" + mkdir -p "$dir/.cursor/ralph" + cat > "$dir/.cursor/ralph/scratchpad.md" <<'EOF' +--- +iteration: 1 +max_iterations: 5 +completion_promise: "ALL GREEN" +--- + +# Spec + +--- + +Step A: iteration: keep this line as it is. + +--- + +Step B. +EOF + echo "$dir" +} + +# --- stop hook: the followup carries the body verbatim, rules included + +project="$(make_project)" +followup="$(echo '{"status":"completed","loop_count":0}' | CURSOR_PROJECT_DIR="$project" bash "$HOOKS_DIR/stop-hook.sh" 2>/tmp/ralph-stderr.$$ | jq -r '.followup_message')" +stderr="$(cat /tmp/ralph-stderr.$$)" +rm -f /tmp/ralph-stderr.$$ + +expected_body="$(awk 'in_body { print; next } NR == 1 && /^---$/ { in_fm = 1; next } in_fm && /^---$/ { in_body = 1 }' "$project/.cursor/ralph/scratchpad.md" | sed 's/^iteration: .*//')" +check "stop hook does not report a corrupted state file" "$([[ -z "$stderr" ]] && echo 1 || echo 0)" +check "stop hook emits a followup" "$([[ -n "$followup" ]] && echo 1 || echo 0)" +check "followup header names iteration 2 and the promise" "$([[ "$followup" == "[Ralph loop iteration 2. To complete: output ALL GREEN ONLY when genuinely true.]"* ]] && echo 1 || echo 0)" +check "followup keeps both --- rules" "$([[ "$(printf '%s\n' "$followup" | grep -c '^---$')" == "2" ]] && echo 1 || echo 0)" +check "followup keeps the body's iteration: line" "$(printf '%s\n' "$followup" | grep -q '^Step A: iteration: keep this line as it is\.$' && echo 1 || echo 0)" +check "followup ends with Step B" "$([[ "$followup" == *"Step B." ]] && echo 1 || echo 0)" + +# --- stop hook: only the frontmatter iteration is rewritten + +state="$project/.cursor/ralph/scratchpad.md" +check "frontmatter iteration advanced to 2" "$([[ "$(sed -n '2p' "$state")" == "iteration: 2" ]] && echo 1 || echo 0)" +check "body iteration: line untouched" "$(grep -q '^Step A: iteration: keep this line as it is\.$' "$state" && echo 1 || echo 0)" +check "state file still has exactly one frontmatter iteration line" "$([[ "$(grep -c '^iteration:' "$state")" == "1" ]] && echo 1 || echo 0)" +rm -rf "$project" + +# --- capture hook: the promise is still read from the frontmatter + +project="$(make_project)" +echo '{"text":"done. ALL GREEN"}' | CURSOR_PROJECT_DIR="$project" bash "$HOOKS_DIR/capture-response.sh" +check "capture hook sets the done flag with rules in the body" "$([[ -f "$project/.cursor/ralph/done" ]] && echo 1 || echo 0)" +rm -rf "$project" + +# --- body with a second frontmatter-like block does not corrupt the loop + +project="$(make_project)" +cat >> "$project/.cursor/ralph/scratchpad.md" <<'EOF' + +Write a SKILL.md that starts with: + +--- +name: example +iteration: not a number +--- +EOF +followup="$(echo '{"status":"completed","loop_count":0}' | CURSOR_PROJECT_DIR="$project" bash "$HOOKS_DIR/stop-hook.sh" 2>/dev/null | jq -r '.followup_message')" +check "a frontmatter-like block in the body does not stop the loop" "$([[ -f "$project/.cursor/ralph/scratchpad.md" && -n "$followup" ]] && echo 1 || echo 0)" +check "the quoted block comes back verbatim" "$(printf '%s\n' "$followup" | grep -q '^iteration: not a number$' && echo 1 || echo 0)" +rm -rf "$project" + +if [[ "$FAILURES" -gt 0 ]]; then + echo "$FAILURES failure(s)" + exit 1 +fi +echo "all passed"