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()