diff --git a/continual-learning/.cursor-plugin/plugin.json b/continual-learning/.cursor-plugin/plugin.json
index 3255db70a..09d6fa93f 100644
--- a/continual-learning/.cursor-plugin/plugin.json
+++ b/continual-learning/.cursor-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "continual-learning",
"displayName": "Continual Learning",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Incrementally learns durable user preferences and workspace facts from transcript changes and keeps AGENTS.md up to date with plain bullet points.",
"author": {
"name": "Eric Zakariasson"
diff --git a/continual-learning/README.md b/continual-learning/README.md
index 410fe9252..1b992644f 100644
--- a/continual-learning/README.md
+++ b/continual-learning/README.md
@@ -20,6 +20,15 @@ It is designed to avoid noisy rewrites by:
/add-plugin continual-learning
```
+## Requirements
+
+The `stop` hook is a TypeScript script executed with [Bun](https://bun.sh) (`bun run hooks/continual-learning-stop.ts`), so `bun` must be on the `PATH` of the shell Cursor uses to run hooks. Node is not enough: the script imports `stdin` from the `bun` module.
+
+- macOS / Linux: `curl -fsSL https://bun.sh/install | bash`
+- Homebrew: `brew install oven-sh/bun/bun`
+
+Without Bun the hook exits with `bun: command not found` on every turn and the learning cadence never triggers. Check the hook output in Cursor if `AGENTS.md` is never updated.
+
## How it works
On eligible `stop` events, the hook may emit a `followup_message` that asks the agent to run the `continual-learning` skill.
diff --git a/continual-learning/hooks/hooks.json b/continual-learning/hooks/hooks.json
index 11a0f739f..d8856f5dc 100644
--- a/continual-learning/hooks/hooks.json
+++ b/continual-learning/hooks/hooks.json
@@ -3,7 +3,7 @@
"hooks": {
"stop": [
{
- "command": "bun run ${CURSOR_PLUGIN_ROOT}/hooks/continual-learning-stop.ts"
+ "command": "bun run \"${CURSOR_PLUGIN_ROOT}/hooks/continual-learning-stop.ts\""
}
]
}
diff --git a/ralph-loop/.cursor-plugin/plugin.json b/ralph-loop/.cursor-plugin/plugin.json
index 529e02ea7..789e82638 100644
--- a/ralph-loop/.cursor-plugin/plugin.json
+++ b/ralph-loop/.cursor-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "ralph-loop",
"displayName": "Ralph Loop",
- "version": "1.0.0",
+ "version": "1.0.1",
"description": "Continuous self-referential AI loops for iterative development, implementing the Ralph Wiggum technique. Run the agent in a while-true loop with the same prompt until task completion.",
"author": {
"name": "Cursor",
diff --git a/ralph-loop/README.md b/ralph-loop/README.md
index 890e184d7..31db34893 100644
--- a/ralph-loop/README.md
+++ b/ralph-loop/README.md
@@ -12,6 +12,16 @@ Two hooks drive the loop. An `afterAgentResponse` hook watches each response for
/add-plugin ralph-loop
```
+## Requirements
+
+The hooks are Bash scripts and need `jq` and `perl` on the `PATH` of the shell Cursor uses to run hooks. `perl` ships with macOS and most Linux distributions; `jq` usually does not:
+
+- macOS: `brew install jq`
+- Debian/Ubuntu: `sudo apt install jq`
+- Fedora: `sudo dnf install jq`
+
+If `jq` is missing, the hooks print a `Ralph loop: jq is required` line to the hook output and stop without changing the loop state.
+
## Quick start
> Start a ralph loop: "Build a REST API for todos. CRUD operations, input validation, tests. Output COMPLETE when done." --completion-promise "COMPLETE" --max-iterations 50
diff --git a/ralph-loop/hooks/capture-response.sh b/ralph-loop/hooks/capture-response.sh
index d8da50a2a..c04c2755e 100755
--- a/ralph-loop/hooks/capture-response.sh
+++ b/ralph-loop/hooks/capture-response.sh
@@ -20,9 +20,18 @@ if [[ ! -f "$STATE_FILE" ]]; then
exit 0
fi
-# Extract completion promise from state file frontmatter
-FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE")
-COMPLETION_PROMISE=$(echo "$FRONTMATTER" | grep '^completion_promise:' | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/')
+# jq parses the hook input and perl extracts the promise. Fail loudly, not silently.
+for dep in jq perl; do
+ if ! command -v "$dep" >/dev/null 2>&1; then
+ echo "Ralph loop: $dep is required but was not found on PATH. Completion promises cannot be detected until it is installed." >&2
+ exit 0
+ fi
+done
+
+# Extract completion promise from state file frontmatter. A missing key is an
+# empty value, not a hook failure (see stop-hook.sh).
+FRONTMATTER=$(awk 'NR == 1 && /^---$/ { next } /^---$/ { exit } { print }' "$STATE_FILE")
+COMPLETION_PROMISE=$(echo "$FRONTMATTER" | { grep '^completion_promise:' || true; } | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/')
# No promise configured, nothing to check
if [[ "$COMPLETION_PROMISE" = "null" ]] || [[ -z "$COMPLETION_PROMISE" ]]; then
@@ -36,8 +45,17 @@ if [[ -z "$RESPONSE_TEXT" ]]; then
exit 0
fi
-# Check for TEXT in the response
-PROMISE_TEXT=$(echo "$RESPONSE_TEXT" | perl -0777 -pe 's/.*?(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g' 2>/dev/null || echo "")
+# Extract the promise text only when a complete TEXT tag is
+# present. `perl -ne` prints nothing when the tag is absent, so an untagged
+# response can never satisfy the completion promise below.
+PROMISE_TEXT=$(echo "$RESPONSE_TEXT" | perl -0777 -ne '
+ if (/(.*?)<\/promise>/s) {
+ my $p = $1;
+ $p =~ s/^\s+|\s+$//g;
+ $p =~ s/\s+/ /g;
+ print $p;
+ }
+' 2>/dev/null || echo "")
if [[ -n "$PROMISE_TEXT" ]] && [[ "$PROMISE_TEXT" = "$COMPLETION_PROMISE" ]]; then
touch "$DONE_FLAG"
diff --git a/ralph-loop/hooks/hooks.json b/ralph-loop/hooks/hooks.json
index 04a996689..c007279f0 100644
--- a/ralph-loop/hooks/hooks.json
+++ b/ralph-loop/hooks/hooks.json
@@ -3,12 +3,12 @@
"hooks": {
"afterAgentResponse": [
{
- "command": "./hooks/capture-response.sh"
+ "command": "bash \"${CURSOR_PLUGIN_ROOT}/hooks/capture-response.sh\""
}
],
"stop": [
{
- "command": "./hooks/stop-hook.sh",
+ "command": "bash \"${CURSOR_PLUGIN_ROOT}/hooks/stop-hook.sh\"",
"loop_limit": null
}
]
diff --git a/ralph-loop/hooks/stop-hook.sh b/ralph-loop/hooks/stop-hook.sh
index ba0ba3434..91008345d 100755
--- a/ralph-loop/hooks/stop-hook.sh
+++ b/ralph-loop/hooks/stop-hook.sh
@@ -21,11 +21,20 @@ if [[ ! -f "$STATE_FILE" ]]; then
exit 0
fi
-# Parse state file frontmatter
-FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$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/')
+# jq is required to emit the followup_message. Check before touching the state
+# file so a missing dependency never advances the iteration counter.
+if ! command -v jq >/dev/null 2>&1; then
+ echo "Ralph loop: jq is required but was not found on PATH (brew install jq / apt install jq). Loop paused; the state file is untouched." >&2
+ exit 0
+fi
+
+# Parse state file frontmatter. `grep` exits 1 when a key is absent, which
+# under `set -e` would end this hook silently before the checks below can
+# report the corrupted file; `|| true` keeps a missing key as an empty value.
+FRONTMATTER=$(awk 'NR == 1 && /^---$/ { next } /^---$/ { exit } { print }' "$STATE_FILE")
+ITERATION=$(echo "$FRONTMATTER" | { grep '^iteration:' || true; } | sed 's/iteration: *//')
+MAX_ITERATIONS=$(echo "$FRONTMATTER" | { grep '^max_iterations:' || true; } | sed 's/max_iterations: *//')
+COMPLETION_PROMISE=$(echo "$FRONTMATTER" | { grep '^completion_promise:' || true; } | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/')
# Validate iteration is numeric
if [[ ! "$ITERATION" =~ ^[0-9]+$ ]]; then
@@ -55,7 +64,7 @@ if [[ $MAX_ITERATIONS -gt 0 ]] && [[ $ITERATION -ge $MAX_ITERATIONS ]]; then
fi
# Extract prompt text (everything after the closing --- in frontmatter)
-PROMPT_TEXT=$(awk '/^---$/{i++; next} i>=2' "$STATE_FILE")
+PROMPT_TEXT=$(awk 'i >= 2 { print; next } /^---$/ { i++ }' "$STATE_FILE")
if [[ -z "$PROMPT_TEXT" ]]; then
echo "Ralph loop: no prompt text found in state file. Stopping." >&2
@@ -66,7 +75,11 @@ fi
# Increment iteration
NEXT_ITERATION=$((ITERATION + 1))
TEMP_FILE="${STATE_FILE}.tmp.$$"
-sed "s/^iteration: .*/iteration: $NEXT_ITERATION/" "$STATE_FILE" > "$TEMP_FILE"
+awk -v iteration="$NEXT_ITERATION" '
+ /^---$/ && boundaries < 2 { boundaries++ }
+ boundaries == 1 && /^iteration: / { print "iteration: " 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/capture-response.test.sh b/ralph-loop/tests/capture-response.test.sh
new file mode 100755
index 000000000..95ce61f2c
--- /dev/null
+++ b/ralph-loop/tests/capture-response.test.sh
@@ -0,0 +1,53 @@
+#!/bin/bash
+
+# Tests for ralph-loop/hooks/capture-response.sh.
+# Standalone (no test runner): bash ralph-loop/tests/capture-response.test.sh
+
+set -uo pipefail
+
+HOOK="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/hooks/capture-response.sh"
+FAILURES=0
+
+run_case() {
+ local name="$1" response="$2" promise="$3" expected="$4"
+ local tmp
+ tmp="$(mktemp -d)"
+ mkdir -p "$tmp/.cursor/ralph"
+ {
+ echo "---"
+ echo "iteration: 1"
+ echo "max_iterations: 5"
+ echo "completion_promise: $promise"
+ echo "---"
+ echo "do the thing"
+ } > "$tmp/.cursor/ralph/scratchpad.md"
+
+ jq -n --arg text "$response" '{text: $text}' \
+ | CURSOR_PROJECT_DIR="$tmp" "$HOOK"
+
+ local actual="not-done"
+ [[ -f "$tmp/.cursor/ralph/done" ]] && actual="done"
+
+ if [[ "$actual" = "$expected" ]]; then
+ echo "PASS: $name"
+ else
+ echo "FAIL: $name (expected $expected, got $actual)"
+ FAILURES=$((FAILURES + 1))
+ fi
+
+ rm -rf "$tmp"
+}
+
+run_case "tagged promise matching" "All good. ALL TESTS PASS" "ALL TESTS PASS" "done"
+run_case "tagged promise with whitespace" " ALL TESTS PASS " "ALL TESTS PASS" "done"
+run_case "untagged response equal to promise" "ALL TESTS PASS" "ALL TESTS PASS" "not-done"
+run_case "different tagged promise" "NOPE" "ALL TESTS PASS" "not-done"
+run_case "unclosed tag" "ALL TESTS PASS" "ALL TESTS PASS" "not-done"
+run_case "no promise configured" "ALL TESTS PASS" "null" "not-done"
+
+if [[ "$FAILURES" -gt 0 ]]; then
+ echo "$FAILURES test(s) failed."
+ exit 1
+fi
+
+echo "All tests passed."
diff --git a/ralph-loop/tests/prerequisites.test.sh b/ralph-loop/tests/prerequisites.test.sh
new file mode 100755
index 000000000..323a36a8d
--- /dev/null
+++ b/ralph-loop/tests/prerequisites.test.sh
@@ -0,0 +1,111 @@
+#!/bin/bash
+
+# Tests for the dependency guards in ralph-loop/hooks/*.sh.
+# Standalone (no test runner): bash ralph-loop/tests/prerequisites.test.sh
+#
+# Each case runs a hook with a PATH that contains the usual coreutils but
+# deliberately omits jq (and, for one case, perl), then checks that the hook
+# reports the missing tool and leaves the loop state untouched.
+
+set -uo pipefail
+
+HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/hooks"
+FAILURES=0
+
+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 TESTS PASS"
+---
+do the thing
+EOF
+ echo "$dir"
+}
+
+# Build a PATH with everything the hooks need except the tools named in $@.
+make_path_without() {
+ local bindir
+ bindir="$(mktemp -d)"
+ local tool
+ for tool in bash sh cat sed grep awk mv rm touch mktemp dirname basename jq perl; do
+ case " $* " in *" $tool "*) continue ;; esac
+ if command -v "$tool" >/dev/null 2>&1; then
+ ln -s "$(command -v "$tool")" "$bindir/$tool"
+ fi
+ done
+ echo "$bindir"
+}
+
+iteration_of() {
+ sed -n 's/^iteration: *//p' "$1/.cursor/ralph/scratchpad.md"
+}
+
+check() {
+ local name="$1" ok="$2"
+ if [[ "$ok" == "1" ]]; then
+ echo "PASS: $name"
+ else
+ echo "FAIL: $name"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+# --- stop hook without jq: message, exit 0, iteration not advanced, no followup
+
+project="$(make_project)"
+bindir="$(make_path_without jq)"
+stdout="$(echo '{"conversation_id":"x","status":"completed","loop_count":0}' \
+ | PATH="$bindir" CURSOR_PROJECT_DIR="$project" bash "$HOOKS_DIR/stop-hook.sh" 2>"$project/stderr")"
+status=$?
+check "stop-hook without jq exits 0" "$([[ $status -eq 0 ]] && echo 1 || echo 0)"
+check "stop-hook without jq names jq on stderr" "$(grep -q 'jq is required' "$project/stderr" && echo 1 || echo 0)"
+check "stop-hook without jq emits no followup" "$([[ -z "$stdout" ]] && echo 1 || echo 0)"
+check "stop-hook without jq leaves iteration at 1" "$([[ "$(iteration_of "$project")" == "1" ]] && echo 1 || echo 0)"
+rm -rf "$project" "$bindir"
+
+# --- stop hook with jq (control): followup emitted, iteration advanced
+
+if command -v jq >/dev/null 2>&1; then
+ project="$(make_project)"
+ stdout="$(echo '{"conversation_id":"x","status":"completed","loop_count":0}' \
+ | CURSOR_PROJECT_DIR="$project" bash "$HOOKS_DIR/stop-hook.sh" 2>/dev/null)"
+ check "stop-hook with jq emits followup_message" "$(printf '%s' "$stdout" | jq -e '.followup_message | test("iteration 2")' >/dev/null 2>&1 && echo 1 || echo 0)"
+ check "stop-hook with jq advances iteration to 2" "$([[ "$(iteration_of "$project")" == "2" ]] && echo 1 || echo 0)"
+ rm -rf "$project"
+else
+ echo "SKIP: control cases need jq on the host"
+fi
+
+# --- capture-response without jq / without perl: message, exit 0, no done flag
+
+for missing in jq perl; do
+ project="$(make_project)"
+ bindir="$(make_path_without "$missing")"
+ echo '{"text":"ALL TESTS PASS"}' \
+ | PATH="$bindir" CURSOR_PROJECT_DIR="$project" bash "$HOOKS_DIR/capture-response.sh" 2>"$project/stderr"
+ status=$?
+ check "capture-response without $missing exits 0" "$([[ $status -eq 0 ]] && echo 1 || echo 0)"
+ check "capture-response without $missing names $missing on stderr" "$(grep -q "$missing is required" "$project/stderr" && echo 1 || echo 0)"
+ check "capture-response without $missing does not create the done flag" "$([[ ! -f "$project/.cursor/ralph/done" ]] && echo 1 || echo 0)"
+ rm -rf "$project" "$bindir"
+done
+
+# --- no active loop: guards must stay silent so unrelated projects see no noise
+
+project="$(mktemp -d)"
+bindir="$(make_path_without jq)"
+echo '{"conversation_id":"x","status":"completed","loop_count":0}' \
+ | PATH="$bindir" CURSOR_PROJECT_DIR="$project" bash "$HOOKS_DIR/stop-hook.sh" 2>"$project/stderr" >/dev/null
+check "stop-hook without jq is silent when no loop is active" "$([[ ! -s "$project/stderr" ]] && echo 1 || echo 0)"
+rm -rf "$project" "$bindir"
+
+if [[ "$FAILURES" -gt 0 ]]; then
+ echo "$FAILURES check(s) failed."
+ exit 1
+fi
+echo "All checks passed."
diff --git a/ralph-loop/tests/test_ralph_prompt.py b/ralph-loop/tests/test_ralph_prompt.py
new file mode 100644
index 000000000..570bb6457
--- /dev/null
+++ b/ralph-loop/tests/test_ralph_prompt.py
@@ -0,0 +1,46 @@
+import json
+import os
+from pathlib import Path
+import subprocess
+import tempfile
+import unittest
+
+HOOK = Path(__file__).resolve().parents[1] / "hooks" / "stop-hook.sh"
+
+class StopHookTest(unittest.TestCase):
+ def run_hook(self, status="completed", prompt="Build the feature.", iteration=1, maximum=5):
+ with tempfile.TemporaryDirectory() as directory:
+ state = Path(directory) / ".cursor/ralph/scratchpad.md"
+ state.parent.mkdir(parents=True)
+ initial = f'---\niteration: {iteration}\nmax_iterations: {maximum}\ncompletion_promise: null\n---\n\n{prompt}\n'
+ state.write_text(initial)
+ result = subprocess.run(["bash", str(HOOK)], input=json.dumps({"status": status, "loop_count": 0}), text=True, capture_output=True, env={**os.environ, "CURSOR_PROJECT_DIR": directory})
+ self.assertEqual(result.returncode, 0, result.stderr)
+ return result.stdout, state.read_text() if state.exists() else None, initial
+
+ def test_iterations_preserve_prompt_metadata_and_markdown_rules(self):
+ prompt = "Use this exact fixture:\niteration: 99\n---\niteration: 77\nKeep this."
+ output, state, initial = self.run_hook(prompt=prompt)
+ self.assertEqual(json.loads(output)["followup_message"].split("\n\n", 1)[1], "\n" + prompt)
+ self.assertEqual(state, initial.replace("iteration: 1\n", "iteration: 2\n", 1))
+
+ def test_body_iteration_line_is_not_rewritten(self):
+ output, state, initial = self.run_hook(prompt="Fixture:\niteration: 99")
+ self.assertEqual(state, initial.replace("iteration: 1\n", "iteration: 2\n", 1))
+
+ def test_capture_reads_only_initial_frontmatter(self):
+ with tempfile.TemporaryDirectory() as directory:
+ state = Path(directory) / ".cursor/ralph/scratchpad.md"
+ state.parent.mkdir(parents=True)
+ state.write_text('---\niteration: 1\nmax_iterations: 5\ncompletion_promise: "DONE"\n---\nExample:\n---\ncompletion_promise: "OTHER"\n---\n')
+ result = subprocess.run(["bash", str(HOOK.with_name("capture-response.sh"))], input=json.dumps({"text": "DONE"}), text=True, capture_output=True, env={**os.environ, "CURSOR_PROJECT_DIR": directory})
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertTrue(state.with_name("done").exists())
+
+ def test_ordinary_prompt_continues(self):
+ output, state, initial = self.run_hook()
+ self.assertEqual(json.loads(output)["followup_message"].split("\n\n", 1)[1], "\nBuild the feature.")
+ self.assertEqual(state, initial.replace("iteration: 1\n", "iteration: 2\n", 1))
+
+if __name__ == "__main__":
+ unittest.main()