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
2 changes: 1 addition & 1 deletion continual-learning/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
9 changes: 9 additions & 0 deletions continual-learning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion continual-learning/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\""
}
]
}
Expand Down
2 changes: 1 addition & 1 deletion ralph-loop/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions ralph-loop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions ralph-loop/hooks/capture-response.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,8 +45,17 @@ if [[ -z "$RESPONSE_TEXT" ]]; then
exit 0
fi

# Check for <promise>TEXT</promise> in the response
PROMISE_TEXT=$(echo "$RESPONSE_TEXT" | perl -0777 -pe 's/.*?<promise>(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g' 2>/dev/null || echo "")
# Extract the promise text only when a complete <promise>TEXT</promise> 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>(.*?)<\/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"
Expand Down
4 changes: 2 additions & 2 deletions ralph-loop/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
Expand Down
27 changes: 20 additions & 7 deletions ralph-loop/hooks/stop-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions ralph-loop/tests/capture-response.test.sh
Original file line number Diff line number Diff line change
@@ -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. <promise>ALL TESTS PASS</promise>" "ALL TESTS PASS" "done"
run_case "tagged promise with whitespace" "<promise> ALL TESTS PASS </promise>" "ALL TESTS PASS" "done"
run_case "untagged response equal to promise" "ALL TESTS PASS" "ALL TESTS PASS" "not-done"
run_case "different tagged promise" "<promise>NOPE</promise>" "ALL TESTS PASS" "not-done"
run_case "unclosed tag" "<promise>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."
111 changes: 111 additions & 0 deletions ralph-loop/tests/prerequisites.test.sh
Original file line number Diff line number Diff line change
@@ -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":"<promise>ALL TESTS PASS</promise>"}' \
| 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."
46 changes: 46 additions & 0 deletions ralph-loop/tests/test_ralph_prompt.py
Original file line number Diff line number Diff line change
@@ -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": "<promise>DONE</promise>"}), 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()