Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions ralph-loop/hooks/capture-response.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 15 additions & 5 deletions ralph-loop/hooks/stop-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/')
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
101 changes: 101 additions & 0 deletions ralph-loop/tests/body-separators.test.sh
Original file line number Diff line number Diff line change
@@ -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 <promise>ALL GREEN</promise> 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. <promise>ALL GREEN</promise>"}' | 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"