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](ralph.webp) -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 +