diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md
deleted file mode 100644
index 38942b5..0000000
--- a/.claude/ralph-loop.local.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-active: true
-iteration: 1
-max_iterations: 40
-completion_promise: "PHASE_2_COMPLETE"
-started_at: "2026-01-10T12:10:57Z"
----
-
-Execute Phase 2 - Core API Integration from PROMPT.md.
-
-Phase 1 is complete. Now implement Phase 2.
-
-Read PROMPT.md for API specifications.
-Read prd.json for Phase 2 features.
-
-Phase 2 tasks:
-1. Claude API client with Anthropic SDK
-2. Perplexity API client with fetch
-3. Architecture classification prompt and parser
-4. Research query generation
-5. Tests for all API clients
-
-API Integration notes:
-- Use environment variables for API keys during testing
-- Implement retry logic with 3 attempts and exponential backoff
-- Handle rate limits gracefully
-- Parse JSON responses safely
-
-For each feature:
-1. Write tests first using mocked API calls
-2. Implement the feature
-3. Verify: npm run build && npm run test && npm run lint
-4. Update prd.json when feature passes
-5. Commit and log progress
-
-When ALL Phase 2 features pass:
-Output PHASE_2_COMPLETE
-
-If blocked:
-Output PHASE_2_BLOCKED
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a02119e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,249 @@
+# Ralph PRD Generator
+
+A CLI tool that generates Ralph Method project scaffolds with AI-powered research and specification generation.
+
+## Features
+
+- **Idea Classification**: Uses Claude to analyze your app idea and classify its architecture
+- **Research Integration**: Uses Perplexity to research best practices for your tech stack
+- **Specification Generation**: Generates detailed features, data models, and interface contracts
+- **PRD Generation**: Creates complete Ralph Method files (PROMPT.md, prd.json, GUIDE.md)
+- **Project Scaffolding**: Sets up a complete project directory with all documentation
+- **Validation**: Validates existing PROMPT.md files for Ralph compatibility
+- **Standalone Research**: Research any topic using Perplexity AI
+
+## Installation
+
+```bash
+npm install -g ralph-vibe
+```
+
+Or with npx (no installation required):
+
+```bash
+npx ralph-vibe --help
+```
+
+## Quick Start
+
+1. **Configure API keys**:
+ ```bash
+ ralph-vibe init
+ ```
+
+2. **Create a new project**:
+ ```bash
+ ralph-vibe new my-app
+ ```
+
+3. **Follow the interactive prompts** to describe your idea and review the generated scaffold.
+
+## Commands
+
+### `ralph-vibe init`
+
+Configure API keys for Claude and Perplexity.
+
+```bash
+ralph-vibe init
+ralph-vibe init --claude-key sk-ant-xxx
+ralph-vibe init --perplexity-key pplx-xxx
+ralph-vibe init --reset # Clear existing configuration
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--claude-key ` | Set Claude API key directly |
+| `--perplexity-key ` | Set Perplexity API key directly |
+| `--reset` | Clear existing configuration |
+
+Keys are stored in `~/.ralph-generator/config.json` with file permissions set to 600.
+
+### `ralph-vibe new `
+
+Create a new Ralph Method project.
+
+```bash
+ralph-vibe new my-app
+ralph-vibe new my-app --idea-file ./idea.txt
+ralph-vibe new my-app --skip-research
+ralph-vibe new my-app --skip-confirm
+ralph-vibe new my-app --dry-run
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--idea-file ` | Read idea from file instead of interactive prompt |
+| `--output-dir ` | Output directory (default: current directory) |
+| `--skip-research` | Skip Perplexity research phase |
+| `--skip-confirm` | Don't ask for confirmation at each stage |
+| `--verbose` | Show detailed progress |
+| `--dry-run` | Show what would be generated without writing files |
+
+**Generated Structure:**
+
+```
+my-app/
+├── PROMPT.md # Main Ralph prompt
+├── prd.json # Feature tracking
+├── progress.txt # Progress log
+├── GUIDE.md # Personalized step-by-step guide
+├── CLAUDE.md # Claude Code configuration
+├── README.md # Project README
+├── docs/
+│ ├── idea-dump.md # Original idea
+│ ├── architecture.md # Architecture decisions
+│ ├── features.md # Feature specifications
+│ ├── tech-stack.md # Tech stack details
+│ ├── data-models.md # Data model specs
+│ ├── interfaces.md # Interface contracts
+│ └── research-notes.md # Research findings
+├── agent_docs/
+│ ├── tech_stack.md # Tech stack context
+│ ├── code_patterns.md # Coding patterns
+│ └── testing.md # Testing guide
+├── src/ # Source directory
+└── .gitignore
+```
+
+### `ralph-vibe validate `
+
+Validate an existing PROMPT.md file for Ralph compatibility.
+
+```bash
+ralph-vibe validate ./PROMPT.md
+ralph-vibe validate ./my-app/PROMPT.md
+```
+
+**Checks for:**
+
+- Required sections (Objective, Application Type, Tech Stack, Completion Criteria)
+- Promise tags for Ralph loop integration
+- Ambiguous language (should, might, could, etc.)
+- Verification commands in completion criteria
+
+**Exit codes:**
+
+- `0` - Valid PROMPT.md
+- `1` - Issues found
+
+### `ralph-vibe research `
+
+Research a topic using Perplexity AI.
+
+```bash
+ralph-vibe research "Node.js best practices 2026"
+ralph-vibe research "React testing strategies" -o research.md
+ralph-vibe research "Tauri 2.0 file handling" --verbose
+```
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `-o, --output ` | Save results to a file |
+| `--verbose` | Show detailed progress |
+
+## Environment Variables
+
+API keys can be set via environment variables (overrides config file):
+
+```bash
+export CLAUDE_API_KEY=sk-ant-xxx
+export PERPLEXITY_API_KEY=pplx-xxx
+```
+
+## Configuration
+
+Configuration is stored in `~/.ralph-generator/config.json`:
+
+```json
+{
+ "claudeApiKey": "sk-ant-xxx",
+ "perplexityApiKey": "pplx-xxx"
+}
+```
+
+File permissions are set to 600 (user read/write only) for security.
+
+## Using Generated Projects
+
+After generating a project:
+
+```bash
+cd my-app
+# Read PROMPT.md for full requirements
+# Follow GUIDE.md for step-by-step instructions
+# Or start a Ralph loop:
+/ralph-wiggum:ralph-loop "$(cat PROMPT.md)" --max-iterations 50 --completion-promise "PROJECT_COMPLETE"
+```
+
+## Troubleshooting
+
+### "Claude API key not found"
+
+Run `ralph-vibe init` to configure your API key, or set the `CLAUDE_API_KEY` environment variable.
+
+### "Perplexity API key not found"
+
+Run `ralph-vibe init` to configure your Perplexity key, or use `--skip-research` to skip the research phase.
+
+### "Invalid API key"
+
+Verify your API key is correct:
+- Claude: https://console.anthropic.com/
+- Perplexity: https://www.perplexity.ai/settings/api
+
+### "Idea must be at least 50 characters"
+
+Provide a more detailed description of your app idea. Include:
+- What the app does
+- Who it's for
+- Key features
+
+### Rate Limiting
+
+The tool automatically retries on rate limits with exponential backoff. If you encounter persistent rate limits, wait a few minutes before trying again.
+
+## Development
+
+```bash
+# Clone the repository
+git clone https://github.com/your-username/ralph-vibe.git
+cd ralph-vibe
+
+# Install dependencies
+npm install
+
+# Build
+npm run build
+
+# Run tests
+npm run test
+
+# Lint
+npm run lint
+
+# Run in development mode
+npm run dev
+```
+
+## Tech Stack
+
+- **Language**: TypeScript (strict mode)
+- **Runtime**: Node.js 20+
+- **CLI Framework**: Commander.js
+- **Prompts**: Inquirer.js
+- **Spinners**: ora
+- **Colors**: chalk
+- **AI**: Anthropic SDK, Perplexity API
+- **Testing**: Vitest
+- **Build**: tsup
+
+## License
+
+MIT
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..19f1d6b
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,41 @@
+# Examples
+
+This directory contains sample files demonstrating ralph-vibe output.
+
+## Files
+
+### `test-idea.txt`
+
+A sample idea file that can be used with the `--idea-file` flag:
+
+```bash
+ralph-vibe new md2pdf --idea-file examples/test-idea.txt
+```
+
+### `sample-PROMPT.md`
+
+An example of a generated PROMPT.md file for a markdown-to-PDF converter CLI tool. Shows:
+- Project structure
+- User flow diagram
+- Feature specifications with acceptance criteria
+- Phase-based implementation plan
+- Completion criteria with verification commands
+- Promise tags for Ralph loop integration
+
+### `sample-prd.json`
+
+An example prd.json file showing the feature tracking structure.
+
+## Usage
+
+To test validation on the sample file:
+
+```bash
+ralph-vibe validate examples/sample-PROMPT.md
+```
+
+To generate a new project using the test idea:
+
+```bash
+ralph-vibe new test-project --idea-file examples/test-idea.txt --skip-confirm
+```
diff --git a/examples/sample-PROMPT.md b/examples/sample-PROMPT.md
new file mode 100644
index 0000000..6cd5d09
--- /dev/null
+++ b/examples/sample-PROMPT.md
@@ -0,0 +1,187 @@
+# Project: md2pdf
+
+## Objective
+
+CLI tool that converts markdown files to beautifully styled PDF documents with syntax highlighting for code blocks, multiple theme support, and optional table of contents.
+
+---
+
+## Application Type
+
+CLI tool
+
+## Architecture
+
+- Interface types: CLI arguments
+- Persistence: None (stateless transformation)
+- Deployment: Package registry (npm) + standalone binary
+
+## Tech Stack
+
+- Language: TypeScript
+- Runtime: Node.js 20+
+- CLI: Commander.js
+- PDF Generation: puppeteer + marked
+- Syntax Highlighting: shiki
+- Testing: Vitest
+- Build: tsup
+
+---
+
+## User Flow
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ USER JOURNEY │
+├─────────────────────────────────────────────────────────────────────┤
+│ │
+│ 1. BASIC USAGE │
+│ $ md2pdf input.md │
+│ → Converts input.md to input.pdf │
+│ │
+│ 2. WITH OPTIONS │
+│ $ md2pdf input.md -o output.pdf --theme dark --toc │
+│ → Converts with dark theme and table of contents │
+│ │
+│ 3. MULTIPLE FILES │
+│ $ md2pdf *.md -o combined.pdf │
+│ → Combines all markdown files into one PDF │
+│ │
+│ 4. STDIN │
+│ $ cat README.md | md2pdf -o readme.pdf │
+│ → Reads from stdin │
+│ │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Commands
+
+### `md2pdf [options]`
+
+Convert markdown to PDF.
+
+```
+Options:
+ -o, --output Output PDF path (default: input with .pdf extension)
+ -t, --theme Theme: light, dark, custom (default: light)
+ --toc Include table of contents
+ --no-highlight Disable syntax highlighting
+ --page-size Page size: A4, Letter, Legal (default: A4)
+ --margin Page margins in mm (default: 20)
+```
+
+---
+
+## Core Features
+
+### Feature 1: Basic Conversion
+
+**Description**: Convert a single markdown file to PDF.
+
+**Acceptance Criteria**:
+- `md2pdf input.md` creates `input.pdf`
+- Output is valid PDF that opens in all readers
+- Markdown headings render as styled headings
+- Links are clickable in PDF
+- Images are embedded
+
+### Feature 2: Syntax Highlighting
+
+**Description**: Code blocks render with syntax highlighting.
+
+**Acceptance Criteria**:
+- Code blocks with language tags are highlighted
+- Supports at least: js, ts, python, rust, go, bash
+- Colors appropriate to selected theme
+- Monospace font for code
+- Line numbers optional
+
+### Feature 3: Theme Support
+
+**Description**: Multiple visual themes for output.
+
+**Acceptance Criteria**:
+- `--theme light` uses light background with dark text
+- `--theme dark` uses dark background with light text
+- Custom CSS can be loaded via `--theme path/to/custom.css`
+- Theme affects both prose and code blocks
+
+### Feature 4: Table of Contents
+
+**Description**: Generate TOC from headings.
+
+**Acceptance Criteria**:
+- `--toc` flag adds TOC at beginning
+- TOC entries are clickable links
+- Supports heading levels 1-3
+- Page numbers shown
+
+### Feature 5: GitHub Flavored Markdown
+
+**Description**: Support GFM extensions.
+
+**Acceptance Criteria**:
+- Tables render correctly
+- Task lists render with checkboxes
+- Strikethrough text renders
+- Autolinks work
+
+---
+
+## Completion Criteria
+
+1. `npm run build` exits 0
+2. `npm run test` exits 0 with >80% coverage
+3. `npm run lint` exits 0
+4. `md2pdf --help` shows all options
+5. `md2pdf README.md` produces valid PDF
+6. `md2pdf test.md --theme dark` uses dark theme
+7. Code blocks show syntax highlighting
+8. `md2pdf test.md --toc` includes table of contents
+9. GFM tables render correctly
+
+Output `PROJECT_COMPLETE` when all criteria met.
+
+---
+
+## Phase 1: Foundation
+
+- [ ] Project setup with TypeScript, tsup, vitest
+- [ ] CLI framework with Commander.js
+- [ ] Basic markdown parsing with marked
+- [ ] PDF generation with puppeteer
+
+**Acceptance**: `md2pdf input.md` produces basic PDF
+
+## Phase 2: Styling
+
+- [ ] Light theme CSS
+- [ ] Dark theme CSS
+- [ ] Custom theme loading
+- [ ] Syntax highlighting with shiki
+
+**Acceptance**: `md2pdf test.md --theme dark` works with highlighting
+
+## Phase 3: Features
+
+- [ ] Table of contents generation
+- [ ] GFM support (tables, task lists)
+- [ ] Multiple file handling
+- [ ] Stdin support
+
+**Acceptance**: All features work, tests pass
+
+## Phase 4: Polish
+
+- [ ] Error handling and messages
+- [ ] Documentation
+- [ ] npm packaging
+
+**Acceptance**: All completion criteria met
+
+---
+
+*PRD Version: 1.0*
+*Target: Claude Code + Ralph Method*
diff --git a/examples/sample-prd.json b/examples/sample-prd.json
new file mode 100644
index 0000000..b8980d8
--- /dev/null
+++ b/examples/sample-prd.json
@@ -0,0 +1,51 @@
+{
+ "project": "md2pdf",
+ "version": "1.0.0",
+ "features": [
+ {
+ "id": "basic-conversion",
+ "phase": 1,
+ "name": "Basic Conversion",
+ "description": "Convert a single markdown file to PDF",
+ "priority": 1,
+ "passes": false,
+ "acceptance": "md2pdf input.md creates input.pdf as valid PDF"
+ },
+ {
+ "id": "syntax-highlighting",
+ "phase": 2,
+ "name": "Syntax Highlighting",
+ "description": "Code blocks render with syntax highlighting",
+ "priority": 2,
+ "passes": false,
+ "acceptance": "Code blocks with language tags are highlighted correctly"
+ },
+ {
+ "id": "theme-support",
+ "phase": 2,
+ "name": "Theme Support",
+ "description": "Multiple visual themes for output",
+ "priority": 3,
+ "passes": false,
+ "acceptance": "--theme light/dark/custom works correctly"
+ },
+ {
+ "id": "table-of-contents",
+ "phase": 3,
+ "name": "Table of Contents",
+ "description": "Generate TOC from headings",
+ "priority": 4,
+ "passes": false,
+ "acceptance": "--toc flag adds clickable table of contents"
+ },
+ {
+ "id": "gfm-support",
+ "phase": 3,
+ "name": "GitHub Flavored Markdown",
+ "description": "Support GFM extensions",
+ "priority": 5,
+ "passes": false,
+ "acceptance": "Tables, task lists, and strikethrough render correctly"
+ }
+ ]
+}
diff --git a/examples/test-idea.txt b/examples/test-idea.txt
new file mode 100644
index 0000000..838d3e1
--- /dev/null
+++ b/examples/test-idea.txt
@@ -0,0 +1 @@
+A CLI tool that converts markdown files to beautifully styled PDF documents with syntax highlighting for code blocks. It should support multiple themes (dark, light, custom), handle GitHub Flavored Markdown, and optionally include a table of contents. The tool should be fast and work offline.
diff --git a/prd.json b/prd.json
index 803f67e..a7ec538 100644
--- a/prd.json
+++ b/prd.json
@@ -62,7 +62,7 @@
"name": "Specification Generation",
"description": "Generate features, data models, interfaces from idea plus research",
"priority": 1,
- "passes": false,
+ "passes": true,
"acceptance": "Produces valid Specification object with all required fields"
},
{
@@ -71,7 +71,7 @@
"name": "PRD Generation",
"description": "Generate PROMPT.md, prd.json, GUIDE.md",
"priority": 2,
- "passes": false,
+ "passes": true,
"acceptance": "Generated PROMPT.md contains all required sections and promise tags"
},
{
@@ -80,7 +80,7 @@
"name": "Project Scaffold",
"description": "Create directory structure and all files",
"priority": 3,
- "passes": false,
+ "passes": true,
"acceptance": "ralph-vibe new test-app creates complete directory with all files"
},
{
@@ -89,7 +89,7 @@
"name": "Interactive Prompts",
"description": "Inquirer.js prompts for user confirmation",
"priority": 4,
- "passes": false,
+ "passes": true,
"acceptance": "User can confirm or modify architecture classification interactively"
},
{
@@ -98,7 +98,7 @@
"name": "Validate Command",
"description": "Validate existing PROMPT.md files",
"priority": 1,
- "passes": false,
+ "passes": true,
"acceptance": "ralph-vibe validate ./PROMPT.md reports issues or confirms valid"
},
{
@@ -107,7 +107,7 @@
"name": "Standalone Research",
"description": "Research a topic without full generation",
"priority": 2,
- "passes": false,
+ "passes": true,
"acceptance": "ralph-vibe research topic outputs formatted research"
},
{
@@ -116,7 +116,7 @@
"name": "Dry Run Mode",
"description": "Show what would be generated without writing",
"priority": 3,
- "passes": false,
+ "passes": true,
"acceptance": "The --dry-run flag shows output without creating files"
},
{
@@ -125,7 +125,7 @@
"name": "Documentation",
"description": "README, help text, examples",
"priority": 4,
- "passes": false,
+ "passes": true,
"acceptance": "README.md covers all commands, examples directory exists"
},
{
@@ -134,7 +134,7 @@
"name": "npm Packaging",
"description": "Package ready for npm publish",
"priority": 5,
- "passes": false,
+ "passes": true,
"acceptance": "npm pack succeeds, npm publish --dry-run succeeds"
}
]
diff --git a/progress.txt b/progress.txt
index 981c08b..61eca7f 100644
--- a/progress.txt
+++ b/progress.txt
@@ -39,3 +39,89 @@
* Given idea text, returns valid Architecture JSON
* Given architecture, generates 4-6 relevant search queries
+[2026-01-10T21:10:00Z] [3] [COMPLETE] - Phase 3 Generation Pipeline implemented
+ - SpecificationGenerator: generates features, data models, interfaces from idea + research
+ * Parses Claude JSON response with validation
+ * Handles markdown code blocks in responses
+ * Normalizes all specification fields
+ - PRDGenerator: generates all PRD files
+ * PROMPT.md with Ralph Method structure
+ * prd.json with features set to passes: false
+ * GUIDE.md personalized to project
+ * CLAUDE.md with tech stack commands
+ * progress.txt initialized
+ - ScaffoldGenerator: creates complete project structure
+ * App-type aware directory structure (CLI, web, desktop, library)
+ * docs/ folder with all documentation
+ * agent_docs/ folder with tech stack, code patterns, testing guides
+ * .gitignore appropriate for tech stack
+ * Git initialization with initial commit
+ * Atomic file writes using temp + rename
+ - Interactive prompts with Inquirer.js
+ * Architecture confirmation/modification
+ * Research query confirmation/modification
+ * Specification confirmation
+ * Scaffold creation confirmation
+ - Full pipeline integration in new command
+ * --idea-file support for file input
+ * --skip-research to bypass Perplexity
+ * --skip-confirm for non-interactive mode
+ * --dry-run to preview without writing
+ * Graceful handling when Perplexity key missing
+ - 110 tests passing, all lint checks pass
+ - All Phase 3 acceptance criteria met:
+ * SpecificationGenerator produces valid Specification object
+ * PRD generation creates all required files with promise tags
+ * ralph-vibe new test-app creates complete directory with all files
+ * User can confirm or modify architecture classification interactively
+
+[2026-01-10T21:15:00Z] [4] [COMPLETE] - Phase 4 Polish implemented
+ - Validate command fully implemented
+ * Checks required sections (Objective, Application Type, Tech Stack, Completion Criteria)
+ * Detects missing promise tags
+ * Detects ambiguous language (should, might, could, possibly, maybe)
+ * Checks for verification commands in completion criteria
+ * Outputs specific suggestions for fixes
+ * Exit code 0 if valid, 1 if issues found
+ - Standalone research command implemented
+ * Uses Perplexity API to research any topic
+ * Outputs formatted markdown with sources
+ * Supports --output flag to save to file
+ * Supports --verbose for detailed progress
+ - Dry-run mode verified working
+ * --dry-run flag shows structure without writing files
+ * Logs all files that would be created
+ - Comprehensive documentation created
+ * README.md with full installation, usage, commands, options
+ * All commands documented with examples
+ * Environment variable configuration documented
+ * Troubleshooting section included
+ - Examples directory created
+ * examples/test-idea.txt - sample idea for testing
+ * examples/sample-PROMPT.md - example generated PROMPT.md
+ * examples/sample-prd.json - example generated prd.json
+ * examples/README.md - documentation for examples
+ - npm packaging verified
+ * npm pack succeeds
+ * npm publish --dry-run succeeds
+ * prepublishOnly hook runs build, test, lint
+ - 110 tests passing, all lint checks pass
+ - All Phase 4 acceptance criteria met:
+ * ralph-vibe validate reports issues or confirms valid
+ * ralph-vibe research outputs formatted research
+ * --dry-run flag shows output without creating files
+ * README.md covers all commands, examples directory exists
+ * npm pack succeeds, npm publish --dry-run succeeds
+
+[2026-01-10T21:15:00Z] [4] [PROJECT COMPLETE] - All completion criteria verified:
+ 1. npm run build exits 0 ✓
+ 2. npm run test exits 0 with 110 tests passing ✓
+ 3. npm run lint exits 0 ✓
+ 4. ralph-vibe --help displays all commands ✓
+ 5. ralph-vibe init stores and validates keys ✓
+ 6. ralph-vibe new generates complete scaffold ✓
+ 7. Generated PROMPT.md passes ralph-vibe validate ✓
+ 8. All generated files are syntactically valid ✓
+ 9. README.md documents all commands and options ✓
+ 10. npm publish --dry-run succeeds ✓
+
diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts
new file mode 100644
index 0000000..a792f90
--- /dev/null
+++ b/src/__tests__/interactive.test.ts
@@ -0,0 +1,141 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Mock inquirer
+vi.mock('inquirer', () => ({
+ default: {
+ prompt: vi.fn(),
+ },
+}));
+
+import inquirer from 'inquirer';
+import {
+ promptForIdeaSimple,
+ confirmArchitecture,
+ confirmResearchQueries,
+ confirmSpecification,
+ confirmScaffold,
+} from '../prompts/interactive.js';
+
+describe('Interactive Prompts', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('promptForIdeaSimple', () => {
+ it('should return trimmed idea', async () => {
+ vi.mocked(inquirer.prompt).mockResolvedValue({ idea: ' A test idea with at least 50 characters to pass validation check ' });
+
+ const result = await promptForIdeaSimple();
+
+ expect(result).toBe('A test idea with at least 50 characters to pass validation check');
+ });
+ });
+
+ describe('confirmArchitecture', () => {
+ const mockArchitecture = {
+ appType: 'cli' as const,
+ appTypeReason: 'CLI tool',
+ interfaces: ['cli_args' as const],
+ interfacesReason: 'CLI interface',
+ persistence: 'in_memory' as const,
+ persistenceReason: 'Stateless',
+ deployment: 'package_registry' as const,
+ deploymentReason: 'npm',
+ suggestedTechStack: {
+ language: 'TypeScript',
+ framework: 'Commander.js',
+ reasoning: 'Best for CLI',
+ },
+ };
+
+ it('should return confirmed architecture when user confirms', async () => {
+ vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: true });
+
+ const result = await confirmArchitecture(mockArchitecture);
+
+ expect(result.confirmed).toBe(true);
+ expect(result.architecture).toEqual(mockArchitecture);
+ });
+
+ it('should allow user to modify architecture when not confirmed', async () => {
+ // First prompt: user doesn't confirm
+ vi.mocked(inquirer.prompt)
+ .mockResolvedValueOnce({ confirm: false })
+ // Second prompt: user modifies architecture
+ .mockResolvedValueOnce({
+ appType: 'web',
+ interfaces: ['rest_api'],
+ persistence: 'remote_db',
+ deployment: 'cloud',
+ language: 'TypeScript',
+ framework: 'Express',
+ });
+
+ const result = await confirmArchitecture(mockArchitecture);
+
+ expect(result.confirmed).toBe(true);
+ expect(result.architecture.appType).toBe('web');
+ expect(result.architecture.appTypeReason).toBe('User modified');
+ });
+ });
+
+ describe('confirmResearchQueries', () => {
+ const mockQueries = ['Query 1', 'Query 2', 'Query 3'];
+
+ it('should return original queries when confirmed', async () => {
+ vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: true });
+
+ const result = await confirmResearchQueries(mockQueries);
+
+ expect(result.proceed).toBe(true);
+ expect(result.queries).toEqual(mockQueries);
+ });
+
+ it('should allow user to modify queries', async () => {
+ vi.mocked(inquirer.prompt)
+ .mockResolvedValueOnce({ confirm: false })
+ .mockResolvedValueOnce({ newQueries: 'Modified Query 1\nModified Query 2' });
+
+ const result = await confirmResearchQueries(mockQueries);
+
+ expect(result.proceed).toBe(true);
+ expect(result.queries).toEqual(['Modified Query 1', 'Modified Query 2']);
+ });
+ });
+
+ describe('confirmSpecification', () => {
+ it('should return true when confirmed', async () => {
+ vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: true });
+
+ const result = await confirmSpecification(5);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when not confirmed', async () => {
+ vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: false });
+
+ const result = await confirmSpecification(5);
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('confirmScaffold', () => {
+ it('should return true when confirmed', async () => {
+ vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: true });
+
+ const result = await confirmScaffold('/path/to/project', 10);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when not confirmed', async () => {
+ vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: false });
+
+ const result = await confirmScaffold('/path/to/project', 10);
+
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/src/__tests__/prd.test.ts b/src/__tests__/prd.test.ts
new file mode 100644
index 0000000..99f6b46
--- /dev/null
+++ b/src/__tests__/prd.test.ts
@@ -0,0 +1,214 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Create mock functions
+const mockAnalyze = vi.fn();
+
+// Mock the Claude client as a class
+vi.mock('../clients/claude.js', () => ({
+ ClaudeClient: class MockClaudeClient {
+ analyze = mockAnalyze;
+ },
+}));
+
+// Import after mocking
+import { PRDGenerator } from '../generators/prd.js';
+
+describe('PRDGenerator', () => {
+ const mockApiKey = 'test-api-key';
+
+ const mockArchitecture = {
+ appType: 'cli' as const,
+ appTypeReason: 'Command-line tool',
+ interfaces: ['cli_args' as const],
+ interfacesReason: 'CLI interface',
+ persistence: 'in_memory' as const,
+ persistenceReason: 'Stateless',
+ deployment: 'package_registry' as const,
+ deploymentReason: 'npm',
+ suggestedTechStack: {
+ language: 'TypeScript',
+ framework: 'Commander.js',
+ reasoning: 'Best for CLI',
+ },
+ };
+
+ const mockSpecification = {
+ features: [
+ {
+ id: 'feature_1',
+ name: 'Feature One',
+ description: 'First feature',
+ userStory: 'As a user, I want feature one',
+ acceptanceCriteria: ['Criterion 1', 'Criterion 2'],
+ },
+ {
+ id: 'feature_2',
+ name: 'Feature Two',
+ description: 'Second feature',
+ userStory: 'As a user, I want feature two',
+ acceptanceCriteria: ['Criterion 3'],
+ },
+ ],
+ dataModels: [],
+ interfaces: [],
+ techStack: {
+ language: 'TypeScript',
+ runtime: 'Node.js 20',
+ framework: 'Commander.js',
+ libraries: ['chalk'],
+ testingFramework: 'Vitest',
+ buildTool: 'tsup',
+ },
+ };
+
+ const mockResearch = {
+ queries: ['query1'],
+ results: [
+ {
+ answer: 'Research answer',
+ sources: [{ title: 'Source', url: 'https://example.com' }],
+ citations: [],
+ },
+ ],
+ summary: 'Research summary',
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('generate', () => {
+ it('should generate all PRD files', async () => {
+ // Mock PROMPT.md response
+ mockAnalyze.mockResolvedValueOnce('# Project: test-project\n\n## Objective\n\nTest project');
+
+ // Mock GUIDE.md response
+ mockAnalyze.mockResolvedValueOnce('# Step-by-Step Guide\n\n## Getting Started');
+
+ const generator = new PRDGenerator({ claudeApiKey: mockApiKey });
+ const result = await generator.generate(
+ 'test-project',
+ 'A test idea',
+ mockArchitecture,
+ mockSpecification,
+ mockResearch
+ );
+
+ expect(result.promptMd).toContain('# Project: test-project');
+ expect(result.guideMd).toContain('# Step-by-Step Guide');
+ expect(result.prdJson).toBeDefined();
+ expect(result.progressTxt).toContain('# Progress Log');
+ expect(result.claudeMd).toContain('# Claude Code Configuration');
+ });
+
+ it('should generate valid prd.json', async () => {
+ mockAnalyze.mockResolvedValue('# Test');
+
+ const generator = new PRDGenerator({ claudeApiKey: mockApiKey });
+ const result = await generator.generate(
+ 'test-project',
+ 'A test idea',
+ mockArchitecture,
+ mockSpecification,
+ mockResearch
+ );
+
+ expect(result.prdJson.project).toBe('test-project');
+ expect(result.prdJson.version).toBe('1.0.0');
+ expect(result.prdJson.features).toHaveLength(2);
+ expect(result.prdJson.features[0].id).toBe('feature_1');
+ expect(result.prdJson.features[0].passes).toBe(false);
+ expect(result.prdJson.features[1].passes).toBe(false);
+ });
+
+ it('should include build commands in CLAUDE.md', async () => {
+ mockAnalyze.mockResolvedValue('# Test');
+
+ const generator = new PRDGenerator({ claudeApiKey: mockApiKey });
+ const result = await generator.generate(
+ 'test-project',
+ 'A test idea',
+ mockArchitecture,
+ mockSpecification,
+ mockResearch
+ );
+
+ expect(result.claudeMd).toContain('npm run build');
+ expect(result.claudeMd).toContain('npm run test');
+ expect(result.claudeMd).toContain('npm run lint');
+ });
+
+ it('should initialize progress.txt with timestamp', async () => {
+ mockAnalyze.mockResolvedValue('# Test');
+
+ const generator = new PRDGenerator({ claudeApiKey: mockApiKey });
+ const result = await generator.generate(
+ 'test-project',
+ 'A test idea',
+ mockArchitecture,
+ mockSpecification,
+ mockResearch
+ );
+
+ expect(result.progressTxt).toContain('[INIT]');
+ expect(result.progressTxt).toContain('Project scaffold created');
+ });
+ });
+
+ describe('build commands for different languages', () => {
+ it('should use Python commands for Python projects', async () => {
+ const pythonSpec = {
+ ...mockSpecification,
+ techStack: {
+ language: 'Python',
+ runtime: 'Python 3.12',
+ libraries: [],
+ testingFramework: 'pytest',
+ buildTool: 'setuptools',
+ },
+ };
+
+ mockAnalyze.mockResolvedValue('# Test');
+
+ const generator = new PRDGenerator({ claudeApiKey: mockApiKey });
+ const result = await generator.generate(
+ 'py-project',
+ 'A Python idea',
+ mockArchitecture,
+ pythonSpec,
+ mockResearch
+ );
+
+ expect(result.claudeMd).toContain('python -m build');
+ expect(result.claudeMd).toContain('pytest');
+ expect(result.claudeMd).toContain('ruff check');
+ });
+
+ it('should use Rust commands for Rust projects', async () => {
+ const rustSpec = {
+ ...mockSpecification,
+ techStack: {
+ language: 'Rust',
+ libraries: [],
+ testingFramework: 'cargo test',
+ buildTool: 'cargo',
+ },
+ };
+
+ mockAnalyze.mockResolvedValue('# Test');
+
+ const generator = new PRDGenerator({ claudeApiKey: mockApiKey });
+ const result = await generator.generate(
+ 'rust-project',
+ 'A Rust idea',
+ mockArchitecture,
+ rustSpec,
+ mockResearch
+ );
+
+ expect(result.claudeMd).toContain('cargo build');
+ expect(result.claudeMd).toContain('cargo test');
+ expect(result.claudeMd).toContain('cargo clippy');
+ });
+ });
+});
diff --git a/src/__tests__/scaffold.test.ts b/src/__tests__/scaffold.test.ts
new file mode 100644
index 0000000..b3124d3
--- /dev/null
+++ b/src/__tests__/scaffold.test.ts
@@ -0,0 +1,282 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { join } from 'path';
+import { rm, readFile, stat, access } from 'fs/promises';
+import { ScaffoldGenerator } from '../generators/scaffold.js';
+
+// Mock execSync for git operations
+vi.mock('child_process', () => ({
+ execSync: vi.fn(),
+}));
+
+describe('ScaffoldGenerator', () => {
+ const testOutputDir = '/tmp/ralph-test-scaffold';
+ const testProjectName = 'test-project';
+
+ const mockInput = {
+ idea: 'A CLI tool that converts markdown to PDF',
+ architecture: {
+ appType: 'cli' as const,
+ appTypeReason: 'CLI tool',
+ interfaces: ['cli_args' as const, 'file_format' as const],
+ interfacesReason: 'CLI interface',
+ persistence: 'in_memory' as const,
+ persistenceReason: 'Stateless',
+ deployment: 'package_registry' as const,
+ deploymentReason: 'npm',
+ suggestedTechStack: {
+ language: 'TypeScript',
+ framework: 'Commander.js',
+ reasoning: 'Best for CLI',
+ },
+ },
+ specification: {
+ features: [
+ {
+ id: 'convert',
+ name: 'Convert Command',
+ description: 'Convert markdown to PDF',
+ userStory: 'As a user, I want to convert markdown',
+ acceptanceCriteria: ['Converts files'],
+ },
+ ],
+ dataModels: [
+ {
+ name: 'Document',
+ fields: [{ name: 'content', type: 'string', required: true }],
+ },
+ ],
+ interfaces: [
+ {
+ name: 'CLI',
+ type: 'cli_args' as const,
+ commands: [{ name: 'convert', args: ['input'], description: 'Convert file' }],
+ },
+ ],
+ techStack: {
+ language: 'TypeScript',
+ runtime: 'Node.js 20',
+ framework: 'Commander.js',
+ libraries: ['marked', 'puppeteer'],
+ testingFramework: 'Vitest',
+ buildTool: 'tsup',
+ },
+ },
+ research: {
+ queries: ['TypeScript CLI'],
+ results: [
+ {
+ answer: 'Best practices for TypeScript CLI',
+ sources: [{ title: 'Guide', url: 'https://example.com' }],
+ citations: [],
+ },
+ ],
+ summary: 'Research summary',
+ },
+ prdOutput: {
+ promptMd: '# Project: test-project\n\n## Objective\n\nTest project',
+ prdJson: {
+ project: 'test-project',
+ version: '1.0.0',
+ features: [
+ {
+ id: 'convert',
+ phase: 1,
+ name: 'Convert Command',
+ description: 'Convert markdown to PDF',
+ priority: 1,
+ passes: false,
+ acceptance: 'Converts files',
+ },
+ ],
+ },
+ progressTxt: '# Progress Log\n\n[2026-01-10] [0] [INIT] - Created',
+ guideMd: '# Step-by-Step Guide\n\n## Getting Started',
+ claudeMd: '# Claude Code Configuration',
+ },
+ };
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ // Clean up test directory if it exists
+ try {
+ await rm(testOutputDir, { recursive: true, force: true });
+ } catch {
+ // Directory doesn't exist, that's fine
+ }
+ });
+
+ afterEach(async () => {
+ // Clean up test directory
+ try {
+ await rm(testOutputDir, { recursive: true, force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ });
+
+ describe('generate', () => {
+ it('should create project directory structure', async () => {
+ const generator = new ScaffoldGenerator({
+ outputDir: testOutputDir,
+ projectName: testProjectName,
+ });
+
+ const result = await generator.generate(mockInput);
+
+ expect(result.projectPath).toBe(join(testOutputDir, testProjectName));
+ expect(result.files.length).toBeGreaterThan(0);
+
+ // Check directories exist
+ await expect(stat(join(result.projectPath, 'docs'))).resolves.toBeDefined();
+ await expect(stat(join(result.projectPath, 'agent_docs'))).resolves.toBeDefined();
+ await expect(stat(join(result.projectPath, 'src'))).resolves.toBeDefined();
+ });
+
+ it('should write core files', async () => {
+ const generator = new ScaffoldGenerator({
+ outputDir: testOutputDir,
+ projectName: testProjectName,
+ });
+
+ const result = await generator.generate(mockInput);
+
+ // Check core files exist
+ const projectPath = result.projectPath;
+ await expect(access(join(projectPath, 'PROMPT.md'))).resolves.toBeUndefined();
+ await expect(access(join(projectPath, 'prd.json'))).resolves.toBeUndefined();
+ await expect(access(join(projectPath, 'progress.txt'))).resolves.toBeUndefined();
+ await expect(access(join(projectPath, 'GUIDE.md'))).resolves.toBeUndefined();
+ await expect(access(join(projectPath, 'CLAUDE.md'))).resolves.toBeUndefined();
+ await expect(access(join(projectPath, 'README.md'))).resolves.toBeUndefined();
+ });
+
+ it('should write docs files', async () => {
+ const generator = new ScaffoldGenerator({
+ outputDir: testOutputDir,
+ projectName: testProjectName,
+ });
+
+ const result = await generator.generate(mockInput);
+
+ const docsPath = join(result.projectPath, 'docs');
+ await expect(access(join(docsPath, 'idea-dump.md'))).resolves.toBeUndefined();
+ await expect(access(join(docsPath, 'architecture.md'))).resolves.toBeUndefined();
+ await expect(access(join(docsPath, 'features.md'))).resolves.toBeUndefined();
+ await expect(access(join(docsPath, 'tech-stack.md'))).resolves.toBeUndefined();
+ await expect(access(join(docsPath, 'research-notes.md'))).resolves.toBeUndefined();
+ });
+
+ it('should write agent_docs files', async () => {
+ const generator = new ScaffoldGenerator({
+ outputDir: testOutputDir,
+ projectName: testProjectName,
+ });
+
+ const result = await generator.generate(mockInput);
+
+ const agentDocsPath = join(result.projectPath, 'agent_docs');
+ await expect(access(join(agentDocsPath, 'tech_stack.md'))).resolves.toBeUndefined();
+ await expect(access(join(agentDocsPath, 'code_patterns.md'))).resolves.toBeUndefined();
+ await expect(access(join(agentDocsPath, 'testing.md'))).resolves.toBeUndefined();
+ });
+
+ it('should write .gitignore', async () => {
+ const generator = new ScaffoldGenerator({
+ outputDir: testOutputDir,
+ projectName: testProjectName,
+ });
+
+ const result = await generator.generate(mockInput);
+
+ const gitignoreContent = await readFile(join(result.projectPath, '.gitignore'), 'utf-8');
+ expect(gitignoreContent).toContain('node_modules');
+ expect(gitignoreContent).toContain('.env');
+ });
+
+ it('should not write files in dry-run mode', async () => {
+ const generator = new ScaffoldGenerator({
+ outputDir: testOutputDir,
+ projectName: testProjectName,
+ dryRun: true,
+ });
+
+ const result = await generator.generate(mockInput);
+
+ expect(result.files).toHaveLength(0);
+ await expect(stat(result.projectPath)).rejects.toThrow();
+ });
+
+ it('should create CLI-specific directories for CLI apps', async () => {
+ const generator = new ScaffoldGenerator({
+ outputDir: testOutputDir,
+ projectName: testProjectName,
+ });
+
+ const result = await generator.generate(mockInput);
+
+ await expect(stat(join(result.projectPath, 'src', 'commands'))).resolves.toBeDefined();
+ await expect(stat(join(result.projectPath, 'src', 'utils'))).resolves.toBeDefined();
+ });
+
+ it('should create web-specific directories for web apps', async () => {
+ const webInput = {
+ ...mockInput,
+ architecture: {
+ ...mockInput.architecture,
+ appType: 'web' as const,
+ },
+ };
+
+ const generator = new ScaffoldGenerator({
+ outputDir: testOutputDir,
+ projectName: testProjectName,
+ });
+
+ const result = await generator.generate(webInput);
+
+ await expect(stat(join(result.projectPath, 'src', 'routes'))).resolves.toBeDefined();
+ await expect(stat(join(result.projectPath, 'src', 'middleware'))).resolves.toBeDefined();
+ await expect(stat(join(result.projectPath, 'src', 'services'))).resolves.toBeDefined();
+ });
+ });
+
+ describe('file content', () => {
+ it('should write correct PROMPT.md content', async () => {
+ const generator = new ScaffoldGenerator({
+ outputDir: testOutputDir,
+ projectName: testProjectName,
+ });
+
+ const result = await generator.generate(mockInput);
+
+ const content = await readFile(join(result.projectPath, 'PROMPT.md'), 'utf-8');
+ expect(content).toBe(mockInput.prdOutput.promptMd);
+ });
+
+ it('should write valid JSON to prd.json', async () => {
+ const generator = new ScaffoldGenerator({
+ outputDir: testOutputDir,
+ projectName: testProjectName,
+ });
+
+ const result = await generator.generate(mockInput);
+
+ const content = await readFile(join(result.projectPath, 'prd.json'), 'utf-8');
+ const parsed = JSON.parse(content);
+ expect(parsed.project).toBe('test-project');
+ expect(parsed.features[0].passes).toBe(false);
+ });
+
+ it('should include original idea in idea-dump.md', async () => {
+ const generator = new ScaffoldGenerator({
+ outputDir: testOutputDir,
+ projectName: testProjectName,
+ });
+
+ const result = await generator.generate(mockInput);
+
+ const content = await readFile(join(result.projectPath, 'docs', 'idea-dump.md'), 'utf-8');
+ expect(content).toContain(mockInput.idea);
+ });
+ });
+});
diff --git a/src/__tests__/specification.test.ts b/src/__tests__/specification.test.ts
new file mode 100644
index 0000000..64d6237
--- /dev/null
+++ b/src/__tests__/specification.test.ts
@@ -0,0 +1,130 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Create mock functions
+const mockAnalyze = vi.fn();
+
+// Mock the Claude client as a class
+vi.mock('../clients/claude.js', () => ({
+ ClaudeClient: class MockClaudeClient {
+ analyze = mockAnalyze;
+ },
+}));
+
+// Import after mocking
+import { SpecificationGenerator } from '../generators/specification.js';
+
+describe('SpecificationGenerator', () => {
+ const mockApiKey = 'test-api-key';
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('generate', () => {
+ const mockIdea = 'A CLI tool that converts markdown to PDF with syntax highlighting';
+ const mockArchitecture = {
+ appType: 'cli' as const,
+ appTypeReason: 'Command-line tool',
+ interfaces: ['cli_args' as const, 'file_format' as const],
+ interfacesReason: 'CLI with file I/O',
+ persistence: 'in_memory' as const,
+ persistenceReason: 'Stateless transformation',
+ deployment: 'package_registry' as const,
+ deploymentReason: 'npm package',
+ suggestedTechStack: {
+ language: 'TypeScript',
+ framework: 'Commander.js',
+ reasoning: 'Best for CLI tools',
+ },
+ };
+ const mockResearch = {
+ queries: ['TypeScript CLI best practices'],
+ results: [],
+ summary: 'Research summary',
+ };
+
+ it('should generate a valid specification', async () => {
+ const mockResponse = JSON.stringify({
+ features: [
+ {
+ id: 'markdown_parsing',
+ name: 'Markdown Parsing',
+ description: 'Parse markdown input',
+ userStory: 'As a user, I want to parse markdown',
+ acceptanceCriteria: ['Parses headers', 'Parses code blocks'],
+ },
+ ],
+ dataModels: [
+ {
+ name: 'Document',
+ fields: [{ name: 'content', type: 'string', required: true }],
+ },
+ ],
+ interfaces: [
+ {
+ name: 'CLI',
+ type: 'cli_args',
+ commands: [{ name: 'convert', args: ['input', 'output'], description: 'Convert file' }],
+ },
+ ],
+ techStack: {
+ language: 'TypeScript',
+ runtime: 'Node.js 20',
+ framework: 'Commander.js',
+ libraries: ['marked', 'puppeteer'],
+ testingFramework: 'Vitest',
+ buildTool: 'tsup',
+ },
+ });
+
+ mockAnalyze.mockResolvedValue(mockResponse);
+
+ const generator = new SpecificationGenerator({ claudeApiKey: mockApiKey });
+ const spec = await generator.generate(mockIdea, mockArchitecture, mockResearch);
+
+ expect(spec.features).toHaveLength(1);
+ expect(spec.features[0].id).toBe('markdown_parsing');
+ expect(spec.dataModels).toHaveLength(1);
+ expect(spec.interfaces).toHaveLength(1);
+ expect(spec.techStack.language).toBe('TypeScript');
+ });
+
+ it('should handle markdown code blocks in response', async () => {
+ const mockResponse = '```json\n' + JSON.stringify({
+ features: [{ id: 'test', name: 'Test', description: 'Test', userStory: 'As a user', acceptanceCriteria: [] }],
+ dataModels: [],
+ interfaces: [],
+ techStack: { language: 'TypeScript', libraries: [], testingFramework: 'Vitest', buildTool: 'tsup' },
+ }) + '\n```';
+
+ mockAnalyze.mockResolvedValue(mockResponse);
+
+ const generator = new SpecificationGenerator({ claudeApiKey: mockApiKey });
+ const spec = await generator.generate(mockIdea, mockArchitecture, mockResearch);
+
+ expect(spec.features).toHaveLength(1);
+ expect(spec.features[0].id).toBe('test');
+ });
+
+ it('should throw on invalid JSON response', async () => {
+ mockAnalyze.mockResolvedValue('not valid json');
+
+ const generator = new SpecificationGenerator({ claudeApiKey: mockApiKey });
+ await expect(generator.generate(mockIdea, mockArchitecture, mockResearch))
+ .rejects.toThrow('Failed to parse specification');
+ });
+
+ it('should throw on missing required fields', async () => {
+ const mockResponse = JSON.stringify({
+ features: [{ id: 'test' }],
+ // missing dataModels, interfaces, techStack
+ });
+
+ mockAnalyze.mockResolvedValue(mockResponse);
+
+ const generator = new SpecificationGenerator({ claudeApiKey: mockApiKey });
+ await expect(generator.generate(mockIdea, mockArchitecture, mockResearch))
+ .rejects.toThrow('Missing or invalid');
+ });
+ });
+});
diff --git a/src/clients/claude.ts b/src/clients/claude.ts
index 5b036fa..8c45dc3 100644
--- a/src/clients/claude.ts
+++ b/src/clients/claude.ts
@@ -16,7 +16,7 @@ export class ClaudeClient {
constructor(options: ClaudeClientOptions) {
this.client = new Anthropic({ apiKey: options.apiKey });
- this.model = options.model || 'claude-sonnet-4-20250514';
+ this.model = options.model || 'claude-sonnet-4-5-20250929';
}
private async retryWithBackoff(
diff --git a/src/commands/new.ts b/src/commands/new.ts
index ab0d731..70af681 100644
--- a/src/commands/new.ts
+++ b/src/commands/new.ts
@@ -1,7 +1,21 @@
+import { resolve } from 'path';
+import ora from 'ora';
import { loadConfig, hasValidKeys } from '../utils/config.js';
+import { readFileContent, fileExists } from '../utils/files.js';
import { logger } from '../utils/logger.js';
+import { ArchitectureGenerator } from '../generators/architecture.js';
+import { SpecificationGenerator } from '../generators/specification.js';
+import { PRDGenerator } from '../generators/prd.js';
+import { ScaffoldGenerator } from '../generators/scaffold.js';
+import {
+ promptForIdeaSimple,
+ confirmArchitecture,
+ confirmResearchQueries,
+ confirmSpecification,
+ confirmScaffold,
+} from '../prompts/interactive.js';
-interface NewOptions {
+export interface NewOptions {
ideaFile?: string;
outputDir?: string;
skipResearch?: boolean;
@@ -21,10 +35,9 @@ export async function newCommand(projectName: string, options: NewOptions): Prom
}
if (!keys.perplexity && !options.skipResearch) {
- logger.error('Perplexity API key not found.');
- logger.info('Run `ralph-vibe init` to configure your API keys.');
- logger.info('Or use --skip-research to skip the research phase.');
- process.exit(1);
+ logger.warn('Perplexity API key not found. Research phase will be skipped.');
+ logger.info('Run `ralph-vibe init` to configure Perplexity API key for research.');
+ options.skipResearch = true;
}
const config = await loadConfig();
@@ -36,12 +49,197 @@ export async function newCommand(projectName: string, options: NewOptions): Prom
logger.info(`Creating new project: ${projectName}`);
- if (options.dryRun) {
- logger.info('[DRY RUN] Would create project at:', options.outputDir || `./${projectName}`);
- return;
+ const outputDir = options.outputDir || process.cwd();
+ const projectPath = resolve(outputDir, projectName);
+
+ // Check if project already exists
+ if (await fileExists(projectPath)) {
+ logger.error(`Directory already exists: ${projectPath}`);
+ process.exit(1);
}
- // TODO: Implement full generation pipeline in Phase 3
- logger.info('Project generation will be implemented in Phase 3.');
- logger.info('Current Phase 1 implementation validates config and CLI framework.');
+ // Step 1: Get idea
+ let idea: string;
+ if (options.ideaFile) {
+ const ideaPath = resolve(options.ideaFile);
+ if (!(await fileExists(ideaPath))) {
+ logger.error(`Idea file not found: ${ideaPath}`);
+ process.exit(1);
+ }
+ idea = await readFileContent(ideaPath);
+ logger.info('Loaded idea from file');
+ } else {
+ idea = await promptForIdeaSimple();
+ }
+
+ if (idea.trim().length < 50) {
+ logger.error('Idea must be at least 50 characters');
+ process.exit(1);
+ }
+
+ logger.success(`Idea received (${idea.length} characters)`);
+
+ // Step 2: Classify architecture
+ const spinner = ora('Analyzing idea with Claude...').start();
+
+ const archGenerator = new ArchitectureGenerator({
+ config,
+ skipResearch: options.skipResearch,
+ });
+
+ let architecture;
+ try {
+ architecture = await archGenerator.classifyIdea(idea);
+ spinner.succeed('Architecture classified');
+ } catch (err) {
+ spinner.fail('Failed to classify architecture');
+ const error = err as Error;
+ logger.error(error.message);
+ process.exit(1);
+ }
+
+ // Confirm architecture with user
+ if (!options.skipConfirm) {
+ const confirmation = await confirmArchitecture(architecture);
+ architecture = confirmation.architecture;
+ }
+
+ // Step 3: Generate research queries and conduct research
+ let research;
+ if (!options.skipResearch) {
+ spinner.start('Generating research queries...');
+
+ let queries;
+ try {
+ queries = await archGenerator.generateResearchQueries(architecture);
+ spinner.succeed(`Generated ${queries.length} research queries`);
+ } catch (err) {
+ spinner.fail('Failed to generate research queries');
+ const error = err as Error;
+ logger.warn(error.message);
+ queries = [];
+ }
+
+ if (queries.length > 0 && !options.skipConfirm) {
+ const queryConfirm = await confirmResearchQueries(queries);
+ queries = queryConfirm.queries;
+ }
+
+ if (queries.length > 0) {
+ spinner.start('Conducting research with Perplexity...');
+ try {
+ research = await archGenerator.conductResearch(queries);
+ spinner.succeed(`Research complete with ${research.results.length} findings`);
+ } catch (err) {
+ spinner.fail('Research failed');
+ const error = err as Error;
+ logger.warn(error.message);
+ research = { queries, results: [], summary: 'Research was skipped due to errors.' };
+ }
+ } else {
+ research = { queries: [], results: [], summary: 'Research was skipped.' };
+ }
+ } else {
+ research = { queries: [], results: [], summary: 'Research was skipped.' };
+ }
+
+ // Step 4: Generate specification
+ spinner.start('Generating specification...');
+
+ const specGenerator = new SpecificationGenerator({
+ claudeApiKey: config.claudeApiKey!,
+ });
+
+ let specification;
+ try {
+ specification = await specGenerator.generate(idea, architecture, research);
+ spinner.succeed(`Specification generated with ${specification.features.length} features`);
+ } catch (err) {
+ spinner.fail('Failed to generate specification');
+ const error = err as Error;
+ logger.error(error.message);
+ process.exit(1);
+ }
+
+ // Confirm specification
+ if (!options.skipConfirm) {
+ const proceed = await confirmSpecification(specification.features.length);
+ if (!proceed) {
+ logger.info('Generation cancelled by user');
+ process.exit(0);
+ }
+ }
+
+ // Step 5: Generate PRD files
+ spinner.start('Generating PRD files (PROMPT.md, prd.json, GUIDE.md, CLAUDE.md)...');
+
+ const prdGenerator = new PRDGenerator({
+ claudeApiKey: config.claudeApiKey!,
+ });
+
+ let prdOutput;
+ try {
+ prdOutput = await prdGenerator.generate(
+ projectName,
+ idea,
+ architecture,
+ specification,
+ research
+ );
+ spinner.succeed('PRD files generated');
+ } catch (err) {
+ spinner.fail('Failed to generate PRD files');
+ const error = err as Error;
+ logger.error(error.message);
+ process.exit(1);
+ }
+
+ // Step 6: Create scaffold
+ if (!options.skipConfirm) {
+ const estimatedFiles = 15; // Approximate number of files
+ const proceed = await confirmScaffold(projectPath, estimatedFiles);
+ if (!proceed) {
+ logger.info('Scaffold creation cancelled by user');
+ process.exit(0);
+ }
+ }
+
+ spinner.start('Creating project scaffold...');
+
+ const scaffoldGenerator = new ScaffoldGenerator({
+ outputDir,
+ projectName,
+ dryRun: options.dryRun,
+ });
+
+ try {
+ const result = await scaffoldGenerator.generate({
+ idea,
+ architecture,
+ specification,
+ research,
+ prdOutput,
+ });
+
+ if (options.dryRun) {
+ spinner.succeed('Dry run complete - no files written');
+ } else {
+ spinner.succeed(`Project scaffold created at ${result.projectPath}`);
+
+ // Print next steps
+ console.log('\n-----------------------------------');
+ console.log('Your project is ready! Next steps:');
+ console.log('-----------------------------------\n');
+ console.log(` cd ${projectName}`);
+ console.log(' # Open in your editor');
+ console.log(' # Follow GUIDE.md or run:');
+ console.log(` /ralph-wiggum:ralph-loop "$(cat PROMPT.md)" --max-iterations 50 --completion-promise "PROJECT_COMPLETE"`);
+ console.log('\n');
+ }
+ } catch (err) {
+ spinner.fail('Failed to create scaffold');
+ const error = err as Error;
+ logger.error(error.message);
+ process.exit(1);
+ }
}
diff --git a/src/commands/research.ts b/src/commands/research.ts
index f42aed1..9703334 100644
--- a/src/commands/research.ts
+++ b/src/commands/research.ts
@@ -1,5 +1,8 @@
+import ora from 'ora';
import { loadConfig, hasValidKeys } from '../utils/config.js';
+import { writeFileAtomic } from '../utils/files.js';
import { logger } from '../utils/logger.js';
+import { PerplexityClient } from '../clients/perplexity.js';
interface ResearchOptions {
output?: string;
@@ -22,9 +25,43 @@ export async function researchCommand(topic: string, options: ResearchOptions):
logger.setLevel('debug');
}
- logger.info(`Researching: ${topic}`);
+ const spinner = ora(`Researching: ${topic}`).start();
- // TODO: Implement Perplexity research in Phase 2
- logger.info('Research functionality will be implemented in Phase 2.');
- logger.info('This command will query Perplexity API for best practices and recommendations.');
+ const client = new PerplexityClient({
+ apiKey: config.perplexityApiKey!,
+ });
+
+ try {
+ const result = await client.search(topic);
+ spinner.succeed('Research complete');
+
+ // Format output
+ const output = formatResearchOutput(topic, result);
+
+ if (options.output) {
+ await writeFileAtomic(options.output, output);
+ logger.success(`Results saved to ${options.output}`);
+ } else {
+ console.log('\n' + output);
+ }
+ } catch (err) {
+ spinner.fail('Research failed');
+ const error = err as Error;
+ logger.error(error.message);
+ process.exit(1);
+ }
+}
+
+function formatResearchOutput(topic: string, result: { answer: string; sources: { title: string; url: string }[]; citations: string[] }): string {
+ let output = `# Research: ${topic}\n\n`;
+ output += result.answer + '\n';
+
+ if (result.sources.length > 0) {
+ output += '\n## Sources\n\n';
+ for (const source of result.sources) {
+ output += `- [${source.title}](${source.url})\n`;
+ }
+ }
+
+ return output;
}
diff --git a/src/generators/prd.ts b/src/generators/prd.ts
new file mode 100644
index 0000000..84b21e2
--- /dev/null
+++ b/src/generators/prd.ts
@@ -0,0 +1,298 @@
+import { ClaudeClient } from '../clients/claude.js';
+import { logger } from '../utils/logger.js';
+import type { Architecture, Specification, Research, PRDOutput, PRDJson } from '../types/index.js';
+
+export interface PRDGeneratorOptions {
+ claudeApiKey: string;
+}
+
+export class PRDGenerator {
+ private claudeClient: ClaudeClient;
+
+ constructor(options: PRDGeneratorOptions) {
+ this.claudeClient = new ClaudeClient({ apiKey: options.claudeApiKey });
+ }
+
+ async generate(
+ projectName: string,
+ idea: string,
+ architecture: Architecture,
+ specification: Specification,
+ research: Research
+ ): Promise {
+ logger.info('Generating PRD files...');
+
+ // Generate all components
+ const [promptMd, prdJson, guideMd, claudeMd, progressTxt] = await Promise.all([
+ this.generatePromptMd(projectName, idea, architecture, specification, research),
+ this.generatePrdJson(projectName, specification),
+ this.generateGuideMd(projectName, architecture, specification),
+ this.generateClaudeMd(projectName, specification),
+ this.generateProgressTxt(projectName),
+ ]);
+
+ logger.success('PRD files generated');
+
+ return {
+ promptMd,
+ prdJson,
+ guideMd,
+ claudeMd,
+ progressTxt,
+ };
+ }
+
+ private async generatePromptMd(
+ projectName: string,
+ idea: string,
+ architecture: Architecture,
+ specification: Specification,
+ research: Research
+ ): Promise {
+ logger.debug('Generating PROMPT.md...');
+
+ const researchSummary = research.results.length > 0
+ ? research.results.map(r => `- ${r.answer.split('\n')[0]}`).join('\n')
+ : research.summary;
+
+ const prompt = `Generate a Ralph Method PROMPT.md file for this project:
+
+${projectName}
+
+${idea}
+
+
+${JSON.stringify(architecture, null, 2)}
+
+
+
+${JSON.stringify(specification, null, 2)}
+
+
+
+${researchSummary}
+
+
+Generate a complete PROMPT.md following the Ralph Method framework. Requirements:
+
+1. Include sections: Objective, Application Type, Architecture, Tech Stack, Completion Criteria
+2. Divide work into 4 phases appropriate for a ${architecture.appType} application:
+ - Phase 1: Foundation (project setup, core infrastructure)
+ - Phase 2: Core Features (main functionality)
+ - Phase 3: Integration (external services, error handling)
+ - Phase 4: Polish (documentation, optimization, packaging)
+3. Each phase must have checkbox items with binary acceptance criteria
+4. Include Constraints section with rules specific to the tech stack
+5. Include Abort Conditions section
+6. Include promise tags: PROJECT_COMPLETE and ABORT_BLOCKED
+7. All completion criteria must be verifiable via commands
+
+Output the complete PROMPT.md content as raw markdown (no code fences around the whole thing):`;
+
+ const response = await this.claudeClient.analyze(prompt);
+ return this.cleanMarkdownResponse(response);
+ }
+
+ private async generatePrdJson(
+ projectName: string,
+ specification: Specification
+ ): Promise {
+ logger.debug('Generating prd.json...');
+
+ // Assign phases based on feature order
+ const features = specification.features.map((f, index) => {
+ // Assign phase based on index (first 2 = phase 1, next 3 = phase 2, etc.)
+ let phase = 1;
+ if (index >= 2 && index < 5) phase = 2;
+ else if (index >= 5 && index < 8) phase = 3;
+ else if (index >= 8) phase = 4;
+
+ return {
+ id: f.id,
+ phase,
+ name: f.name,
+ description: f.description,
+ priority: index + 1,
+ passes: false, // Always start with false
+ acceptance: f.acceptanceCriteria[0] || `${f.name} is implemented and tested`,
+ };
+ });
+
+ return {
+ project: projectName,
+ version: '1.0.0',
+ features,
+ };
+ }
+
+ private async generateGuideMd(
+ projectName: string,
+ architecture: Architecture,
+ specification: Specification
+ ): Promise {
+ logger.debug('Generating GUIDE.md...');
+
+ const prompt = `Generate a personalized step-by-step GUIDE.md for this project:
+
+${projectName}
+
+${architecture.appType}
+
+
+${JSON.stringify(specification.techStack, null, 2)}
+
+
+
+${specification.features.map((f, i) => `${i + 1}. ${f.name}: ${f.description}`).join('\n')}
+
+
+Generate a GUIDE.md that:
+1. Is specific to this ${architecture.appType} project
+2. Uses exact commands for ${specification.techStack.language} / ${specification.techStack.framework || 'the tech stack'}
+3. References specific phases from PROMPT.md
+4. Includes cost estimates based on ${specification.features.length} features
+5. Guides through the Ralph Method workflow
+
+Format as clear, actionable markdown with numbered steps.
+
+Output the complete GUIDE.md content as raw markdown:`;
+
+ const response = await this.claudeClient.analyze(prompt);
+ return this.cleanMarkdownResponse(response);
+ }
+
+ private async generateClaudeMd(
+ projectName: string,
+ specification: Specification
+ ): Promise {
+ logger.debug('Generating CLAUDE.md...');
+
+ const { techStack } = specification;
+ const buildCmd = this.getBuildCommand(techStack.language);
+ const testCmd = this.getTestCommand(techStack.testingFramework);
+ const lintCmd = this.getLintCommand(techStack.language);
+
+ return `# Claude Code Configuration
+
+## Project Context
+
+${projectName} - Read PROMPT.md for full requirements.
+
+Read prd.json for feature tracking.
+Append progress to progress.txt after each significant change.
+
+## Tech Stack
+
+- ${techStack.language}${techStack.runtime ? ` with ${techStack.runtime}` : ''}
+${techStack.framework ? `- ${techStack.framework}` : ''}
+${techStack.libraries.map(l => `- ${l}`).join('\n')}
+- ${techStack.testingFramework} for testing
+- ${techStack.buildTool} for building
+
+## Working Rules
+
+1. Always run tests before committing
+2. Never commit failing code
+3. Update prd.json when features complete by setting passes to true
+4. Use conventional commit messages
+5. Make reasonable decisions - do not ask questions
+
+## Commands
+
+- Build: ${buildCmd}
+- Test: ${testCmd}
+- Lint: ${lintCmd}
+- All: ${buildCmd} && ${testCmd} && ${lintCmd}
+
+## Key Files
+
+- PROMPT.md: Full specification
+- prd.json: Feature tracking
+- progress.txt: Append-only progress log
+- GUIDE.md: Step-by-step guide
+`;
+ }
+
+ private async generateProgressTxt(projectName: string): Promise {
+ const timestamp = new Date().toISOString();
+ return `# Progress Log - ${projectName}
+# Format: [TIMESTAMP] [ITERATION] [STATUS] - [DETAILS]
+# Agent: Append only, never modify previous entries
+
+---
+
+[${timestamp}] [0] [INIT] - Project scaffold created
+`;
+ }
+
+ private cleanMarkdownResponse(response: string): string {
+ let cleaned = response.trim();
+
+ // Remove outer code fences if Claude wrapped the whole response
+ if (cleaned.startsWith('```markdown')) {
+ cleaned = cleaned.replace(/^```markdown\s*\n?/, '').replace(/\n?```\s*$/, '');
+ } else if (cleaned.startsWith('```')) {
+ cleaned = cleaned.replace(/^```\s*\n?/, '').replace(/\n?```\s*$/, '');
+ }
+
+ return cleaned.trim();
+ }
+
+ private getBuildCommand(language: string): string {
+ const lang = language.toLowerCase();
+ if (lang.includes('typescript') || lang.includes('javascript')) {
+ return 'npm run build';
+ }
+ if (lang.includes('python')) {
+ return 'python -m build';
+ }
+ if (lang.includes('rust')) {
+ return 'cargo build';
+ }
+ if (lang.includes('go')) {
+ return 'go build ./...';
+ }
+ return 'npm run build';
+ }
+
+ private getTestCommand(testFramework: string): string {
+ const tf = testFramework.toLowerCase();
+ if (tf.includes('vitest') || tf.includes('jest')) {
+ return 'npm run test';
+ }
+ if (tf.includes('pytest')) {
+ return 'pytest';
+ }
+ if (tf.includes('cargo') || tf.includes('rust')) {
+ return 'cargo test';
+ }
+ if (tf.includes('go')) {
+ return 'go test ./...';
+ }
+ return 'npm run test';
+ }
+
+ private getLintCommand(language: string): string {
+ const lang = language.toLowerCase();
+ if (lang.includes('typescript') || lang.includes('javascript')) {
+ return 'npm run lint';
+ }
+ if (lang.includes('python')) {
+ return 'ruff check .';
+ }
+ if (lang.includes('rust')) {
+ return 'cargo clippy';
+ }
+ if (lang.includes('go')) {
+ return 'golangci-lint run';
+ }
+ return 'npm run lint';
+ }
+}
+
+// Extend PRDOutput to include progressTxt
+declare module '../types/index.js' {
+ interface PRDOutput {
+ progressTxt: string;
+ }
+}
diff --git a/src/generators/scaffold.ts b/src/generators/scaffold.ts
new file mode 100644
index 0000000..cbda49f
--- /dev/null
+++ b/src/generators/scaffold.ts
@@ -0,0 +1,673 @@
+import { join } from 'path';
+import { execSync } from 'child_process';
+import { ensureDir, writeFileAtomic, writeJsonFile } from '../utils/files.js';
+import { logger } from '../utils/logger.js';
+import type { Architecture, Specification, Research, PRDOutput, ScaffoldOutput, AppType } from '../types/index.js';
+
+export interface ScaffoldGeneratorOptions {
+ outputDir: string;
+ projectName: string;
+ dryRun?: boolean;
+}
+
+export interface ScaffoldInput {
+ idea: string;
+ architecture: Architecture;
+ specification: Specification;
+ research: Research;
+ prdOutput: PRDOutput;
+}
+
+export class ScaffoldGenerator {
+ private options: ScaffoldGeneratorOptions;
+
+ constructor(options: ScaffoldGeneratorOptions) {
+ this.options = options;
+ }
+
+ async generate(input: ScaffoldInput): Promise {
+ const { outputDir, projectName, dryRun } = this.options;
+ const projectPath = join(outputDir, projectName);
+ const files: string[] = [];
+
+ logger.info(`Creating project scaffold at ${projectPath}...`);
+
+ if (dryRun) {
+ logger.info('[DRY RUN] Would create the following structure:');
+ this.logDryRunStructure(projectPath, input);
+ return { projectPath, files: [] };
+ }
+
+ // Create directories
+ await this.createDirectories(projectPath, input.architecture.appType);
+
+ // Write core files
+ const coreFiles = await this.writeCoreFiles(projectPath, input);
+ files.push(...coreFiles);
+
+ // Write docs files
+ const docsFiles = await this.writeDocsFiles(projectPath, input);
+ files.push(...docsFiles);
+
+ // Write agent_docs files
+ const agentDocsFiles = await this.writeAgentDocsFiles(projectPath, input);
+ files.push(...agentDocsFiles);
+
+ // Write config files
+ const configFiles = await this.writeConfigFiles(projectPath, input);
+ files.push(...configFiles);
+
+ // Initialize git
+ await this.initGit(projectPath);
+
+ logger.success(`Project scaffold created with ${files.length} files`);
+
+ return { projectPath, files };
+ }
+
+ private async createDirectories(projectPath: string, appType: AppType): Promise {
+ const dirs = [
+ projectPath,
+ join(projectPath, 'docs'),
+ join(projectPath, 'agent_docs'),
+ join(projectPath, 'src'),
+ ];
+
+ // Add app-type specific directories
+ switch (appType) {
+ case 'web':
+ dirs.push(
+ join(projectPath, 'src', 'routes'),
+ join(projectPath, 'src', 'middleware'),
+ join(projectPath, 'src', 'services'),
+ join(projectPath, 'src', 'models'),
+ join(projectPath, 'tests')
+ );
+ break;
+ case 'cli':
+ dirs.push(
+ join(projectPath, 'src', 'commands'),
+ join(projectPath, 'src', 'utils'),
+ join(projectPath, 'tests')
+ );
+ break;
+ case 'desktop':
+ dirs.push(
+ join(projectPath, 'src', 'components'),
+ join(projectPath, 'src', 'views'),
+ join(projectPath, 'src-tauri')
+ );
+ break;
+ case 'library':
+ dirs.push(
+ join(projectPath, 'src', 'lib'),
+ join(projectPath, 'tests'),
+ join(projectPath, 'examples')
+ );
+ break;
+ default:
+ dirs.push(
+ join(projectPath, 'src', 'lib'),
+ join(projectPath, 'tests')
+ );
+ }
+
+ for (const dir of dirs) {
+ await ensureDir(dir);
+ logger.debug(`Created directory: ${dir}`);
+ }
+ }
+
+ private async writeCoreFiles(projectPath: string, input: ScaffoldInput): Promise {
+ const files: string[] = [];
+ const { prdOutput } = input;
+
+ // PROMPT.md
+ const promptPath = join(projectPath, 'PROMPT.md');
+ await writeFileAtomic(promptPath, prdOutput.promptMd);
+ files.push('PROMPT.md');
+
+ // prd.json
+ const prdPath = join(projectPath, 'prd.json');
+ await writeJsonFile(prdPath, prdOutput.prdJson);
+ files.push('prd.json');
+
+ // progress.txt
+ const progressPath = join(projectPath, 'progress.txt');
+ await writeFileAtomic(progressPath, prdOutput.progressTxt);
+ files.push('progress.txt');
+
+ // GUIDE.md
+ const guidePath = join(projectPath, 'GUIDE.md');
+ await writeFileAtomic(guidePath, prdOutput.guideMd);
+ files.push('GUIDE.md');
+
+ // CLAUDE.md
+ const claudePath = join(projectPath, 'CLAUDE.md');
+ await writeFileAtomic(claudePath, prdOutput.claudeMd);
+ files.push('CLAUDE.md');
+
+ // README.md
+ const readmePath = join(projectPath, 'README.md');
+ const readme = this.generateReadme(input);
+ await writeFileAtomic(readmePath, readme);
+ files.push('README.md');
+
+ return files;
+ }
+
+ private async writeDocsFiles(projectPath: string, input: ScaffoldInput): Promise {
+ const files: string[] = [];
+ const docsDir = join(projectPath, 'docs');
+
+ // idea-dump.md
+ const ideaPath = join(docsDir, 'idea-dump.md');
+ await writeFileAtomic(ideaPath, `# Original Idea\n\n${input.idea}\n`);
+ files.push('docs/idea-dump.md');
+
+ // architecture.md
+ const archPath = join(docsDir, 'architecture.md');
+ await writeFileAtomic(archPath, this.formatArchitecture(input.architecture));
+ files.push('docs/architecture.md');
+
+ // features.md
+ const featuresPath = join(docsDir, 'features.md');
+ await writeFileAtomic(featuresPath, this.formatFeatures(input.specification));
+ files.push('docs/features.md');
+
+ // tech-stack.md
+ const techPath = join(docsDir, 'tech-stack.md');
+ await writeFileAtomic(techPath, this.formatTechStack(input.specification));
+ files.push('docs/tech-stack.md');
+
+ // data-models.md
+ const modelsPath = join(docsDir, 'data-models.md');
+ await writeFileAtomic(modelsPath, this.formatDataModels(input.specification));
+ files.push('docs/data-models.md');
+
+ // interfaces.md
+ const interfacesPath = join(docsDir, 'interfaces.md');
+ await writeFileAtomic(interfacesPath, this.formatInterfaces(input.specification));
+ files.push('docs/interfaces.md');
+
+ // research-notes.md
+ const researchPath = join(docsDir, 'research-notes.md');
+ await writeFileAtomic(researchPath, this.formatResearch(input.research));
+ files.push('docs/research-notes.md');
+
+ return files;
+ }
+
+ private async writeAgentDocsFiles(projectPath: string, input: ScaffoldInput): Promise {
+ const files: string[] = [];
+ const agentDir = join(projectPath, 'agent_docs');
+
+ // tech_stack.md
+ const techPath = join(agentDir, 'tech_stack.md');
+ const techContent = `# Tech Stack Decisions
+
+${this.formatTechStack(input.specification)}
+
+## Rationale
+
+${input.architecture.suggestedTechStack.reasoning}
+`;
+ await writeFileAtomic(techPath, techContent);
+ files.push('agent_docs/tech_stack.md');
+
+ // code_patterns.md
+ const patternsPath = join(agentDir, 'code_patterns.md');
+ const patternsContent = this.generateCodePatterns(input.architecture, input.specification);
+ await writeFileAtomic(patternsPath, patternsContent);
+ files.push('agent_docs/code_patterns.md');
+
+ // testing.md
+ const testingPath = join(agentDir, 'testing.md');
+ const testingContent = this.generateTestingGuide(input.specification);
+ await writeFileAtomic(testingPath, testingContent);
+ files.push('agent_docs/testing.md');
+
+ return files;
+ }
+
+ private async writeConfigFiles(projectPath: string, input: ScaffoldInput): Promise {
+ const files: string[] = [];
+ const { architecture, specification } = input;
+
+ // .gitignore
+ const gitignorePath = join(projectPath, '.gitignore');
+ const gitignore = this.generateGitignore(architecture.appType, specification.techStack.language);
+ await writeFileAtomic(gitignorePath, gitignore);
+ files.push('.gitignore');
+
+ return files;
+ }
+
+ private async initGit(projectPath: string): Promise {
+ try {
+ execSync('git init', { cwd: projectPath, stdio: 'pipe' });
+ execSync('git add -A', { cwd: projectPath, stdio: 'pipe' });
+ execSync('git commit -m "Initial Ralph scaffold"', { cwd: projectPath, stdio: 'pipe' });
+ logger.debug('Git repository initialized');
+ } catch {
+ logger.warn('Failed to initialize git repository');
+ }
+ }
+
+ private logDryRunStructure(projectPath: string, _input: ScaffoldInput): void {
+ const structure = [
+ `${projectPath}/`,
+ ' PROMPT.md',
+ ' prd.json',
+ ' progress.txt',
+ ' GUIDE.md',
+ ' CLAUDE.md',
+ ' README.md',
+ ' .gitignore',
+ ' docs/',
+ ' idea-dump.md',
+ ' architecture.md',
+ ' features.md',
+ ' tech-stack.md',
+ ' data-models.md',
+ ' interfaces.md',
+ ' research-notes.md',
+ ' agent_docs/',
+ ' tech_stack.md',
+ ' code_patterns.md',
+ ' testing.md',
+ ' src/',
+ ];
+
+ structure.forEach(line => logger.info(line));
+ }
+
+ private generateReadme(input: ScaffoldInput): string {
+ const { prdOutput, specification } = input;
+ const projectName = prdOutput.prdJson.project;
+
+ return `# ${projectName}
+
+${specification.features[0]?.description || 'A project generated with Ralph PRD Generator.'}
+
+## Tech Stack
+
+- **Language**: ${specification.techStack.language}
+${specification.techStack.runtime ? `- **Runtime**: ${specification.techStack.runtime}` : ''}
+${specification.techStack.framework ? `- **Framework**: ${specification.techStack.framework}` : ''}
+- **Testing**: ${specification.techStack.testingFramework}
+- **Build**: ${specification.techStack.buildTool}
+
+## Getting Started
+
+1. Read \`PROMPT.md\` for full project requirements
+2. Follow \`GUIDE.md\` for step-by-step instructions
+3. Track progress in \`prd.json\`
+
+## Development with Ralph Method
+
+\`\`\`bash
+# Start a Ralph loop to implement this project
+/ralph-wiggum:ralph-loop "$(cat PROMPT.md)" --max-iterations 50 --completion-promise "PROJECT_COMPLETE"
+\`\`\`
+
+## Project Structure
+
+\`\`\`
+.
+├── PROMPT.md # Main Ralph prompt
+├── prd.json # Feature tracking
+├── progress.txt # Progress log
+├── GUIDE.md # Step-by-step guide
+├── CLAUDE.md # Claude Code config
+├── docs/ # Documentation
+│ ├── idea-dump.md
+│ ├── architecture.md
+│ ├── features.md
+│ └── ...
+├── agent_docs/ # Agent context
+│ ├── tech_stack.md
+│ ├── code_patterns.md
+│ └── testing.md
+└── src/ # Source code
+\`\`\`
+
+---
+
+*Generated with [Ralph PRD Generator](https://github.com/your-username/ralph-vibe)*
+`;
+ }
+
+ private formatArchitecture(architecture: Architecture): string {
+ return `# Architecture
+
+## Application Type
+
+**${architecture.appType}**
+
+${architecture.appTypeReason}
+
+## Interface Types
+
+${architecture.interfaces.map(i => `- ${i}`).join('\n')}
+
+${architecture.interfacesReason}
+
+## Persistence
+
+**${architecture.persistence}**
+
+${architecture.persistenceReason}
+
+## Deployment
+
+**${architecture.deployment}**
+
+${architecture.deploymentReason}
+
+## Suggested Tech Stack
+
+- **Language**: ${architecture.suggestedTechStack.language}
+- **Framework**: ${architecture.suggestedTechStack.framework}
+
+${architecture.suggestedTechStack.reasoning}
+`;
+ }
+
+ private formatFeatures(specification: Specification): string {
+ return `# Features
+
+${specification.features.map((f, i) => `
+## ${i + 1}. ${f.name}
+
+**ID**: \`${f.id}\`
+
+${f.description}
+
+### User Story
+
+${f.userStory}
+
+### Acceptance Criteria
+
+${f.acceptanceCriteria.map(c => `- [ ] ${c}`).join('\n')}
+`).join('\n')}
+`;
+ }
+
+ private formatTechStack(specification: Specification): string {
+ const { techStack } = specification;
+ return `# Tech Stack
+
+- **Language**: ${techStack.language}
+${techStack.runtime ? `- **Runtime**: ${techStack.runtime}` : ''}
+${techStack.framework ? `- **Framework**: ${techStack.framework}` : ''}
+- **Testing**: ${techStack.testingFramework}
+- **Build Tool**: ${techStack.buildTool}
+
+## Libraries
+
+${techStack.libraries.map(l => `- ${l}`).join('\n')}
+`;
+ }
+
+ private formatDataModels(specification: Specification): string {
+ return `# Data Models
+
+${specification.dataModels.map(m => `
+## ${m.name}
+
+| Field | Type | Required |
+|-------|------|----------|
+${m.fields.map(f => `| ${f.name} | ${f.type} | ${f.required ? 'Yes' : 'No'} |`).join('\n')}
+
+${m.relationships ? `### Relationships\n\n${m.relationships.map(r => `- ${r}`).join('\n')}` : ''}
+`).join('\n')}
+`;
+ }
+
+ private formatInterfaces(specification: Specification): string {
+ return `# Interface Contracts
+
+${specification.interfaces.map(i => {
+ let content = `## ${i.name}\n\n**Type**: ${i.type}\n\n`;
+
+ if (i.endpoints) {
+ content += '### Endpoints\n\n| Method | Path | Description |\n|--------|------|-------------|\n';
+ content += i.endpoints.map(e => `| ${e.method} | ${e.path} | ${e.description} |`).join('\n');
+ }
+
+ if (i.commands) {
+ content += '### Commands\n\n| Command | Args | Description |\n|---------|------|-------------|\n';
+ content += i.commands.map(c => `| ${c.name} | ${c.args.join(', ')} | ${c.description} |`).join('\n');
+ }
+
+ return content;
+ }).join('\n\n')}
+`;
+ }
+
+ private formatResearch(research: Research): string {
+ if (research.results.length === 0) {
+ return `# Research Notes\n\n${research.summary}\n`;
+ }
+
+ return `# Research Notes
+
+${research.queries.map((q, i) => {
+ const result = research.results[i];
+ if (!result) return `## Query: ${q}\n\nNo results.\n`;
+
+ return `## Query: ${q}
+
+${result.answer}
+
+### Sources
+
+${result.sources.map(s => `- [${s.title}](${s.url})`).join('\n')}
+`;
+ }).join('\n')}
+`;
+ }
+
+ private generateCodePatterns(architecture: Architecture, specification: Specification): string {
+ const lang = specification.techStack.language.toLowerCase();
+ let patterns = `# Code Patterns
+
+## Project Conventions
+
+`;
+
+ if (lang.includes('typescript')) {
+ patterns += `
+### TypeScript Guidelines
+
+- Use strict mode
+- No \`any\` types
+- Use interfaces for object shapes
+- Use type guards for narrowing
+- Export types from \`types/index.ts\`
+`;
+ }
+
+ if (architecture.appType === 'cli') {
+ patterns += `
+### CLI Patterns
+
+- Use Commander.js for argument parsing
+- Use Inquirer.js for interactive prompts
+- Use chalk for colored output
+- Use ora for spinners
+- Exit with proper codes (0 success, 1 error)
+`;
+ }
+
+ if (architecture.appType === 'web') {
+ patterns += `
+### API Patterns
+
+- Use middleware for cross-cutting concerns
+- Validate all input
+- Return consistent error responses
+- Use proper HTTP status codes
+- Log all requests
+`;
+ }
+
+ patterns += `
+## Error Handling
+
+- Always catch and handle errors
+- Log errors with context
+- Return user-friendly messages
+- Never expose internal details
+`;
+
+ return patterns;
+ }
+
+ private generateTestingGuide(specification: Specification): string {
+ const framework = specification.techStack.testingFramework.toLowerCase();
+
+ let guide = `# Testing Guide
+
+## Framework
+
+${specification.techStack.testingFramework}
+
+## Test Structure
+
+\`\`\`
+tests/
+├── unit/ # Unit tests
+├── integration/ # Integration tests
+└── e2e/ # End-to-end tests
+\`\`\`
+
+## Running Tests
+
+`;
+
+ if (framework.includes('vitest') || framework.includes('jest')) {
+ guide += `
+\`\`\`bash
+# Run all tests
+npm run test
+
+# Run with coverage
+npm run test -- --coverage
+
+# Run specific test file
+npm run test -- path/to/test.ts
+\`\`\`
+`;
+ } else if (framework.includes('pytest')) {
+ guide += `
+\`\`\`bash
+# Run all tests
+pytest
+
+# Run with coverage
+pytest --cov
+
+# Run specific test file
+pytest tests/test_file.py
+\`\`\`
+`;
+ }
+
+ guide += `
+## Coverage Requirements
+
+- Minimum 80% coverage
+- All public APIs must be tested
+- All error paths must be tested
+
+## Test Patterns
+
+- Arrange-Act-Assert pattern
+- One assertion per test when possible
+- Descriptive test names
+- Mock external dependencies
+`;
+
+ return guide;
+ }
+
+ private generateGitignore(appType: AppType, language: string): string {
+ const lang = language.toLowerCase();
+ let gitignore = `# Dependencies
+node_modules/
+.pnpm-store/
+
+# Build outputs
+dist/
+build/
+*.js.map
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Test
+coverage/
+.nyc_output/
+
+`;
+
+ if (lang.includes('typescript')) {
+ gitignore += `# TypeScript
+*.tsbuildinfo
+`;
+ }
+
+ if (lang.includes('python')) {
+ gitignore += `# Python
+__pycache__/
+*.py[cod]
+*$py.class
+.Python
+*.egg-info/
+.eggs/
+venv/
+.venv/
+`;
+ }
+
+ if (lang.includes('rust')) {
+ gitignore += `# Rust
+target/
+Cargo.lock
+`;
+ }
+
+ if (appType === 'desktop') {
+ gitignore += `# Desktop
+src-tauri/target/
+*.app/
+*.dmg
+*.exe
+`;
+ }
+
+ return gitignore;
+ }
+}
diff --git a/src/generators/specification.ts b/src/generators/specification.ts
new file mode 100644
index 0000000..5b8fa76
--- /dev/null
+++ b/src/generators/specification.ts
@@ -0,0 +1,236 @@
+import { ClaudeClient } from '../clients/claude.js';
+import { logger } from '../utils/logger.js';
+import type { Architecture, Research, Specification, Feature, DataModel, InterfaceContract, TechStack } from '../types/index.js';
+
+export interface SpecificationGeneratorOptions {
+ claudeApiKey: string;
+}
+
+export class SpecificationGenerator {
+ private claudeClient: ClaudeClient;
+
+ constructor(options: SpecificationGeneratorOptions) {
+ this.claudeClient = new ClaudeClient({ apiKey: options.claudeApiKey });
+ }
+
+ async generate(
+ idea: string,
+ architecture: Architecture,
+ research: Research
+ ): Promise {
+ logger.info('Generating specification...');
+
+ const prompt = this.buildPrompt(idea, architecture, research);
+ const response = await this.claudeClient.analyze(prompt);
+ const spec = this.parseResponse(response);
+
+ logger.success('Specification generated');
+ return spec;
+ }
+
+ private buildPrompt(idea: string, architecture: Architecture, research: Research): string {
+ const researchSummary = research.results.length > 0
+ ? research.results.map(r => r.answer).join('\n\n')
+ : research.summary;
+
+ return `Generate a detailed specification for this application:
+
+
+${idea}
+
+
+
+App Type: ${architecture.appType}
+Reason: ${architecture.appTypeReason}
+
+Interfaces: ${architecture.interfaces.join(', ')}
+Reason: ${architecture.interfacesReason}
+
+Persistence: ${architecture.persistence}
+Reason: ${architecture.persistenceReason}
+
+Deployment: ${architecture.deployment}
+Reason: ${architecture.deploymentReason}
+
+Suggested Tech Stack:
+- Language: ${architecture.suggestedTechStack.language}
+- Framework: ${architecture.suggestedTechStack.framework}
+- Reasoning: ${architecture.suggestedTechStack.reasoning}
+
+
+
+${researchSummary}
+
+
+Generate a complete specification with:
+
+1. FEATURES: Break down the app into 5-10 features. Each feature must have:
+ - id: unique snake_case identifier
+ - name: human-readable name
+ - description: what this feature does
+ - userStory: "As a [user], I want [goal], so that [benefit]"
+ - acceptanceCriteria: array of 2-5 binary (pass/fail) test criteria
+
+2. DATA MODELS: Define the data structures needed. Each model must have:
+ - name: PascalCase name
+ - fields: array of { name, type, required }
+ - relationships: optional array of relationship descriptions
+
+3. INTERFACES: Define the interface contracts. For:
+ - CLI apps: commands with name, args, description
+ - Web apps: endpoints with method, path, description
+ - Libraries: modules with exports
+ - Others: appropriate interface type
+
+4. TECH STACK: Finalize the tech stack:
+ - language: primary language
+ - runtime: if applicable (e.g., Node.js, Python)
+ - framework: main framework
+ - libraries: array of recommended libraries
+ - testingFramework: testing tool
+ - buildTool: build tool
+
+Output ONLY valid JSON with this structure (no markdown, no explanation):
+{
+ "features": [
+ {
+ "id": "feature_id",
+ "name": "Feature Name",
+ "description": "Description",
+ "userStory": "As a...",
+ "acceptanceCriteria": ["Criterion 1 passes", "Criterion 2 passes"]
+ }
+ ],
+ "dataModels": [
+ {
+ "name": "ModelName",
+ "fields": [{"name": "fieldName", "type": "string", "required": true}],
+ "relationships": ["Has many X"]
+ }
+ ],
+ "interfaces": [
+ {
+ "name": "InterfaceName",
+ "type": "cli_args",
+ "commands": [{"name": "cmd", "args": ["arg1"], "description": "Does X"}]
+ }
+ ],
+ "techStack": {
+ "language": "TypeScript",
+ "runtime": "Node.js 20",
+ "framework": "Commander.js",
+ "libraries": ["lib1", "lib2"],
+ "testingFramework": "Vitest",
+ "buildTool": "tsup"
+ }
+}`;
+ }
+
+ private parseResponse(response: string): Specification {
+ let jsonStr = response.trim();
+
+ // Remove markdown code blocks if present
+ const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
+ if (jsonMatch) {
+ jsonStr = jsonMatch[1].trim();
+ }
+
+ try {
+ const parsed = JSON.parse(jsonStr);
+ return this.validateAndNormalize(parsed);
+ } catch (err) {
+ const error = err as Error;
+ logger.debug(`Failed to parse specification response: ${response.substring(0, 500)}`);
+ throw new Error(`Failed to parse specification: ${error.message}`);
+ }
+ }
+
+ private validateAndNormalize(parsed: unknown): Specification {
+ const data = parsed as Record;
+
+ // Validate required fields
+ if (!data.features || !Array.isArray(data.features)) {
+ throw new Error('Missing or invalid features array');
+ }
+ if (!data.dataModels || !Array.isArray(data.dataModels)) {
+ throw new Error('Missing or invalid dataModels array');
+ }
+ if (!data.interfaces || !Array.isArray(data.interfaces)) {
+ throw new Error('Missing or invalid interfaces array');
+ }
+ if (!data.techStack || typeof data.techStack !== 'object') {
+ throw new Error('Missing or invalid techStack object');
+ }
+
+ // Normalize features
+ const features: Feature[] = (data.features as Record[]).map((f, i) => ({
+ id: String(f.id || `feature_${i + 1}`),
+ name: String(f.name || `Feature ${i + 1}`),
+ description: String(f.description || ''),
+ userStory: String(f.userStory || ''),
+ acceptanceCriteria: Array.isArray(f.acceptanceCriteria)
+ ? f.acceptanceCriteria.map(String)
+ : [],
+ }));
+
+ // Normalize data models
+ const dataModels: DataModel[] = (data.dataModels as Record[]).map(m => ({
+ name: String(m.name || 'Model'),
+ fields: Array.isArray(m.fields)
+ ? m.fields.map((f: Record) => ({
+ name: String(f.name || 'field'),
+ type: String(f.type || 'string'),
+ required: Boolean(f.required),
+ }))
+ : [],
+ relationships: Array.isArray(m.relationships)
+ ? m.relationships.map(String)
+ : undefined,
+ }));
+
+ // Normalize interfaces
+ const interfaces: InterfaceContract[] = (data.interfaces as Record[]).map(i => {
+ const base = {
+ name: String(i.name || 'Interface'),
+ type: String(i.type || 'module') as InterfaceContract['type'],
+ };
+
+ if (Array.isArray(i.endpoints)) {
+ return {
+ ...base,
+ endpoints: i.endpoints.map((e: Record) => ({
+ method: String(e.method || 'GET'),
+ path: String(e.path || '/'),
+ description: String(e.description || ''),
+ })),
+ };
+ }
+
+ if (Array.isArray(i.commands)) {
+ return {
+ ...base,
+ commands: i.commands.map((c: Record) => ({
+ name: String(c.name || 'command'),
+ args: Array.isArray(c.args) ? c.args.map(String) : [],
+ description: String(c.description || ''),
+ })),
+ };
+ }
+
+ return base;
+ });
+
+ // Normalize tech stack
+ const ts = data.techStack as Record;
+ const techStack: TechStack = {
+ language: String(ts.language || 'TypeScript'),
+ runtime: ts.runtime ? String(ts.runtime) : undefined,
+ framework: ts.framework ? String(ts.framework) : undefined,
+ libraries: Array.isArray(ts.libraries) ? ts.libraries.map(String) : [],
+ testingFramework: String(ts.testingFramework || 'Vitest'),
+ buildTool: String(ts.buildTool || 'tsup'),
+ };
+
+ return { features, dataModels, interfaces, techStack };
+ }
+}
diff --git a/src/prompts/interactive.ts b/src/prompts/interactive.ts
new file mode 100644
index 0000000..8ae17e4
--- /dev/null
+++ b/src/prompts/interactive.ts
@@ -0,0 +1,254 @@
+import inquirer from 'inquirer';
+import type { Architecture, AppType, InterfaceType, PersistenceType, DeploymentType } from '../types/index.js';
+
+export interface IdeaInput {
+ idea: string;
+}
+
+export async function promptForIdea(): Promise {
+ const { idea } = await inquirer.prompt([
+ {
+ type: 'editor',
+ name: 'idea',
+ message: 'Describe your app idea (an editor will open):',
+ waitUserInput: true,
+ validate: (input: string) => {
+ if (input.trim().length < 50) {
+ return 'Please provide at least 50 characters describing your app idea.';
+ }
+ return true;
+ },
+ },
+ ]);
+
+ return idea.trim();
+}
+
+export async function promptForIdeaSimple(): Promise {
+ const { idea } = await inquirer.prompt([
+ {
+ type: 'input',
+ name: 'idea',
+ message: 'Describe your app idea (press Enter when done):',
+ validate: (input: string) => {
+ if (input.trim().length < 50) {
+ return 'Please provide at least 50 characters describing your app idea.';
+ }
+ return true;
+ },
+ },
+ ]);
+
+ return idea.trim();
+}
+
+export interface ArchitectureConfirmation {
+ confirmed: boolean;
+ architecture: Architecture;
+}
+
+export async function confirmArchitecture(architecture: Architecture): Promise {
+ console.log('\n--- Architecture Classification ---\n');
+ console.log(`App Type: ${architecture.appType}`);
+ console.log(` Reason: ${architecture.appTypeReason}`);
+ console.log(`\nInterfaces: ${architecture.interfaces.join(', ')}`);
+ console.log(` Reason: ${architecture.interfacesReason}`);
+ console.log(`\nPersistence: ${architecture.persistence}`);
+ console.log(` Reason: ${architecture.persistenceReason}`);
+ console.log(`\nDeployment: ${architecture.deployment}`);
+ console.log(` Reason: ${architecture.deploymentReason}`);
+ console.log(`\nSuggested Tech Stack:`);
+ console.log(` Language: ${architecture.suggestedTechStack.language}`);
+ console.log(` Framework: ${architecture.suggestedTechStack.framework}`);
+ console.log(` Reason: ${architecture.suggestedTechStack.reasoning}`);
+ console.log('\n-----------------------------------\n');
+
+ const { confirm } = await inquirer.prompt<{ confirm: boolean }>([
+ {
+ type: 'confirm',
+ name: 'confirm',
+ message: 'Is this classification correct?',
+ default: true,
+ },
+ ]);
+
+ if (confirm) {
+ return { confirmed: true, architecture };
+ }
+
+ // Allow user to modify
+ const modified = await modifyArchitecture(architecture);
+ return { confirmed: true, architecture: modified };
+}
+
+async function modifyArchitecture(architecture: Architecture): Promise {
+ const appTypes: AppType[] = ['web', 'desktop', 'cli', 'library', 'mobile', 'script', 'other'];
+ const interfaceTypes: InterfaceType[] = [
+ 'rest_api', 'graphql', 'ipc', 'module', 'cli_args', 'file_format', 'websocket', 'grpc'
+ ];
+ const persistenceTypes: PersistenceType[] = [
+ 'remote_db', 'local_db', 'file_based', 'in_memory', 'cloud_storage'
+ ];
+ const deploymentTypes: DeploymentType[] = [
+ 'cloud', 'self_hosted', 'desktop_installer', 'package_registry', 'app_store', 'none'
+ ];
+
+ const answers = await inquirer.prompt([
+ {
+ type: 'list',
+ name: 'appType',
+ message: 'Select app type:',
+ choices: appTypes,
+ default: architecture.appType,
+ },
+ {
+ type: 'checkbox',
+ name: 'interfaces',
+ message: 'Select interface types:',
+ choices: interfaceTypes,
+ default: architecture.interfaces,
+ },
+ {
+ type: 'list',
+ name: 'persistence',
+ message: 'Select persistence type:',
+ choices: persistenceTypes,
+ default: architecture.persistence,
+ },
+ {
+ type: 'list',
+ name: 'deployment',
+ message: 'Select deployment type:',
+ choices: deploymentTypes,
+ default: architecture.deployment,
+ },
+ {
+ type: 'input',
+ name: 'language',
+ message: 'Primary language:',
+ default: architecture.suggestedTechStack.language,
+ },
+ {
+ type: 'input',
+ name: 'framework',
+ message: 'Framework (optional):',
+ default: architecture.suggestedTechStack.framework,
+ },
+ ]);
+
+ return {
+ appType: answers.appType,
+ appTypeReason: 'User modified',
+ interfaces: answers.interfaces,
+ interfacesReason: 'User modified',
+ persistence: answers.persistence,
+ persistenceReason: 'User modified',
+ deployment: answers.deployment,
+ deploymentReason: 'User modified',
+ suggestedTechStack: {
+ language: answers.language,
+ framework: answers.framework,
+ reasoning: 'User specified',
+ },
+ };
+}
+
+export interface ResearchConfirmation {
+ proceed: boolean;
+ queries: string[];
+}
+
+export async function confirmResearchQueries(queries: string[]): Promise {
+ console.log('\n--- Research Queries ---\n');
+ queries.forEach((q, i) => console.log(`${i + 1}. ${q}`));
+ console.log('\n------------------------\n');
+
+ const { confirm } = await inquirer.prompt<{ confirm: boolean }>([
+ {
+ type: 'confirm',
+ name: 'confirm',
+ message: 'Proceed with these research queries?',
+ default: true,
+ },
+ ]);
+
+ if (confirm) {
+ return { proceed: true, queries };
+ }
+
+ // Allow user to modify queries
+ const { newQueries } = await inquirer.prompt<{ newQueries: string }>([
+ {
+ type: 'editor',
+ name: 'newQueries',
+ message: 'Edit research queries (one per line):',
+ default: queries.join('\n'),
+ },
+ ]);
+
+ const modified = newQueries.split('\n').filter((q: string) => q.trim().length > 0);
+ return { proceed: true, queries: modified };
+}
+
+export async function confirmSpecification(featureCount: number): Promise {
+ console.log(`\n--- Specification Generated ---`);
+ console.log(`Features: ${featureCount}`);
+ console.log(`-------------------------------\n`);
+
+ const { confirm } = await inquirer.prompt<{ confirm: boolean }>([
+ {
+ type: 'confirm',
+ name: 'confirm',
+ message: 'Proceed with PRD generation?',
+ default: true,
+ },
+ ]);
+
+ return confirm;
+}
+
+export async function confirmScaffold(projectPath: string, fileCount: number): Promise {
+ console.log(`\n--- Ready to Create Scaffold ---`);
+ console.log(`Location: ${projectPath}`);
+ console.log(`Files: ${fileCount}`);
+ console.log(`---------------------------------\n`);
+
+ const { confirm } = await inquirer.prompt<{ confirm: boolean }>([
+ {
+ type: 'confirm',
+ name: 'confirm',
+ message: 'Create project scaffold?',
+ default: true,
+ },
+ ]);
+
+ return confirm;
+}
+
+export async function promptForApiKeys(): Promise<{ claudeKey: string; perplexityKey: string }> {
+ const answers = await inquirer.prompt([
+ {
+ type: 'password',
+ name: 'claudeKey',
+ message: 'Enter your Claude API key:',
+ mask: '*',
+ validate: (input: string) => {
+ if (!input || input.trim().length < 10) {
+ return 'Please enter a valid API key';
+ }
+ return true;
+ },
+ },
+ {
+ type: 'password',
+ name: 'perplexityKey',
+ message: 'Enter your Perplexity API key (optional, press Enter to skip):',
+ mask: '*',
+ },
+ ]);
+
+ return {
+ claudeKey: answers.claudeKey.trim(),
+ perplexityKey: answers.perplexityKey?.trim() || '',
+ };
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index 8248551..9814a6b 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -117,6 +117,11 @@ export interface PRDOutput {
claudeMd: string;
}
+export interface ScaffoldOutput {
+ projectPath: string;
+ files: string[];
+}
+
export interface ValidationResult {
valid: boolean;
issues: {
diff --git a/src/utils/files.ts b/src/utils/files.ts
index d40b692..8668714 100644
--- a/src/utils/files.ts
+++ b/src/utils/files.ts
@@ -1,6 +1,5 @@
import { readFile, writeFile, mkdir, stat, rename, unlink } from 'fs/promises';
-import { dirname, join } from 'path';
-import { tmpdir } from 'os';
+import { dirname } from 'path';
import { randomUUID } from 'crypto';
export async function ensureDir(path: string): Promise {
@@ -20,12 +19,13 @@ export async function readFileContent(path: string): Promise {
return readFile(path, 'utf-8');
}
-// Atomic file write: write to temp, then rename
+// Atomic file write: write to temp in same directory, then rename
export async function writeFileAtomic(path: string, content: string): Promise {
const dir = dirname(path);
await ensureDir(dir);
- const tempFile = join(tmpdir(), `ralph-${randomUUID()}.tmp`);
+ // Create temp file in the same directory to avoid EXDEV errors on rename
+ const tempFile = `${path}.${randomUUID()}.tmp`;
try {
await writeFile(tempFile, content, 'utf-8');
diff --git a/test-idea.txt b/test-idea.txt
new file mode 100644
index 0000000..c1dbf85
--- /dev/null
+++ b/test-idea.txt
@@ -0,0 +1 @@
+A CLI tool that converts markdown to PDF with syntax highlighting