Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/assets/harness-flow.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/ralph.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
406 changes: 406 additions & 0 deletions docs/user-guide/harness-loop.md

Large diffs are not rendered by default.

170 changes: 170 additions & 0 deletions src/cli/commands/harness/create-specs.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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');
}
}
104 changes: 104 additions & 0 deletions src/cli/commands/harness/index.ts
Original file line number Diff line number Diff line change
@@ -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 <feature>
* devorch harness loop <feature> [--max-iterations N] [--verbose]
* devorch harness create-specs <feature>
* devorch harness list
* devorch harness status <feature>
*/
export async function harnessCommand(options: HarnessOptions = {}): Promise<void> {
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 <feature> Initialize a new feature folder structure');
console.log(' loop <feature> Run the external bash loop');
console.log(' create-specs <feature> Boot Claude to write detailed specs');
console.log(' list List existing features');
console.log(' status <feature> 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');
}
107 changes: 107 additions & 0 deletions src/cli/commands/harness/init.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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');
}
Loading
Loading