diff --git a/docs/assets/harness-flow.jpg b/docs/assets/harness-flow.jpg new file mode 100644 index 0000000..7b8ffc9 Binary files /dev/null and b/docs/assets/harness-flow.jpg differ diff --git a/docs/assets/ralph.gif b/docs/assets/ralph.gif new file mode 100644 index 0000000..f778db9 Binary files /dev/null and b/docs/assets/ralph.gif differ diff --git a/docs/user-guide/harness-loop.md b/docs/user-guide/harness-loop.md new file mode 100644 index 0000000..f7b257d --- /dev/null +++ b/docs/user-guide/harness-loop.md @@ -0,0 +1,406 @@ +# Harness Loop + +![Ralph Wiggum](../assets/ralph.gif) + +![Harness Loop Workflow](../assets/harness-flow.jpg) + +**Run Claude Code in an external bash loop for autonomous, iterative development.** + +> Learn more about the technique: **[The Ralph Wiggum Technique](https://ghuntley.com/ralph/)** + +## Overview + +The Harness Loop implements the [Ralph Wiggum technique](https://ghuntley.com/ralph/) for fully autonomous development by running Claude Code in an external bash loop. Each iteration, Claude reads specs, works on tasks, logs progress, and continues until all acceptance criteria are met. + +**Key benefits:** +- **Autonomous execution:** Claude works through tasks without manual intervention +- **Progress tracking:** Acceptance criteria checkboxes track completion +- **Iteration logs:** Every iteration creates a detailed report +- **Graceful completion:** Loop stops when "## COMPLETED" marker is added to PLAN.md + +## Prerequisites + +Ralph requires **context training** to be configured. This provides Claude with your project's patterns and conventions. + +```yaml +# spec-machine/config.local.yml +profile: + context_training: your-training-name +``` + +> Run `/train-context` to create context training if you haven't already. + +## Quick Start + +```bash +# 1. Initialize a feature folder (requires context_training in config) +devorch harness init my-feature + +# 2. Generate specs (Claude investigates and writes detailed specs) +devorch harness create-specs my-feature + +# 3. Review and edit PLAN.md to add tasks + +# 4. Start the autonomous loop +devorch harness loop my-feature +``` + +## Commands + +### `devorch harness init ` + +Creates the feature folder structure with templates. + +```bash +devorch harness init auth-system +``` + +**Creates:** +``` +devorch/harness/auth-system/ +├── PROMPT.md # Prompt sent to Claude each iteration +├── PLAN.md # Tasks with acceptance criteria +├── specs/ # For specification files +└── logs/ # Iteration reports +``` + +### `devorch harness create-specs ` + +Boots Claude interactively to investigate the codebase and write detailed specifications. + +```bash +devorch harness create-specs auth-system + +# Or run in headless mode (no user interaction) +devorch harness create-specs auth-system --headless +``` + +Claude will: +1. Ask clarifying questions about the feature (interactive mode) +2. Explore the existing codebase structure +3. Identify relevant patterns and conventions +4. Write specification files in `specs/` +5. Update PLAN.md with tasks + +### `devorch harness loop ` + +Runs Claude in an external bash loop until completion. + +```bash +# Basic usage +devorch harness loop auth-system + +# With iteration limit +devorch harness loop auth-system --max-iterations 20 + +# Show all message types (tool calls, etc.) +devorch harness loop auth-system --verbose +``` + +**Options:** +| Flag | Description | +|------|-------------| +| `--max-iterations N` | Stop after N iterations (default: 20) | +| `--verbose` | Show all message types instead of just assistant/result | + +**Loop behavior:** +1. Reads PROMPT.md and sends to Claude +2. Claude works on the first incomplete task +3. Claude creates iteration log in `logs/` +4. Claude updates PLAN.md with progress +5. Loop checks for "## COMPLETED" marker +6. Repeats until complete or max iterations reached + +**Stopping the loop:** +- Press `Ctrl+C` to stop gracefully after current iteration +- Add `## COMPLETED` to PLAN.md to signal completion + +### `devorch harness list` + +Lists all existing features with their status. + +```bash +devorch harness list +``` + +**Output:** +``` +Found 3 feature(s): + + [DONE] auth-system + 3 specs, 12 iterations + + [....] payment-flow + 2 specs, 5 iterations + + [ ] new-feature + no specs, no iterations +``` + +### `devorch harness status ` + +Shows detailed progress for a feature. + +```bash +devorch harness status auth-system +``` + +**Output:** +``` +Feature: auth-system +Path: devorch/harness/auth-system + +Specs: + - overview.md + - auth-flow.md + - token-management.md + +Iterations: + 5 completed + - iteration-003.md + - iteration-004.md + - iteration-005.md + +================================================== +PLAN.md Progress +================================================== + +Status: IN_PROGRESS + +Tasks: + [x] Implement login endpoint + [x] Create POST /auth/login route + [x] Add password validation + [x] Return JWT token + + [2/3] Add token refresh + [x] Create refresh endpoint + [x] Implement token rotation + [ ] Add refresh token storage + +================================================== +Progress: 5/6 acceptance criteria + [################----] 83% +``` + +## Workflow + +### 1. Initialize Feature + +```bash +devorch harness init my-feature +``` + +This creates the folder structure and template files. + +### 2. Create Specifications + +```bash +devorch harness create-specs my-feature +``` + +When prompted, describe what the feature should do. Claude will investigate your codebase and create detailed specs in the `specs/` folder. + +### 3. Define Tasks in PLAN.md + +Edit `PLAN.md` to define tasks with acceptance criteria: + +```markdown +# Plan: my-feature + +## Status: IN_PROGRESS + +## Tasks + +### Task 1: Create database schema +**Acceptance Criteria:** +- [ ] Add users table with email, password_hash columns +- [ ] Add sessions table with user_id, token, expires_at +- [ ] Create migration file + +### Task 2: Implement authentication endpoints +**Acceptance Criteria:** +- [ ] POST /auth/register - create new user +- [ ] POST /auth/login - authenticate and return token +- [ ] POST /auth/logout - invalidate session +- [ ] Add input validation for all endpoints + +### Task 3: Add middleware +**Acceptance Criteria:** +- [ ] Create auth middleware to verify tokens +- [ ] Apply middleware to protected routes +- [ ] Return 401 for invalid/expired tokens +``` + +### 4. Run the Loop + +```bash +devorch harness loop my-feature +``` + +Claude will: +1. Read specs and PLAN.md +2. Find the first task with unchecked criteria +3. Work on that task only +4. Create an iteration log +5. Update PLAN.md checkboxes +6. Repeat until all tasks complete + +### 5. Monitor Progress + +Check progress anytime: + +```bash +devorch harness status my-feature +``` + +Or review iteration logs in `devorch/harness/my-feature/logs/`. + +## File Formats + +### PROMPT.md + +The prompt sent to Claude each iteration. Created at `init` time with your context training path baked in: + +```markdown +# Feature: my-feature + +## Load Context First + +**IMPORTANT:** Before starting work, use the Explore subagent to understand project patterns: + +Task tool with subagent_type=Explore: +"Quickly scan spec-machine/context-training/your-training-name for key patterns..." + +--- + +## Files to Read + +1. **Specs folder** - Read ALL spec files to understand the feature +2. **Plan file** - Read to see current progress and find the next incomplete task +3. **Previous logs** - Read only the last 2-3 iteration logs for recent context + +## Instructions + +1. Read first: Use Read tool on all spec files, PLAN.md, and last 2-3 logs +2. Find next task: Look in PLAN.md for the FIRST task with unchecked acceptance criteria +3. Work on ONE task: Complete only that single task +4. Verify criteria: Ensure ALL acceptance criteria for the task are met +5. Update PLAN.md: Check off completed criteria with [x] +6. Write iteration log: Create a log file documenting what you did + +## CRITICAL: When to Stop + +After completing ONE task (or if blocked), you MUST stop. +When ALL tasks done: Add "## COMPLETED" to PLAN.md +``` + +### PLAN.md + +Tracks tasks and progress: + +```markdown +# Plan: {{FEATURE_NAME}} + +## Status: IN_PROGRESS + +## Tasks + +### Task 1: [Task Name] +**Acceptance Criteria:** +- [ ] Criterion 1 +- [ ] Criterion 2 + +### Task 2: [Task Name] +**Acceptance Criteria:** +- [ ] Criterion 1 +- [ ] Criterion 2 +``` + +### Iteration Logs + +Created in `logs/iteration-NNN.md`: + +```markdown +# Iteration 005 + +**Timestamp:** 2024-01-15T14:30:00Z +**Task:** Task 2 - Implement authentication endpoints +**Status:** COMPLETE + +## Changes Made +- `src/routes/auth.ts` - Created - Added login/register/logout endpoints +- `src/middleware/auth.ts` - Created - Token verification middleware + +## Approach +Implemented JWT-based authentication following existing patterns in the codebase. + +## Verification +- [x] POST /auth/register - create new user: Works, tested with curl +- [x] POST /auth/login - authenticate and return token: Returns valid JWT +- [x] POST /auth/logout - invalidate session: Removes session from DB + +## Next Steps +Move to Task 3: Add middleware to protected routes +``` + +## Best Practices + +### Writing Good Acceptance Criteria + +- **Be specific:** "Add email validation" → "Validate email format using regex, return 400 for invalid" +- **Be testable:** Each criterion should be verifiable +- **Be atomic:** One thing per criterion +- **Include edge cases:** "Handle duplicate email registration with 409 response" + +### Organizing Specs + +Number spec files for clear ordering (00-, 01-, etc.): + +``` +specs/ +├── 00-overview.md # High-level feature description +├── 01-data-model.md # Database schema, types +├── 02-api-endpoints.md # REST API specifications +├── 03-business-logic.md # Core logic requirements +└── 99-integration.md # How components connect (last) +``` + +### When to Use Harness Loop + +**Good fit:** +- Multi-step feature implementation +- Refactoring with clear acceptance criteria +- Bug fixes requiring multiple changes +- Adding tests to existing code + +**Not ideal for:** +- Exploratory work without clear specs +- One-off quick fixes +- Tasks requiring human judgment at each step + +## Troubleshooting + +### Loop stops unexpectedly + +Check the latest iteration log in `logs/` for blockers or errors. + +### Claude skips tasks + +Ensure acceptance criteria use the exact format: +```markdown +- [ ] Criterion text +``` + +### Progress not updating + +Verify PLAN.md has the correct task format: +```markdown +### Task N: Task Name +**Acceptance Criteria:** +- [ ] ... +``` + +### Loop never completes + +Claude adds `## COMPLETED` when all criteria are checked. Verify all `- [ ]` are changed to `- [x]`. diff --git a/src/cli/commands/harness/create-specs.ts b/src/cli/commands/harness/create-specs.ts new file mode 100644 index 0000000..24e8eed --- /dev/null +++ b/src/cli/commands/harness/create-specs.ts @@ -0,0 +1,170 @@ +/** + * Ralph create-specs command + * Boots Claude to investigate the codebase and write detailed specs + */ + +import type { GlobalOptions } from '@/cli/lib/cli/command-registry.js'; +import { loadConfig } from '@/cli/lib/config/config-loader.js'; +import { + featureExists, + formatExistingSpecs, + getExistingSpecs, + getSpecsPath, + renderCreateSpecsPrompt, + runClaudeHeadless, + runClaudeInteractive, +} from '@/cli/lib/harness/index.js'; +import { flushAndExit } from '@/cli/lib/telemetry/telemetry.js'; +import { logger } from '@/utils/logger.js'; +import { log, promptConfirm, promptText } from '@/utils/ui.js'; + +interface CreateSpecsOptions extends GlobalOptions { + feature?: string; + headless?: boolean; + verbose?: boolean; +} + +export async function createSpecsCommand(options: CreateSpecsOptions): Promise { + logger.debug('Ralph create-specs command started', { options }); + + // Prompt for feature name if not provided + let featureName = options.feature; + if (!featureName) { + featureName = await promptText('Enter feature name:'); + } + + // Guardrail: Check if feature exists + if (!featureExists(featureName)) { + log(`Feature "${featureName}" not found.`, 'error'); + log(`Run \`devorch harness init ${featureName}\` first.`, 'info'); + await flushAndExit(1); + return; + } + + const specsPath = getSpecsPath(featureName); + const isHeadless = options.headless ?? false; + + // Check for existing specs + const existingSpecs = getExistingSpecs(specsPath); + let existingSpecsContent = ''; + + if (existingSpecs.length > 0) { + if (isHeadless) { + // In headless mode, include existing specs without prompting + existingSpecsContent = formatExistingSpecs(existingSpecs); + log(`Found ${existingSpecs.length} existing spec file(s), including as context.`, 'info'); + } else { + log('', 'info'); + log(`Found ${existingSpecs.length} existing spec file(s):`, 'warn'); + for (const spec of existingSpecs) { + log(` - ${spec.name}`, 'info'); + } + log('', 'info'); + + const shouldRegenerate = await promptConfirm( + 'Regenerating will overwrite existing specs. Continue?' + ); + + if (!shouldRegenerate) { + log('Cancelled. Existing specs preserved.', 'info'); + await flushAndExit(0); + return; + } + + // Include existing specs in the prompt for context + existingSpecsContent = formatExistingSpecs(existingSpecs); + log('Existing specs will be included as context for regeneration.', 'info'); + } + } + + // Prompt for feature description + log('', 'info'); + log( + 'Describe this feature - can be a short summary, detailed PRD, or anything in between.', + 'info' + ); + log('', 'info'); + const description = await promptText('Feature description:'); + + log('', 'info'); + if (isHeadless) { + log(`Starting headless Claude session to create specs for "${featureName}"...`, 'info'); + } else { + log(`Starting interactive Claude session to create specs for "${featureName}"...`, 'info'); + log('Claude will ask clarifying questions before creating specifications.', 'info'); + } + log('', 'info'); + + // Load and render the template + let promptContent = renderCreateSpecsPrompt(featureName, description, specsPath); + + // Inject context training exploration if configured + let contextTrainingName: string | undefined; + try { + const config = loadConfig(); + contextTrainingName = config.profile.context_training; + } catch { + // Config not found - continue without context training + } + + if (contextTrainingName) { + const contextTrainingPath = `spec-machine/context-training/${contextTrainingName}`; + const contextPreamble = `## Load Project Context First + +**IMPORTANT:** Before investigating, use the Explore subagent to understand project patterns: + +\`\`\` +Task tool with subagent_type=Explore: +"Quickly scan ${contextTrainingPath} for key patterns and conventions. +Focus on: component patterns, testing requirements, naming conventions, and domain rules. +Summarize the highlights - don't load everything." +\`\`\` + +--- + +`; + promptContent = contextPreamble + promptContent; + } + + // Add headless mode instructions + if (isHeadless) { + const headlessInstructions = ` +--- + +## HEADLESS MODE - IMPORTANT + +You are running in **headless mode** without user interaction. + +**DO NOT use the AskUserQuestion tool.** Instead: +- Make reasonable assumptions based on the feature description +- Document your assumptions in the specs +- Choose sensible defaults for any ambiguous requirements +- Proceed directly to investigating the codebase and writing specs + +Skip step 1 (Clarify Requirements) and go straight to step 2 (Investigate the Codebase). +`; + promptContent = headlessInstructions + promptContent; + } + + // Append existing specs if present + if (existingSpecsContent) { + promptContent = `${promptContent}\n\n${existingSpecsContent}`; + } + + // Run Claude in appropriate mode + const exitCode = isHeadless + ? await runClaudeHeadless(promptContent) + : await runClaudeInteractive(promptContent); + + if (exitCode === 0) { + log('', 'info'); + log(`Specs created in: ${specsPath}`, 'success'); + log('', 'info'); + log('Next steps:', 'info'); + log(` 1. Review the specs in ${specsPath}`, 'info'); + log(` 2. Run: devorch harness loop ${featureName}`, 'info'); + } else { + log('Claude session ended.', 'info'); + log(`Check ${specsPath} for any created specs.`, 'info'); + } +} diff --git a/src/cli/commands/harness/index.ts b/src/cli/commands/harness/index.ts new file mode 100644 index 0000000..d99af68 --- /dev/null +++ b/src/cli/commands/harness/index.ts @@ -0,0 +1,104 @@ +/** + * Harness command dispatcher + * Implements the "Ralph Wiggum" external harness pattern - running Claude Code + * in a bash loop with feature-based specs and progress tracking. + */ + +import minimist from 'minimist'; +import type { GlobalOptions } from '@/cli/lib/cli/command-registry.js'; +import { flushAndExit } from '@/cli/lib/telemetry/telemetry.js'; +import { logger } from '@/utils/logger.js'; +import { createSpecsCommand } from './create-specs.js'; +import { initCommand } from './init.js'; +import { listCommand } from './list.js'; +import { loopCommand } from './loop.js'; +import { statusCommand } from './status.js'; + +interface HarnessOptions extends GlobalOptions { + subCommand?: string; +} + +/** + * Harness command dispatcher + * Usage: + * devorch harness init + * devorch harness loop [--max-iterations N] [--verbose] + * devorch harness create-specs + * devorch harness list + * devorch harness status + */ +export async function harnessCommand(options: HarnessOptions = {}): Promise { + const subCommand = options.subCommand; + + if (!subCommand) { + console.error('No subcommand provided for harness command\n'); + printUsage(); + await flushAndExit(1); + } + + // Re-parse argv to get all positional arguments + const argv = minimist(process.argv.slice(2)); + const command = subCommand; + const feature = argv._[2]; // Feature name is the third positional argument + + try { + switch (command) { + case 'init': + await initCommand({ ...options, feature }); + break; + + case 'loop': + await loopCommand({ + ...options, + feature, + maxIterations: argv['max-iterations'] as number | undefined, + verbose: argv['verbose'] as boolean | undefined, + }); + break; + + case 'create-specs': + await createSpecsCommand({ + ...options, + feature, + headless: argv['headless'] as boolean | undefined, + verbose: argv['verbose'] as boolean | undefined, + }); + break; + + case 'list': + await listCommand(options); + break; + + case 'status': + await statusCommand({ ...options, feature }); + break; + + default: + console.error(`Unknown harness subcommand: ${command}\n`); + printUsage(); + await flushAndExit(1); + } + } catch (err) { + logger.error('Harness command failed', { subCommand, error: err }); + throw err; + } +} + +function printUsage(): void { + console.log('Available subcommands:'); + console.log(' init Initialize a new feature folder structure'); + console.log(' loop Run the external bash loop'); + console.log(' create-specs Boot Claude to write detailed specs'); + console.log(' list List existing features'); + console.log(' status Show PLAN.md progress'); + console.log(''); + console.log('Usage:'); + console.log(' devorch harness init my-feature'); + console.log(' devorch harness create-specs my-feature'); + console.log(' devorch harness create-specs my-feature --headless'); + console.log(' devorch harness loop my-feature'); + console.log(' devorch harness loop my-feature --verbose # Show all message types'); + console.log(' devorch harness loop my-feature --max-iterations 10'); + console.log(' devorch harness list'); + console.log(' devorch harness status my-feature'); +} diff --git a/src/cli/commands/harness/init.ts b/src/cli/commands/harness/init.ts new file mode 100644 index 0000000..642d39d --- /dev/null +++ b/src/cli/commands/harness/init.ts @@ -0,0 +1,107 @@ +/** + * Ralph init command + * Initializes a new feature folder structure for the Ralph Wiggum loop + */ + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { GlobalOptions } from '@/cli/lib/cli/command-registry.js'; +import { loadConfig } from '@/cli/lib/config/config-loader.js'; +import { + getAsciiArt, + getFeaturePath, + renderPlanTemplate, + renderPromptTemplate, +} from '@/cli/lib/harness/index.js'; +import { flushAndExit } from '@/cli/lib/telemetry/telemetry.js'; +import { ensureDir, writeFile } from '@/utils/files.js'; +import { logger } from '@/utils/logger.js'; +import { log, promptConfirm, promptText } from '@/utils/ui.js'; + +interface InitOptions extends GlobalOptions { + feature?: string; +} + +export async function initCommand(options: InitOptions): Promise { + logger.debug('Ralph init command started', { options }); + + // Require context_training to be configured + let contextTrainingName: string | undefined; + try { + const config = loadConfig(); + contextTrainingName = config.profile.context_training; + } catch { + // Config not found + } + + if (!contextTrainingName) { + log('Context training not configured.', 'error'); + log('Ralph requires context training to provide Claude with project patterns.', 'info'); + log('', 'info'); + log('To fix this:', 'info'); + log(' 1. Run /train-context to create context training for your project', 'info'); + log(' 2. Add to spec-machine/config.local.yml:', 'info'); + log(' profile:', 'info'); + log(' context_training: your-training-name', 'info'); + await flushAndExit(1); + return; + } + + // Prompt for feature name if not provided + let featureName = options.feature; + if (!featureName) { + featureName = await promptText('Enter feature name:', 'my-feature'); + } + + // Sanitize feature name (convert to kebab-case) + featureName = featureName.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + + const featurePath = getFeaturePath(featureName); + + // Check if feature already exists + if (existsSync(featurePath)) { + log(`Feature "${featureName}" already exists at ${featurePath}`, 'warn'); + const shouldUpdate = await promptConfirm('Do you want to regenerate PROMPT.md?'); + if (shouldUpdate) { + const promptContent = renderPromptTemplate(featureName, contextTrainingName); + writeFile(join(featurePath, 'PROMPT.md'), promptContent); + log('PROMPT.md regenerated!', 'success'); + } else { + log('Cancelled.', 'info'); + } + return; + } + + // Display welcome banner + console.log(getAsciiArt()); + console.log('\n "I\'m helping!" - Ralph Wiggum\n'); + console.log(` Feature: ${featureName}`); + console.log(` Path: ${featurePath}`); + console.log(''); + + // Create folder structure + logger.debug('Creating feature folder structure', { featurePath }); + + // Create main feature directory + ensureDir(featurePath); + + // Create subdirectories + ensureDir(join(featurePath, 'specs')); + ensureDir(join(featurePath, 'logs')); + + // Write PROMPT.md from template + const promptContent = renderPromptTemplate(featureName, contextTrainingName); + writeFile(join(featurePath, 'PROMPT.md'), promptContent); + + // Write PLAN.md from template + const planContent = renderPlanTemplate(featureName); + writeFile(join(featurePath, 'PLAN.md'), planContent); + + log('', 'info'); + log(`Feature "${featureName}" initialized!`, 'success'); + log('', 'info'); + log('Next steps:', 'info'); + log(` 1. Run: devorch harness create-specs ${featureName}`, 'info'); + log(` 2. Review and edit: ${join(featurePath, 'PLAN.md')}`, 'info'); + log(` 3. Start the loop: devorch harness loop ${featureName}`, 'info'); +} diff --git a/src/cli/commands/harness/list.ts b/src/cli/commands/harness/list.ts new file mode 100644 index 0000000..f85f24b --- /dev/null +++ b/src/cli/commands/harness/list.ts @@ -0,0 +1,41 @@ +/** + * Ralph list command + * Lists all existing features in the ralph directory + */ + +import type { GlobalOptions } from '@/cli/lib/cli/command-registry.js'; +import { getFeatureInfo, getStatusIcon, listFeatures } from '@/cli/lib/harness/index.js'; +import { logger } from '@/utils/logger.js'; +import { log } from '@/utils/ui.js'; + +export async function listCommand(_options: GlobalOptions): Promise { + logger.debug('Ralph list command started'); + + const features = listFeatures(); + + if (features.length === 0) { + log('No features found.', 'info'); + log('', 'info'); + log('Create a new feature with:', 'info'); + log(' devorch harness init ', 'info'); + return; + } + + console.log(''); + console.log(`Found ${features.length} feature(s):\n`); + + for (const feature of features) { + const info = getFeatureInfo(feature); + const statusIcon = getStatusIcon(info.status); + const specsInfo = info.specsCount > 0 ? `${info.specsCount} specs` : 'no specs'; + const logsInfo = info.logsCount > 0 ? `${info.logsCount} iterations` : 'no iterations'; + + console.log(` ${statusIcon} ${feature}`); + console.log(` ${specsInfo}, ${logsInfo}`); + console.log(''); + } + + console.log('Commands:'); + console.log(' devorch harness status - View detailed progress'); + console.log(' devorch harness loop - Start/resume the loop'); +} diff --git a/src/cli/commands/harness/loop.ts b/src/cli/commands/harness/loop.ts new file mode 100644 index 0000000..789a7db --- /dev/null +++ b/src/cli/commands/harness/loop.ts @@ -0,0 +1,129 @@ +/** + * Ralph loop command + * Runs Claude Code in an external bash loop with progress tracking + */ + +import type { GlobalOptions } from '@/cli/lib/cli/command-registry.js'; +import { + featureExists, + getAsciiArt, + getPlanPath, + getPromptPath, + getSpecsPath, + hasSpecs, + hasTasks, + runClaudeHeadless, + sleep, +} from '@/cli/lib/harness/index.js'; +import { flushAndExit } from '@/cli/lib/telemetry/telemetry.js'; +import { readFile } from '@/utils/files.js'; +import { logger } from '@/utils/logger.js'; +import { log, promptText } from '@/utils/ui.js'; + +interface LoopOptions extends GlobalOptions { + feature?: string; + maxIterations?: number; + verbose?: boolean; +} + +export async function loopCommand(options: LoopOptions): Promise { + logger.debug('Ralph loop command started', { options }); + + // Prompt for feature name if not provided + let featureName = options.feature; + if (!featureName) { + featureName = await promptText('Enter feature name:'); + } + + // Guardrail: Check if feature exists + if (!featureExists(featureName)) { + log(`Feature "${featureName}" not found.`, 'error'); + log(`Run \`devorch harness init ${featureName}\` first.`, 'info'); + await flushAndExit(1); + return; + } + + // Guardrail: Check if specs exist + const specsPath = getSpecsPath(featureName); + if (!hasSpecs(specsPath)) { + log(`No specs found for feature "${featureName}".`, 'error'); + log(`Run \`devorch harness create-specs ${featureName}\` first.`, 'info'); + await flushAndExit(1); + return; + } + + // Guardrail: Check if PLAN.md has tasks + const planPath = getPlanPath(featureName); + if (!hasTasks(planPath)) { + log(`No tasks found in PLAN.md for feature "${featureName}".`, 'error'); + log(`Add tasks to ${planPath} before starting the loop.`, 'info'); + await flushAndExit(1); + return; + } + + // Read PROMPT.md content (context training is baked in from init) + const promptPath = getPromptPath(featureName); + const promptContent = readFile(promptPath); + + const maxIterations = options.maxIterations ?? 20; + + // Display ASCII art banner + console.log(getAsciiArt()); + + log(`Starting Ralph loop for "${featureName}"`, 'info'); + log(`Max iterations: ${maxIterations}`, 'info'); + log('Press Ctrl+C to stop the loop gracefully.', 'info'); + console.log(''); + + // Set up signal handling for graceful shutdown + let shouldStop = false; + const handleSignal = () => { + console.log('\n\nReceived interrupt signal. Stopping after current iteration...'); + shouldStop = true; + }; + process.on('SIGINT', handleSignal); + process.on('SIGTERM', handleSignal); + + let iteration = 0; + + try { + while (!shouldStop && iteration < maxIterations) { + iteration++; + console.log(`\n${'='.repeat(60)}`); + console.log( + `ITERATION ${iteration}${maxIterations !== Infinity ? ` / ${maxIterations}` : ''}` + ); + console.log(`${'='.repeat(60)}\n`); + + // Run Claude with the prompt (headless with pretty-printed output) + const exitCode = await runClaudeHeadless(promptContent); + + if (exitCode !== 0) { + log(`Claude exited with code ${exitCode}`, 'warn'); + } + + // Check for completion marker in PLAN.md + const planContent = readFile(planPath); + if (planContent.includes('## COMPLETED')) { + console.log('\n'); + log('All tasks completed! Found "## COMPLETED" marker in PLAN.md', 'success'); + break; + } + + // Brief pause between iterations + await sleep(1000); + } + + if (shouldStop) { + log('Loop stopped by user.', 'info'); + } else if (iteration >= maxIterations) { + log(`Reached maximum iterations (${maxIterations}).`, 'info'); + } + } finally { + // Clean up signal handlers + process.off('SIGINT', handleSignal); + process.off('SIGTERM', handleSignal); + } + + log(`Completed ${iteration} iteration(s).`, 'info'); +} diff --git a/src/cli/commands/harness/status.ts b/src/cli/commands/harness/status.ts new file mode 100644 index 0000000..3093568 --- /dev/null +++ b/src/cli/commands/harness/status.ts @@ -0,0 +1,156 @@ +/** + * Ralph status command + * Shows detailed progress for a feature from PLAN.md + */ + +import { readdirSync } from 'node:fs'; +import type { GlobalOptions } from '@/cli/lib/cli/command-registry.js'; +import { + analyzePlan, + countCriteria, + createProgressBar, + featureExists, + getFeaturePath, + getLogsPath, + getPlanPath, + getSpecsPath, +} from '@/cli/lib/harness/index.js'; +import { flushAndExit } from '@/cli/lib/telemetry/telemetry.js'; +import { fileExists, readFile } from '@/utils/files.js'; +import { logger } from '@/utils/logger.js'; +import { log, promptText } from '@/utils/ui.js'; + +interface StatusOptions extends GlobalOptions { + feature?: string; +} + +export async function statusCommand(options: StatusOptions): Promise { + logger.debug('Ralph status command started', { options }); + + // Prompt for feature name if not provided + let featureName = options.feature; + if (!featureName) { + featureName = await promptText('Enter feature name:'); + } + + // Check if feature exists + if (!featureExists(featureName)) { + log(`Feature "${featureName}" not found.`, 'error'); + log(`Run \`devorch harness init ${featureName}\` first.`, 'info'); + await flushAndExit(1); + return; + } + + const planPath = getPlanPath(featureName); + const specsPath = getSpecsPath(featureName); + const logsPath = getLogsPath(featureName); + const featurePath = getFeaturePath(featureName); + + console.log(''); + console.log(`Feature: ${featureName}`); + console.log(`Path: ${featurePath}`); + console.log(''); + + // Show specs status + console.log('Specs:'); + if (fileExists(specsPath)) { + try { + const specFiles = readdirSync(specsPath).filter((f) => f.endsWith('.md')); + if (specFiles.length > 0) { + for (const file of specFiles) { + console.log(` - ${file}`); + } + } else { + console.log(' (no spec files)'); + } + } catch { + console.log(' (error reading specs)'); + } + } else { + console.log(' (specs directory not found)'); + } + console.log(''); + + // Show iterations + console.log('Iterations:'); + if (fileExists(logsPath)) { + try { + const logFiles = readdirSync(logsPath) + .filter((f) => f.startsWith('iteration-')) + .sort(); + if (logFiles.length > 0) { + console.log(` ${logFiles.length} completed`); + // Show last 3 iterations + const lastLogs = logFiles.slice(-3); + for (const file of lastLogs) { + console.log(` - ${file}`); + } + if (logFiles.length > 3) { + console.log(` ... and ${logFiles.length - 3} more`); + } + } else { + console.log(' (no iterations yet)'); + } + } catch { + console.log(' (error reading logs)'); + } + } else { + console.log(' (logs directory not found)'); + } + console.log(''); + + // Parse and show PLAN.md progress + if (!fileExists(planPath)) { + log('PLAN.md not found.', 'warn'); + return; + } + + const planContent = readFile(planPath); + const analysis = analyzePlan(planContent); + + // Show overall status + console.log('='.repeat(50)); + console.log('PLAN.md Progress'); + console.log('='.repeat(50)); + console.log(''); + + if (analysis.isCompleted) { + console.log('Status: COMPLETED'); + } else { + console.log(`Status: ${analysis.statusLine || 'IN_PROGRESS'}`); + } + console.log(''); + + // Show task progress + console.log('Tasks:'); + if (analysis.tasks.length === 0) { + console.log(' (no tasks defined)'); + } else { + for (const task of analysis.tasks) { + const completed = task.criteria.filter((c) => c.checked).length; + const total = task.criteria.length; + const icon = completed === total ? '[x]' : `[${completed}/${total}]`; + console.log(` ${icon} ${task.name}`); + + // Show individual criteria + for (const criterion of task.criteria) { + const checkIcon = criterion.checked ? '[x]' : '[ ]'; + console.log(` ${checkIcon} ${criterion.text}`); + } + console.log(''); + } + } + + // Show summary + const { total, completed } = countCriteria(analysis); + + console.log('='.repeat(50)); + console.log(`Progress: ${completed}/${total} acceptance criteria`); + + if (total > 0) { + const percentage = Math.round((completed / total) * 100); + const bar = createProgressBar(percentage, 30); + console.log(` ${bar} ${percentage}%`); + } + console.log(''); +} diff --git a/src/cli/lib/claude-runner/index.ts b/src/cli/lib/claude-runner/index.ts new file mode 100644 index 0000000..e58f75a --- /dev/null +++ b/src/cli/lib/claude-runner/index.ts @@ -0,0 +1,26 @@ +/** + * Claude CLI Runner + * + * Provides abstractions for spawning Claude CLI in different modes: + * - print: One-shot execution with direct output + * - interactive: Full interactive session with stdin pipe + * - headless: Non-interactive with optional pretty-printing + * + * @example + * ```typescript + * import { ClaudeRunner } from '@/cli/lib/claude-runner/index.js'; + * + * // One-shot print mode + * await ClaudeRunner.print('Explain this code', { skipPermissions: true }); + * + * // Interactive session + * await ClaudeRunner.interactive('Help me write tests'); + * + * // Headless with pretty-printing + * await ClaudeRunner.headless(promptContent); + * ``` + */ + +export { spawnWithPrettyPrinter } from './pretty-printer.js'; +export * from './runner.js'; +export * from './types.js'; diff --git a/src/cli/lib/claude-runner/pretty-printer.ts b/src/cli/lib/claude-runner/pretty-printer.ts new file mode 100644 index 0000000..45c44ee --- /dev/null +++ b/src/cli/lib/claude-runner/pretty-printer.ts @@ -0,0 +1,67 @@ +/** + * Integration with claude-pretty-printer for formatted output + */ + +import { logger } from '@/utils/logger.js'; +import type { ClaudeRunResult } from './types.js'; + +export interface PrettyPrinterOptions { + /** Skip permission prompts */ + skipPermissions?: boolean; + /** Filter message types (e.g., ['assistant', 'result']) */ + filter?: string[]; +} + +/** + * Spawn Claude with output piped through claude-pretty-printer + * + * @param prompt - The prompt to send to Claude + * @param options - Pretty printer options + * @returns Result with exit code + */ +export async function spawnWithPrettyPrinter( + prompt: string, + options: PrettyPrinterOptions = {} +): Promise { + const { skipPermissions = true, filter } = options; + + // Note: --verbose is REQUIRED when using -p with --output-format=stream-json + const claudeArgs = ['claude', '-p', '--output-format=stream-json', '--verbose']; + + if (skipPermissions) { + claudeArgs.push('--dangerously-skip-permissions'); + } + + logger.debug('Spawning Claude with pretty-printer', { filter }); + + try { + const claude = Bun.spawn(claudeArgs, { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'inherit', + }); + + claude.stdin.write(prompt); + claude.stdin.end(); + + // Build pretty-printer args + const prettyPrinterArgs = ['npx', 'claude-pretty-printer']; + if (filter && filter.length > 0) { + prettyPrinterArgs.push('--filter', filter.join(',')); + } + + const prettyPrinter = Bun.spawn(prettyPrinterArgs, { + stdin: claude.stdout, + stdout: 'inherit', + stderr: 'inherit', + }); + + await prettyPrinter.exited; + const exitCode = prettyPrinter.exitCode ?? 0; + logger.debug('Claude with pretty-printer completed', { exitCode }); + return { exitCode }; + } catch (err) { + logger.error('Failed to run Claude with pretty-printer', { error: err }); + return { exitCode: 1 }; + } +} diff --git a/src/cli/lib/claude-runner/runner.ts b/src/cli/lib/claude-runner/runner.ts new file mode 100644 index 0000000..2cfed2f --- /dev/null +++ b/src/cli/lib/claude-runner/runner.ts @@ -0,0 +1,174 @@ +/** + * Claude CLI process runner + * + * Provides methods for spawning Claude CLI in different modes: + * - print: One-shot execution with direct output + * - interactive: Full interactive session with stdin pipe + * - headless: Non-interactive with optional pretty-printing + */ + +import { logger } from '@/utils/logger.js'; +import { spawnWithPrettyPrinter } from './pretty-printer.js'; +import type { ClaudeRunOptions, ClaudeRunResult, HeadlessOptions } from './types.js'; + +/** + * Run Claude CLI in print mode (one-shot, outputs directly) + * + * @param prompt - The prompt to send to Claude + * @param options - Run options + * @returns Result with exit code + */ +export async function runPrint( + prompt: string, + options: ClaudeRunOptions = {} +): Promise { + const args = ['claude', '-p', prompt]; + + if (options.skipPermissions) { + args.push('--dangerously-skip-permissions'); + } + + if (options.permissionMode) { + args.push('--permission-mode', options.permissionMode); + } + + if (options.verbose) { + args.push('--verbose'); + } + + logger.debug('Running Claude in print mode', { + argsCount: args.length, + promptLength: prompt.length, + }); + + try { + const claude = Bun.spawn(args, { + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', + }); + + const exitCode = await claude.exited; + logger.debug('Claude print mode completed', { exitCode }); + return { exitCode: exitCode ?? 0 }; + } catch (err) { + logger.error('Failed to spawn Claude in print mode', { error: err }); + return { exitCode: 1 }; + } +} + +/** + * Run Claude CLI in fully interactive mode + * + * Allows AskUserQuestion and other interactive tools to work. + * Uses acceptEdits mode + dangerously-skip-permissions for Shift+Tab override. + * + * @param prompt - The initial prompt to send via stdin + * @param options - Run options + * @returns Result with exit code + */ +export async function runInteractive( + prompt: string, + options: ClaudeRunOptions = {} +): Promise { + const args = ['claude']; + + // Default to acceptEdits + skip permissions for interactive mode + args.push('--permission-mode', options.permissionMode ?? 'acceptEdits'); + args.push('--dangerously-skip-permissions'); + + if (options.verbose) { + args.push('--verbose'); + } + + logger.debug('Running Claude in interactive mode'); + + try { + const claude = Bun.spawn(args, { + stdin: 'pipe', + stdout: 'inherit', + stderr: 'inherit', + }); + + claude.stdin.write(prompt); + claude.stdin.end(); + + await claude.exited; + const exitCode = claude.exitCode ?? 0; + logger.debug('Claude interactive mode completed', { exitCode }); + return { exitCode }; + } catch (err) { + logger.error('Failed to run interactive Claude', { error: err }); + return { exitCode: 1 }; + } +} + +/** + * Run Claude CLI in headless mode with optional pretty-printed output + * + * @param prompt - The prompt to send to Claude + * @param options - Headless options including pretty-print settings + * @returns Result with exit code + */ +export async function runHeadless( + prompt: string, + options: HeadlessOptions = {} +): Promise { + const { prettyPrint = true, filter, ...baseOptions } = options; + + logger.debug('Running Claude in headless mode', { prettyPrint }); + + if (prettyPrint) { + return spawnWithPrettyPrinter(prompt, { + skipPermissions: true, + filter: filter ?? ['assistant', 'result'], + }); + } + + // Non-pretty-print headless mode + // Note: --verbose is REQUIRED when using -p with --output-format=stream-json + const args = ['claude', '-p', '--output-format=stream-json', '--verbose']; + + if (baseOptions.skipPermissions ?? true) { + args.push('--dangerously-skip-permissions'); + } + + try { + const claude = Bun.spawn(args, { + stdin: 'pipe', + stdout: 'inherit', + stderr: 'inherit', + }); + + claude.stdin.write(prompt); + claude.stdin.end(); + + await claude.exited; + const exitCode = claude.exitCode ?? 0; + logger.debug('Claude headless mode completed', { exitCode }); + return { exitCode }; + } catch (err) { + logger.error('Failed to run headless Claude', { error: err }); + return { exitCode: 1 }; + } +} + +/** + * ClaudeRunner - Static class providing Claude CLI execution methods + */ +export const ClaudeRunner = { + /** + * Run Claude in print mode (one-shot, direct output) + */ + print: runPrint, + + /** + * Run Claude in interactive mode (full session with stdin) + */ + interactive: runInteractive, + + /** + * Run Claude in headless mode (non-interactive with pretty-print) + */ + headless: runHeadless, +} as const; diff --git a/src/cli/lib/claude-runner/types.ts b/src/cli/lib/claude-runner/types.ts new file mode 100644 index 0000000..8f5866b --- /dev/null +++ b/src/cli/lib/claude-runner/types.ts @@ -0,0 +1,43 @@ +/** + * Types for Claude CLI runner + */ + +/** + * Claude CLI execution modes + */ +export type ClaudeMode = 'print' | 'interactive' | 'headless'; + +/** + * Permission modes for Claude CLI + */ +export type PermissionMode = 'acceptEdits' | 'bypassPermissions'; + +/** + * Base options for running Claude CLI + */ +export interface ClaudeRunOptions { + /** Skip permission prompts entirely */ + skipPermissions?: boolean; + /** Permission mode setting */ + permissionMode?: PermissionMode; + /** Enable verbose output */ + verbose?: boolean; +} + +/** + * Options for headless mode with pretty-printing + */ +export interface HeadlessOptions extends Omit { + /** Enable pretty-printing via claude-pretty-printer */ + prettyPrint?: boolean; + /** Filter message types (e.g., ['assistant', 'result']) */ + filter?: string[]; +} + +/** + * Result from running Claude CLI + */ +export interface ClaudeRunResult { + /** Exit code from the process */ + exitCode: number; +} diff --git a/src/cli/lib/cli/command-registry.ts b/src/cli/lib/cli/command-registry.ts index 984d6c0..5b48374 100644 --- a/src/cli/lib/cli/command-registry.ts +++ b/src/cli/lib/cli/command-registry.ts @@ -5,6 +5,8 @@ import { agentCommand } from '@/cli/commands/agent/index.js'; // Troubleshoot commands import { countTokensCommand } from '@/cli/commands/diagnose/count-tokens.js'; import { diagnoseCommand } from '@/cli/commands/diagnose/index.js'; +// Workflow commands +import { harnessCommand } from '@/cli/commands/harness/index.js'; // Manage commands import { checkVersionCommand } from '@/cli/commands/manage/check-version/index.js'; import { updateCommand } from '@/cli/commands/manage/update/index.js'; @@ -41,7 +43,7 @@ export type CommandHandler = (options?: GlobalOptions) => Promise; export interface CommandMetadata { name: string; handler: CommandHandler; - category: 'setup' | 'manage' | 'spec' | 'troubleshoot' | 'agent'; + category: 'setup' | 'manage' | 'spec' | 'troubleshoot' | 'agent' | 'workflow'; description: string; hint: string; } @@ -115,6 +117,15 @@ export const COMMAND_REGISTRY: Record = { description: 'Diagnose installation', hint: 'Show status and run health checks', }, + + // Workflow commands + harness: { + name: 'harness', + handler: harnessCommand, + category: 'workflow', + description: 'Ralph Wiggum autonomous loop', + hint: 'Run external harness loop for autonomous development', + }, }; /** diff --git a/src/cli/lib/harness/claude.ts b/src/cli/lib/harness/claude.ts new file mode 100644 index 0000000..ac33ddd --- /dev/null +++ b/src/cli/lib/harness/claude.ts @@ -0,0 +1,33 @@ +/** + * Claude process utilities for Harness commands + * + * Re-exports from the shared claude-runner module for backwards compatibility. + */ + +import { runHeadless, runInteractive, runPrint } from '@/cli/lib/claude-runner/index.js'; + +/** + * Run Claude CLI with the given prompt (print mode - outputs and exits) + */ +export async function runClaude(promptContent: string, skipPermissions: boolean): Promise { + const result = await runPrint(promptContent, { skipPermissions }); + return result.exitCode; +} + +/** + * Run Claude CLI in fully interactive mode + * Allows AskUserQuestion and other interactive tools to work + * Uses acceptEdits mode + dangerously-skip-permissions for Shift+Tab override + */ +export async function runClaudeInteractive(promptContent: string): Promise { + const result = await runInteractive(promptContent); + return result.exitCode; +} + +/** + * Run Claude CLI in headless mode with pretty-printed output + */ +export async function runClaudeHeadless(promptContent: string): Promise { + const result = await runHeadless(promptContent); + return result.exitCode; +} diff --git a/src/cli/lib/harness/index.ts b/src/cli/lib/harness/index.ts new file mode 100644 index 0000000..32b11e9 --- /dev/null +++ b/src/cli/lib/harness/index.ts @@ -0,0 +1,10 @@ +/** + * Ralph library - shared logic for Ralph Wiggum loop automation + */ + +export * from './claude.js'; +export * from './paths.js'; +export * from './plan.js'; +export * from './specs.js'; +export * from './templates.js'; +export * from './utils.js'; diff --git a/src/cli/lib/harness/paths.test.ts b/src/cli/lib/harness/paths.test.ts new file mode 100644 index 0000000..a83d9b3 --- /dev/null +++ b/src/cli/lib/harness/paths.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { + featureExists, + getFeaturePath, + getHarnessRootPath, + getLogsPath, + getPlanPath, + getPromptPath, + getSpecsPath, + HARNESS_ROOT, + listFeatures, +} from '@/cli/lib/harness/paths.js'; + +describe('lib/harness/paths', () => { + const cwd = process.cwd(); + + describe('HARNESS_ROOT', () => { + it('should be devorch/harness', () => { + expect(HARNESS_ROOT).toBe('devorch/harness'); + }); + }); + + describe('getHarnessRootPath', () => { + it('should return path under cwd', () => { + expect(getHarnessRootPath()).toBe(join(cwd, 'devorch/harness')); + }); + }); + + describe('getFeaturePath', () => { + it('should return path with feature name', () => { + expect(getFeaturePath('my-feature')).toBe(join(cwd, 'devorch/harness/my-feature')); + }); + }); + + describe('getPromptPath', () => { + it('should return PROMPT.md path', () => { + expect(getPromptPath('my-feature')).toBe(join(cwd, 'devorch/harness/my-feature/PROMPT.md')); + }); + }); + + describe('getPlanPath', () => { + it('should return PLAN.md path', () => { + expect(getPlanPath('my-feature')).toBe(join(cwd, 'devorch/harness/my-feature/PLAN.md')); + }); + }); + + describe('getSpecsPath', () => { + it('should return specs directory path', () => { + expect(getSpecsPath('my-feature')).toBe(join(cwd, 'devorch/harness/my-feature/specs')); + }); + }); + + describe('getLogsPath', () => { + it('should return logs directory path', () => { + expect(getLogsPath('my-feature')).toBe(join(cwd, 'devorch/harness/my-feature/logs')); + }); + }); + + describe('featureExists', () => { + const testFeaturePath = join(cwd, 'devorch/harness/test-feature-exists'); + + afterEach(() => { + rmSync(testFeaturePath, { recursive: true, force: true }); + }); + + it('should return false for non-existent feature', () => { + expect(featureExists('non-existent-feature-xyz')).toBe(false); + }); + + it('should return true for existing feature', () => { + mkdirSync(testFeaturePath, { recursive: true }); + expect(featureExists('test-feature-exists')).toBe(true); + }); + }); + + describe('listFeatures', () => { + const testRalphRoot = join(cwd, 'devorch/harness'); + const testFeature1 = join(testRalphRoot, 'test-list-feature-1'); + const testFeature2 = join(testRalphRoot, 'test-list-feature-2'); + + beforeEach(() => { + mkdirSync(testFeature1, { recursive: true }); + mkdirSync(testFeature2, { recursive: true }); + }); + + afterEach(() => { + rmSync(testFeature1, { recursive: true, force: true }); + rmSync(testFeature2, { recursive: true, force: true }); + }); + + it('should list features as directories', () => { + const features = listFeatures(); + expect(features).toContain('test-list-feature-1'); + expect(features).toContain('test-list-feature-2'); + }); + }); +}); diff --git a/src/cli/lib/harness/paths.ts b/src/cli/lib/harness/paths.ts new file mode 100644 index 0000000..4f5298f --- /dev/null +++ b/src/cli/lib/harness/paths.ts @@ -0,0 +1,79 @@ +/** + * Path utilities for Harness commands + */ + +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileExists } from '@/utils/files.js'; + +/** + * Root directory for all Ralph features + */ +export const HARNESS_ROOT = 'devorch/harness'; + +/** + * Get the full path to a feature directory + */ +export function getFeaturePath(featureName: string): string { + return join(process.cwd(), HARNESS_ROOT, featureName); +} + +/** + * Get the path to the ralph root directory + */ +export function getHarnessRootPath(): string { + return join(process.cwd(), HARNESS_ROOT); +} + +/** + * Check if a feature exists + */ +export function featureExists(featureName: string): boolean { + return fileExists(getFeaturePath(featureName)); +} + +/** + * Get all feature names in the ralph directory + */ +export function listFeatures(): string[] { + const harnessRoot = getHarnessRootPath(); + + if (!fileExists(harnessRoot)) { + return []; + } + + try { + const entries = readdirSync(harnessRoot, { withFileTypes: true }); + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); + } catch { + return []; + } +} + +/** + * Get the path to PROMPT.md for a feature + */ +export function getPromptPath(featureName: string): string { + return join(getFeaturePath(featureName), 'PROMPT.md'); +} + +/** + * Get the path to PLAN.md for a feature + */ +export function getPlanPath(featureName: string): string { + return join(getFeaturePath(featureName), 'PLAN.md'); +} + +/** + * Get the path to the specs directory for a feature + */ +export function getSpecsPath(featureName: string): string { + return join(getFeaturePath(featureName), 'specs'); +} + +/** + * Get the path to the logs directory for a feature + */ +export function getLogsPath(featureName: string): string { + return join(getFeaturePath(featureName), 'logs'); +} diff --git a/src/cli/lib/harness/plan.test.ts b/src/cli/lib/harness/plan.test.ts new file mode 100644 index 0000000..1a38418 --- /dev/null +++ b/src/cli/lib/harness/plan.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'bun:test'; +import { analyzePlan, countCriteria } from '@/cli/lib/harness/plan.js'; + +describe('lib/harness/plan', () => { + describe('analyzePlan', () => { + it('should detect completed status', () => { + const content = `# Plan +## COMPLETED +Some content`; + const analysis = analyzePlan(content); + expect(analysis.isCompleted).toBe(true); + }); + + it('should detect not completed status', () => { + const content = `# Plan +## Status: IN_PROGRESS +Some content`; + const analysis = analyzePlan(content); + expect(analysis.isCompleted).toBe(false); + }); + + it('should extract status line', () => { + const content = `# Plan +## Status: IN_PROGRESS +Some content`; + const analysis = analyzePlan(content); + expect(analysis.statusLine).toBe('IN_PROGRESS'); + }); + + it('should parse tasks', () => { + const content = `# Plan +## Status: IN_PROGRESS + +### Task 1: First task +**Acceptance Criteria:** +- [ ] Do something +- [x] Do another thing + +### Task 2: Second task +**Acceptance Criteria:** +- [ ] Third thing +`; + const analysis = analyzePlan(content); + expect(analysis.tasks).toHaveLength(2); + expect(analysis.tasks[0].name).toBe('First task'); + expect(analysis.tasks[1].name).toBe('Second task'); + }); + + it('should parse acceptance criteria checkboxes', () => { + const content = `### Task 1: Test task +- [ ] Unchecked +- [x] Checked lowercase +- [X] Checked uppercase +`; + const analysis = analyzePlan(content); + expect(analysis.tasks[0].criteria).toHaveLength(3); + expect(analysis.tasks[0].criteria[0].checked).toBe(false); + expect(analysis.tasks[0].criteria[0].text).toBe('Unchecked'); + expect(analysis.tasks[0].criteria[1].checked).toBe(true); + expect(analysis.tasks[0].criteria[2].checked).toBe(true); + }); + + it('should handle empty plan', () => { + const content = `# Plan +Nothing here`; + const analysis = analyzePlan(content); + expect(analysis.tasks).toHaveLength(0); + expect(analysis.isCompleted).toBe(false); + expect(analysis.statusLine).toBeNull(); + }); + + it('should handle task with colon separator', () => { + const content = `### Task 1: My task name +- [ ] Criterion`; + const analysis = analyzePlan(content); + expect(analysis.tasks[0].name).toBe('My task name'); + }); + + it('should handle task with period separator', () => { + const content = `### Task 1. My task name +- [ ] Criterion`; + const analysis = analyzePlan(content); + expect(analysis.tasks[0].name).toBe('My task name'); + }); + }); + + describe('countCriteria', () => { + it('should count total and completed criteria', () => { + const analysis = { + isCompleted: false, + statusLine: null, + tasks: [ + { + name: 'Task 1', + criteria: [ + { text: 'A', checked: true }, + { text: 'B', checked: false }, + ], + }, + { + name: 'Task 2', + criteria: [ + { text: 'C', checked: true }, + { text: 'D', checked: true }, + { text: 'E', checked: false }, + ], + }, + ], + }; + const { total, completed } = countCriteria(analysis); + expect(total).toBe(5); + expect(completed).toBe(3); + }); + + it('should handle empty tasks', () => { + const analysis = { + isCompleted: false, + statusLine: null, + tasks: [], + }; + const { total, completed } = countCriteria(analysis); + expect(total).toBe(0); + expect(completed).toBe(0); + }); + }); +}); diff --git a/src/cli/lib/harness/plan.ts b/src/cli/lib/harness/plan.ts new file mode 100644 index 0000000..fe889f2 --- /dev/null +++ b/src/cli/lib/harness/plan.ts @@ -0,0 +1,151 @@ +/** + * Plan parsing and analysis utilities for Harness commands + */ + +import { readdirSync } from 'node:fs'; +import { fileExists, readFile } from '@/utils/files.js'; +import { getFeaturePath, getPlanPath, getSpecsPath } from './paths.js'; + +export interface Task { + name: string; + criteria: { text: string; checked: boolean }[]; +} + +export interface PlanAnalysis { + isCompleted: boolean; + statusLine: string | null; + tasks: Task[]; +} + +export type FeatureStatus = 'completed' | 'in_progress' | 'not_started'; + +export interface FeatureInfo { + status: FeatureStatus; + specsCount: number; + logsCount: number; +} + +/** + * Check if PLAN.md has any tasks (lines starting with "### Task") + */ +export function hasTasks(planPath: string): boolean { + if (!fileExists(planPath)) { + return false; + } + + try { + const content = readFile(planPath); + return /^###\s+Task\s+\d+/m.test(content); + } catch { + return false; + } +} + +/** + * Parse and analyze a PLAN.md file + */ +export function analyzePlan(content: string): PlanAnalysis { + const lines = content.split('\n'); + const analysis: PlanAnalysis = { + isCompleted: content.includes('## COMPLETED'), + statusLine: null, + tasks: [], + }; + + // Find status line + const statusMatch = content.match(/^##\s+Status:\s*(.+)$/m); + if (statusMatch) { + analysis.statusLine = statusMatch[1].trim(); + } + + // Parse tasks + let currentTask: Task | null = null; + + for (const line of lines) { + // Match task headers like "### Task 1: Task Name" + const taskMatch = line.match(/^###\s+Task\s+\d+[:.]\s*(.+)$/); + if (taskMatch) { + if (currentTask) { + analysis.tasks.push(currentTask); + } + currentTask = { name: taskMatch[1].trim(), criteria: [] }; + continue; + } + + // Match acceptance criteria checkboxes + const criteriaMatch = line.match(/^-\s*\[([ xX])\]\s*(.+)$/); + if (criteriaMatch && currentTask) { + currentTask.criteria.push({ + checked: criteriaMatch[1].toLowerCase() === 'x', + text: criteriaMatch[2].trim(), + }); + } + } + + // Don't forget the last task + if (currentTask) { + analysis.tasks.push(currentTask); + } + + return analysis; +} + +/** + * Get summary info about a feature (status, specs count, logs count) + */ +export function getFeatureInfo(featureName: string): FeatureInfo { + const planPath = getPlanPath(featureName); + const specsPath = getSpecsPath(featureName); + const logsPath = `${getFeaturePath(featureName)}/logs`; + + // Count specs + let specsCount = 0; + if (fileExists(specsPath)) { + try { + const files = readdirSync(specsPath); + specsCount = files.filter((f) => f.endsWith('.md')).length; + } catch { + // Ignore errors + } + } + + // Count logs + let logsCount = 0; + if (fileExists(logsPath)) { + try { + const files = readdirSync(logsPath); + logsCount = files.filter((f) => f.startsWith('iteration-')).length; + } catch { + // Ignore errors + } + } + + // Determine status from PLAN.md + let status: FeatureStatus = 'not_started'; + if (fileExists(planPath)) { + try { + const content = readFile(planPath); + if (content.includes('## COMPLETED')) { + status = 'completed'; + } else if (logsCount > 0 || content.includes('[x]') || content.includes('[X]')) { + status = 'in_progress'; + } + } catch { + // Ignore errors + } + } + + return { status, specsCount, logsCount }; +} + +/** + * Count total and completed acceptance criteria from plan analysis + */ +export function countCriteria(analysis: PlanAnalysis): { total: number; completed: number } { + const total = analysis.tasks.reduce((sum, t) => sum + t.criteria.length, 0); + const completed = analysis.tasks.reduce( + (sum, t) => sum + t.criteria.filter((c) => c.checked).length, + 0 + ); + return { total, completed }; +} diff --git a/src/cli/lib/harness/specs.test.ts b/src/cli/lib/harness/specs.test.ts new file mode 100644 index 0000000..eb8d2b0 --- /dev/null +++ b/src/cli/lib/harness/specs.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + countSpecs, + formatExistingSpecs, + getExistingSpecs, + hasSpecs, +} from '@/cli/lib/harness/specs.js'; + +describe('lib/harness/specs', () => { + const testDir = join(process.cwd(), 'tests', '.tmp-specs-test'); + + beforeEach(() => { + // Clean up and create test directory + rmSync(testDir, { recursive: true, force: true }); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + describe('hasSpecs', () => { + it('should return false for non-existent directory', () => { + expect(hasSpecs('/non/existent/path')).toBe(false); + }); + + it('should return false for empty directory', () => { + expect(hasSpecs(testDir)).toBe(false); + }); + + it('should return false for directory with non-md files', () => { + writeFileSync(join(testDir, 'file.txt'), 'content'); + expect(hasSpecs(testDir)).toBe(false); + }); + + it('should return true for directory with md files', () => { + writeFileSync(join(testDir, 'spec.md'), 'content'); + expect(hasSpecs(testDir)).toBe(true); + }); + }); + + describe('countSpecs', () => { + it('should return 0 for non-existent directory', () => { + expect(countSpecs('/non/existent/path')).toBe(0); + }); + + it('should return 0 for empty directory', () => { + expect(countSpecs(testDir)).toBe(0); + }); + + it('should count only md files', () => { + writeFileSync(join(testDir, 'spec1.md'), 'content'); + writeFileSync(join(testDir, 'spec2.md'), 'content'); + writeFileSync(join(testDir, 'other.txt'), 'content'); + expect(countSpecs(testDir)).toBe(2); + }); + }); + + describe('getExistingSpecs', () => { + it('should return empty array for non-existent directory', () => { + expect(getExistingSpecs('/non/existent/path')).toEqual([]); + }); + + it('should return empty array for empty directory', () => { + expect(getExistingSpecs(testDir)).toEqual([]); + }); + + it('should return specs with names and content', () => { + writeFileSync(join(testDir, '00-overview.md'), '# Overview'); + writeFileSync(join(testDir, '01-api.md'), '# API'); + + const specs = getExistingSpecs(testDir); + expect(specs).toHaveLength(2); + expect(specs.find((s) => s.name === '00-overview.md')?.content).toBe('# Overview'); + expect(specs.find((s) => s.name === '01-api.md')?.content).toBe('# API'); + }); + + it('should only return md files', () => { + writeFileSync(join(testDir, 'spec.md'), 'md content'); + writeFileSync(join(testDir, 'other.txt'), 'txt content'); + + const specs = getExistingSpecs(testDir); + expect(specs).toHaveLength(1); + expect(specs[0].name).toBe('spec.md'); + }); + }); + + describe('formatExistingSpecs', () => { + it('should return empty string for empty array', () => { + expect(formatExistingSpecs([])).toBe(''); + }); + + it('should format specs with markdown code blocks', () => { + const specs = [ + { name: '00-overview.md', content: '# Overview\nSome content' }, + { name: '01-api.md', content: '# API' }, + ]; + + const formatted = formatExistingSpecs(specs); + + expect(formatted).toContain('## Existing Specs'); + expect(formatted).toContain('### 00-overview.md'); + expect(formatted).toContain('```markdown'); + expect(formatted).toContain('# Overview\nSome content'); + expect(formatted).toContain('### 01-api.md'); + }); + }); +}); diff --git a/src/cli/lib/harness/specs.ts b/src/cli/lib/harness/specs.ts new file mode 100644 index 0000000..e0eb8f4 --- /dev/null +++ b/src/cli/lib/harness/specs.ts @@ -0,0 +1,76 @@ +/** + * Spec file utilities for Harness commands + */ + +import { readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileExists, readFile } from '@/utils/files.js'; + +export interface ExistingSpec { + name: string; + content: string; +} + +/** + * Get list of existing spec files with their contents + */ +export function getExistingSpecs(specsPath: string): ExistingSpec[] { + if (!fileExists(specsPath) || !statSync(specsPath).isDirectory()) { + return []; + } + + try { + const files = readdirSync(specsPath).filter((f) => f.endsWith('.md')); + return files.map((name) => ({ + name, + content: readFile(join(specsPath, name)), + })); + } catch { + return []; + } +} + +/** + * Format existing specs for inclusion in a prompt + */ +export function formatExistingSpecs(specs: ExistingSpec[]): string { + if (specs.length === 0) return ''; + + const formatted = specs + .map((spec) => `### ${spec.name}\n\`\`\`markdown\n${spec.content}\n\`\`\``) + .join('\n\n'); + + return `## Existing Specs (for reference)\n\nThe following specs already exist. Review them and improve/update as needed:\n\n${formatted}\n\n`; +} + +/** + * Check if the specs directory has any .md files + */ +export function hasSpecs(specsPath: string): boolean { + if (!fileExists(specsPath)) { + return false; + } + + try { + const files = readdirSync(specsPath); + return files.some((file) => file.endsWith('.md')); + } catch { + return false; + } +} + +/** + * Count the number of spec files in a directory + */ +export function countSpecs(specsPath: string): number { + if (!fileExists(specsPath)) { + return 0; + } + + try { + const files = readdirSync(specsPath); + return files.filter((f) => f.endsWith('.md')).length; + } catch { + return 0; + } +} diff --git a/src/cli/lib/harness/templates.test.ts b/src/cli/lib/harness/templates.test.ts new file mode 100644 index 0000000..7a7027d --- /dev/null +++ b/src/cli/lib/harness/templates.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'bun:test'; +import { + getAsciiArt, + loadTemplate, + renderCreateSpecsPrompt, + renderPlanTemplate, + renderPromptTemplate, + renderTemplate, +} from '@/cli/lib/harness/templates.js'; + +describe('lib/harness/templates', () => { + describe('loadTemplate', () => { + it('should load ascii-art.txt', () => { + const content = loadTemplate('ascii-art.txt'); + expect(content.length).toBeGreaterThan(100); + }); + + it('should load PLAN.template.md', () => { + const content = loadTemplate('PLAN.template.md'); + expect(content).toContain('{{FEATURE_NAME}}'); + }); + + it('should load PROMPT.template.md', () => { + const content = loadTemplate('PROMPT.template.md'); + expect(content).toContain('{{{FEATURE_NAME}}}'); + }); + + it('should load create-specs-prompt.md', () => { + const content = loadTemplate('create-specs-prompt.md'); + expect(content).toContain('{{{FEATURE_NAME}}}'); + expect(content).toContain('{{{DESCRIPTION}}}'); + }); + }); + + describe('renderTemplate', () => { + it('should render template with context', () => { + const rendered = renderTemplate('PLAN.template.md', { + FEATURE_NAME: 'my-feature', + }); + expect(rendered).toContain('my-feature'); + expect(rendered).not.toContain('{{FEATURE_NAME}}'); + }); + }); + + describe('getAsciiArt', () => { + it('should return ascii art content', () => { + const art = getAsciiArt(); + expect(art.length).toBeGreaterThan(0); + }); + }); + + describe('renderPromptTemplate', () => { + it('should render with feature name, ralph dir, and context training', () => { + const rendered = renderPromptTemplate('auth-system', 'mobile-app'); + expect(rendered).toContain('auth-system'); + expect(rendered).toContain('devorch/harness/auth-system'); + expect(rendered).toContain('spec-machine/context-training/mobile-app'); + }); + }); + + describe('renderPlanTemplate', () => { + it('should render with feature name', () => { + const rendered = renderPlanTemplate('auth-system'); + expect(rendered).toContain('auth-system'); + }); + }); + + describe('renderCreateSpecsPrompt', () => { + it('should render with all context', () => { + const rendered = renderCreateSpecsPrompt( + 'auth-system', + 'Build user authentication', + 'devorch/harness/auth-system/specs' + ); + expect(rendered).toContain('auth-system'); + expect(rendered).toContain('Build user authentication'); + expect(rendered).toContain('devorch/harness/auth-system/specs'); + }); + }); +}); diff --git a/src/cli/lib/harness/templates.ts b/src/cli/lib/harness/templates.ts new file mode 100644 index 0000000..2fe3776 --- /dev/null +++ b/src/cli/lib/harness/templates.ts @@ -0,0 +1,99 @@ +/** + * Template loading and rendering for Harness commands + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import Mustache from 'mustache'; + +/** + * Directory containing ralph templates + */ +const TEMPLATES_DIR = join(__dirname, 'templates'); + +/** + * Available template names + */ +export type TemplateName = + | 'ascii-art.txt' + | 'PLAN.template.md' + | 'PROMPT.template.md' + | 'create-specs-prompt.md' + | 'iteration-report.template.md'; + +/** + * Load a raw template file + */ +export function loadTemplate(name: TemplateName): string { + const templatePath = join(TEMPLATES_DIR, name); + return readFileSync(templatePath, 'utf-8'); +} + +/** + * Load and render a template with Mustache + */ +export function renderTemplate(name: TemplateName, context: Record): string { + const template = loadTemplate(name); + return Mustache.render(template, context); +} + +/** + * Get the ASCII art banner + */ +export function getAsciiArt(): string { + return loadTemplate('ascii-art.txt'); +} + +/** + * Render the PROMPT.md template for a feature + */ +export function renderPromptTemplate(featureName: string, contextTrainingName: string): string { + return renderTemplate('PROMPT.template.md', { + FEATURE_NAME: featureName, + HARNESS_DIR: `devorch/harness/${featureName}`, + CONTEXT_TRAINING_PATH: `spec-machine/context-training/${contextTrainingName}`, + }); +} + +/** + * Render the PLAN.md template for a feature + */ +export function renderPlanTemplate(featureName: string): string { + return renderTemplate('PLAN.template.md', { + FEATURE_NAME: featureName, + }); +} + +/** + * Render the create-specs prompt template + */ +export function renderCreateSpecsPrompt( + featureName: string, + description: string, + specsPath: string +): string { + return renderTemplate('create-specs-prompt.md', { + FEATURE_NAME: featureName, + DESCRIPTION: description, + SPECS_PATH: specsPath, + }); +} + +/** + * Render the iteration report template + */ +export function renderIterationReport(context: { + iterationNumber: string; + timestamp: string; + taskId: string; + taskName: string; + status: string; +}): string { + return renderTemplate('iteration-report.template.md', { + ITERATION_NUMBER: context.iterationNumber, + TIMESTAMP: context.timestamp, + TASK_ID: context.taskId, + TASK_NAME: context.taskName, + STATUS: context.status, + }); +} diff --git a/src/cli/lib/harness/templates/PLAN.template.md b/src/cli/lib/harness/templates/PLAN.template.md new file mode 100644 index 0000000..1c65a73 --- /dev/null +++ b/src/cli/lib/harness/templates/PLAN.template.md @@ -0,0 +1,16 @@ +# Plan: {{FEATURE_NAME}} + +## Status: NOT_STARTED + +> **This is a template placeholder.** +> +> Run `devorch harness create-specs {{FEATURE_NAME}}` to: +> 1. Gather requirements through interactive questions +> 2. Generate detailed specs in the `specs/` folder +> 3. Populate this file with concrete tasks and acceptance criteria +> +> The `create-specs` command will replace this content with a real plan. + +## Tasks + +_No tasks yet. Run `create-specs` to generate tasks from specifications._ diff --git a/src/cli/lib/harness/templates/PROMPT.template.md b/src/cli/lib/harness/templates/PROMPT.template.md new file mode 100644 index 0000000..d1a067f --- /dev/null +++ b/src/cli/lib/harness/templates/PROMPT.template.md @@ -0,0 +1,75 @@ +# Feature: {{{FEATURE_NAME}}} + + +- **Specs:** `{{{RALPH_DIR}}}/specs/` +- **Plan:** `{{{RALPH_DIR}}}/PLAN.md` +- **Logs:** `{{{RALPH_DIR}}}/logs/` +- **Context training:** `{{{CONTEXT_TRAINING_PATH}}}` + + + +1. **Discover files** - Glob in parallel (single message): + - `{{{CONTEXT_TRAINING_PATH}}}/implementers/*.md` + - `{{{CONTEXT_TRAINING_PATH}}}/verifiers/*.md` + - `{{{RALPH_DIR}}}/specs/*.md` + - `{{{RALPH_DIR}}}/logs/iteration-*.md` +2. **Read all files** - Read in parallel (single message): + - `{{{CONTEXT_TRAINING_PATH}}}/specification.md` (skip if not found) + - `{{{CONTEXT_TRAINING_PATH}}}/implementation.md` (skip if not found) + - All implementer/verifier files from step 1 + - All spec files from step 1 + - `{{{RALPH_DIR}}}/PLAN.md` + - Last 2-3 logs only (save context) +3. **Find task** - First task with unchecked acceptance criteria +4. **Do the work** - Complete that ONE task +5. **Verify criteria** - Ensure ALL acceptance criteria are met +6. **Run CI locally** - Find PR workflows, run same checks, fix failures: + ```bash + grep -l "pull_request" .github/workflows/*.yml .github/workflows/*.yaml 2>/dev/null + ``` +7. **Update PLAN.md** - Check off completed criteria with `[x]` +8. **Write log** - Create iteration log (see format below) +9. **STOP** - Do not continue to next task + + + +- **One task only** - Complete ONE task per iteration, never more +- **CI required** - Task not complete until CI checks pass locally +- **Must stop** - After task completion (or if blocked), STOP immediately +- **Defer scope creep** - Out of scope work? Add new task to PLAN.md, continue current +- **Completion marker** - When ALL tasks done, add `## COMPLETED` to PLAN.md + + + +Create at: `{{{RALPH_DIR}}}/logs/iteration-{NNN}.md` + +```markdown +# Iteration {NNN} + +**Timestamp:** {ISO timestamp} +**Task:** {task-id} - {task-name} +**Status:** COMPLETE | BLOCKED | IN_PROGRESS + +## Changes Made +- `path/to/file.ext` - Created/Modified - [What and why] + +## Approach +[1-2 sentences on approach and key decisions] + +## Verification +- [x] Acceptance criterion 1: {result} +- [x] Acceptance criterion 2: {result} + +## CI Checks +- [x] {check command} - passed/failed + +## Blockers (if any) +- **Type:** [Error type] +- **Description:** [What went wrong] +- **Attempted Solutions:** [What you tried] +- **Recommended Fix:** [What should be done] + +## Next Steps +[What needs to happen next iteration] +``` + diff --git a/src/cli/lib/harness/templates/ascii-art.txt b/src/cli/lib/harness/templates/ascii-art.txt new file mode 100644 index 0000000..af49302 --- /dev/null +++ b/src/cli/lib/harness/templates/ascii-art.txt @@ -0,0 +1,68 @@ +┌─────────────────────────────────────────┐ +│ RALPH WIGGUM LOOP │ +│ "I'm helping!" │ +└─────────────────────────────────────────┘ + + ⢀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀ + ⢀⣠⣤⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣤⣀ + ⢀⣠⣴⣿⢟⣯⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⣿⣿⣿⣿⣹⣿⣿⣿⣿⣿⣿⣿⣿⣶⣤⣀ + ⣠⣶⣿⡿⢫⣴⣿⣿⣷⣿⣿⣿⣿⣿⢿⣿⣿⡿⣭⣽⣿⣿⢹⣿⣿⠘⣿⣷⡝⢿⣿⣿⣿⣿⣿⣿⣿⣦⣄ + ⢀⣴⡾⣻⡿⠫⣾⣿⣻⣿⣿⣿⡟⣹⣿⣿⢻⣿⣷⢿⢛⣼⣿⣿⡧⣼⣿⣿⠆⢹⣿⣿⣨⢿⣿⣏⢻⣿⣿⣿⣿⣿⣷⣦⡀ + ⣰⡿⢫⣾⠟⢨⣾⣿⡿⣳⣿⣿⠿⣽⣿⣾⠇⣿⣿⡿⠸⢂⣿⢻⡇⠂⣿⣿⣿ ⠈⣿⣿⡇⠈⢿⣿⣧⠹⣿⣿⡻⣿⣿⣿⣿⣦⣄ + ⢀⣾⠏⣠⡿⠁⣲⣿⣷⡿⣻⡿⣿⢷⣽⡿⣿⠃⢸⣿⣿⠇ ⠸⣿⣿ ⢆⣿⢿⡿ ⠰⢻⣿⣷ ⠈⣿⣿⣦⣹⣿⣿⠟⣿⣿⣿⣿⡟⢷⡄ + ⢠⡿⠁⣰⡿⠁⣰⣿⣿⠟⢰⣿⣿⠟⣠⣿⣳⡟ ⠘⢿⣿⢆⡀⠐⢠⣌⣐⠂⠐⠐⣂ ⠐⡌⠙⠛ ⠈⢉⡉⠁⠉⠙⠮⠄⢹⣿⣿⣿⡌⠻⣦ + ⢀⣿⠁⢠⣿⣧⢰⣿⣿⡿⣄⣿⣿⡿⠖⣽⣿⡿ ⣨⣷⠿⠛⠛⠛⠛⠻⢷⣯⡀⡡ ⣠⡶⠿⠛⠛⠛⠿⢷⣿⣿⣿⣷ + ⣸⡇ ⣾⠇⠍⣿⣿⣿⣃⣸⣿⣿⡇ ⠉⠙⠛ ⣾⠟ ⠙⣷⡉ ⢀⣾⠋ ⠙⣿⣿⣿⡀ + ⠿ ⢸⣿ ⢀⣿⣿⢏⡠⣿⡟⡿⠁ ⢰⣿ ⣠⣤⣄ ⢸⣷ ⢸⣿ ⣴⣶⡄ ⢹⣿⣿⡇ + ⢀⣼⣯⣿⣬⣍⢻⡆⠐⠛⠛⠃ ⠺⣿⡀ ⠻⠿⠟ ⢸⡟ ⡈⣿⡄ ⠙⠋⠁ ⢀⣾⣿⣿⠁ + ⢠⣾⢋⡡⠸⢍⣛⠿ ⠠ ⠹⣷⣄ ⣠⡿⣱⣨⣤⣤⣤⣄⣈⢻⣦⣄⣀⣀⣀⣀⣤⣾⣿⣿⣿ + ⢸⣿⢱⣾⣿⢷⠧⡀ ⠨⠛⠿⣶⣦⣶⣶⣶⠟⠋⠆⠱⠙⠩⠯⠙⠛⠛⣷⣮⡙⠛⢛⠛⠫⡟⣿⣿⣿⣇ + ⠈⢿⣌⢿⡿⣶⢾⢷⠆ ⠈⠁ ⢈⣶⠿⣉⣀⣀⡂ ⠘⣦⣿⣇⠈⠸⠒⢽⣂⠙⢿⣿⣿⣦ + ⠈⠻⢷⣮⣭⣬⣥⡆ ⣸⣿⡿⠟⢛⠻⣿⣶⣶⣶⣿⣷⣿⣷⠄⢀ ⢻⣿⣿⣷⡄ + ⠈⠹⣿⣿⣦⠁ ⢠⡖⣢⣿⠟⠉⠚⢀⣠⣾⣿⣿⣿⣿⣿⣿⣿⡟ ⠈ ⢿⣿⣿⣿ + ⠙⣿⣿⣷⣄ ⢀⣿⡧⣄⣞⣽⡟⠁ ⠘⢳⣾⣿⣿⣿⣿⣿⣿⣿⣯⡏ ⣀⣸⣿⣿⣿⡿ + ⠈⢻⣿⣿⣦⡀ ⣹⣿⣷⣶⣼⣿⠈⠁ ⠈⣿⣿⠟⠋⢩⣻⣿⣿⣿⣟⣀⣀⣠⣤⣴⣾⣿⣿⣿⣿⠟⠋ + ⣰⡿⠉⠻⣿⣯⣷⠄⣀⡀ ⢹⢿⡭⠅⠉⣿⣇⡇ ⠣⡄⢺⠁⣂⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠛⠉⠁ + ⢰⣿⠁⣼⣿⣾⣟⣿⣿⣦⣽⣀⠄ ⣿⣿ ⢈ ⡛⠋ ⣽⣿⣿⣿⣿⣿⠇ + ⣼⣧⣠⣳⣿⣿⣿⣶⣷⣭⢿⣻⠿⣿⣶⣦⣤⣴⣶⣦⣿⣟⡐⠂ ⢀⣠⣼⣿⣿⣿⣿⣿⣥⣀ + ⢀⣠⣴⣿⣿⣿⣾⡻⣿⣿⣿⣿⣵⣿⣿⣿⣭⣷⣿⢏⣿⡟⠉⣿⣻⡿⣿⣦⣌⡠ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦ + ⣰⡟⣩⣤⡤⣤⢈⠛⢿⣿⣹⢿⣿⣿⣿⣿⣿⣿⣿⢩⣾⠏⢀⣿⣿⣿⣿⣿⣿⣿⣿⣦⣄ ⢠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ + ⢠⣿⣾⣿⣿⣿⣿⣿⣷⣤⠙⠻⢿⣯⣟⠿⣿⣯⡿⣷⡿⢃⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄ + ⢸⣿⢺⣿⣿⣿⣿⣿⣿⣿⣿⣷⣆⣉⠛⠿⢿⣯⣿⢟⢁⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣿⣷⣻⣿⣿⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣦ + ⠈⣿⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢧⣶⠾⢛⣵⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣵⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⡀ + ⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣶⣮⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⡀ + ⣰⡟⢁⢿⣿⣿⣼⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄ + ⣰⡟ ⢼⣮⢿⣿⣿⣿⣾⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣫⣍⣭⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆ + ⢠⡿ ⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣭⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆ + ⣼⡇⢠⢻⣿⣿⣿⣿⣿⢛⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆ + ⣿⠃⢠⣿⣿⣿⣿⣿⣿⣿⣷⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄ +⢀⣿⣦⣬⣿⣿⡿⣿⣿⡿⣿⠟⠿⣿⣿⣏⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷ +⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣍⣛⣳⣿⣿⣿⣿⣿⣿⣿⣶⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ +⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣿⣿⣭⣽⣛⣛⣿⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣴⣿⣿⡿⠟⢟⡻⡯⣍⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣿⣿ +⠈⣿⡿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣾⣿⣿⣿⣯⣭⣭⣭⣭⣿⣯⣭⣭⣭⣭⣭⣯⣽⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ + ⢿⡇ ⠈⠉⠛⠻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣿⣷ + ⢸⣧ ⠉⠉⠛⠛⠻⠿⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⣿⣏⠿⣿⣿ + ⠈⣿⡄ ⠉⠉⠉⠛⠛⠛⠛⠛⠻⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⣿⣿⣿⣿⣿⡿⠿⠿⠛⠋⠉ ⢠⣿⣿⣿⣿⡋ + ⠸⣧ ⣸⡿⠸⣿⣿⣿⣦⡀ + ⠹⣧⡀ ⢠⣿⠃ ⠙⢻⣿⣿⡀ + ⠹⣷⡄ ⢄⣾⢏⣦⣿⣯⣜⣻⣿⡿ + ⠘⣿⣄ ⢀⣾⣿⣖⠙⣿⣿⡏⠋⠉ + ⠈⢿⡄ ⢠⣾⣿⣿⡏⡀⣿⣿⠇ + ⢸⣷ ⢀⣴⣿⡯⣿⣿⣧⣼⣿⠟ + ⠈⣿ ⣶⠟⠁⣿⡿⠿⠋⠉⠉⠁ + ⣿ ⣿ + ⣿ ⣀⡀ ⣀⣤⡄ ⣿ + ⢠⣿ ⣰⣿⣁⣀⣀⣀⣀⣀⣀⣤⣤⣴⡶⠿⠛⠋⠁ ⣿⡀ + ⢸⣿ ⢠⣿⠋⠛⠛⠛⠛⠛⠛⣿⡉⠁ ⣿⡇ + ⢸⡿ ⢸⡿ ⣿⡇ ⢿⡇ + ⣾⡇ ⢸⡇ ⣿⡇ ⢸⡇ + ⢀⣿⠃ ⢸⣧ ⢀⣿ ⠈⣿ + ⢸⡟ ⢿⡆ ⣸⡇ ⢻⣇ + ⣿⠃ ⢸⣷ ⠸⣧⣄⣀ ⣀⣀⣤⣶⣾⣯⣀⣀⡀ + ⠙⣿⣶⣤⣀⡀ ⣀⣠⣴⣾⣟⡀ ⣼⣿⣿⣿⣿⣿⣶⣶⣶⣶⣶⣶⣶⣶⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣤⣀ + ⢀⣾⣿⣿⣿⣿⣿⣿⣷⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄ + ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀ + ⠈⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃ + ⠈⠉⠛⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣎⠉⠉⠙⠛⠛⠻⠿⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠟⠋⠁ + ⠉⠙⠛⠻⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟ + ⠉⠉⠙⠛⠛⠛⠻⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠟⠋ diff --git a/src/cli/lib/harness/templates/create-specs-prompt.md b/src/cli/lib/harness/templates/create-specs-prompt.md new file mode 100644 index 0000000..7d17852 --- /dev/null +++ b/src/cli/lib/harness/templates/create-specs-prompt.md @@ -0,0 +1,108 @@ +# Create Specifications for: {{{FEATURE_NAME}}} + +## Feature Description +{{{DESCRIPTION}}} + +## Your Task +You are tasked with creating detailed specifications for this feature. Follow these steps: + +### 1. Clarify Requirements +Before investigating the codebase, **use the AskUserQuestion tool** to resolve any ambiguity about the feature: +- Ask about unclear requirements or edge cases +- Confirm assumptions about scope and behavior +- Clarify integration points or dependencies +- Understand priority of different aspects + +Example questions to consider: +- "Should this feature support [X] or [Y] behavior?" +- "What should happen when [edge case] occurs?" +- "Should this integrate with [existing system]?" + +**Important**: Ask all your clarifying questions upfront before diving into implementation details. Group related questions together to minimize back-and-forth. + +### 2. Investigate the Codebase +After clarifying requirements: +- Explore the existing codebase structure +- Identify relevant files, patterns, and conventions +- Understand how similar features are implemented +- Note any dependencies or constraints + +### 3. Write Specification Files +Create specification files in: `{{{SPECS_PATH}}}/` + +Each spec file should include these sections: + +#### **Overview** +- What this feature/component is +- High-level description of its purpose +- How it fits into the larger system + +#### **Requirements** +- Detailed functional requirements +- Input/output specifications +- Edge cases to handle +- Performance requirements (if applicable) + +#### **Acceptance Criteria** +Write acceptance criteria that are **verifiable and complete**. Each AC should include both the action AND its verification. + +**BAD examples (incomplete):** +- [ ] Write unit tests +- [ ] Add error handling +- [ ] Create component + +**GOOD examples (verifiable):** +- [ ] Unit tests written AND passing (`bun test` exits 0) +- [ ] Error handling implemented AND tested (invalid input returns 400) +- [ ] Component renders correctly AND matches design spec + +Each criterion must answer: "How do I VERIFY this is done?" +- Include the success condition, not just the task +- Reference specific commands, outputs, or behaviors +- Cover both happy path and error cases + +### 4. File Organization +Number spec files for clear ordering (00-, 01-, etc.): +- `{{{SPECS_PATH}}}/00-overview.md` - High-level feature overview +- `{{{SPECS_PATH}}}/01-[component-name].md` - Detailed specs for each component +- `{{{SPECS_PATH}}}/02-[another-component].md` - Additional components as needed +- `{{{SPECS_PATH}}}/NN-integration.md` - How components work together (last file) + +### 5. Update PLAN.md +After creating specs, update `devorch/harness/{{{FEATURE_NAME}}}/PLAN.md` with tasks extracted from the specs. + +Each task should have: +```markdown +### Task N: [Task Name] +- [ ] AC 1: [Action] AND [Verification] (e.g., "Tests written AND passing") +- [ ] AC 2: [Action] AND [Verification] +``` + +**Important:** Copy the acceptance criteria from specs into PLAN.md tasks. The loop uses PLAN.md to track progress. + +### Important Guidelines +- Be specific and actionable +- Reference existing code patterns where relevant +- Include code examples where helpful +- Consider error handling and edge cases +- Keep acceptance criteria atomic and testable + +--- + +## When You're Done + +After creating all specification files, provide a brief summary of: +1. What spec files were created +2. Key decisions made based on user input +3. Any remaining open questions or considerations + +Then instruct the user: +> **Specs complete!** Please type `/exit` or press `Ctrl+C` to close this session. +> +> Next steps: +> 1. Review the specs in `{{{SPECS_PATH}}}/` +> 2. Run `devorch harness loop {{{FEATURE_NAME}}}` to start implementation + +--- + +Begin by asking clarifying questions about the feature requirements. diff --git a/src/cli/lib/harness/templates/iteration-report.template.md b/src/cli/lib/harness/templates/iteration-report.template.md new file mode 100644 index 0000000..eb0dd82 --- /dev/null +++ b/src/cli/lib/harness/templates/iteration-report.template.md @@ -0,0 +1,24 @@ +# Iteration {{ITERATION_NUMBER}} + +**Timestamp:** {{TIMESTAMP}} +**Task:** {{TASK_ID}} - {{TASK_NAME}} +**Status:** {{STATUS}} + +## Changes Made +- `path/to/file.ext` - Created/Modified - [What and why] + +## Approach +[1-2 sentences: How you approached this task and key decisions] + +## Verification +- [ ] Acceptance criterion 1: {result} +- [ ] Acceptance criterion 2: {result} + +## Blockers (if any) +- **Type:** [Error type] +- **Description:** [What went wrong] +- **Attempted Solutions:** [What you tried] +- **Recommended Fix:** [What should be done] + +## Next Steps +[What needs to happen in the next iteration] diff --git a/src/cli/lib/harness/utils.test.ts b/src/cli/lib/harness/utils.test.ts new file mode 100644 index 0000000..f330c1e --- /dev/null +++ b/src/cli/lib/harness/utils.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'bun:test'; +import { createProgressBar, getStatusIcon, sleep } from '@/cli/lib/harness/utils.js'; + +describe('lib/harness/utils', () => { + describe('sleep', () => { + it('should sleep for approximately the specified time', async () => { + const start = Date.now(); + await sleep(50); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(45); + expect(elapsed).toBeLessThan(100); + }); + }); + + describe('createProgressBar', () => { + it('should create a progress bar at 0%', () => { + const bar = createProgressBar(0, 10); + expect(bar).toBe('[----------]'); + }); + + it('should create a progress bar at 50%', () => { + const bar = createProgressBar(50, 10); + expect(bar).toBe('[#####-----]'); + }); + + it('should create a progress bar at 100%', () => { + const bar = createProgressBar(100, 10); + expect(bar).toBe('[##########]'); + }); + + it('should handle custom widths', () => { + const bar = createProgressBar(25, 20); + expect(bar).toBe('[#####---------------]'); + }); + + it('should round percentages correctly', () => { + const bar = createProgressBar(33, 10); + expect(bar).toBe('[###-------]'); + }); + }); + + describe('getStatusIcon', () => { + it('should return [DONE] for completed status', () => { + expect(getStatusIcon('completed')).toBe('[DONE]'); + }); + + it('should return [....] for in_progress status', () => { + expect(getStatusIcon('in_progress')).toBe('[....]'); + }); + + it('should return [ ] for not_started status', () => { + expect(getStatusIcon('not_started')).toBe('[ ]'); + }); + }); +}); diff --git a/src/cli/lib/harness/utils.ts b/src/cli/lib/harness/utils.ts new file mode 100644 index 0000000..653d6df --- /dev/null +++ b/src/cli/lib/harness/utils.ts @@ -0,0 +1,35 @@ +/** + * General utilities for Harness commands + */ + +import type { FeatureStatus } from './plan.js'; + +/** + * Sleep for the given number of milliseconds + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Create an ASCII progress bar + */ +export function createProgressBar(percentage: number, width: number): string { + const filled = Math.round((percentage / 100) * width); + const empty = width - filled; + return `[${'#'.repeat(filled)}${'-'.repeat(empty)}]`; +} + +/** + * Get a status icon for a feature status + */ +export function getStatusIcon(status: FeatureStatus): string { + switch (status) { + case 'completed': + return '[DONE]'; + case 'in_progress': + return '[....]'; + case 'not_started': + return '[ ]'; + } +} diff --git a/templates/skills/workflow/harness/SKILL.md b/templates/skills/workflow/harness/SKILL.md new file mode 100644 index 0000000..f135401 --- /dev/null +++ b/templates/skills/workflow/harness/SKILL.md @@ -0,0 +1,295 @@ +--- +name: workflow:harness +description: "WHAT: Ralph Wiggum external harness pattern for autonomous Claude Code loops with spec-based progress tracking. WHEN: implementing multi-step features, refactoring with clear criteria, autonomous development. KEYWORDS: harness, loop, autonomous, Ralph Wiggum, specs, PLAN.md, iterations, acceptance criteria, external bash loop, devorch harness." +--- + +# Harness Loop (Ralph Wiggum Technique) + +**Run Claude Code in an external bash loop for autonomous, iterative development.** + +The Harness Loop implements the [Ralph Wiggum technique](https://ghuntley.com/ralph/) for fully autonomous development by running Claude Code in an external bash loop. Each iteration, Claude reads specs, works on tasks, logs progress, and continues until all acceptance criteria are met. + +## Overview + +**Key benefits:** +- **Autonomous execution:** Claude works through tasks without manual intervention +- **Progress tracking:** Acceptance criteria checkboxes track completion +- **Iteration logs:** Every iteration creates a detailed report +- **Graceful completion:** Loop stops when "## COMPLETED" marker is added to PLAN.md + +## Quick Start + +```bash +# 1. Initialize a feature folder +devorch harness init my-feature + +# 2. Generate specs (Claude investigates and writes detailed specs) +devorch harness create-specs my-feature + +# 3. Review and edit PLAN.md to add tasks + +# 4. Start the autonomous loop +devorch harness loop my-feature +``` + +## Available Commands + +### `devorch harness init ` + +Creates the feature folder structure with templates. + +```bash +devorch harness init auth-system +``` + +**Creates:** +``` +devorch/harness/auth-system/ +├── PROMPT.md # Prompt sent to Claude each iteration +├── PLAN.md # Tasks with acceptance criteria +├── specs/ # For specification files +└── logs/ # Iteration reports +``` + +### `devorch harness create-specs ` + +Boots Claude interactively to investigate the codebase and write detailed specifications. + +```bash +devorch harness create-specs auth-system + +# Or run in headless mode (no user interaction) +devorch harness create-specs auth-system --headless +``` + +Claude will: +1. Ask clarifying questions about the feature (interactive mode) +2. Explore the existing codebase structure +3. Identify relevant patterns and conventions +4. Write specification files in `specs/` +5. Update PLAN.md with tasks + +### `devorch harness loop ` + +Runs Claude in an external bash loop until completion. + +```bash +# Basic usage +devorch harness loop auth-system + +# With iteration limit +devorch harness loop auth-system --max-iterations 20 + +# Show all message types (tool calls, etc.) +devorch harness loop auth-system --verbose +``` + +**Options:** +| Flag | Description | +|------|-------------| +| `--max-iterations N` | Stop after N iterations (default: 20) | +| `--verbose` | Show all message types instead of just assistant/result | + +**Loop behavior:** +1. Reads PROMPT.md and sends to Claude +2. Claude works on the first incomplete task +3. Claude creates iteration log in `logs/` +4. Claude updates PLAN.md with progress +5. Loop checks for "## COMPLETED" marker +6. Repeats until complete or max iterations reached + +**Stopping the loop:** +- Press `Ctrl+C` to stop gracefully after current iteration +- Add `## COMPLETED` to PLAN.md to signal completion + +### `devorch harness list` + +Lists all existing features with their status. + +```bash +devorch harness list +``` + +**Output:** +``` +Found 3 feature(s): + + [DONE] auth-system + 3 specs, 12 iterations + + [....] payment-flow + 2 specs, 5 iterations + + [ ] new-feature + no specs, no iterations +``` + +### `devorch harness status ` + +Shows detailed progress for a feature. + +```bash +devorch harness status auth-system +``` + +## Workflow + +### 1. Initialize Feature + +```bash +devorch harness init my-feature +``` + +This creates the folder structure and template files. + +### 2. Create Specifications + +```bash +devorch harness create-specs my-feature +``` + +When prompted, describe what the feature should do. Claude will investigate your codebase and create detailed specs in the `specs/` folder. + +### 3. Define Tasks in PLAN.md + +Edit `PLAN.md` to define tasks with acceptance criteria: + +```markdown +# Plan: my-feature + +## Status: IN_PROGRESS + +## Tasks + +### Task 1: Create database schema +**Acceptance Criteria:** +- [ ] Add users table with email, password_hash columns +- [ ] Add sessions table with user_id, token, expires_at +- [ ] Create migration file + +### Task 2: Implement authentication endpoints +**Acceptance Criteria:** +- [ ] POST /auth/register - create new user +- [ ] POST /auth/login - authenticate and return token +- [ ] POST /auth/logout - invalidate session +- [ ] Add input validation for all endpoints +``` + +### 4. Run the Loop + +```bash +devorch harness loop my-feature +``` + +Claude will: +1. Read specs and PLAN.md +2. Find the first task with unchecked criteria +3. Work on that task only +4. Create an iteration log +5. Update PLAN.md checkboxes +6. Repeat until all tasks complete + +### 5. Monitor Progress + +Check progress anytime: + +```bash +devorch harness status my-feature +``` + +Or review iteration logs in `devorch/harness/my-feature/logs/`. + +## File Formats + +### PROMPT.md + +The prompt sent to Claude each iteration. Created at `init` time with context training path baked in. + +### PLAN.md + +Tracks tasks and progress: + +```markdown +# Plan: {{FEATURE_NAME}} + +## Status: IN_PROGRESS + +## Tasks + +### Task 1: [Task Name] +**Acceptance Criteria:** +- [ ] Criterion 1 +- [ ] Criterion 2 + +### Task 2: [Task Name] +**Acceptance Criteria:** +- [ ] Criterion 1 +- [ ] Criterion 2 +``` + +### Iteration Logs + +Created in `logs/iteration-NNN.md`: + +```markdown +# Iteration 005 + +**Timestamp:** 2024-01-15T14:30:00Z +**Task:** Task 2 - Implement authentication endpoints +**Status:** COMPLETE + +## Changes Made +- `src/routes/auth.ts` - Created - Added login/register/logout endpoints +- `src/middleware/auth.ts` - Created - Token verification middleware + +## Approach +Implemented JWT-based authentication following existing patterns in the codebase. + +## Verification +- [x] POST /auth/register - create new user: Works, tested with curl +- [x] POST /auth/login - authenticate and return token: Returns valid JWT +- [x] POST /auth/logout - invalidate session: Removes session from DB + +## Next Steps +Move to Task 3: Add middleware to protected routes +``` + +## Best Practices + +### Writing Good Acceptance Criteria + +- **Be specific:** "Add email validation" → "Validate email format using regex, return 400 for invalid" +- **Be testable:** Each criterion should be verifiable +- **Be atomic:** One thing per criterion +- **Include edge cases:** "Handle duplicate email registration with 409 response" + +### Organizing Specs + +Number spec files for clear ordering (00-, 01-, etc.): + +``` +specs/ +├── 00-overview.md # High-level feature description +├── 01-data-model.md # Database schema, types +├── 02-api-endpoints.md # REST API specifications +├── 03-business-logic.md # Core logic requirements +└── 99-integration.md # How components connect (last) +``` + +### When to Use Harness Loop + +**Good fit:** +- Multi-step feature implementation +- Refactoring with clear acceptance criteria +- Bug fixes requiring multiple changes +- Adding tests to existing code + +**Not ideal for:** +- Exploratory work without clear specs +- One-off quick fixes +- Tasks requiring human judgment at each step + +## Learn More + +- **Full Documentation:** [docs/user-guide/harness-loop.md](../../docs/user-guide/harness-loop.md) +- **Ralph Wiggum Technique:** [https://ghuntley.com/ralph/](https://ghuntley.com/ralph/)