diff --git a/.cursor/rules/ralph-prd.mdc b/.cursor/rules/ralph-prd.mdc
new file mode 100644
index 00000000..1e605116
--- /dev/null
+++ b/.cursor/rules/ralph-prd.mdc
@@ -0,0 +1,138 @@
+# Ralph PRD Generator Rule
+
+This rule guides Cursor to generate Product Requirements Documents (PRDs) for the Ralph autonomous agent system.
+
+## Purpose
+
+Create detailed PRDs that are clear, actionable, and suitable for conversion to `prd.json` format for autonomous execution by Ralph.
+
+## The Job
+
+1. Receive a feature description from the user
+2. Ask 3-5 essential clarifying questions (with lettered options)
+3. Generate a structured PRD based on answers
+4. Save to `tasks/prd-[feature-name].md`
+
+**Important:** Do NOT start implementing. Just create the PRD.
+
+## Step 1: Clarifying Questions
+
+Ask only critical questions where the initial prompt is ambiguous. Focus on:
+
+- **Problem/Goal:** What problem does this solve?
+- **Core Functionality:** What are the key actions?
+- **Scope/Boundaries:** What should it NOT do?
+- **Success Criteria:** How do we know it's done?
+
+### Format Questions Like This:
+
+```
+1. What is the primary goal of this feature?
+ A. Improve user onboarding experience
+ B. Increase user retention
+ C. Reduce support burden
+ D. Other: [please specify]
+
+2. Who is the target user?
+ A. New users only
+ B. Existing users only
+ C. All users
+ D. Admin users only
+
+3. What is the scope?
+ A. Minimal viable version
+ B. Full-featured implementation
+ C. Just the backend/API
+ D. Just the UI
+```
+
+This lets users respond with "1A, 2C, 3B" for quick iteration.
+
+## Step 2: PRD Structure
+
+Generate the PRD with these sections:
+
+### 1. Introduction/Overview
+Brief description of the feature and the problem it solves.
+
+### 2. Goals
+Specific, measurable objectives (bullet list).
+
+### 3. User Stories
+Each story needs:
+- **Title:** Short descriptive name
+- **Description:** "As a [user], I want [feature] so that [benefit]"
+- **Acceptance Criteria:** Verifiable checklist of what "done" means
+
+Each story should be small enough to implement in one focused session.
+
+**Format:**
+```markdown
+### US-001: [Title]
+**Description:** As a [user], I want [feature] so that [benefit].
+
+**Acceptance Criteria:**
+- [ ] Specific verifiable criterion
+- [ ] Another criterion
+- [ ] Typecheck/lint passes
+- [ ] **[UI stories only]** Verify in browser (use browser MCP tools if available, or mark for manual verification)
+```
+
+**Important:**
+- Acceptance criteria must be verifiable, not vague. "Works correctly" is bad. "Button shows confirmation dialog before deleting" is good.
+- **For any story with UI changes:** Always include browser verification requirement. If browser MCP tools are available, use them. Otherwise, mark for manual verification or require automated tests.
+
+### 4. Functional Requirements
+Numbered list of specific functionalities:
+- "FR-1: The system must allow users to..."
+- "FR-2: When a user clicks X, the system must..."
+
+Be explicit and unambiguous.
+
+### 5. Non-Goals (Out of Scope)
+What this feature will NOT include. Critical for managing scope.
+
+### 6. Design Considerations (Optional)
+- UI/UX requirements
+- Link to mockups if available
+- Relevant existing components to reuse
+
+### 7. Technical Considerations (Optional)
+- Known constraints or dependencies
+- Integration points with existing systems
+- Performance requirements
+
+### 8. Success Metrics
+How will success be measured?
+- "Reduce time to complete X by 50%"
+- "Increase conversion rate by 10%"
+
+### 9. Open Questions
+Remaining questions or areas needing clarification.
+
+## Writing for Junior Developers
+
+The PRD reader may be a junior developer or AI agent. Therefore:
+
+- Be explicit and unambiguous
+- Avoid jargon or explain it
+- Provide enough detail to understand purpose and core logic
+- Number requirements for easy reference
+- Use concrete examples where helpful
+
+## Output
+
+- **Format:** Markdown (`.md`)
+- **Location:** `tasks/`
+- **Filename:** `prd-[feature-name].md` (kebab-case)
+
+## Checklist
+
+Before saving the PRD:
+
+- [ ] Asked clarifying questions with lettered options
+- [ ] Incorporated user's answers
+- [ ] User stories are small and specific
+- [ ] Functional requirements are numbered and unambiguous
+- [ ] Non-goals section defines clear boundaries
+- [ ] Saved to `tasks/prd-[feature-name].md`
diff --git a/AGENTS.md b/AGENTS.md
index a2d3fb64..23b74799 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,7 @@
## Overview
-Ralph is an autonomous AI agent loop that runs Amp repeatedly until all PRD items are complete. Each iteration is a fresh Amp instance with clean context.
+Ralph is an autonomous AI agent loop that runs an AI worker (default: Amp, optional: Cursor CLI) repeatedly until all PRD items are complete. Each iteration is a fresh worker invocation with clean context.
## Commands
@@ -13,15 +13,22 @@ cd flowchart && npm run dev
# Build the flowchart
cd flowchart && npm run build
-# Run Ralph (from your project that has prd.json)
-./ralph.sh [max_iterations]
+# Run Ralph (from your project that has scripts/ralph/prd.json)
+./scripts/ralph/ralph.sh [max_iterations] [--worker amp|cursor] [--cursor-timeout SECONDS]
+
+# Convert PRD markdown to prd.json using Cursor CLI
+./scripts/ralph/cursor/convert-to-prd-json.sh tasks/prd-[feature-name].md [--model MODEL] [--out OUT_JSON]
```
## Key Files
-- `ralph.sh` - The bash loop that spawns fresh Amp instances
-- `prompt.md` - Instructions given to each Amp instance
-- `prd.json.example` - Example PRD format
+- `scripts/ralph/ralph.sh` - The bash loop (Amp + optional Cursor worker)
+- `scripts/ralph/prompt.md` - Instructions given to each Amp iteration
+- `scripts/ralph/cursor/prompt.cursor.md` - Instructions given to each Cursor iteration
+- `scripts/ralph/cursor/convert-to-prd-json.sh` - Convert PRD markdown → `scripts/ralph/prd.json` via Cursor CLI
+- `scripts/ralph/prd.json.example` - Example PRD format
+- `scripts/ralph/prd.json` - User stories with `passes` status (the task list)
+- `scripts/ralph/progress.txt` - Append-only learnings for future iterations
- `flowchart/` - Interactive React Flow diagram explaining how Ralph works
## Flowchart
@@ -37,7 +44,8 @@ npm run dev
## Patterns
-- Each iteration spawns a fresh Amp instance with clean context
-- Memory persists via git history, `progress.txt`, and `prd.json`
+- Each iteration spawns a fresh worker invocation (Amp or Cursor) with clean context
+- Memory persists via git history, `scripts/ralph/progress.txt`, and `scripts/ralph/prd.json`
- Stories should be small enough to complete in one context window
- Always update AGENTS.md with discovered patterns for future iterations
+- Cursor-specific prompts are in `scripts/ralph/cursor/` subfolder
diff --git a/README.md b/README.md
index 67d98d16..2901ee04 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@

-Ralph is an autonomous AI agent loop that runs [Amp](https://ampcode.com) repeatedly until all PRD items are complete. Each iteration is a fresh Amp instance with clean context. Memory persists via git history, `progress.txt`, and `prd.json`.
+Ralph is an autonomous AI agent loop that runs an AI worker (default: [Amp](https://ampcode.com), optional: Cursor CLI) repeatedly until all PRD items are complete. Each iteration is a fresh worker invocation with clean context. Memory persists via git history, `scripts/ralph/progress.txt`, and `scripts/ralph/prd.json`.
Based on [Geoffrey Huntley's Ralph pattern](https://ghuntley.com/ralph/).
@@ -10,7 +10,9 @@ Based on [Geoffrey Huntley's Ralph pattern](https://ghuntley.com/ralph/).
## Prerequisites
-- [Amp CLI](https://ampcode.com) installed and authenticated
+- One worker installed:
+ - [Amp CLI](https://ampcode.com) installed and authenticated, and/or
+ - Cursor CLI (`cursor`) installed and authenticated
- `jq` installed (`brew install jq` on macOS)
- A git repository for your project
@@ -18,14 +20,14 @@ Based on [Geoffrey Huntley's Ralph pattern](https://ghuntley.com/ralph/).
### Option 1: Copy to your project
-Copy the ralph files into your project:
+Copy the Ralph templates into your project:
```bash
# From your project root
mkdir -p scripts/ralph
-cp /path/to/ralph/ralph.sh scripts/ralph/
-cp /path/to/ralph/prompt.md scripts/ralph/
+cp -R /path/to/ralph/scripts/ralph/* scripts/ralph/
chmod +x scripts/ralph/ralph.sh
+chmod +x scripts/ralph/cursor/convert-to-prd-json.sh
```
### Option 2: Install skills globally
@@ -53,7 +55,7 @@ This enables automatic handoff when context fills up, allowing Ralph to handle l
### 1. Create a PRD
-Use the PRD skill to generate a detailed requirements document:
+If you use Amp skills, use the PRD skill to generate a detailed requirements document:
```
Load the prd skill and create a PRD for [your feature description]
@@ -61,43 +63,60 @@ Load the prd skill and create a PRD for [your feature description]
Answer the clarifying questions. The skill saves output to `tasks/prd-[feature-name].md`.
+If you use Cursor in the IDE, you can also generate a PRD using the repo's Cursor rules (see `.cursor/rules/`).
+
### 2. Convert PRD to Ralph format
-Use the Ralph skill to convert the markdown PRD to JSON:
+If you use Amp skills, use the Ralph skill to convert the markdown PRD to JSON:
```
Load the ralph skill and convert tasks/prd-[feature-name].md to prd.json
```
-This creates `prd.json` with user stories structured for autonomous execution.
+Alternatively, you can convert PRD markdown to `scripts/ralph/prd.json` using the Cursor helper script:
+
+```bash
+./scripts/ralph/cursor/convert-to-prd-json.sh tasks/prd-[feature-name].md
+```
+
+This creates `scripts/ralph/prd.json` with user stories structured for autonomous execution.
### 3. Run Ralph
```bash
-./scripts/ralph/ralph.sh [max_iterations]
+./scripts/ralph/ralph.sh [max_iterations] [--worker amp|cursor] [--cursor-timeout SECONDS]
```
Default is 10 iterations.
-Ralph will:
-1. Create a feature branch (from PRD `branchName`)
-2. Pick the highest priority story where `passes: false`
-3. Implement that single story
-4. Run quality checks (typecheck, tests)
-5. Commit if checks pass
-6. Update `prd.json` to mark story as `passes: true`
-7. Append learnings to `progress.txt`
-8. Repeat until all stories pass or max iterations reached
+The runner loop will invoke the selected worker repeatedly. The worker prompt instructs it to:
+- Read `scripts/ralph/prd.json` and `scripts/ralph/progress.txt`
+- Implement one story per iteration, run checks, commit, and update `passes: true`
+- Stop by outputting `COMPLETE` when all stories pass
+
+Examples:
+
+```bash
+# Default worker is Amp
+./scripts/ralph/ralph.sh 10
+
+# Run with Cursor CLI (with a per-iteration timeout)
+./scripts/ralph/ralph.sh 10 --worker cursor --cursor-timeout 1800
+```
+
+Note: `--cursor-timeout` only applies if a `timeout` binary is available on your PATH. If it isn't, Ralph will run Cursor without a hard timeout.
## Key Files
| File | Purpose |
|------|---------|
-| `ralph.sh` | The bash loop that spawns fresh Amp instances |
-| `prompt.md` | Instructions given to each Amp instance |
-| `prd.json` | User stories with `passes` status (the task list) |
-| `prd.json.example` | Example PRD format for reference |
-| `progress.txt` | Append-only learnings for future iterations |
+| `scripts/ralph/ralph.sh` | The bash loop that spawns fresh worker invocations |
+| `scripts/ralph/prompt.md` | Instructions given to each Amp iteration |
+| `scripts/ralph/cursor/prompt.cursor.md` | Instructions given to each Cursor iteration |
+| `scripts/ralph/cursor/convert-to-prd-json.sh` | Convert PRD markdown → `scripts/ralph/prd.json` via Cursor CLI |
+| `scripts/ralph/prd.json` | User stories with `passes` status (the task list) |
+| `scripts/ralph/prd.json.example` | Example PRD format for reference |
+| `scripts/ralph/progress.txt` | Append-only learnings for future iterations |
| `skills/prd/` | Skill for generating PRDs |
| `skills/ralph/` | Skill for converting PRDs to JSON |
| `flowchart/` | Interactive visualization of how Ralph works |
@@ -120,10 +139,10 @@ npm run dev
### Each Iteration = Fresh Context
-Each iteration spawns a **new Amp instance** with clean context. The only memory between iterations is:
+Each iteration spawns a **new worker invocation** (Amp or Cursor) with clean context. The only memory between iterations is:
- Git history (commits from previous iterations)
-- `progress.txt` (learnings and context)
-- `prd.json` (which stories are done)
+- `scripts/ralph/progress.txt` (learnings and context)
+- `scripts/ralph/prd.json` (which stories are done)
### Small Tasks
@@ -170,25 +189,29 @@ Check current state:
```bash
# See which stories are done
-cat prd.json | jq '.userStories[] | {id, title, passes}'
+cat scripts/ralph/prd.json | jq '.userStories[] | {id, title, passes}'
# See learnings from previous iterations
-cat progress.txt
+cat scripts/ralph/progress.txt
# Check git history
git log --oneline -10
```
-## Customizing prompt.md
+## Customizing prompts
-Edit `prompt.md` to customize Ralph's behavior for your project:
+Edit the worker prompt(s) to customize Ralph's behavior for your project:
- Add project-specific quality check commands
- Include codebase conventions
- Add common gotchas for your stack
+Worker prompt locations:
+- Amp: `scripts/ralph/prompt.md`
+- Cursor: `scripts/ralph/cursor/prompt.cursor.md`
+
## Archiving
-Ralph automatically archives previous runs when you start a new feature (different `branchName`). Archives are saved to `archive/YYYY-MM-DD-feature-name/`.
+Ralph automatically archives previous runs when you start a new feature (different `branchName`). Archives are saved to `scripts/ralph/archive/YYYY-MM-DD-feature-name/`.
## References
diff --git a/bin/ralph.js b/bin/ralph.js
new file mode 100755
index 00000000..987f69ae
--- /dev/null
+++ b/bin/ralph.js
@@ -0,0 +1,226 @@
+#!/usr/bin/env node
+/**
+ * Ralph CLI - Autonomous AI agent loop installer and runner
+ *
+ * Commands:
+ * ralph init [--worker amp|cursor|both] [--force] [--cursor-rules] [--cursor-cli]
+ * ralph run [--worker amp|cursor] [--iterations N]
+ *
+ * Init options:
+ * --worker: Install files for 'amp', 'cursor', or 'both' (default: 'both')
+ * --force: Overwrite existing files
+ * --cursor-rules: Also install .cursor/rules/ralph-prd.mdc
+ * --cursor-cli: Also install .cursor/cli.json template
+ */
+
+import { fileURLToPath } from 'url';
+import { dirname, join } from 'path';
+import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, copyFileSync } from 'fs';
+import { chmodSync } from 'fs';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const PACKAGE_ROOT = join(__dirname, '..');
+const TEMPLATES_DIR = join(PACKAGE_ROOT, 'templates');
+
+// Get command and args
+const [,, command, ...args] = process.argv;
+
+if (!command) {
+ console.error('Usage: ralph [options]');
+ process.exit(1);
+}
+
+if (command === 'init') {
+ handleInit(args);
+} else if (command === 'run') {
+ handleRun(args);
+} else {
+ console.error(`Unknown command: ${command}`);
+ console.error('Usage: ralph [options]');
+ process.exit(1);
+}
+
+function handleInit(args) {
+ const flags = parseFlags(args);
+ const force = flags.has('--force');
+ const cursorRules = flags.has('--cursor-rules');
+ const cursorCli = flags.has('--cursor-cli');
+ const worker = flags.get('--worker') || 'both'; // 'amp', 'cursor', or 'both' (default)
+
+ if (worker !== 'amp' && worker !== 'cursor' && worker !== 'both') {
+ console.error(`Error: --worker must be 'amp', 'cursor', or 'both' (got: ${worker})`);
+ process.exit(1);
+ }
+
+ const repoRoot = process.cwd();
+ const targetDir = join(repoRoot, 'scripts', 'ralph');
+
+ // Create scripts/ralph directory
+ if (!existsSync(targetDir)) {
+ mkdirSync(targetDir, { recursive: true });
+ console.log(`Created: ${targetDir}`);
+ }
+
+ // Always required files (needed for both workers)
+ const alwaysRequired = [
+ { src: 'scripts/ralph/ralph.sh', dest: 'scripts/ralph/ralph.sh', executable: true },
+ { src: 'scripts/ralph/prd.json.example', dest: 'scripts/ralph/prd.json.example', executable: false },
+ ];
+
+ // Amp-specific files
+ const ampFiles = [
+ { src: 'scripts/ralph/prompt.md', dest: 'scripts/ralph/prompt.md', executable: false },
+ ];
+
+ // Cursor-specific files
+ const cursorFiles = [
+ { src: 'scripts/ralph/cursor/prompt.cursor.md', dest: 'scripts/ralph/cursor/prompt.cursor.md', executable: false },
+ { src: 'scripts/ralph/cursor/prompt.convert-to-prd-json.md', dest: 'scripts/ralph/cursor/prompt.convert-to-prd-json.md', executable: false },
+ { src: 'scripts/ralph/cursor/prompt.generate-prd.md', dest: 'scripts/ralph/cursor/prompt.generate-prd.md', executable: false },
+ { src: 'scripts/ralph/cursor/convert-to-prd-json.sh', dest: 'scripts/ralph/cursor/convert-to-prd-json.sh', executable: true },
+ ];
+
+ // Build file list based on worker selection
+ let requiredFiles = [...alwaysRequired];
+ if (worker === 'amp' || worker === 'both') {
+ requiredFiles = [...requiredFiles, ...ampFiles];
+ }
+ if (worker === 'cursor' || worker === 'both') {
+ requiredFiles = [...requiredFiles, ...cursorFiles];
+ }
+
+ const created = [];
+ const skipped = [];
+
+ for (const file of requiredFiles) {
+ const srcPath = join(TEMPLATES_DIR, file.src);
+ const destPath = join(repoRoot, file.dest);
+ const destDir = dirname(destPath);
+
+ // Create subdirectory if needed
+ if (!existsSync(destDir)) {
+ mkdirSync(destDir, { recursive: true });
+ }
+
+ if (existsSync(destPath) && !force) {
+ skipped.push(file.dest);
+ continue;
+ }
+
+ copyFileSync(srcPath, destPath);
+ if (file.executable) {
+ chmodSync(destPath, 0o755);
+ }
+ created.push(file.dest);
+ }
+
+ // Optional: .cursor/rules/ralph-prd.mdc (only relevant for cursor)
+ if (cursorRules && (worker === 'cursor' || worker === 'both')) {
+ const cursorRulesDir = join(repoRoot, '.cursor', 'rules');
+ const cursorRulesFile = join(cursorRulesDir, 'ralph-prd.mdc');
+
+ if (!existsSync(cursorRulesDir)) {
+ mkdirSync(cursorRulesDir, { recursive: true });
+ }
+
+ if (existsSync(cursorRulesFile) && !force) {
+ skipped.push('.cursor/rules/ralph-prd.mdc');
+ } else {
+ const srcRules = join(PACKAGE_ROOT, '.cursor', 'rules', 'ralph-prd.mdc');
+ if (existsSync(srcRules)) {
+ copyFileSync(srcRules, cursorRulesFile);
+ created.push('.cursor/rules/ralph-prd.mdc');
+ }
+ }
+ }
+
+ // Optional: .cursor/cli.json (only relevant for cursor)
+ if (cursorCli && (worker === 'cursor' || worker === 'both')) {
+ const cursorCliFile = join(repoRoot, '.cursor', 'cli.json');
+ const cursorDir = dirname(cursorCliFile);
+
+ if (!existsSync(cursorDir)) {
+ mkdirSync(cursorDir, { recursive: true });
+ }
+
+ if (existsSync(cursorCliFile) && !force) {
+ skipped.push('.cursor/cli.json');
+ } else {
+ // Create a basic template
+ const cliTemplate = {
+ "mcpServers": {
+ "cursor-ide-browser": {
+ "command": "node",
+ "args": []
+ }
+ }
+ };
+ writeFileSync(cursorCliFile, JSON.stringify(cliTemplate, null, 2) + '\n');
+ created.push('.cursor/cli.json');
+ }
+ }
+
+ // Print summary
+ console.log('\nSummary:');
+ if (worker !== 'both') {
+ console.log(`Worker setup: ${worker}`);
+ }
+ if (created.length > 0) {
+ console.log(`\nCreated ${created.length} file(s):`);
+ created.forEach(f => console.log(` - ${f}`));
+ }
+ if (skipped.length > 0) {
+ console.log(`\nSkipped ${skipped.length} file(s) (already exist, use --force to overwrite):`);
+ skipped.forEach(f => console.log(` - ${f}`));
+ }
+ console.log('\nRalph initialized! Run `ralph run` to start.');
+}
+
+async function handleRun(args) {
+ const flags = parseFlags(args);
+ const worker = flags.get('--worker') || 'amp';
+ const iterations = flags.get('--iterations') || '10';
+
+ if (worker !== 'amp' && worker !== 'cursor') {
+ console.error(`Error: Worker must be 'amp' or 'cursor' (got: ${worker})`);
+ process.exit(1);
+ }
+
+ const repoRoot = process.cwd();
+ const runnerScript = join(repoRoot, 'scripts', 'ralph', 'ralph.sh');
+
+ if (!existsSync(runnerScript)) {
+ console.error('Error: Ralph not initialized in this repository.');
+ console.error('Run `ralph init` first to set up Ralph.');
+ process.exit(1);
+ }
+
+ // Execute the runner script with appropriate arguments
+ const { spawn } = await import('child_process');
+
+ const child = spawn('bash', [runnerScript, iterations, '--worker', worker], {
+ stdio: 'inherit',
+ cwd: repoRoot,
+ });
+
+ child.on('exit', (code) => {
+ process.exit(code || 0);
+ });
+}
+
+function parseFlags(args) {
+ const flags = new Map();
+ for (let i = 0; i < args.length; i++) {
+ const arg = args[i];
+ if (arg.startsWith('--')) {
+ if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
+ flags.set(arg, args[i + 1]);
+ i++;
+ } else {
+ flags.set(arg, true);
+ }
+ }
+ }
+ return flags;
+}
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..22a44840
--- /dev/null
+++ b/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@jarekbird/ralph",
+ "version": "0.1.0",
+ "description": "Ralph - Autonomous AI agent loop for Amp and Cursor",
+ "type": "module",
+ "bin": {
+ "ralph": "./bin/ralph.js"
+ },
+ "files": [
+ "bin/",
+ "templates/",
+ "README.md"
+ ],
+ "scripts": {
+ "test": "node --test tests/*.test.js"
+ },
+ "keywords": [
+ "ai",
+ "agent",
+ "automation",
+ "amp",
+ "cursor"
+ ],
+ "author": "jarekbird",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+}
diff --git a/scripts/ralph/cursor/convert-to-prd-json.sh b/scripts/ralph/cursor/convert-to-prd-json.sh
new file mode 100755
index 00000000..02c7f2e1
--- /dev/null
+++ b/scripts/ralph/cursor/convert-to-prd-json.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+# Convert PRD markdown -> prd.json using Cursor CLI (template version).
+#
+# Usage:
+# ./scripts/ralph/cursor/convert-to-prd-json.sh [--model MODEL] [--out OUT_JSON]
+#
+# Defaults:
+# - MODEL: "auto"
+# - OUT_JSON: ../prd.json (in scripts/ralph/ directory, same level as prd.json.example)
+#
+# Notes:
+# - This is a convenience helper to streamline PRD->prd.json conversion.
+# - It is intentionally separate from the Ralph iteration loop.
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+PRD_MD_FILE=""
+MODEL="auto"
+OUT_JSON="$SCRIPT_DIR/../prd.json"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --model)
+ MODEL="${2:-}"
+ shift 2
+ ;;
+ --out)
+ OUT_JSON="${2:-}"
+ shift 2
+ ;;
+ -*)
+ echo "Unknown flag: $1" >&2
+ exit 2
+ ;;
+ *)
+ if [[ -z "$PRD_MD_FILE" ]]; then
+ PRD_MD_FILE="$1"
+ else
+ echo "Unexpected argument: $1" >&2
+ exit 2
+ fi
+ shift
+ ;;
+ esac
+done
+
+if [[ -z "$PRD_MD_FILE" ]]; then
+ echo "Usage: $0 [--model MODEL] [--out OUT_JSON]" >&2
+ exit 2
+fi
+
+PROMPT_TEMPLATE_FILE="$SCRIPT_DIR/prompt.convert-to-prd-json.md"
+if [[ ! -f "$PROMPT_TEMPLATE_FILE" ]]; then
+ echo "Error: missing prompt template: $PROMPT_TEMPLATE_FILE" >&2
+ exit 1
+fi
+
+EXAMPLE_FILE="$SCRIPT_DIR/../prd.json.example"
+if [[ ! -f "$EXAMPLE_FILE" ]]; then
+ echo "Error: missing example file: $EXAMPLE_FILE" >&2
+ exit 1
+fi
+
+CURSOR_BIN="${RALPH_CURSOR_BIN:-cursor}"
+
+PROMPT_TEXT="$(
+ cat "$PROMPT_TEMPLATE_FILE"
+ printf "\n\n---\n\n"
+ printf "## Inputs\n"
+ printf "Read the PRD markdown file at: %s\n" "$PRD_MD_FILE"
+ printf "Read the format reference at: %s\n" "$EXAMPLE_FILE"
+ printf "\n"
+ printf "## Output\n"
+ printf "Write/overwrite prd.json at: %s\n" "$OUT_JSON"
+)"
+
+exec "$CURSOR_BIN" --model "$MODEL" --print --force --approve-mcps "$PROMPT_TEXT" "Add user notification system"
+
+**Split into:**
+1. US-001: Add notifications table to database
+2. US-002: Create notification service for sending notifications
+3. US-003: Add notification bell icon to header
+4. US-004: Create notification dropdown panel
+5. US-005: Add mark-as-read functionality
+6. US-006: Add notification preferences page
+
+Each is one focused change that can be completed and verified independently.
+
+## Reference Example
+
+See `prd.json.example` in the same directory for a complete example of the expected format.
+
+## Checklist Before Saving
+
+Before writing prd.json, verify:
+
+- [ ] Each story is completable in one iteration (small enough)
+- [ ] Stories are ordered by dependency (schema to backend to UI)
+- [ ] Every story has "Typecheck passes" as criterion
+- [ ] UI stories have browser verification requirement
+- [ ] Acceptance criteria are verifiable (not vague)
+- [ ] No story depends on a later story
+- [ ] JSON is valid and follows the example format
diff --git a/scripts/ralph/cursor/prompt.cursor.md b/scripts/ralph/cursor/prompt.cursor.md
new file mode 100644
index 00000000..8ada424e
--- /dev/null
+++ b/scripts/ralph/cursor/prompt.cursor.md
@@ -0,0 +1,111 @@
+# Ralph Agent Instructions (Cursor)
+
+You are an autonomous coding agent working on a software project using Cursor.
+
+## Your Task
+
+1. Read the PRD at `prd.json` (in the same directory as this file)
+2. Read the progress log at `progress.txt` (check Codebase Patterns section first)
+3. Check you're on the correct branch from PRD `branchName`. If not, check it out or create from main.
+4. Pick the **highest priority** user story where `passes: false`
+5. Implement that single user story
+6. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires)
+7. Update AGENTS.md files if you discover reusable patterns (see below)
+8. If checks pass, commit ALL changes with message: `feat: [Story ID] - [Story Title]`
+9. Update the PRD to set `passes: true` for the completed story
+10. Append your progress to `progress.txt`
+
+## Progress Report Format
+
+APPEND to progress.txt (never replace, always append):
+```
+## [Date/Time] - [Story ID]
+- What was implemented
+- Files changed
+- **Learnings for future iterations:**
+ - Patterns discovered (e.g., "this codebase uses X for Y")
+ - Gotchas encountered (e.g., "don't forget to update Z when changing W")
+ - Useful context (e.g., "the evaluation panel is in component X")
+---
+```
+
+The learnings section is critical - it helps future iterations avoid repeating mistakes and understand the codebase better.
+
+## Consolidate Patterns
+
+If you discover a **reusable pattern** that future iterations should know, add it to the `## Codebase Patterns` section at the TOP of progress.txt (create it if it doesn't exist). This section should consolidate the most important learnings:
+
+```
+## Codebase Patterns
+- Example: Use `sql` template for aggregations
+- Example: Always use `IF NOT EXISTS` for migrations
+- Example: Export types from actions.ts for UI components
+```
+
+Only add patterns that are **general and reusable**, not story-specific details.
+
+## Update AGENTS.md Files
+
+Before committing, check if any edited files have learnings worth preserving in nearby AGENTS.md files:
+
+1. **Identify directories with edited files** - Look at which directories you modified
+2. **Check for existing AGENTS.md** - Look for AGENTS.md in those directories or parent directories
+3. **Add valuable learnings** - If you discovered something future developers/agents should know:
+ - API patterns or conventions specific to that module
+ - Gotchas or non-obvious requirements
+ - Dependencies between files
+ - Testing approaches for that area
+ - Configuration or environment requirements
+
+**Examples of good AGENTS.md additions:**
+- "When modifying X, also update Y to keep them in sync"
+- "This module uses pattern Z for all API calls"
+- "Tests require the dev server running on PORT 3000"
+- "Field names must match the template exactly"
+
+**Do NOT add:**
+- Story-specific implementation details
+- Temporary debugging notes
+- Information already in progress.txt
+
+Only update AGENTS.md if you have **genuinely reusable knowledge** that would help future work in that directory.
+
+## Quality Requirements
+
+- ALL commits must pass your project's quality checks (typecheck, lint, test)
+- Do NOT commit broken code
+- Keep changes focused and minimal
+- Follow existing code patterns
+
+## Browser Testing (Required for Frontend Stories)
+
+For any story that changes UI, you MUST verify it works in the browser:
+
+- If browser MCP tools are available (configured in your Cursor setup), use them to:
+ 1. Navigate to the relevant page
+ 2. Verify the UI changes work as expected
+ 3. Take a screenshot if helpful for the progress log
+- If no browser MCP tools are configured:
+ - Ensure the story has automated tests (e.g., Playwright, Cypress) that cover the UI changes
+ - If tests are not feasible, mark the story as "needs manual verification" in your progress entry
+ - Do NOT mark the story as complete until verification is possible
+
+A frontend story is NOT complete until browser verification passes (via MCP tools or automated tests).
+
+## Stop Condition
+
+After completing a user story, check if ALL stories have `passes: true`.
+
+If ALL stories are complete and passing, reply with:
+COMPLETE
+
+If there are still stories with `passes: false`, end your response normally (another iteration will pick up the next story).
+
+## Important
+
+- Work on ONE story per iteration
+- Commit frequently
+- Keep CI green
+- Read the Codebase Patterns section in progress.txt before starting
+- Use Cursor's file editing capabilities to make changes directly
+- Rely on `.cursor/rules/*` files for repository-specific conventions when available
diff --git a/prd.json.example b/scripts/ralph/prd.json.example
similarity index 100%
rename from prd.json.example
rename to scripts/ralph/prd.json.example
diff --git a/prompt.md b/scripts/ralph/prompt.md
similarity index 100%
rename from prompt.md
rename to scripts/ralph/prompt.md
diff --git a/ralph.sh b/scripts/ralph/ralph.sh
similarity index 51%
rename from ralph.sh
rename to scripts/ralph/ralph.sh
index 677b120c..ae38bf9b 100755
--- a/ralph.sh
+++ b/scripts/ralph/ralph.sh
@@ -1,10 +1,40 @@
#!/bin/bash
# Ralph Wiggum - Long-running AI agent loop
-# Usage: ./ralph.sh [max_iterations]
+# Usage: ./ralph.sh [max_iterations] [--worker amp|cursor]
+# or set RALPH_WORKER environment variable (amp|cursor)
+# Default worker is 'amp' if not specified
set -e
-MAX_ITERATIONS=${1:-10}
+# Parse arguments
+MAX_ITERATIONS=10
+WORKER="${RALPH_WORKER:-amp}"
+CURSOR_TIMEOUT="${RALPH_CURSOR_TIMEOUT:-1800}" # Default: 30 minutes (in seconds)
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --worker)
+ WORKER="$2"
+ shift 2
+ ;;
+ --cursor-timeout)
+ CURSOR_TIMEOUT="$2"
+ shift 2
+ ;;
+ *)
+ if [[ "$1" =~ ^[0-9]+$ ]]; then
+ MAX_ITERATIONS="$1"
+ fi
+ shift
+ ;;
+ esac
+done
+
+# Validate worker
+if [[ "$WORKER" != "amp" && "$WORKER" != "cursor" ]]; then
+ echo "Error: Worker must be 'amp' or 'cursor' (got: $WORKER)" >&2
+ exit 1
+fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PRD_FILE="$SCRIPT_DIR/prd.json"
PROGRESS_FILE="$SCRIPT_DIR/progress.txt"
@@ -52,15 +82,41 @@ if [ ! -f "$PROGRESS_FILE" ]; then
fi
echo "Starting Ralph - Max iterations: $MAX_ITERATIONS"
+echo "Worker: $WORKER"
for i in $(seq 1 $MAX_ITERATIONS); do
echo ""
echo "═══════════════════════════════════════════════════════"
- echo " Ralph Iteration $i of $MAX_ITERATIONS"
+ echo " Ralph Iteration $i of $MAX_ITERATIONS (Worker: $WORKER)"
echo "═══════════════════════════════════════════════════════"
- # Run amp with the ralph prompt
- OUTPUT=$(cat "$SCRIPT_DIR/prompt.md" | amp --dangerously-allow-all 2>&1 | tee /dev/stderr) || true
+ # Select prompt and execute based on worker
+ if [[ "$WORKER" == "amp" ]]; then
+ # Amp worker: use prompt.md and execute amp
+ PROMPT_FILE="$SCRIPT_DIR/prompt.md"
+ OUTPUT=$(cat "$PROMPT_FILE" | amp --dangerously-allow-all 2>&1 | tee /dev/stderr) || true
+ elif [[ "$WORKER" == "cursor" ]]; then
+ # Cursor worker: use cursor/prompt.cursor.md and execute cursor CLI
+ # Uses non-interactive headless mode with file edits enabled
+ # Always uses normal spawn (never PTY), stdin is closed (no interactive prompts)
+ PROMPT_FILE="$SCRIPT_DIR/cursor/prompt.cursor.md"
+ PROMPT_TEXT=$(cat "$PROMPT_FILE")
+ # Execute cursor with: --model auto --print --force --approve-mcps
+ # stdin is automatically closed when using command substitution in bash
+ # Per-iteration hard timeout (wall-clock) - kills process if exceeded
+ # Note: MCP cleanup is handled by Cursor CLI itself when processes exit normally
+ # If MCP processes are orphaned, they may need manual cleanup (outside scope of this script)
+ if command -v timeout >/dev/null 2>&1; then
+ OUTPUT=$(timeout "$CURSOR_TIMEOUT" cursor --model auto --print --force --approve-mcps "$PROMPT_TEXT" &1 | tee /dev/stderr) || true
+ TIMEOUT_EXIT=$?
+ if [[ $TIMEOUT_EXIT -eq 124 ]]; then
+ echo "Warning: Cursor iteration timed out after ${CURSOR_TIMEOUT} seconds" >&2
+ fi
+ else
+ # Fallback if timeout command is not available
+ OUTPUT=$(cursor --model auto --print --force --approve-mcps "$PROMPT_TEXT" &1 | tee /dev/stderr) || true
+ fi
+ fi
# Check for completion signal
if echo "$OUTPUT" | grep -q "COMPLETE"; then
diff --git a/tests/cli.test.js b/tests/cli.test.js
new file mode 100644
index 00000000..d43511ea
--- /dev/null
+++ b/tests/cli.test.js
@@ -0,0 +1,365 @@
+import { test } from 'node:test';
+import assert from 'node:assert';
+import { spawn } from 'child_process';
+import { mkdtemp, rm, readFile, access, constants } from 'fs/promises';
+import { join } from 'path';
+import { tmpdir } from 'os';
+
+const CLI_PATH = join(process.cwd(), 'bin', 'ralph.js');
+
+async function runCLI(args, cwd) {
+ return new Promise((resolve, reject) => {
+ const child = spawn('node', [CLI_PATH, ...args], {
+ cwd,
+ stdio: 'pipe',
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ child.stdout.on('data', (data) => {
+ stdout += data.toString();
+ });
+
+ child.stderr.on('data', (data) => {
+ stderr += data.toString();
+ });
+
+ child.on('exit', (code) => {
+ resolve({ code, stdout, stderr });
+ });
+
+ child.on('error', reject);
+ });
+}
+
+test('ralph init creates scripts/ralph/ directory and files', async () => {
+ const testDir = await mkdtemp(join(tmpdir(), 'ralph-test-'));
+
+ try {
+ const result = await runCLI(['init'], testDir);
+
+ assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}. stderr: ${result.stderr}`);
+
+ // Check that required files were created
+ const requiredFiles = [
+ 'scripts/ralph/ralph.sh',
+ 'scripts/ralph/prompt.md',
+ 'scripts/ralph/prd.json.example',
+ 'scripts/ralph/cursor/prompt.cursor.md',
+ 'scripts/ralph/cursor/prompt.convert-to-prd-json.md',
+ 'scripts/ralph/cursor/prompt.generate-prd.md',
+ 'scripts/ralph/cursor/convert-to-prd-json.sh',
+ ];
+
+ for (const file of requiredFiles) {
+ const filePath = join(testDir, file);
+ try {
+ await access(filePath, constants.F_OK);
+ } catch (err) {
+ assert.fail(`File ${file} was not created`);
+ }
+ }
+
+ // Check that ralph.sh is executable
+ try {
+ await access(join(testDir, 'scripts/ralph/ralph.sh'), constants.X_OK);
+ } catch (err) {
+ assert.fail('ralph.sh is not executable');
+ }
+
+ // Check that convert-to-prd-json.sh is executable
+ try {
+ await access(join(testDir, 'scripts/ralph/cursor/convert-to-prd-json.sh'), constants.X_OK);
+ } catch (err) {
+ assert.fail('convert-to-prd-json.sh is not executable');
+ }
+
+ assert(result.stdout.includes('Created') || result.stdout.includes('file'), 'Should show files were created');
+ } finally {
+ await rm(testDir, { recursive: true, force: true });
+ }
+});
+
+test('ralph init does not overwrite existing files by default', async () => {
+ const testDir = await mkdtemp(join(tmpdir(), 'ralph-test-'));
+
+ try {
+ // First init
+ await runCLI(['init'], testDir);
+
+ // Read original content
+ const originalContent = await readFile(join(testDir, 'scripts/ralph/prompt.md'), 'utf-8');
+
+ // Modify the file
+ const modifiedContent = originalContent + '\n# Modified';
+ await import('fs/promises').then(fs =>
+ fs.writeFile(join(testDir, 'scripts/ralph/prompt.md'), modifiedContent)
+ );
+
+ // Run init again
+ const result = await runCLI(['init'], testDir);
+
+ assert.strictEqual(result.code, 0);
+
+ // Check that file was not overwritten
+ const currentContent = await readFile(join(testDir, 'scripts/ralph/prompt.md'), 'utf-8');
+ assert.strictEqual(currentContent, modifiedContent, 'File should not be overwritten');
+
+ assert(result.stdout.includes('Skipped'), 'Should show files were skipped');
+ } finally {
+ await rm(testDir, { recursive: true, force: true });
+ }
+});
+
+test('ralph init --force overwrites existing files', async () => {
+ const testDir = await mkdtemp(join(tmpdir(), 'ralph-test-'));
+
+ try {
+ // First init
+ await runCLI(['init'], testDir);
+
+ // Modify the file
+ const modifiedContent = '# Modified';
+ await import('fs/promises').then(fs =>
+ fs.writeFile(join(testDir, 'scripts/ralph/prompt.md'), modifiedContent)
+ );
+
+ // Run init with --force
+ const result = await runCLI(['init', '--force'], testDir);
+
+ assert.strictEqual(result.code, 0);
+
+ // Check that file was overwritten (should have original content, not modified)
+ const currentContent = await readFile(join(testDir, 'scripts/ralph/prompt.md'), 'utf-8');
+ assert.notStrictEqual(currentContent, modifiedContent, 'File should be overwritten');
+ assert(currentContent.length > modifiedContent.length, 'File should have original content');
+ } finally {
+ await rm(testDir, { recursive: true, force: true });
+ }
+});
+
+test('ralph run invokes repo-local runner', async () => {
+ const testDir = await mkdtemp(join(tmpdir(), 'ralph-test-'));
+
+ try {
+ // Initialize
+ await runCLI(['init'], testDir);
+
+ // Create stub runner that just prints a message
+ const stubRunner = `#!/bin/bash
+echo "STUB_RUNNER_CALLED"
+echo "ITERATIONS: $1"
+echo "WORKER: $3"
+exit 0
+`;
+ await import('fs/promises').then(fs =>
+ fs.writeFile(join(testDir, 'scripts/ralph/ralph.sh'), stubRunner)
+ );
+ await import('fs/promises').then(fs =>
+ fs.chmod(join(testDir, 'scripts/ralph/ralph.sh'), 0o755)
+ );
+
+ // Create stub binaries
+ const binDir = join(testDir, 'bin');
+ await import('fs/promises').then(fs => fs.mkdir(binDir, { recursive: true }));
+
+ const stubAmp = `#!/bin/bash
+echo "stub amp"
+exit 0
+`;
+ await import('fs/promises').then(fs =>
+ fs.writeFile(join(binDir, 'amp'), stubAmp)
+ );
+ await import('fs/promises').then(fs =>
+ fs.chmod(join(binDir, 'amp'), 0o755)
+ );
+
+ // Run with modified PATH
+ const env = { ...process.env, PATH: `${binDir}:${process.env.PATH}` };
+ const result = await new Promise((resolve, reject) => {
+ const child = spawn('node', [CLI_PATH, 'run', '--iterations', '5'], {
+ cwd: testDir,
+ env,
+ stdio: 'pipe',
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ child.stdout.on('data', (data) => {
+ stdout += data.toString();
+ });
+
+ child.stderr.on('data', (data) => {
+ stderr += data.toString();
+ });
+
+ child.on('exit', (code) => {
+ resolve({ code, stdout, stderr });
+ });
+
+ child.on('error', reject);
+ });
+
+ assert(result.stdout.includes('STUB_RUNNER_CALLED'), 'Runner should be invoked');
+ assert(result.stdout.includes('ITERATIONS: 5'), 'Iterations should be passed');
+ assert(result.stdout.includes('WORKER: amp'), 'Default worker should be amp');
+ } finally {
+ await rm(testDir, { recursive: true, force: true });
+ }
+});
+
+test('ralph run --worker cursor passes cursor worker', async () => {
+ const testDir = await mkdtemp(join(tmpdir(), 'ralph-test-'));
+
+ try {
+ // Initialize
+ await runCLI(['init'], testDir);
+
+ // Create stub runner
+ const stubRunner = `#!/bin/bash
+echo "WORKER: $3"
+exit 0
+`;
+ await import('fs/promises').then(fs =>
+ fs.writeFile(join(testDir, 'scripts/ralph/ralph.sh'), stubRunner)
+ );
+ await import('fs/promises').then(fs =>
+ fs.chmod(join(testDir, 'scripts/ralph/ralph.sh'), 0o755)
+ );
+
+ // Create stub cursor binary
+ const binDir = join(testDir, 'bin');
+ await import('fs/promises').then(fs => fs.mkdir(binDir, { recursive: true }));
+
+ const stubCursor = `#!/bin/bash
+echo "stub cursor"
+exit 0
+`;
+ await import('fs/promises').then(fs =>
+ fs.writeFile(join(binDir, 'cursor'), stubCursor)
+ );
+ await import('fs/promises').then(fs =>
+ fs.chmod(join(binDir, 'cursor'), 0o755)
+ );
+
+ // Run with cursor worker
+ const env = { ...process.env, PATH: `${binDir}:${process.env.PATH}` };
+ const result = await new Promise((resolve, reject) => {
+ const child = spawn('node', [CLI_PATH, 'run', '--worker', 'cursor'], {
+ cwd: testDir,
+ env,
+ stdio: 'pipe',
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ child.stdout.on('data', (data) => {
+ stdout += data.toString();
+ });
+
+ child.stderr.on('data', (data) => {
+ stderr += data.toString();
+ });
+
+ child.on('exit', (code) => {
+ resolve({ code, stdout, stderr });
+ });
+
+ child.on('error', reject);
+ });
+
+ assert(result.stdout.includes('WORKER: cursor'), 'Cursor worker should be passed');
+ } finally {
+ await rm(testDir, { recursive: true, force: true });
+ }
+});
+
+test('ralph run fails if not initialized', async () => {
+ const testDir = await mkdtemp(join(tmpdir(), 'ralph-test-'));
+
+ try {
+ const result = await runCLI(['run'], testDir);
+
+ assert.notStrictEqual(result.code, 0, 'Should exit with error code');
+ assert(result.stderr.includes('not initialized') || result.stdout.includes('not initialized'),
+ 'Should indicate Ralph is not initialized');
+ } finally {
+ await rm(testDir, { recursive: true, force: true });
+ }
+});
+
+test('ralph init --worker amp only installs amp files', async () => {
+ const testDir = await mkdtemp(join(tmpdir(), 'ralph-test-'));
+
+ try {
+ const result = await runCLI(['init', '--worker', 'amp'], testDir);
+
+ assert.strictEqual(result.code, 0);
+
+ // Check amp files exist
+ await access(join(testDir, 'scripts/ralph/prompt.md'), constants.F_OK);
+
+ // Check cursor files do NOT exist
+ try {
+ await access(join(testDir, 'scripts/ralph/cursor/prompt.cursor.md'), constants.F_OK);
+ assert.fail('Cursor files should not be installed with --worker amp');
+ } catch (err) {
+ // Expected - file should not exist
+ }
+
+ // Check common files exist
+ await access(join(testDir, 'scripts/ralph/ralph.sh'), constants.F_OK);
+ await access(join(testDir, 'scripts/ralph/prd.json.example'), constants.F_OK);
+ } finally {
+ await rm(testDir, { recursive: true, force: true });
+ }
+});
+
+test('ralph init --worker cursor only installs cursor files', async () => {
+ const testDir = await mkdtemp(join(tmpdir(), 'ralph-test-'));
+
+ try {
+ const result = await runCLI(['init', '--worker', 'cursor'], testDir);
+
+ assert.strictEqual(result.code, 0);
+
+ // Check cursor files exist
+ await access(join(testDir, 'scripts/ralph/cursor/prompt.cursor.md'), constants.F_OK);
+ await access(join(testDir, 'scripts/ralph/cursor/convert-to-prd-json.sh'), constants.F_OK);
+
+ // Check amp files do NOT exist
+ try {
+ await access(join(testDir, 'scripts/ralph/prompt.md'), constants.F_OK);
+ assert.fail('Amp files should not be installed with --worker cursor');
+ } catch (err) {
+ // Expected - file should not exist
+ }
+
+ // Check common files exist
+ await access(join(testDir, 'scripts/ralph/ralph.sh'), constants.F_OK);
+ await access(join(testDir, 'scripts/ralph/prd.json.example'), constants.F_OK);
+ } finally {
+ await rm(testDir, { recursive: true, force: true });
+ }
+});
+
+test('ralph init --worker both installs all files', async () => {
+ const testDir = await mkdtemp(join(tmpdir(), 'ralph-test-'));
+
+ try {
+ const result = await runCLI(['init', '--worker', 'both'], testDir);
+
+ assert.strictEqual(result.code, 0);
+
+ // Check all files exist
+ await access(join(testDir, 'scripts/ralph/prompt.md'), constants.F_OK);
+ await access(join(testDir, 'scripts/ralph/cursor/prompt.cursor.md'), constants.F_OK);
+ await access(join(testDir, 'scripts/ralph/ralph.sh'), constants.F_OK);
+ await access(join(testDir, 'scripts/ralph/prd.json.example'), constants.F_OK);
+ } finally {
+ await rm(testDir, { recursive: true, force: true });
+ }
+});
diff --git a/tests/test_ralph.sh b/tests/test_ralph.sh
new file mode 100755
index 00000000..be86e932
--- /dev/null
+++ b/tests/test_ralph.sh
@@ -0,0 +1,343 @@
+#!/bin/bash
+# Test suite for Ralph runner + templates
+# Tests run without requiring real Amp or real Cursor (use stub binaries via PATH).
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+TEST_DIR="$SCRIPT_DIR/test-tmp"
+
+# These are set per variant
+CURRENT_VARIANT_NAME=""
+CURRENT_LAYOUT=""
+CURRENT_SOURCE_DIR=""
+
+RALPH_SCRIPT=""
+RALPH_WORK_DIR=""
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Setup test environment
+setup_test_env() {
+ rm -rf "$TEST_DIR"
+ mkdir -p "$TEST_DIR/project"
+
+ local project_dir="$TEST_DIR/project"
+ local runner_dir=""
+
+ if [[ "$CURRENT_LAYOUT" == "scripts" ]]; then
+ runner_dir="$project_dir/scripts/ralph"
+ mkdir -p "$runner_dir/cursor"
+ cp "$CURRENT_SOURCE_DIR/ralph.sh" "$runner_dir/ralph.sh"
+ cp "$CURRENT_SOURCE_DIR/prompt.md" "$runner_dir/prompt.md"
+ cp "$CURRENT_SOURCE_DIR/prd.json.example" "$runner_dir/prd.json.example"
+ cp "$CURRENT_SOURCE_DIR/cursor/prompt.cursor.md" "$runner_dir/cursor/prompt.cursor.md"
+ cp "$CURRENT_SOURCE_DIR/cursor/prompt.convert-to-prd-json.md" "$runner_dir/cursor/prompt.convert-to-prd-json.md"
+ cp "$CURRENT_SOURCE_DIR/cursor/convert-to-prd-json.sh" "$runner_dir/cursor/convert-to-prd-json.sh"
+ chmod +x "$runner_dir/ralph.sh"
+ chmod +x "$runner_dir/cursor/convert-to-prd-json.sh"
+ RALPH_SCRIPT="$runner_dir/ralph.sh"
+ RALPH_WORK_DIR="$runner_dir"
+ else
+ echo "Invalid CURRENT_LAYOUT: $CURRENT_LAYOUT" >&2
+ exit 1
+ fi
+
+ cd "$project_dir"
+
+ # Create stub binaries
+ mkdir -p "$project_dir/bin"
+ export PATH="$project_dir/bin:$PATH"
+
+ # Create stub amp binary
+ cat > "$project_dir/bin/amp" << 'EOF'
+#!/bin/bash
+# Stub amp binary for testing
+echo "Stub amp executed with args: $@"
+if [ -t 0 ]; then
+ echo "Stub amp: stdin is a TTY"
+else
+ echo "Stub amp: stdin is not a TTY"
+fi
+echo "Some amp output"
+echo "COMPLETE"
+EOF
+ chmod +x "$project_dir/bin/amp"
+
+ # Create stub cursor binary
+ cat > "$project_dir/bin/cursor" << 'EOF'
+#!/bin/bash
+# Stub cursor binary for testing
+echo "Stub cursor executed with args: $@"
+if [ -t 0 ]; then
+ echo "Stub cursor: stdin is a TTY"
+else
+ echo "Stub cursor: stdin is not a TTY"
+fi
+# Check for required flags (model can vary)
+if [[ "$*" == *"--model"* ]] && [[ "$*" == *"--print"* ]] && [[ "$*" == *"--force"* ]] && [[ "$*" == *"--approve-mcps"* ]]; then
+ echo "Stub cursor: all required flags present"
+else
+ echo "Stub cursor: WARNING - missing required flags" >&2
+fi
+echo "Some cursor output"
+EOF
+ chmod +x "$project_dir/bin/cursor"
+
+ # Create test prd.json
+ cat > "$RALPH_WORK_DIR/prd.json" << 'EOF'
+{
+ "project": "TestProject",
+ "branchName": "ralph/test",
+ "description": "Test feature",
+ "userStories": [
+ {
+ "id": "US-001",
+ "title": "Test story",
+ "description": "Test description",
+ "acceptanceCriteria": ["Test criterion"],
+ "priority": 1,
+ "passes": false,
+ "notes": ""
+ }
+ ]
+}
+EOF
+
+ # Create test progress.txt
+ echo "# Ralph Progress Log" > "$RALPH_WORK_DIR/progress.txt"
+ echo "Started: $(date)" >> "$RALPH_WORK_DIR/progress.txt"
+ echo "---" >> "$RALPH_WORK_DIR/progress.txt"
+}
+
+cleanup_test_env() {
+ cd "$SCRIPT_DIR" || true
+ rm -rf "$TEST_DIR"
+}
+
+test_default_worker_amp() {
+ setup_test_env
+ OUTPUT=$(bash "$RALPH_SCRIPT" 1 2>&1 || true)
+ if echo "$OUTPUT" | grep -q "Stub amp executed"; then
+ echo -e "${GREEN}PASS${NC}: Default worker is Amp"
+ else
+ echo -e "${RED}FAIL${NC}: Default worker is not Amp"
+ echo "Output: $OUTPUT"
+ cleanup_test_env
+ return 1
+ fi
+ cleanup_test_env
+}
+
+test_cursor_worker_explicit() {
+ setup_test_env
+ OUTPUT=$(bash "$RALPH_SCRIPT" 1 --worker cursor 2>&1 || true)
+ if echo "$OUTPUT" | grep -q "Stub cursor executed"; then
+ echo -e "${GREEN}PASS${NC}: Cursor worker used when explicitly selected"
+ else
+ echo -e "${RED}FAIL${NC}: Cursor worker not used when selected"
+ echo "Output: $OUTPUT"
+ cleanup_test_env
+ return 1
+ fi
+
+ OUTPUT=$(RALPH_WORKER=cursor bash "$RALPH_SCRIPT" 1 2>&1 || true)
+ if echo "$OUTPUT" | grep -q "Stub cursor executed"; then
+ echo -e "${GREEN}PASS${NC}: Cursor worker used with RALPH_WORKER env var"
+ else
+ echo -e "${RED}FAIL${NC}: Cursor worker not used with RALPH_WORKER"
+ echo "Output: $OUTPUT"
+ cleanup_test_env
+ return 1
+ fi
+
+ cleanup_test_env
+}
+
+test_cursor_invocation_flags() {
+ setup_test_env
+ OUTPUT=$(bash "$RALPH_SCRIPT" 1 --worker cursor 2>&1 || true)
+ if echo "$OUTPUT" | grep -q "all required flags present"; then
+ echo -e "${GREEN}PASS${NC}: Cursor command includes all required flags"
+ else
+ echo -e "${RED}FAIL${NC}: Cursor command missing required flags"
+ echo "Output: $OUTPUT"
+ cleanup_test_env
+ return 1
+ fi
+ cleanup_test_env
+}
+
+test_cursor_no_pty() {
+ setup_test_env
+ OUTPUT=$(bash "$RALPH_SCRIPT" 1 --worker cursor 2>&1 || true)
+ if echo "$OUTPUT" | grep -q "stdin is not a TTY"; then
+ echo -e "${GREEN}PASS${NC}: Cursor invocation uses normal spawn (no PTY)"
+ else
+ echo -e "${RED}FAIL${NC}: Cursor invocation may be using PTY"
+ echo "Output: $OUTPUT"
+ cleanup_test_env
+ return 1
+ fi
+ cleanup_test_env
+}
+
+test_convert_prd_json_model_override() {
+ setup_test_env
+ mkdir -p "$TEST_DIR/project/tasks"
+ echo "# PRD: Example" > "$TEST_DIR/project/tasks/prd-example.md"
+
+ local convert_script="$RALPH_WORK_DIR/cursor/convert-to-prd-json.sh"
+ if [[ ! -f "$convert_script" ]]; then
+ echo -e "${RED}FAIL${NC}: convert-to-prd-json.sh not found"
+ cleanup_test_env
+ return 1
+ fi
+
+ OUTPUT=$(bash "$convert_script" "$TEST_DIR/project/tasks/prd-example.md" --model "gpt-4.1" 2>&1 || true)
+ if echo "$OUTPUT" | grep -qF -- "--model gpt-4.1"; then
+ echo -e "${GREEN}PASS${NC}: convert-to-prd-json.sh forwards --model override"
+ else
+ echo -e "${RED}FAIL${NC}: convert-to-prd-json.sh did not forward --model override"
+ echo "Output: $OUTPUT"
+ cleanup_test_env
+ return 1
+ fi
+ cleanup_test_env
+}
+
+test_stop_condition_complete() {
+ setup_test_env
+ cat > "$TEST_DIR/project/bin/amp" << 'EOF'
+#!/bin/bash
+echo "Iteration output"
+echo "COMPLETE"
+EOF
+ chmod +x "$TEST_DIR/project/bin/amp"
+
+ OUTPUT=$(bash "$RALPH_SCRIPT" 10 2>&1 || true)
+ if echo "$OUTPUT" | grep -q "Ralph completed all tasks"; then
+ echo -e "${GREEN}PASS${NC}: Loop exits on COMPLETE signal"
+ else
+ echo -e "${RED}FAIL${NC}: Loop does not exit on COMPLETE signal"
+ echo "Output: $OUTPUT"
+ cleanup_test_env
+ return 1
+ fi
+ cleanup_test_env
+}
+
+test_stop_condition_no_complete() {
+ setup_test_env
+ cat > "$TEST_DIR/project/bin/amp" << 'EOF'
+#!/bin/bash
+echo "Iteration output without COMPLETE"
+EOF
+ chmod +x "$TEST_DIR/project/bin/amp"
+
+ OUTPUT=$(bash "$RALPH_SCRIPT" 2 2>&1 || true)
+ if echo "$OUTPUT" | grep -q "Iteration 2 of 2"; then
+ echo -e "${GREEN}PASS${NC}: Loop continues when no COMPLETE signal"
+ else
+ echo -e "${RED}FAIL${NC}: Loop does not continue without COMPLETE"
+ echo "Output: $OUTPUT"
+ cleanup_test_env
+ return 1
+ fi
+ cleanup_test_env
+}
+
+test_progress_append_only() {
+ setup_test_env
+ ORIGINAL_CONTENT=$(cat "$RALPH_WORK_DIR/progress.txt")
+ bash "$RALPH_SCRIPT" 1 >/dev/null 2>&1 || true
+ NEW_CONTENT=$(cat "$RALPH_WORK_DIR/progress.txt")
+
+ if [[ "$NEW_CONTENT" == "$ORIGINAL_CONTENT"* ]]; then
+ echo -e "${GREEN}PASS${NC}: progress.txt is append-only"
+ else
+ echo -e "${RED}FAIL${NC}: progress.txt was overwritten"
+ cleanup_test_env
+ return 1
+ fi
+ cleanup_test_env
+}
+
+test_prd_json_parsing_failure() {
+ setup_test_env
+ echo "invalid json content" > "$RALPH_WORK_DIR/prd.json"
+ if bash "$RALPH_SCRIPT" 1 >/dev/null 2>&1; then
+ echo -e "${GREEN}PASS${NC}: Runner handles invalid prd.json gracefully"
+ else
+ echo -e "${RED}FAIL${NC}: Runner crashes on invalid prd.json"
+ cleanup_test_env
+ return 1
+ fi
+
+ rm -f "$RALPH_WORK_DIR/prd.json"
+ if bash "$RALPH_SCRIPT" 1 >/dev/null 2>&1; then
+ echo -e "${GREEN}PASS${NC}: Runner handles missing prd.json gracefully"
+ else
+ echo -e "${RED}FAIL${NC}: Runner crashes on missing prd.json"
+ cleanup_test_env
+ return 1
+ fi
+
+ cleanup_test_env
+}
+
+run_variant() {
+ local variant_name="$1"
+ local layout="$2"
+ local source_dir="$3"
+
+ CURRENT_VARIANT_NAME="$variant_name"
+ CURRENT_LAYOUT="$layout"
+ CURRENT_SOURCE_DIR="$source_dir"
+
+ echo "Running Ralph test suite (${CURRENT_VARIANT_NAME})..."
+ echo ""
+
+ local tests_passed=0
+ local tests_failed=0
+
+ if test_default_worker_amp; then ((tests_passed+=1)); else ((tests_failed+=1)); fi
+ if test_cursor_worker_explicit; then ((tests_passed+=1)); else ((tests_failed+=1)); fi
+ if test_cursor_invocation_flags; then ((tests_passed+=1)); else ((tests_failed+=1)); fi
+ if test_cursor_no_pty; then ((tests_passed+=1)); else ((tests_failed+=1)); fi
+ if test_convert_prd_json_model_override; then ((tests_passed+=1)); else ((tests_failed+=1)); fi
+ if test_stop_condition_complete; then ((tests_passed+=1)); else ((tests_failed+=1)); fi
+ if test_stop_condition_no_complete; then ((tests_passed+=1)); else ((tests_failed+=1)); fi
+ if test_progress_append_only; then ((tests_passed+=1)); else ((tests_failed+=1)); fi
+ if test_prd_json_parsing_failure; then ((tests_passed+=1)); else ((tests_failed+=1)); fi
+
+ echo ""
+ echo "========================================="
+ echo "Variant: $CURRENT_VARIANT_NAME"
+ echo "Tests passed: $tests_passed"
+ echo "Tests failed: $tests_failed"
+ echo "========================================="
+
+ if [ $tests_failed -eq 0 ]; then
+ echo -e "${GREEN}All tests passed!${NC}"
+ return 0
+ else
+ echo -e "${RED}Some tests failed!${NC}"
+ return 1
+ fi
+}
+
+main() {
+ # Test canonical scripts (scripts/ralph/)
+ run_variant "scripts" "scripts" "$REPO_ROOT/scripts/ralph"
+ exit $?
+}
+
+main
+