From a500e569a4135934a21f3db60d1b27b99aaa7366 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Thu, 5 Feb 2026 05:20:02 +0000 Subject: [PATCH 01/35] feat: Show rate limit stats and reset time when limit reached Closes github#80 Generated by ralph-starter auto mode --- IMPLEMENTATION_PLAN.md | 4 +- README.md | 27 +++ docs/docs/advanced/rate-limits.md | 156 ++++++++++++++ docs/sidebars.ts | 1 + src/cli.ts | 12 ++ src/commands/pause.ts | 82 +++++++ src/loop/executor.ts | 44 +++- src/loop/session.ts | 346 ++++++++++++++++++++++++++++++ src/utils/rate-limit-display.ts | 283 ++++++++++++++++++++++++ 9 files changed, 945 insertions(+), 10 deletions(-) create mode 100644 docs/docs/advanced/rate-limits.md create mode 100644 src/commands/pause.ts create mode 100644 src/loop/session.ts create mode 100644 src/utils/rate-limit-display.ts diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9d248d17..b10296ba 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -57,8 +57,8 @@ ## In Progress ### Session Management -- [ ] Create `src/loop/session.ts` for pause/resume support -- [ ] Add `ralph-starter pause` command +- [x] Create `src/loop/session.ts` for pause/resume support +- [x] Add `ralph-starter pause` command - [ ] Add `ralph-starter resume` command - [ ] Store session state in `.ralph-session.json` diff --git a/README.md b/README.md index efd3c78b..0904f410 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,33 @@ Control API call frequency to manage costs: ralph-starter run --rate-limit 50 "build X" ``` +**When rate limits are reached**, ralph-starter displays detailed stats: + +``` +⚠ Claude rate limit reached + +Rate Limit Stats: + • Session usage: 100% (50K / 50K tokens) + • Requests made: 127 this hour + • Time until reset: ~47 minutes (resets at 04:30 UTC) + +Session Progress: + • Tasks completed: 3/5 + • Current task: "Add swarm mode CLI flags" + • Branch: auto/github-54 + • Iterations completed: 12 + +To resume when limit resets: + ralph-starter run + +Tip: Check your limits at https://claude.ai/settings +``` + +This helps you: +- Know exactly when you can resume +- Track progress on your current session +- Understand your usage patterns + ### Cost Tracking Track estimated token usage and costs during loops: diff --git a/docs/docs/advanced/rate-limits.md b/docs/docs/advanced/rate-limits.md new file mode 100644 index 00000000..4529bc5f --- /dev/null +++ b/docs/docs/advanced/rate-limits.md @@ -0,0 +1,156 @@ +--- +sidebar_position: 4 +title: Rate Limits +description: Understanding and handling API rate limits +keywords: [rate limits, throttling, API limits, tokens, reset time] +--- + +# Rate Limits + +ralph-starter helps you manage API rate limits when running autonomous coding loops. This guide explains how rate limiting works and how to handle it effectively. + +## Built-in Rate Limiter + +Control the frequency of API calls with the `--rate-limit` flag: + +```bash +# Limit to 50 API calls per hour +ralph-starter run --rate-limit 50 "build X" + +# Limit to 100 calls per hour (default if set) +ralph-starter run --rate-limit 100 "implement feature" +``` + +The rate limiter: +- Tracks calls per minute and per hour +- Automatically waits when limits are approached +- Shows countdown timer during wait periods +- Warns at 80% capacity + +## When Rate Limits Are Reached + +When Claude or another AI agent hits a rate limit, ralph-starter displays detailed information: + +``` +⚠ Claude rate limit reached + +Rate Limit Stats: + • Session usage: 100% (50K / 50K tokens) + • Requests made: 127 this hour + • Time until reset: ~47 minutes (resets at 04:30 UTC) + +Session Progress: + • Tasks completed: 3/5 + • Current task: "Add swarm mode CLI flags" + • Branch: auto/github-54 + • Iterations completed: 12 + +To resume when limit resets: + ralph-starter run + +Tip: Check your limits at https://claude.ai/settings +``` + +### What's Displayed + +| Field | Description | +|-------|-------------| +| **Session usage** | Percentage of token quota used | +| **Requests made** | Number of API calls this hour | +| **Time until reset** | Estimated time when you can resume | +| **Tasks completed** | Progress through your implementation plan | +| **Current task** | What was being worked on when limited | +| **Branch** | Git branch for context | +| **Iterations** | Number of loop iterations completed | + +## Handling Rate Limits + +### 1. Wait and Resume + +The simplest approach is to wait for the reset time shown: + +```bash +# Wait for the indicated time, then run again +ralph-starter run +``` + +### 2. Use Lower Rate Limits + +Prevent hitting limits by setting a conservative rate: + +```bash +# Use a lower rate to stay under limits +ralph-starter run --rate-limit 30 "build feature" +``` + +### 3. Check Your Limits + +Different Claude plans have different limits: +- **Free tier**: Limited requests per day +- **Pro**: Higher limits, faster resets +- **Team/Enterprise**: Custom limits + +Check your current usage at [claude.ai/settings](https://claude.ai/settings). + +## Rate Limit Detection + +ralph-starter detects rate limits through: + +1. **Output analysis**: Parsing agent output for rate limit messages +2. **API headers**: Extracting `x-ratelimit-*` headers when available +3. **Error patterns**: Recognizing common rate limit error messages + +Detected patterns include: +- "rate limit" or "usage limit" messages +- "100%" usage indicators +- "exceeded" or "too many requests" errors +- HTTP 429 responses + +## Best Practices + +### For Long Tasks + +```bash +# Use rate limiting for multi-hour tasks +ralph-starter run --rate-limit 40 --max-iterations 20 "refactor auth system" +``` + +### For Batch Processing + +```bash +# Lower rate for batch processing multiple issues +ralph-starter auto --source github --project owner/repo --limit 5 +``` + +### For Cost Control + +Combine rate limiting with cost tracking: + +```bash +ralph-starter run --rate-limit 50 --track-cost "build feature" +``` + +## Troubleshooting + +### "Rate limit reached" immediately + +- You may have hit limits in a previous session +- Wait for the displayed reset time +- Check [claude.ai/settings](https://claude.ai/settings) for current usage + +### Loop stops unexpectedly + +Rate limits from the AI agent (like Claude Code) are separate from ralph-starter's built-in rate limiter. Both can cause stops: + +- **Built-in rate limiter**: Shows waiting countdown, then continues +- **AI agent rate limit**: Shows detailed stats and stops + +### Inconsistent reset times + +Reset times are estimated based on available information. If the AI agent doesn't provide exact headers, ralph-starter estimates based on typical reset windows (usually hourly). + +## Related Features + +- [Cost Tracking](/docs/cli/run#cost-tracking) - Monitor token usage and costs +- [Validation](/docs/advanced/validation) - Ensure quality while managing rate limits +- [Circuit Breaker](/docs/cli/run#circuit-breaker) - Stop loops that are stuck diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 1448bce1..c15a5c96 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -63,6 +63,7 @@ const sidebars: SidebarsConfig = { 'advanced/ralph-playbook', 'advanced/validation', 'advanced/git-automation', + 'advanced/rate-limits', ], }, { diff --git a/src/cli.ts b/src/cli.ts index b6508314..11e333ac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { checkCommand } from './commands/check.js'; import { configCommand } from './commands/config.js'; import { initCommand } from './commands/init.js'; import { integrationsCommand } from './commands/integrations.js'; +import { pauseCommand } from './commands/pause.js'; import { planCommand } from './commands/plan.js'; import { runCommand } from './commands/run.js'; import { setupCommand } from './commands/setup.js'; @@ -246,6 +247,17 @@ program }); }); +// ralph-starter pause - Pause a running session +program + .command('pause') + .description('Pause a running session for later resumption') + .option('--reason ', 'Reason for pausing the session') + .action(async (options) => { + await pauseCommand({ + reason: options.reason, + }); + }); + // ralph-starter presets - List available workflow presets program .command('presets') diff --git a/src/commands/pause.ts b/src/commands/pause.ts new file mode 100644 index 00000000..b82e9d03 --- /dev/null +++ b/src/commands/pause.ts @@ -0,0 +1,82 @@ +/** + * Pause command for ralph-starter + * Pauses a running session for later resumption + */ + +import chalk from 'chalk'; +import { + formatSessionSummary, + hasActiveSession, + loadSession, + pauseSession, +} from '../loop/session.js'; + +export interface PauseCommandOptions { + reason?: string; +} + +/** + * Run the pause command + */ +export async function pauseCommand(options: PauseCommandOptions): Promise { + const cwd = process.cwd(); + + console.log(); + + // Check if there's an active session + const hasSession = await hasActiveSession(cwd); + if (!hasSession) { + console.log(chalk.yellow(' No active session found in this directory.')); + console.log(); + console.log(chalk.dim(' Sessions are created when you run:')); + console.log(chalk.dim(' ralph-starter run [task]')); + console.log(); + process.exit(1); + } + + // Load the session to check its status + const session = await loadSession(cwd); + if (!session) { + console.log(chalk.red(' Failed to load session data.')); + process.exit(1); + } + + if (session.status === 'paused') { + console.log(chalk.yellow(' Session is already paused.')); + console.log(); + console.log(formatSessionSummary(session)); + console.log(); + console.log(chalk.dim(' To resume, run:')); + console.log(chalk.dim(' ralph-starter resume')); + console.log(); + process.exit(0); + } + + if (session.status !== 'running') { + console.log(chalk.yellow(` Session is not running (status: ${session.status}).`)); + console.log(); + console.log(chalk.dim(' Only running sessions can be paused.')); + console.log(); + process.exit(1); + } + + // Pause the session + const pausedSession = await pauseSession(cwd, options.reason); + if (!pausedSession) { + console.log(chalk.red(' Failed to pause session.')); + process.exit(1); + } + + console.log(chalk.green(' ✓ Session paused successfully')); + console.log(); + console.log(formatSessionSummary(pausedSession)); + console.log(); + console.log(chalk.bold(' To resume later, run:')); + console.log(chalk.cyan(' ralph-starter resume')); + console.log(); + + if (options.reason) { + console.log(chalk.dim(` Pause reason: ${options.reason}`)); + console.log(); + } +} diff --git a/src/loop/executor.ts b/src/loop/executor.ts index ba843791..ccea6de1 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -2,8 +2,20 @@ import { readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import chalk from 'chalk'; import ora from 'ora'; -import { createPullRequest, gitCommit, gitPush, hasUncommittedChanges } from '../automation/git.js'; +import { + createPullRequest, + getCurrentBranch, + gitCommit, + gitPush, + hasUncommittedChanges, +} from '../automation/git.js'; import { ProgressRenderer } from '../ui/progress-renderer.js'; +import { + displayRateLimitStats, + parseRateLimitFromOutput, + type RateLimitInfo, + type SessionContext, +} from '../utils/rate-limit-display.js'; import { type Agent, type AgentRunOptions, runAgent } from './agents.js'; import { CircuitBreaker, type CircuitBreakerConfig } from './circuit-breaker.js'; import { CostTracker, type CostTrackerStats, formatCost } from './cost-tracker.js'; @@ -643,13 +655,29 @@ Complete these subtasks, then mark them done in IMPLEMENTATION_PLAN.md by changi console.log(); if (isRateLimit) { - console.log(chalk.red.bold(' ⚠ Claude rate limit reached')); - console.log(); - console.log(chalk.yellow(' Your Claude session usage is at 100%.')); - console.log(chalk.yellow(' Wait for your rate limit to reset, then run again:')); - console.log(chalk.dim(' ralph-starter run')); - console.log(); - console.log(chalk.dim(' Tip: Check your limits at https://claude.ai/settings')); + // Parse rate limit info from output + const rateLimitInfo = parseRateLimitFromOutput(result.output); + + // Build session context for display + const taskCount = parsePlanTasks(options.cwd); + let currentBranch: string | undefined; + try { + currentBranch = await getCurrentBranch(options.cwd); + } catch { + // Ignore branch detection errors + } + const currentTask = getCurrentTask(options.cwd); + + const sessionContext: SessionContext = { + tasksCompleted: taskCount.completed, + totalTasks: taskCount.total, + currentTask: currentTask?.name, + branch: currentBranch, + iterations: i, + }; + + // Display detailed rate limit stats + displayRateLimitStats(rateLimitInfo, taskCount.total > 0 ? sessionContext : undefined); } else if (isPermission) { console.log(chalk.red.bold(' ⚠ Permission denied')); console.log(); diff --git a/src/loop/session.ts b/src/loop/session.ts new file mode 100644 index 00000000..be8429a6 --- /dev/null +++ b/src/loop/session.ts @@ -0,0 +1,346 @@ +/** + * Session Management for pause/resume support + * Allows saving and restoring loop state across CLI invocations + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { Agent } from './agents.js'; +import type { CircuitBreakerConfig } from './circuit-breaker.js'; +import type { CostTrackerStats } from './cost-tracker.js'; + +const SESSION_FILE = '.ralph-session.json'; + +/** + * Session state that can be serialized and restored + */ +export interface SessionState { + /** Unique session identifier */ + id: string; + /** Session creation timestamp */ + createdAt: string; + /** Last update timestamp */ + updatedAt: string; + /** Session status */ + status: 'running' | 'paused' | 'completed' | 'failed'; + /** Current iteration number */ + iteration: number; + /** Maximum iterations allowed */ + maxIterations: number; + /** The original task description */ + task: string; + /** Working directory */ + cwd: string; + /** Agent being used */ + agent: { + name: string; + command: string; + }; + /** Options that were passed to the loop */ + options: { + auto?: boolean; + commit?: boolean; + push?: boolean; + pr?: boolean; + prTitle?: string; + validate?: boolean; + completionPromise?: string; + requireExitSignal?: boolean; + minCompletionIndicators?: number; + circuitBreaker?: Partial; + rateLimit?: number; + trackProgress?: boolean; + checkFileCompletion?: boolean; + trackCost?: boolean; + model?: string; + }; + /** Commits made so far */ + commits: string[]; + /** Accumulated statistics */ + stats: { + totalDuration: number; + validationFailures: number; + costStats?: CostTrackerStats; + }; + /** Reason for pausing (if paused) */ + pauseReason?: string; + /** Error message (if failed) */ + error?: string; + /** Exit reason */ + exitReason?: + | 'completed' + | 'blocked' + | 'max_iterations' + | 'circuit_breaker' + | 'rate_limit' + | 'file_signal' + | 'paused'; +} + +/** + * Generate a unique session ID + */ +function generateSessionId(): string { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + return `ralph-${timestamp}-${random}`; +} + +/** + * Get the session file path for a directory + */ +export function getSessionPath(cwd: string): string { + return path.join(cwd, SESSION_FILE); +} + +/** + * Check if an active session exists + */ +export async function hasActiveSession(cwd: string): Promise { + const sessionPath = getSessionPath(cwd); + try { + await fs.access(sessionPath); + const session = await loadSession(cwd); + return session !== null && (session.status === 'running' || session.status === 'paused'); + } catch { + return false; + } +} + +/** + * Load an existing session from disk + */ +export async function loadSession(cwd: string): Promise { + const sessionPath = getSessionPath(cwd); + try { + const content = await fs.readFile(sessionPath, 'utf-8'); + const session = JSON.parse(content) as SessionState; + + // Validate the session has required fields + if (!session.id || !session.task || !session.agent) { + return null; + } + + return session; + } catch { + return null; + } +} + +/** + * Save session state to disk + */ +export async function saveSession(session: SessionState): Promise { + const sessionPath = getSessionPath(session.cwd); + const content = JSON.stringify(session, null, 2); + await fs.writeFile(sessionPath, content, 'utf-8'); +} + +/** + * Create a new session + */ +export function createSession( + cwd: string, + task: string, + agent: Agent, + options: SessionState['options'] = {}, + maxIterations: number = 50 +): SessionState { + const now = new Date().toISOString(); + return { + id: generateSessionId(), + createdAt: now, + updatedAt: now, + status: 'running', + iteration: 0, + maxIterations, + task, + cwd, + agent: { + name: agent.name, + command: agent.command, + }, + options, + commits: [], + stats: { + totalDuration: 0, + validationFailures: 0, + }, + }; +} + +/** + * Update session after an iteration + */ +export async function updateSessionIteration( + cwd: string, + iteration: number, + duration: number, + commits: string[], + costStats?: CostTrackerStats +): Promise { + const session = await loadSession(cwd); + if (!session) return null; + + session.updatedAt = new Date().toISOString(); + session.iteration = iteration; + session.commits = commits; + session.stats.totalDuration += duration; + if (costStats) { + session.stats.costStats = costStats; + } + + await saveSession(session); + return session; +} + +/** + * Pause the current session + */ +export async function pauseSession(cwd: string, reason?: string): Promise { + const session = await loadSession(cwd); + if (!session) return null; + + session.updatedAt = new Date().toISOString(); + session.status = 'paused'; + session.exitReason = 'paused'; + session.pauseReason = reason; + + await saveSession(session); + return session; +} + +/** + * Resume a paused session + */ +export async function resumeSession(cwd: string): Promise { + const session = await loadSession(cwd); + if (!session) return null; + + if (session.status !== 'paused') { + return null; // Can only resume paused sessions + } + + session.updatedAt = new Date().toISOString(); + session.status = 'running'; + session.exitReason = undefined; + session.pauseReason = undefined; + + await saveSession(session); + return session; +} + +/** + * Mark session as completed + */ +export async function completeSession( + cwd: string, + exitReason: SessionState['exitReason'], + error?: string +): Promise { + const session = await loadSession(cwd); + if (!session) return null; + + session.updatedAt = new Date().toISOString(); + session.status = + exitReason === 'completed' || exitReason === 'file_signal' ? 'completed' : 'failed'; + session.exitReason = exitReason; + session.error = error; + + await saveSession(session); + return session; +} + +/** + * Delete the session file + */ +export async function deleteSession(cwd: string): Promise { + const sessionPath = getSessionPath(cwd); + try { + await fs.unlink(sessionPath); + return true; + } catch { + return false; + } +} + +/** + * Get a summary of the session for display + */ +export function formatSessionSummary(session: SessionState): string { + const lines: string[] = []; + + lines.push(`Session: ${session.id}`); + lines.push(`Status: ${session.status}`); + lines.push(`Task: ${session.task.slice(0, 60)}${session.task.length > 60 ? '...' : ''}`); + lines.push(`Progress: ${session.iteration}/${session.maxIterations} iterations`); + lines.push(`Agent: ${session.agent.name}`); + + if (session.commits.length > 0) { + lines.push(`Commits: ${session.commits.length}`); + } + + const duration = session.stats.totalDuration; + if (duration > 0) { + const minutes = Math.floor(duration / 60000); + const seconds = Math.floor((duration % 60000) / 1000); + lines.push(`Duration: ${minutes}m ${seconds}s`); + } + + if (session.stats.costStats) { + const cost = session.stats.costStats.totalCost.totalCost; + lines.push(`Cost: $${cost.toFixed(3)}`); + } + + if (session.pauseReason) { + lines.push(`Pause reason: ${session.pauseReason}`); + } + + if (session.error) { + lines.push(`Error: ${session.error}`); + } + + return lines.join('\n'); +} + +/** + * Check if session can be resumed + */ +export function canResume(session: SessionState): boolean { + return session.status === 'paused'; +} + +/** + * Check if session is still active (running or paused) + */ +export function isActiveSession(session: SessionState): boolean { + return session.status === 'running' || session.status === 'paused'; +} + +/** + * Calculate remaining iterations + */ +export function getRemainingIterations(session: SessionState): number { + return Math.max(0, session.maxIterations - session.iteration); +} + +/** + * Reconstruct agent object from session data + */ +export function reconstructAgent(session: SessionState): Agent { + // Determine agent type from command + const typeMap: Record = { + claude: 'claude-code', + cursor: 'cursor', + codex: 'codex', + opencode: 'opencode', + }; + const agentType = typeMap[session.agent.command] || 'unknown'; + + return { + type: agentType, + name: session.agent.name, + command: session.agent.command, + available: true, // Assume available since it was used before + }; +} diff --git a/src/utils/rate-limit-display.ts b/src/utils/rate-limit-display.ts new file mode 100644 index 00000000..824119d1 --- /dev/null +++ b/src/utils/rate-limit-display.ts @@ -0,0 +1,283 @@ +/** + * Rate Limit Display Utilities + * + * Formats and displays detailed rate limit information when limits are reached. + */ + +import chalk from 'chalk'; + +/** + * Rate limit information extracted from API responses or agent output + */ +export interface RateLimitInfo { + /** Current usage as percentage (0-100) */ + usagePercent: number; + /** Tokens used in current period */ + tokensUsed?: number; + /** Maximum tokens allowed */ + tokensLimit?: number; + /** Number of requests made this hour */ + requestsMade?: number; + /** Timestamp when rate limit resets (Unix epoch seconds) */ + resetTimestamp?: number; + /** Seconds until reset (from retry-after header) */ + retryAfterSeconds?: number; +} + +/** + * Session context for display when rate limited + */ +export interface SessionContext { + /** Number of tasks completed */ + tasksCompleted: number; + /** Total number of tasks */ + totalTasks: number; + /** Current task being worked on */ + currentTask?: string; + /** Current git branch */ + branch?: string; + /** Number of loop iterations completed */ + iterations?: number; +} + +/** + * Parse rate limit headers from API response headers + */ +export function parseRateLimitHeaders(headers: Record): RateLimitInfo { + const info: RateLimitInfo = { + usagePercent: 100, // Assume 100% if we're being rate limited + }; + + // Parse standard rate limit headers (x-ratelimit-* or anthropic-ratelimit-*) + const limit = + headers['x-ratelimit-limit'] || + headers['anthropic-ratelimit-limit-tokens'] || + headers['ratelimit-limit']; + const remaining = + headers['x-ratelimit-remaining'] || + headers['anthropic-ratelimit-remaining-tokens'] || + headers['ratelimit-remaining']; + const reset = + headers['x-ratelimit-reset'] || + headers['anthropic-ratelimit-reset-tokens'] || + headers['ratelimit-reset']; + const retryAfter = headers['retry-after']; + + if (limit && remaining) { + const limitNum = parseInt(limit, 10); + const remainingNum = parseInt(remaining, 10); + if (!isNaN(limitNum) && !isNaN(remainingNum) && limitNum > 0) { + info.tokensLimit = limitNum; + info.tokensUsed = limitNum - remainingNum; + info.usagePercent = Math.round(((limitNum - remainingNum) / limitNum) * 100); + } + } + + if (reset) { + const resetNum = parseInt(reset, 10); + if (!isNaN(resetNum)) { + // If reset is in the past, it might be seconds from now + if (resetNum < Date.now() / 1000 - 86400) { + info.retryAfterSeconds = resetNum; + } else { + info.resetTimestamp = resetNum; + } + } + } + + if (retryAfter) { + const retryNum = parseInt(retryAfter, 10); + if (!isNaN(retryNum)) { + info.retryAfterSeconds = retryNum; + } + } + + return info; +} + +/** + * Extract rate limit info from agent output text + */ +export function parseRateLimitFromOutput(output: string): RateLimitInfo { + const info: RateLimitInfo = { + usagePercent: 100, + }; + + // Look for percentage patterns like "100%" or "at 100%" + const percentMatch = output.match(/(\d+)%/); + if (percentMatch) { + info.usagePercent = parseInt(percentMatch[1], 10); + } + + // Look for token patterns like "50,000 / 50,000 tokens" + const tokenMatch = output.match(/(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s*tokens?/i); + if (tokenMatch) { + info.tokensUsed = parseInt(tokenMatch[1].replace(/,/g, ''), 10); + info.tokensLimit = parseInt(tokenMatch[2].replace(/,/g, ''), 10); + } + + // Look for time patterns like "resets in X minutes" or "retry in X seconds" + const timeMatch = output.match( + /(?:reset|retry)(?:s|ing)?\s+in\s+(\d+)\s*(minute|second|hour)s?/i + ); + if (timeMatch) { + let seconds = parseInt(timeMatch[1], 10); + const unit = timeMatch[2].toLowerCase(); + if (unit === 'minute') seconds *= 60; + else if (unit === 'hour') seconds *= 3600; + info.retryAfterSeconds = seconds; + } + + return info; +} + +/** + * Format time duration in human-readable format + */ +export function formatDuration(seconds: number): string { + if (seconds < 60) { + return `${Math.ceil(seconds)} seconds`; + } + if (seconds < 3600) { + const mins = Math.ceil(seconds / 60); + return `~${mins} minute${mins !== 1 ? 's' : ''}`; + } + const hours = Math.floor(seconds / 3600); + const mins = Math.ceil((seconds % 3600) / 60); + if (mins === 0) { + return `~${hours} hour${hours !== 1 ? 's' : ''}`; + } + return `~${hours}h ${mins}m`; +} + +/** + * Format a timestamp as local and UTC time + */ +export function formatResetTime(timestamp: number): string { + const date = new Date(timestamp * 1000); + const localTime = date.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); + const utcTime = date.toUTCString().split(' ')[4].slice(0, 5); + return `${localTime} (${utcTime} UTC)`; +} + +/** + * Format token count with K/M suffixes + */ +export function formatTokenCount(tokens: number): string { + if (tokens < 1000) { + return tokens.toLocaleString(); + } + if (tokens < 1_000_000) { + return `${(tokens / 1000).toFixed(1)}K`; + } + return `${(tokens / 1_000_000).toFixed(2)}M`; +} + +/** + * Display detailed rate limit information + */ +export function displayRateLimitStats( + rateLimitInfo: RateLimitInfo, + sessionContext?: SessionContext +): void { + console.log(); + console.log(chalk.red.bold(' ⚠ Claude rate limit reached')); + console.log(); + + // Rate Limit Stats section + console.log(chalk.yellow(' Rate Limit Stats:')); + + // Usage percentage + if (rateLimitInfo.tokensUsed !== undefined && rateLimitInfo.tokensLimit !== undefined) { + console.log( + chalk.dim( + ` • Session usage: ${rateLimitInfo.usagePercent}% (${formatTokenCount(rateLimitInfo.tokensUsed)} / ${formatTokenCount(rateLimitInfo.tokensLimit)} tokens)` + ) + ); + } else { + console.log(chalk.dim(` • Session usage: ${rateLimitInfo.usagePercent}%`)); + } + + // Requests made + if (rateLimitInfo.requestsMade !== undefined) { + console.log(chalk.dim(` • Requests made: ${rateLimitInfo.requestsMade} this hour`)); + } + + // Time until reset + if (rateLimitInfo.retryAfterSeconds !== undefined) { + const resetTimestamp = Math.floor(Date.now() / 1000) + rateLimitInfo.retryAfterSeconds; + console.log( + chalk.dim( + ` • Time until reset: ${formatDuration(rateLimitInfo.retryAfterSeconds)} (resets at ${formatResetTime(resetTimestamp)})` + ) + ); + } else if (rateLimitInfo.resetTimestamp !== undefined) { + const now = Math.floor(Date.now() / 1000); + const secondsUntilReset = Math.max(0, rateLimitInfo.resetTimestamp - now); + console.log( + chalk.dim( + ` • Time until reset: ${formatDuration(secondsUntilReset)} (resets at ${formatResetTime(rateLimitInfo.resetTimestamp)})` + ) + ); + } + + // Session Progress section (if context provided) + if (sessionContext) { + console.log(); + console.log(chalk.yellow(' Session Progress:')); + console.log( + chalk.dim( + ` • Tasks completed: ${sessionContext.tasksCompleted}/${sessionContext.totalTasks}` + ) + ); + if (sessionContext.currentTask) { + // Truncate long task names + const taskDisplay = + sessionContext.currentTask.length > 50 + ? sessionContext.currentTask.slice(0, 47) + '...' + : sessionContext.currentTask; + console.log(chalk.dim(` • Current task: "${taskDisplay}"`)); + } + if (sessionContext.branch) { + console.log(chalk.dim(` • Branch: ${sessionContext.branch}`)); + } + if (sessionContext.iterations !== undefined) { + console.log(chalk.dim(` • Iterations completed: ${sessionContext.iterations}`)); + } + } + + // Resume instructions + console.log(); + console.log(chalk.yellow(' To resume when limit resets:')); + console.log(chalk.dim(' ralph-starter run')); + console.log(); + console.log(chalk.dim(' Tip: Check your limits at https://claude.ai/settings')); +} + +/** + * Format rate limit stats as a single-line summary + */ +export function formatRateLimitSummary(rateLimitInfo: RateLimitInfo): string { + const parts: string[] = []; + + parts.push(`Usage: ${rateLimitInfo.usagePercent}%`); + + if (rateLimitInfo.tokensUsed !== undefined && rateLimitInfo.tokensLimit !== undefined) { + parts.push( + `Tokens: ${formatTokenCount(rateLimitInfo.tokensUsed)}/${formatTokenCount(rateLimitInfo.tokensLimit)}` + ); + } + + if (rateLimitInfo.retryAfterSeconds !== undefined) { + parts.push(`Reset in: ${formatDuration(rateLimitInfo.retryAfterSeconds)}`); + } else if (rateLimitInfo.resetTimestamp !== undefined) { + const now = Math.floor(Date.now() / 1000); + const secondsUntilReset = Math.max(0, rateLimitInfo.resetTimestamp - now); + parts.push(`Reset in: ${formatDuration(secondsUntilReset)}`); + } + + return parts.join(' | '); +} From f4bde6fa4b5ca966b33533721d685fa74c72c22b Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Thu, 5 Feb 2026 05:21:56 +0000 Subject: [PATCH 02/35] feat: Add session pause/resume for rate limit recovery Closes github#79 Generated by ralph-starter auto mode --- IMPLEMENTATION_PLAN.md | 2 +- src/cli.ts | 12 +++ src/commands/resume.ts | 169 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 src/commands/resume.ts diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index b10296ba..5b404db4 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -59,7 +59,7 @@ ### Session Management - [x] Create `src/loop/session.ts` for pause/resume support - [x] Add `ralph-starter pause` command -- [ ] Add `ralph-starter resume` command +- [x] Add `ralph-starter resume` command - [ ] Store session state in `.ralph-session.json` --- diff --git a/src/cli.ts b/src/cli.ts index 11e333ac..9fef6e51 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import { initCommand } from './commands/init.js'; import { integrationsCommand } from './commands/integrations.js'; import { pauseCommand } from './commands/pause.js'; import { planCommand } from './commands/plan.js'; +import { resumeCommand } from './commands/resume.js'; import { runCommand } from './commands/run.js'; import { setupCommand } from './commands/setup.js'; import { skillCommand } from './commands/skill.js'; @@ -258,6 +259,17 @@ program }); }); +// ralph-starter resume - Resume a paused session +program + .command('resume') + .description('Resume a paused session from where it left off') + .option('--force', 'Force resume even if session is not paused') + .action(async (options) => { + await resumeCommand({ + force: options.force, + }); + }); + // ralph-starter presets - List available workflow presets program .command('presets') diff --git a/src/commands/resume.ts b/src/commands/resume.ts new file mode 100644 index 00000000..efc8446e --- /dev/null +++ b/src/commands/resume.ts @@ -0,0 +1,169 @@ +/** + * Resume command for ralph-starter + * Resumes a paused session from where it left off + */ + +import chalk from 'chalk'; +import ora from 'ora'; +import { type LoopOptions, runLoop } from '../loop/executor.js'; +import { + canResume, + formatSessionSummary, + getRemainingIterations, + hasActiveSession, + loadSession, + reconstructAgent, + resumeSession, +} from '../loop/session.js'; + +export interface ResumeCommandOptions { + /** Force resume even if session is not paused */ + force?: boolean; +} + +/** + * Run the resume command + */ +export async function resumeCommand(options: ResumeCommandOptions = {}): Promise { + const cwd = process.cwd(); + const spinner = ora(); + + console.log(); + console.log(chalk.cyan.bold('ralph-starter resume')); + console.log(chalk.dim('Resume a paused session')); + console.log(); + + // Check if there's an active session + const hasSession = await hasActiveSession(cwd); + if (!hasSession) { + console.log(chalk.yellow(' No active session found in this directory.')); + console.log(); + console.log(chalk.dim(' Sessions are created when you run:')); + console.log(chalk.dim(' ralph-starter run [task]')); + console.log(chalk.dim(' ralph-starter auto --source github --label auto-ready')); + console.log(); + process.exit(1); + } + + // Load the session + const session = await loadSession(cwd); + if (!session) { + console.log(chalk.red(' Failed to load session data.')); + process.exit(1); + } + + // Check if session can be resumed + if (!canResume(session) && !options.force) { + console.log(chalk.yellow(` Session cannot be resumed (status: ${session.status}).`)); + console.log(); + console.log(formatSessionSummary(session)); + console.log(); + + if (session.status === 'running') { + console.log(chalk.dim(' A session is already running.')); + console.log(chalk.dim(' Use `ralph-starter pause` to pause it first.')); + } else if (session.status === 'completed') { + console.log(chalk.dim(' Session has already completed.')); + console.log(chalk.dim(' Start a new session with `ralph-starter run [task]`.')); + } else if (session.status === 'failed') { + console.log(chalk.dim(' Session failed. Use --force to attempt to resume anyway.')); + console.log(chalk.dim(' Or start a new session with `ralph-starter run [task]`.')); + } + console.log(); + process.exit(1); + } + + // Display session info before resuming + console.log(chalk.bold(' Session details:')); + console.log(); + const summaryLines = formatSessionSummary(session).split('\n'); + for (const line of summaryLines) { + console.log(` ${line}`); + } + console.log(); + + // Calculate remaining iterations + const remainingIterations = getRemainingIterations(session); + if (remainingIterations === 0) { + console.log(chalk.yellow(' No remaining iterations.')); + console.log(chalk.dim(' The session has reached its maximum iteration count.')); + console.log(); + process.exit(1); + } + + console.log(chalk.dim(` Remaining iterations: ${remainingIterations}`)); + console.log(); + + // Resume the session + spinner.start('Resuming session...'); + const resumedSession = await resumeSession(cwd); + if (!resumedSession) { + spinner.fail('Failed to resume session'); + process.exit(1); + } + spinner.succeed('Session resumed'); + console.log(); + + // Reconstruct the agent from session data + const agent = reconstructAgent(session); + + // Build loop options from session state + const loopOptions: LoopOptions = { + task: session.task, + cwd: session.cwd, + agent, + maxIterations: remainingIterations, + auto: session.options.auto, + commit: session.options.commit, + push: session.options.push, + pr: session.options.pr, + prTitle: session.options.prTitle, + validate: session.options.validate, + completionPromise: session.options.completionPromise, + requireExitSignal: session.options.requireExitSignal, + circuitBreaker: session.options.circuitBreaker, + rateLimit: session.options.rateLimit, + trackProgress: session.options.trackProgress, + checkFileCompletion: session.options.checkFileCompletion, + trackCost: session.options.trackCost, + model: session.options.model, + }; + + // Run the loop + console.log(chalk.cyan(' Continuing from iteration'), chalk.bold(session.iteration)); + console.log(); + + const result = await runLoop(loopOptions); + + // Print summary + console.log(); + if (result.success) { + console.log(chalk.green.bold(' ✓ Session completed successfully!')); + console.log(chalk.dim(` Exit reason: ${result.exitReason}`)); + console.log(chalk.dim(` Total iterations: ${session.iteration + result.iterations}`)); + if (result.commits.length > 0) { + console.log(chalk.dim(` New commits: ${result.commits.length}`)); + } + if (result.stats?.costStats) { + const cost = result.stats.costStats.totalCost.totalCost; + console.log(chalk.dim(` Session cost: $${cost.toFixed(3)}`)); + } + } else { + // Check if it's a rate limit issue + const isRateLimit = result.error?.includes('Rate limit'); + + if (isRateLimit) { + console.log(chalk.yellow.bold(' ⏸ Session paused due to rate limit')); + console.log(); + console.log(chalk.dim(' To resume later, run:')); + console.log(chalk.cyan(' ralph-starter resume')); + } else { + console.log(chalk.red.bold(' ✗ Session failed')); + console.log(chalk.dim(` Exit reason: ${result.exitReason}`)); + if (result.error) { + console.log(chalk.dim(` Error: ${result.error}`)); + } + } + } + console.log(); +} From 717246d6920978e6e8612da8fd0f3437e7cd48d0 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 05:53:56 +0000 Subject: [PATCH 03/35] fix: improve cleanTaskName to handle list prefixes, HTML, and links Strip numbered list prefixes, bullet markers, HTML tags, markdown links, and collapse whitespace in task names for cleaner display. Co-Authored-By: Claude Opus 4.6 --- src/loop/executor.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/loop/executor.ts b/src/loop/executor.ts index ebd8eb6e..ebac9ff2 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -38,13 +38,18 @@ function sleep(ms: number): Promise { } /** - * Strip markdown formatting from task names + * Strip markdown and list formatting from task names */ function cleanTaskName(name: string): string { return name .replace(/\*\*/g, '') // Remove bold ** .replace(/\*/g, '') // Remove italic * .replace(/`/g, '') // Remove code backticks + .replace(/<[^>]+>/g, '') // Remove HTML tags + .replace(/^\d+\.\s+/, '') // Remove numbered list prefix (1. ) + .replace(/^[-*]\s+/, '') // Remove bullet list prefix (- or * ) + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Convert [text](url) to text + .replace(/\s+/g, ' ') // Collapse whitespace .trim(); } From 769e8374b561c4c1fcc5543f8ecb73a8a53ab053 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 05:54:17 +0000 Subject: [PATCH 04/35] fix: increase task name truncation limits for better readability Increase completed task name limit from 25 to 50 chars and loop header task name limit from 40 to 60 chars so task context is preserved in the CLI output. Co-Authored-By: Claude Opus 4.6 --- src/loop/executor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/loop/executor.ts b/src/loop/executor.ts index ebac9ff2..9d84499f 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -520,7 +520,7 @@ export async function runLoop(options: LoopOptions): Promise { .filter((t) => t.completed && t.index >= previousCompletedTasks && t.index < completedTasks) .map((t) => { const clean = cleanTaskName(t.name); - return clean.length > 25 ? `${clean.slice(0, 22)}...` : clean; + return clean.length > 50 ? `${clean.slice(0, 47)}...` : clean; }); if (completedNames.length > 0) { @@ -536,7 +536,7 @@ export async function runLoop(options: LoopOptions): Promise { if (currentTask && totalTasks > 0) { const taskNum = completedTasks + 1; const cleanName = cleanTaskName(currentTask.name); - const taskName = cleanName.length > 40 ? `${cleanName.slice(0, 37)}...` : cleanName; + const taskName = cleanName.length > 60 ? `${cleanName.slice(0, 57)}...` : cleanName; console.log(chalk.cyan.bold(` Task ${taskNum}/${totalTasks} │ ${taskName}`)); } else { console.log(chalk.cyan.bold(` Loop ${i}/${maxIterations} │ Running ${options.agent.name}`)); From 7a98fea14dd0eed22cd8cf816893c23f7843c482 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 05:54:57 +0000 Subject: [PATCH 05/35] fix: use terminal width for dynamic task name truncation Replace hardcoded truncation limits with terminal-width-aware helpers. Task names now adapt to the available terminal space instead of cutting at arbitrary character counts. Co-Authored-By: Claude Opus 4.6 --- src/loop/executor.ts | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/loop/executor.ts b/src/loop/executor.ts index 9d84499f..9727310b 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -37,6 +37,21 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Get terminal width with a sensible fallback + */ +function getTerminalWidth(): number { + return process.stdout.columns || 80; +} + +/** + * Truncate text to fit within available width + */ +function truncateToFit(text: string, maxWidth: number): string { + if (text.length <= maxWidth) return text; + return `${text.slice(0, maxWidth - 3)}...`; +} + /** * Strip markdown and list formatting from task names */ @@ -516,12 +531,10 @@ export async function runLoop(options: LoopOptions): Promise { const newlyCompleted = completedTasks - previousCompletedTasks; if (newlyCompleted > 0 && i > 1) { // Get names of newly completed tasks (strip markdown) + const maxNameWidth = Math.max(30, getTerminalWidth() - 30); const completedNames = taskInfo.tasks .filter((t) => t.completed && t.index >= previousCompletedTasks && t.index < completedTasks) - .map((t) => { - const clean = cleanTaskName(t.name); - return clean.length > 50 ? `${clean.slice(0, 47)}...` : clean; - }); + .map((t) => truncateToFit(cleanTaskName(t.name), maxNameWidth)); if (completedNames.length > 0) { console.log( @@ -532,16 +545,20 @@ export async function runLoop(options: LoopOptions): Promise { previousCompletedTasks = completedTasks; // Show loop header with task info - console.log(chalk.cyan(`\n═══════════════════════════════════════════════════════════════`)); + const termWidth = getTerminalWidth(); + const headerWidth = Math.min(63, termWidth - 2); + const headerLine = '═'.repeat(headerWidth); + console.log(chalk.cyan(`\n${headerLine}`)); if (currentTask && totalTasks > 0) { const taskNum = completedTasks + 1; - const cleanName = cleanTaskName(currentTask.name); - const taskName = cleanName.length > 60 ? `${cleanName.slice(0, 57)}...` : cleanName; - console.log(chalk.cyan.bold(` Task ${taskNum}/${totalTasks} │ ${taskName}`)); + const prefix = ` Task ${taskNum}/${totalTasks} │ `; + const maxTaskWidth = Math.max(20, headerWidth - prefix.length - 2); + const taskName = truncateToFit(cleanTaskName(currentTask.name), maxTaskWidth); + console.log(chalk.cyan.bold(`${prefix}${taskName}`)); } else { console.log(chalk.cyan.bold(` Loop ${i}/${maxIterations} │ Running ${options.agent.name}`)); } - console.log(chalk.cyan(`═══════════════════════════════════════════════════════════════\n`)); + console.log(chalk.cyan(`${headerLine}\n`)); // Create progress renderer for this iteration const iterProgress = new ProgressRenderer(); From 9e9ee419d783ead8bcd8fb0e6be4da3d0cbf87c8 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:01:23 +0000 Subject: [PATCH 06/35] feat: add source integration icons to task name display Show a compact icon (GitHub, Linear, Figma, Notion, etc.) next to task names in the loop header so users can quickly identify where tasks originated from. Co-Authored-By: Claude Opus 4.6 --- src/commands/run.ts | 1 + src/loop/executor.ts | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/commands/run.ts b/src/commands/run.ts index 3a84cff0..525340cf 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -576,6 +576,7 @@ Focus on one task at a time. After completing a task, update IMPLEMENTATION_PLAN prIssueRef: sourceIssueRef, prLabels: options.auto ? ['AUTO'] : undefined, validate: options.validate ?? preset?.validate, + sourceType: options.from?.toLowerCase(), // New options completionPromise: options.completionPromise ?? preset?.completionPromise, requireExitSignal: options.requireExitSignal, diff --git a/src/loop/executor.ts b/src/loop/executor.ts index 9727310b..e3b6770f 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -52,6 +52,29 @@ function truncateToFit(text: string, maxWidth: number): string { return `${text.slice(0, maxWidth - 3)}...`; } +/** + * Get a compact icon for the task source integration + */ +function getSourceIcon(source?: string): string { + switch (source?.toLowerCase()) { + case 'github': + return ''; + case 'linear': + return '◫'; + case 'figma': + return '◆'; + case 'notion': + return '▤'; + case 'file': + case 'pdf': + return '▫'; + case 'url': + return '◎'; + default: + return '▸'; + } +} + /** * Strip markdown and list formatting from task names */ @@ -145,6 +168,7 @@ export interface LoopOptions { prIssueRef?: IssueRef; // Issue to link in PR body prType?: SemanticPrType; // Type for semantic PR title validate?: boolean; // Run tests/lint/build as backpressure + sourceType?: string; // Source integration type (github, linear, figma, notion, file) // New options completionPromise?: string; // Custom completion promise string requireExitSignal?: boolean; // Require explicit EXIT_SIGNAL: true @@ -549,14 +573,19 @@ export async function runLoop(options: LoopOptions): Promise { const headerWidth = Math.min(63, termWidth - 2); const headerLine = '═'.repeat(headerWidth); console.log(chalk.cyan(`\n${headerLine}`)); + const sourceIcon = getSourceIcon(options.sourceType); if (currentTask && totalTasks > 0) { const taskNum = completedTasks + 1; - const prefix = ` Task ${taskNum}/${totalTasks} │ `; + const prefix = ` ${sourceIcon} Task ${taskNum}/${totalTasks} │ `; const maxTaskWidth = Math.max(20, headerWidth - prefix.length - 2); const taskName = truncateToFit(cleanTaskName(currentTask.name), maxTaskWidth); console.log(chalk.cyan.bold(`${prefix}${taskName}`)); } else { - console.log(chalk.cyan.bold(` Loop ${i}/${maxIterations} │ Running ${options.agent.name}`)); + console.log( + chalk.cyan.bold( + ` ${sourceIcon} Loop ${i}/${maxIterations} │ Running ${options.agent.name}` + ) + ); } console.log(chalk.cyan(`${headerLine}\n`)); From 85ccb5d0bf3b6a899f5ab14603c096c67eaa03e8 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:02:13 +0000 Subject: [PATCH 07/35] fix: replace shimmer with readable progress text Replace the per-character color cycling shimmer effect with a subtle slow pulse between white and cyan. Much more readable and accessible while still providing visual feedback. Co-Authored-By: Claude Opus 4.6 --- src/ui/shimmer.ts | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/ui/shimmer.ts b/src/ui/shimmer.ts index 43449e6a..51c121a0 100644 --- a/src/ui/shimmer.ts +++ b/src/ui/shimmer.ts @@ -1,24 +1,16 @@ import chalk from 'chalk'; /** - * Colors for shimmer effect - creates a flowing gradient - */ -const SHIMMER_COLORS = [chalk.white, chalk.gray, chalk.dim, chalk.gray, chalk.white]; - -/** - * Apply shimmer effect to text - * @param text The text to apply shimmer to + * Apply a subtle pulse effect to text - alternates between white and cyan + * Much more readable than the old per-character shimmer + * @param text The text to display * @param offset Frame offset for animation - * @returns Colorized text with shimmer effect + * @returns Colorized text */ export function applyShimmer(text: string, offset: number): string { - return text - .split('') - .map((char, i) => { - const colorIndex = (i + offset) % SHIMMER_COLORS.length; - return SHIMMER_COLORS[colorIndex](char); - }) - .join(''); + // Slow pulse: switch color every ~20 frames (~2 seconds at 100ms interval) + const phase = Math.floor(offset / 20) % 2; + return phase === 0 ? chalk.white(text) : chalk.cyan(text); } /** From 4fa7a3a1bca8c0b2fd865cb48cb8484e6b96583c Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:03:23 +0000 Subject: [PATCH 08/35] feat: add progress bar and box-drawing utilities Add box.ts with drawBox(), drawSeparator(), and renderProgressBar() helpers. Update ProgressRenderer with iteration tracking, progress bar display, and live cost indicator. Co-Authored-By: Claude Opus 4.6 --- src/ui/box.ts | 68 +++++++++++++++++++++++++++++++++++++ src/ui/progress-renderer.ts | 52 ++++++++++++++++++++++++---- 2 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 src/ui/box.ts diff --git a/src/ui/box.ts b/src/ui/box.ts new file mode 100644 index 00000000..12b693b3 --- /dev/null +++ b/src/ui/box.ts @@ -0,0 +1,68 @@ +import chalk, { type ChalkInstance } from 'chalk'; + +/** + * Get terminal width with a sensible fallback + */ +export function getTerminalWidth(): number { + return process.stdout.columns || 80; +} + +/** + * Draw a box with box-drawing characters around content lines + */ +export function drawBox( + lines: string[], + options: { color?: ChalkInstance; width?: number } = {} +): string { + const color = options.color || chalk.cyan; + const width = options.width || Math.min(60, getTerminalWidth() - 4); + const innerWidth = width - 2; + + const output: string[] = []; + output.push(color(`┌${'─'.repeat(innerWidth)}┐`)); + + for (const line of lines) { + // Strip ANSI codes to measure real length + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence detection requires control characters + const stripped = line.replace(/\u001b\[[0-9;]*m/g, ''); + const padding = Math.max(0, innerWidth - stripped.length); + output.push(color('│') + line + ' '.repeat(padding) + color('│')); + } + + output.push(color(`└${'─'.repeat(innerWidth)}┘`)); + return output.join('\n'); +} + +/** + * Draw a horizontal separator with an optional centered label + */ +export function drawSeparator(label?: string, width?: number): string { + const w = width || Math.min(60, getTerminalWidth() - 4); + + if (!label) { + return chalk.dim('─'.repeat(w)); + } + + const labelLen = label.length + 2; // space on each side + const sideLen = Math.max(1, Math.floor((w - labelLen) / 2)); + const left = '─'.repeat(sideLen); + const right = '─'.repeat(w - sideLen - labelLen); + return chalk.dim(`${left} ${label} ${right}`); +} + +/** + * Render a progress bar + */ +export function renderProgressBar( + current: number, + total: number, + options: { width?: number; label?: string } = {} +): string { + const barWidth = options.width || 20; + const ratio = Math.min(1, Math.max(0, current / total)); + const filled = Math.round(ratio * barWidth); + const empty = barWidth - filled; + const bar = `${'█'.repeat(filled)}${'░'.repeat(empty)}`; + const info = options.label ? ` │ ${options.label}` : ''; + return `${chalk.cyan(bar)} ${current}/${total}${chalk.dim(info)}`; +} diff --git a/src/ui/progress-renderer.ts b/src/ui/progress-renderer.ts index 21f6ef8f..c1fbc2d5 100644 --- a/src/ui/progress-renderer.ts +++ b/src/ui/progress-renderer.ts @@ -15,12 +15,14 @@ export function formatElapsed(ms: number): string { } /** - * ProgressRenderer - Single-line progress display with shimmer effect + * ProgressRenderer - Single-line progress display with progress bar * * Features: * - Animated spinner - * - Shimmer text effect + * - Readable text (subtle pulse) + * - Progress bar with iteration tracking * - Elapsed time counter + * - Live cost display * - Dynamic step updates * - Sub-step indicator */ @@ -33,6 +35,9 @@ export class ProgressRenderer { private lastRender = ''; private lastStepUpdate = 0; private minStepInterval = 500; // ms - debounce step updates + private currentIteration = 0; + private maxIterations = 0; + private currentCost = 0; /** * Start the progress renderer @@ -49,6 +54,17 @@ export class ProgressRenderer { this.interval = setInterval(() => this.render(), 100); } + /** + * Update iteration progress for the progress bar + */ + updateProgress(iteration: number, maxIterations: number, cost?: number): void { + this.currentIteration = iteration; + this.maxIterations = maxIterations; + if (cost !== undefined) { + this.currentCost = cost; + } + } + /** * Update the main step text (debounced to prevent rapid switching) */ @@ -72,7 +88,7 @@ export class ProgressRenderer { } /** - * Render the progress line + * Render the progress line(s) */ private render(): void { this.frame++; @@ -82,7 +98,7 @@ export class ProgressRenderer { const timeStr = formatElapsed(elapsed); const shimmerText = applyShimmer(this.currentStep, this.frame); - // Main line + // Main line: spinner + step + time let line = ` ${chalk.cyan(spinner)} ${shimmerText} ${chalk.dim(timeStr)}`; // Sub-step on same line if present @@ -90,9 +106,25 @@ export class ProgressRenderer { line += chalk.dim(` - ${this.subStep}`); } + // Progress bar line (if iteration info is available) + if (this.maxIterations > 0) { + const barWidth = 16; + const ratio = Math.min(1, this.currentIteration / this.maxIterations); + const filled = Math.round(ratio * barWidth); + const empty = barWidth - filled; + const bar = `${'█'.repeat(filled)}${'░'.repeat(empty)}`; + const costStr = this.currentCost > 0 ? ` │ $${this.currentCost.toFixed(2)}` : ''; + line += `\n ${chalk.cyan(bar)} ${chalk.dim(`${this.currentIteration}/${this.maxIterations}${costStr}`)}`; + } + // Only update if changed (reduces flicker) if (line !== this.lastRender) { - process.stdout.write(`\r\x1B[K${line}`); + // Clear current line(s) and write + const lineCount = this.maxIterations > 0 ? 2 : 1; + const clearUp = lineCount > 1 ? `\x1B[${lineCount - 1}A\r\x1B[J` : '\r\x1B[K'; + // On first render, don't try to go up + const clear = this.lastRender ? clearUp : '\r\x1B[K'; + process.stdout.write(`${clear}${line}`); this.lastRender = line; } } @@ -110,8 +142,16 @@ export class ProgressRenderer { const timeStr = formatElapsed(elapsed); const icon = success ? chalk.green('✓') : chalk.red('✗'); const message = finalMessage || this.currentStep; + const costStr = this.currentCost > 0 ? chalk.dim(` ~$${this.currentCost.toFixed(2)}`) : ''; + + // Clear progress bar line if present + if (this.maxIterations > 0 && this.lastRender) { + process.stdout.write('\x1B[1A\r\x1B[J'); + } else { + process.stdout.write('\r\x1B[K'); + } - process.stdout.write(`\r\x1B[K ${icon} ${message} ${chalk.dim(`(${timeStr})`)}\n`); + process.stdout.write(` ${icon} ${message} ${chalk.dim(`(${timeStr})`)}${costStr}\n`); this.lastRender = ''; } From 0c2817ad92f4df5819e78f4e261e994dcddcfeed Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:04:51 +0000 Subject: [PATCH 09/35] feat: box-drawing headers, startup summary, and completion banner Replace plain separator lines with box-drawing UI throughout the executor. Adds a startup config summary box, per-iteration header boxes with agent/iteration info, and a clean completion banner with stats. Wire progress bar with iteration and cost tracking. Co-Authored-By: Claude Opus 4.6 --- src/loop/executor.ts | 94 ++++++++++++++++++++++++-------------------- 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/src/loop/executor.ts b/src/loop/executor.ts index ebd8eb6e..30949520 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -12,6 +12,7 @@ import { type IssueRef, type SemanticPrType, } from '../automation/git.js'; +import { drawBox, drawSeparator, getTerminalWidth } from '../ui/box.js'; import { ProgressRenderer } from '../ui/progress-renderer.js'; import { type Agent, type AgentRunOptions, runAgent } from './agents.js'; import { CircuitBreaker, type CircuitBreakerConfig } from './circuit-breaker.js'; @@ -385,44 +386,48 @@ export async function runLoop(options: LoopOptions): Promise { // Get initial task count for estimates const initialTaskCount = parsePlanTasks(options.cwd); + // Show startup summary box + const startupLines: string[] = []; + startupLines.push(chalk.cyan.bold(' Ralph-Starter')); + startupLines.push(` Agent: ${chalk.white(options.agent.name)}`); + startupLines.push(` Max loops: ${chalk.white(String(maxIterations))}`); + if (validationCommands.length > 0) { + startupLines.push( + ` Validation: ${chalk.white(validationCommands.map((c) => c.name).join(', '))}` + ); + } + if (options.commit) { + startupLines.push(` Auto-commit: ${chalk.green('enabled')}`); + } + if (detectedSkills.length > 0) { + startupLines.push(` Skills: ${chalk.white(`${detectedSkills.length} detected`)}`); + } + if (rateLimiter) { + startupLines.push(` Rate limit: ${chalk.white(`${options.rateLimit}/hour`)}`); + } + console.log(); - console.log(chalk.cyan.bold('Starting Ralph Wiggum Loop')); - console.log(chalk.dim(`Agent: ${options.agent.name}`)); + console.log(drawBox(startupLines, { color: chalk.cyan })); // Show task count and estimates if we have tasks if (initialTaskCount.total > 0) { console.log( chalk.dim( - `Tasks: ${initialTaskCount.pending} pending, ${initialTaskCount.completed} completed` + ` Tasks: ${initialTaskCount.pending} pending, ${initialTaskCount.completed} completed` ) ); // Show estimate const estimate = estimateLoop(initialTaskCount); console.log(); - console.log(chalk.yellow.bold('📋 Estimate:')); for (const line of formatEstimateDetailed(estimate)) { - console.log(chalk.yellow(` ${line}`)); + console.log(chalk.dim(` ${line}`)); } } else { console.log( - chalk.dim(`Task: ${options.task.slice(0, 60)}${options.task.length > 60 ? '...' : ''}`) + chalk.dim(` Task: ${options.task.slice(0, 60)}${options.task.length > 60 ? '...' : ''}`) ); } - - console.log(); - if (validationCommands.length > 0) { - console.log(chalk.dim(`Validation: ${validationCommands.map((c) => c.name).join(', ')}`)); - } - if (detectedSkills.length > 0) { - console.log(chalk.dim(`Skills: ${detectedSkills.map((s) => s.name).join(', ')}`)); - } - if (options.completionPromise) { - console.log(chalk.dim(`Completion promise: ${options.completionPromise}`)); - } - if (rateLimiter) { - console.log(chalk.dim(`Rate limit: ${options.rateLimit}/hour`)); - } console.log(); // Track completed tasks to show progress diff between iterations @@ -527,20 +532,29 @@ export async function runLoop(options: LoopOptions): Promise { previousCompletedTasks = completedTasks; // Show loop header with task info - console.log(chalk.cyan(`\n═══════════════════════════════════════════════════════════════`)); + const headerLines: string[] = []; if (currentTask && totalTasks > 0) { const taskNum = completedTasks + 1; const cleanName = cleanTaskName(currentTask.name); - const taskName = cleanName.length > 40 ? `${cleanName.slice(0, 37)}...` : cleanName; - console.log(chalk.cyan.bold(` Task ${taskNum}/${totalTasks} │ ${taskName}`)); + const tw = getTerminalWidth(); + const maxNameLen = Math.max(20, tw - 30); + const taskName = + cleanName.length > maxNameLen ? `${cleanName.slice(0, maxNameLen - 3)}...` : cleanName; + headerLines.push(` Task ${taskNum}/${totalTasks} │ ${chalk.white.bold(taskName)}`); + headerLines.push(chalk.dim(` ${options.agent.name} │ Iter ${i}/${maxIterations}`)); } else { - console.log(chalk.cyan.bold(` Loop ${i}/${maxIterations} │ Running ${options.agent.name}`)); + headerLines.push( + ` Loop ${i}/${maxIterations} │ ${chalk.white.bold(`Running ${options.agent.name}`)}` + ); } - console.log(chalk.cyan(`═══════════════════════════════════════════════════════════════\n`)); + console.log(); + console.log(drawBox(headerLines, { color: chalk.cyan })); + console.log(); // Create progress renderer for this iteration const iterProgress = new ProgressRenderer(); iterProgress.start('Working...'); + iterProgress.updateProgress(i, maxIterations, costTracker?.getStats()?.totalCost?.totalCost); // Build iteration-specific task with current task context let iterationTask: string; @@ -833,27 +847,23 @@ Complete these subtasks, then mark them done in IMPLEMENTATION_PLAN.md by changi } if (status === 'done') { - console.log(); - console.log( - chalk.green.bold('═══════════════════════════════════════════════════════════════') - ); - console.log(chalk.green.bold(' ✓ Task completed successfully!')); - console.log( - chalk.green.bold('═══════════════════════════════════════════════════════════════') - ); - - // Show completion reason (UX 3: clear completion signals) const completionReason = getCompletionReason(result.output, completionOptions); - console.log(chalk.dim(` Reason: ${completionReason}`)); - console.log(chalk.dim(` Iterations: ${i}`)); - if (costTracker) { - const stats = costTracker.getStats(); - console.log(chalk.dim(` Total cost: ${formatCost(stats.totalCost.totalCost)}`)); - } const duration = Date.now() - startTime; const minutes = Math.floor(duration / 60000); const seconds = Math.floor((duration % 60000) / 1000); - console.log(chalk.dim(` Time: ${minutes}m ${seconds}s`)); + + const completionLines: string[] = []; + completionLines.push(chalk.green.bold(' ✓ Task completed successfully')); + const details: string[] = [`Iterations: ${i}`, `Time: ${minutes}m ${seconds}s`]; + if (costTracker) { + const stats = costTracker.getStats(); + details.push(`Cost: ${formatCost(stats.totalCost.totalCost)}`); + } + completionLines.push(chalk.dim(` ${details.join(' │ ')}`)); + completionLines.push(chalk.dim(` Reason: ${completionReason}`)); + + console.log(); + console.log(drawBox(completionLines, { color: chalk.green })); console.log(); finalIteration = i; From af1546c14e4fe098187d6fd3c95498200593365e Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:05:34 +0000 Subject: [PATCH 10/35] feat: add status separators and compact validation feedback Show a status separator between iterations with iteration count, task progress, cost, and elapsed time. Replace verbose validation error output with a compact one-line summary. Co-Authored-By: Claude Opus 4.6 --- src/loop/executor.ts | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/loop/executor.ts b/src/loop/executor.ts index 30949520..6b0dc793 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -738,23 +738,23 @@ Complete these subtasks, then mark them done in IMPLEMENTATION_PLAN.md by changi const feedback = formatValidationFeedback(validationResults); spinner.fail(chalk.red(`Loop ${i}: Validation failed`)); - // Show which validations failed (UX 4: specific validation errors) + // Show compact validation summary + const failedSummaries: string[] = []; for (const vr of validationResults) { if (!vr.success) { - console.log(chalk.red(` ✗ ${vr.command}`)); - if (vr.error) { - const errorLines = vr.error.split('\n').slice(0, 5); - for (const line of errorLines) { - console.log(chalk.dim(` ${line}`)); - } - } else if (vr.output) { - const outputLines = vr.output.split('\n').slice(0, 5); - for (const line of outputLines) { - console.log(chalk.dim(` ${line}`)); - } - } + const errorText = vr.error || vr.output || ''; + const failCount = (errorText.match(/fail/gi) || []).length; + const errorCount = (errorText.match(/error/gi) || []).length; + const hint = + failCount > 0 + ? `${failCount} failures` + : errorCount > 0 + ? `${errorCount} errors` + : 'failed'; + failedSummaries.push(`${vr.command} (${hint})`); } } + console.log(chalk.red(` ✗ ${failedSummaries.join(' │ ')}`)); // Record failure in circuit breaker const errorMsg = validationResults @@ -871,6 +871,20 @@ Complete these subtasks, then mark them done in IMPLEMENTATION_PLAN.md by changi break; } + // Status separator between iterations + const elapsed = Date.now() - startTime; + const elapsedMin = Math.floor(elapsed / 60000); + const elapsedSec = Math.floor((elapsed % 60000) / 1000); + const costLabel = costTracker + ? ` │ ${formatCost(costTracker.getStats().totalCost.totalCost)}` + : ''; + const taskLabel = completedTasks > 0 ? ` │ Tasks: ${completedTasks}/${totalTasks}` : ''; + console.log( + drawSeparator( + `Iter ${i}/${maxIterations}${taskLabel}${costLabel} │ ${elapsedMin}m ${elapsedSec}s` + ) + ); + // Small delay between iterations await new Promise((resolve) => setTimeout(resolve, 1000)); } From ba50341ee47abbd879a320d2e01120b75a6cb962 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:06:49 +0000 Subject: [PATCH 11/35] feat: improve skill detection with YAML frontmatter and .agents/skills support - Add .agents/skills/ directory scanning (multi-agent skill sharing) - Support subdirectories with SKILL.md inside (not just flat .md files) - Parse YAML frontmatter for skill name and description - Parse npx add-skill commands from skills.sh - Add findSkill() helper for looking up skills by name - Refactor directory scanning into reusable scanSkillsDir() Co-Authored-By: Claude Opus 4.6 --- src/loop/skills.ts | 161 ++++++++++++++++++++++++++++++++------------- 1 file changed, 116 insertions(+), 45 deletions(-) diff --git a/src/loop/skills.ts b/src/loop/skills.ts index 49950cf1..a57fb2e1 100644 --- a/src/loop/skills.ts +++ b/src/loop/skills.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -6,14 +6,38 @@ export interface ClaudeSkill { name: string; path: string; description?: string; - source: 'global' | 'project' | 'skills.sh'; + source: 'global' | 'project' | 'agents' | 'skills.sh'; +} + +/** + * Parse YAML frontmatter from markdown content + * Returns name and description if found + */ +function parseFrontmatter(content: string): { name?: string; description?: string } { + const match = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (!match) return {}; + + const yaml = match[1]; + const result: { name?: string; description?: string } = {}; + + const nameMatch = yaml.match(/^name:\s*(.+)$/m); + if (nameMatch) result.name = nameMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + const descMatch = yaml.match(/^description:\s*(.+)$/m); + if (descMatch) result.description = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + return result; } /** * Extract skill description from markdown content - * Looks for first paragraph after title + * First tries YAML frontmatter, then falls back to first paragraph after title */ function extractDescription(content: string): string | undefined { + // Try frontmatter first + const fm = parseFrontmatter(content); + if (fm.description) return fm.description; + const lines = content.split('\n'); let foundTitle = false; @@ -40,56 +64,78 @@ function extractDescription(content: string): string | undefined { } /** - * Detect Claude Code skills from various sources + * Extract skill name from content (frontmatter or filename) */ -export function detectClaudeSkills(cwd: string): ClaudeSkill[] { +function extractName(content: string, fallbackName: string): string { + const fm = parseFrontmatter(content); + return fm.name || fallbackName; +} + +/** + * Scan a directory for skill files (.md files and subdirectories with SKILL.md) + */ +function scanSkillsDir(dir: string, source: ClaudeSkill['source']): ClaudeSkill[] { const skills: ClaudeSkill[] = []; - // 1. Check global skills directory (~/.claude/skills/) - const globalSkillsDir = join(homedir(), '.claude', 'skills'); - if (existsSync(globalSkillsDir)) { - try { - const files = readdirSync(globalSkillsDir); - for (const file of files) { - if (file.endsWith('.md')) { - const skillPath = join(globalSkillsDir, file); - const content = readFileSync(skillPath, 'utf-8'); + if (!existsSync(dir)) return skills; + + try { + const entries = readdirSync(dir); + for (const entry of entries) { + const fullPath = join(dir, entry); + + try { + const stats = statSync(fullPath); + + if (stats.isFile() && entry.endsWith('.md')) { + // Flat .md skill file + const content = readFileSync(fullPath, 'utf-8'); skills.push({ - name: file.replace('.md', ''), - path: skillPath, + name: extractName(content, entry.replace('.md', '')), + path: fullPath, description: extractDescription(content), - source: 'global', + source, }); + } else if (stats.isDirectory()) { + // Check for SKILL.md inside subdirectory + const skillMdPath = join(fullPath, 'SKILL.md'); + if (existsSync(skillMdPath)) { + const content = readFileSync(skillMdPath, 'utf-8'); + skills.push({ + name: extractName(content, entry), + path: skillMdPath, + description: extractDescription(content), + source, + }); + } } + } catch { + // Skip unreadable entries } - } catch { - // Directory not readable } + } catch { + // Directory not readable } + return skills; +} + +/** + * Detect Claude Code skills from various sources + */ +export function detectClaudeSkills(cwd: string): ClaudeSkill[] { + const skills: ClaudeSkill[] = []; + + // 1. Check global skills directory (~/.claude/skills/) + skills.push(...scanSkillsDir(join(homedir(), '.claude', 'skills'), 'global')); + // 2. Check project skills directory (.claude/skills/) - const projectSkillsDir = join(cwd, '.claude', 'skills'); - if (existsSync(projectSkillsDir)) { - try { - const files = readdirSync(projectSkillsDir); - for (const file of files) { - if (file.endsWith('.md')) { - const skillPath = join(projectSkillsDir, file); - const content = readFileSync(skillPath, 'utf-8'); - skills.push({ - name: file.replace('.md', ''), - path: skillPath, - description: extractDescription(content), - source: 'project', - }); - } - } - } catch { - // Directory not readable - } - } + skills.push(...scanSkillsDir(join(cwd, '.claude', 'skills'), 'project')); - // 3. Check for skills.sh script (common pattern for skill installation) + // 3. Check .agents/skills/ directory (multi-agent skill sharing pattern) + skills.push(...scanSkillsDir(join(cwd, '.agents', 'skills'), 'agents')); + + // 4. Check for skills.sh scripts const skillsShPaths = [ join(cwd, 'skills.sh'), join(cwd, '.claude', 'skills.sh'), @@ -100,11 +146,11 @@ export function detectClaudeSkills(cwd: string): ClaudeSkill[] { if (existsSync(skillsShPath)) { try { const content = readFileSync(skillsShPath, 'utf-8'); - // Parse skills from skills.sh - // Common patterns: skill names in comments or install commands - const skillMatches = content.match(/# Skill: (.+)/gi); - if (skillMatches) { - for (const match of skillMatches) { + + // Parse "# Skill: " comments + const commentMatches = content.match(/# Skill: (.+)/gi); + if (commentMatches) { + for (const match of commentMatches) { const name = match.replace(/# Skill: /i, '').trim(); skills.push({ name, @@ -114,6 +160,23 @@ export function detectClaudeSkills(cwd: string): ClaudeSkill[] { }); } } + + // Parse "npx add-skill " install commands + const installMatches = content.match(/npx\s+add-skill\s+(\S+)/gi); + if (installMatches) { + for (const match of installMatches) { + const name = match.replace(/npx\s+add-skill\s+/i, '').trim(); + // Avoid duplicates from the comment patterns above + if (!skills.some((s) => s.name === name)) { + skills.push({ + name, + path: skillsShPath, + description: 'From skills.sh', + source: 'skills.sh', + }); + } + } + } } catch { // File not readable } @@ -203,3 +266,11 @@ export function formatSkillsForPrompt(skills: ClaudeSkill[]): string { export function hasSkills(cwd: string): boolean { return detectClaudeSkills(cwd).length > 0; } + +/** + * Find a specific skill by name across all locations + */ +export function findSkill(cwd: string, name: string): ClaudeSkill | undefined { + const skills = detectClaudeSkills(cwd); + return skills.find((s) => s.name.toLowerCase() === name.toLowerCase()); +} From baca46076f958b972289e18d241bbc9751ce596c Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:09:28 +0000 Subject: [PATCH 12/35] feat: expand skills registry with categories and info command - Add 6 curated skill entries across 4 categories (agents, development, testing, design) - Group skills by category in list output with visual separators - Add 'info' action to show installed skill details - Search now matches against categories - Add interactive browse with categorized choices Co-Authored-By: Claude Opus 4.6 --- src/commands/skill.ts | 135 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 127 insertions(+), 8 deletions(-) diff --git a/src/commands/skill.ts b/src/commands/skill.ts index 8b034aa8..ecfdf586 100644 --- a/src/commands/skill.ts +++ b/src/commands/skill.ts @@ -1,17 +1,28 @@ +import { readFileSync } from 'node:fs'; import chalk from 'chalk'; import { execa } from 'execa'; import inquirer from 'inquirer'; import ora from 'ora'; +import { findSkill } from '../loop/skills.js'; interface SkillOptions { global?: boolean; } +interface SkillEntry { + name: string; + description: string; + category: string; + skills: string[]; +} + // Popular skills registry (curated list) -const POPULAR_SKILLS = [ +const POPULAR_SKILLS: SkillEntry[] = [ + // Agents { name: 'vercel-labs/agent-skills', description: 'React, Next.js, and Vercel best practices', + category: 'agents', skills: [ 'react-best-practices', 'nextjs-best-practices', @@ -19,9 +30,49 @@ const POPULAR_SKILLS = [ 'web-design-review', ], }, - // Add more as the ecosystem grows + { + name: 'anthropics/claude-code-best-practices', + description: 'Claude Code optimization patterns and workflows', + category: 'agents', + skills: ['claude-code-patterns', 'prompt-engineering'], + }, + // Development + { + name: 'nicepkg/aide-skill', + description: 'Universal coding assistant skills for multiple editors', + category: 'development', + skills: ['code-generation', 'refactoring'], + }, + { + name: 'nickbaumann98/cursor-skills', + description: 'Cursor IDE rules and development patterns', + category: 'development', + skills: ['cursor-rules', 'code-review'], + }, + // Testing + { + name: 'testing-patterns/vitest-skills', + description: 'Testing best practices with Vitest and Jest', + category: 'testing', + skills: ['vitest-patterns', 'testing-strategies', 'mocking'], + }, + // Design + { + name: 'design-system/figma-to-code', + description: 'Figma design to code conversion workflows', + category: 'design', + skills: ['figma-react', 'design-tokens', 'component-extraction'], + }, ]; +// Category display names and order +const CATEGORY_LABELS: Record = { + agents: 'Agent Skills', + development: 'Development', + testing: 'Testing', + design: 'Design', +}; + export async function skillCommand( action: string, skillName?: string, @@ -52,6 +103,10 @@ export async function skillCommand( await browseSkills(); break; + case 'info': + await showSkillInfo(skillName); + break; + default: console.log(chalk.red(`Unknown action: ${action}`)); showSkillHelp(); @@ -102,15 +157,26 @@ async function listSkills(): Promise { console.log(chalk.cyan.bold('Popular Skills')); console.log(); - for (const repo of POPULAR_SKILLS) { - console.log(chalk.white.bold(` ${repo.name}`)); - console.log(chalk.dim(` ${repo.description}`)); - console.log(chalk.gray(` Skills: ${repo.skills.join(', ')}`)); + // Group by category + const categories = [...new Set(POPULAR_SKILLS.map((s) => s.category))]; + + for (const category of categories) { + const label = CATEGORY_LABELS[category] || category; + console.log(chalk.dim(` ── ${label} ──`)); console.log(); + + const categorySkills = POPULAR_SKILLS.filter((s) => s.category === category); + for (const repo of categorySkills) { + console.log(chalk.white.bold(` ${repo.name}`)); + console.log(chalk.dim(` ${repo.description}`)); + console.log(chalk.gray(` Skills: ${repo.skills.join(', ')}`)); + console.log(); + } } console.log(chalk.dim('Install with: ralph-starter skill add ')); - console.log(chalk.dim('Browse more: https://github.com/topics/agent-skills')); + console.log(chalk.dim('Show details: ralph-starter skill info ')); + console.log(chalk.dim('Browse more: https://github.com/topics/agent-skills')); } async function searchSkills(query?: string): Promise { @@ -129,6 +195,7 @@ async function searchSkills(query?: string): Promise { (repo) => repo.name.toLowerCase().includes(query.toLowerCase()) || repo.description.toLowerCase().includes(query.toLowerCase()) || + repo.category.toLowerCase().includes(query.toLowerCase()) || repo.skills.some((s) => s.toLowerCase().includes(query.toLowerCase())) ); @@ -141,10 +208,60 @@ async function searchSkills(query?: string): Promise { for (const repo of results) { console.log(chalk.white.bold(` ${repo.name}`)); console.log(chalk.dim(` ${repo.description}`)); + console.log(chalk.gray(` Category: ${CATEGORY_LABELS[repo.category] || repo.category}`)); console.log(); } } +async function showSkillInfo(name?: string): Promise { + if (!name) { + console.log(chalk.yellow('Please specify a skill name.')); + console.log(chalk.gray(' Example: ralph-starter skill info react-best-practices')); + return; + } + + const cwd = process.cwd(); + const skill = findSkill(cwd, name); + + if (!skill) { + console.log(chalk.yellow(`Skill "${name}" not found locally.`)); + console.log(chalk.dim(' Searched: ~/.claude/skills/, .claude/skills/, .agents/skills/')); + console.log(); + + // Check if it's in the registry + const registered = POPULAR_SKILLS.find( + (s) => + s.name.toLowerCase().includes(name.toLowerCase()) || + s.skills.some((sk) => sk.toLowerCase().includes(name.toLowerCase())) + ); + if (registered) { + console.log(chalk.cyan(`Found in registry: ${registered.name}`)); + console.log(chalk.dim(` ${registered.description}`)); + console.log(chalk.dim(` Install: ralph-starter skill add ${registered.name}`)); + } + return; + } + + console.log(); + console.log(chalk.cyan.bold(`Skill: ${skill.name}`)); + console.log(chalk.dim(` Source: ${skill.source}`)); + console.log(chalk.dim(` Path: ${skill.path}`)); + if (skill.description) { + console.log(chalk.dim(` Description: ${skill.description}`)); + } + console.log(); + + // Show skill content + try { + const content = readFileSync(skill.path, 'utf-8'); + console.log(chalk.dim('─'.repeat(60))); + console.log(content); + console.log(chalk.dim('─'.repeat(60))); + } catch { + console.log(chalk.red(' Could not read skill file')); + } +} + async function browseSkills(): Promise { const { skill } = await inquirer.prompt([ { @@ -173,12 +290,14 @@ function showSkillHelp(): void { console.log(); console.log('Commands:'); console.log(chalk.gray(' add Install a skill from a git repository')); - console.log(chalk.gray(' list List popular skills')); + console.log(chalk.gray(' list List popular skills by category')); console.log(chalk.gray(' search Search for skills')); + console.log(chalk.gray(' info Show details of an installed skill')); console.log(chalk.gray(' browse Interactive skill browser')); console.log(); console.log('Examples:'); console.log(chalk.gray(' ralph-starter skill add vercel-labs/agent-skills')); console.log(chalk.gray(' ralph-starter skill list')); console.log(chalk.gray(' ralph-starter skill search react')); + console.log(chalk.gray(' ralph-starter skill info frontend-design')); } From f8e00d0b83cec3bd7a60748e8f866bf3acd2d18b Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:10:57 +0000 Subject: [PATCH 13/35] fix: sync MCP server version with package.json Read version dynamically from package.json instead of hardcoding '0.1.0'. Co-Authored-By: Claude Opus 4.6 --- src/mcp/server.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2ac073bd..71212e9e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { @@ -12,6 +15,17 @@ import { getPrompts, handleGetPrompt } from './prompts.js'; import { getResources, handleResourceRead } from './resources.js'; import { getTools, handleToolCall } from './tools.js'; +function getPackageVersion(): string { + try { + const __dirname = dirname(fileURLToPath(import.meta.url)); + const pkgPath = join(__dirname, '..', '..', 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + return pkg.version || '0.1.0'; + } catch { + return '0.1.0'; + } +} + /** * Create and configure the MCP server */ @@ -19,7 +33,7 @@ export function createMcpServer(): Server { const server = new Server( { name: 'ralph-starter', - version: '0.1.0', + version: getPackageVersion(), }, { capabilities: { From 6aa1ae524be5fac9eadb6f1b5aed3c8cd8b89894 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:12:42 +0000 Subject: [PATCH 14/35] feat: add ralph_list_presets and ralph_fetch_spec MCP tools - Add ralph_list_presets tool to discover all 19 workflow presets by category - Add ralph_fetch_spec tool to preview specs from GitHub, Linear, Notion, and Figma without running the full coding loop - Improve all existing tool descriptions with detailed context for LLM clients - ralph_fetch_spec supports Figma modes (spec, tokens, components, content, assets) Co-Authored-By: Claude Opus 4.6 --- src/mcp/tools.ts | 192 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 173 insertions(+), 19 deletions(-) diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 133ab94e..92dae463 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -41,6 +41,31 @@ const toolSchemas = { ralph_validate: z.object({ path: z.string().describe('Project path'), }), + + ralph_list_presets: z.object({ + category: z + .string() + .optional() + .describe('Filter by category (development, debugging, review, documentation, specialized)'), + }), + + ralph_fetch_spec: z.object({ + path: z.string().describe('Project directory path'), + source: z + .enum(['github', 'linear', 'notion', 'figma']) + .describe('Integration source to fetch from'), + identifier: z + .string() + .describe( + 'Source identifier: GitHub repo/issue URL, Linear project name, Notion page URL, or Figma file URL' + ), + mode: z + .string() + .optional() + .describe('Figma-specific mode: spec, tokens, components, content, assets'), + project: z.string().optional().describe('Project or team filter (for Linear/GitHub)'), + label: z.string().optional().describe('Label filter (for GitHub/Linear issues)'), + }), }; /** @@ -51,17 +76,17 @@ export function getTools(): Tool[] { { name: 'ralph_init', description: - 'Initialize Ralph Playbook in a project. Creates AGENTS.md, PROMPT_plan.md, PROMPT_build.md, specs/, and IMPLEMENTATION_PLAN.md.', + 'Initialize Ralph Playbook in a project directory. Creates the scaffolding files needed for autonomous coding: AGENTS.md (agent config), PROMPT_plan.md and PROMPT_build.md (workflow prompts), specs/ directory, and IMPLEMENTATION_PLAN.md. Auto-detects project type (Node.js, Python, Rust, Go) and configures validation commands.', inputSchema: { type: 'object', properties: { path: { type: 'string', - description: 'Project path to initialize', + description: 'Absolute path to the project directory to initialize', }, name: { type: 'string', - description: 'Project name', + description: 'Project name (defaults to directory name)', }, }, required: ['path'], @@ -70,17 +95,17 @@ export function getTools(): Tool[] { { name: 'ralph_plan', description: - 'Create an implementation plan from specs. Analyzes specs/ directory and generates IMPLEMENTATION_PLAN.md.', + 'Create an implementation plan from specification files. Analyzes the specs/ directory using an AI coding agent and generates a structured IMPLEMENTATION_PLAN.md with checkboxed tasks. The plan breaks down the spec into actionable development tasks.', inputSchema: { type: 'object', properties: { path: { type: 'string', - description: 'Project path', + description: 'Project directory path containing specs/ folder', }, auto: { type: 'boolean', - description: 'Run in automated mode (skip permissions)', + description: 'Run in automated mode without interactive prompts', }, }, required: ['path'], @@ -89,41 +114,43 @@ export function getTools(): Tool[] { { name: 'ralph_run', description: - 'Execute an autonomous coding loop. Uses the implementation plan and agents to build the project.', + 'Execute an autonomous AI coding loop that iterates until task completion. The agent reads specs, writes code, runs validation (tests/lint/build), and auto-commits. Supports Claude Code, Cursor, Codex, OpenCode, Copilot, Gemini CLI, Amp, and Openclaw agents. Can fetch tasks from GitHub issues, Linear tickets, Notion pages, or Figma designs. Use workflow presets (see ralph_list_presets) to configure behavior.', inputSchema: { type: 'object', properties: { path: { type: 'string', - description: 'Project path', + description: 'Project directory path', }, task: { type: 'string', - description: 'Task to execute (optional if using Ralph Playbook)', + description: + 'Task description to execute. Optional if using Ralph Playbook (reads from IMPLEMENTATION_PLAN.md)', }, auto: { type: 'boolean', - description: 'Run in automated mode (skip permissions)', + description: 'Run in automated mode — processes all tasks without interactive prompts', }, commit: { type: 'boolean', - description: 'Auto-commit changes after each task', + description: 'Auto-commit changes after each completed task', }, validate: { type: 'boolean', - description: 'Run tests/lint/build validation', + description: 'Run validation commands (tests, lint, build) after each iteration', }, from: { type: 'string', - description: 'Source to fetch spec from (file, url, github, todoist, linear, notion)', + description: + 'Source integration to fetch spec from: file, url, github, linear, notion, figma', }, project: { type: 'string', - description: 'Project/repo name for source integrations', + description: 'Project/repo name filter for GitHub or Linear integrations', }, label: { type: 'string', - description: 'Label filter for source integrations', + description: 'Label filter to select specific issues from GitHub or Linear', }, }, required: ['path'], @@ -132,13 +159,13 @@ export function getTools(): Tool[] { { name: 'ralph_status', description: - 'Check Ralph Playbook status. Shows available files, implementation progress, and agent availability.', + 'Check Ralph Playbook status for a project. Returns available playbook files, implementation plan progress (completed/total tasks), and spec files. Useful for understanding where a project stands before continuing work.', inputSchema: { type: 'object', properties: { path: { type: 'string', - description: 'Project path', + description: 'Project directory path to check', }, }, required: ['path'], @@ -147,18 +174,71 @@ export function getTools(): Tool[] { { name: 'ralph_validate', description: - 'Run validation commands (tests, lint, build). Checks project health and reports issues.', + 'Run all detected validation commands (tests, linting, build) for a project. Auto-detects validation commands from package.json scripts, Makefile targets, and common patterns. Returns pass/fail status with output for each command.', inputSchema: { type: 'object', properties: { path: { type: 'string', - description: 'Project path', + description: 'Project directory path to validate', }, }, required: ['path'], }, }, + { + name: 'ralph_list_presets', + description: + 'List all available workflow presets for ralph-starter. Presets configure the coding loop behavior: iteration limits, validation, auto-commit, and specialized prompts. Categories include Development (feature, TDD, refactor), Debugging (debug, incident-response), Review (code review, PR review, adversarial), Documentation, and Specialized (API design, migration, performance).', + inputSchema: { + type: 'object', + properties: { + category: { + type: 'string', + description: + 'Filter by category: development, debugging, review, documentation, specialized. Returns all if omitted.', + }, + }, + }, + }, + { + name: 'ralph_fetch_spec', + description: + 'Fetch a specification from an external integration without running the coding loop. Returns the raw spec content as markdown. Supports GitHub (issues, PRs), Linear (tickets by project/team), Notion (pages, databases), and Figma (design specs, tokens, components, content, assets). Use this to preview what will be built before committing to a full run.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Project directory path (used as working directory)', + }, + source: { + type: 'string', + description: 'Integration source: github, linear, notion, or figma', + enum: ['github', 'linear', 'notion', 'figma'], + }, + identifier: { + type: 'string', + description: + 'Source identifier — GitHub: repo URL or "owner/repo#123", Linear: project name, Notion: page URL, Figma: file URL', + }, + mode: { + type: 'string', + description: + 'Figma-specific extraction mode: spec (design specs), tokens (design tokens), components (component code), content (text extraction), assets (icons/images)', + }, + project: { + type: 'string', + description: 'Project or team name filter (for Linear and GitHub)', + }, + label: { + type: 'string', + description: 'Label filter for issue selection (for GitHub and Linear)', + }, + }, + required: ['path', 'source', 'identifier'], + }, + }, ]; } @@ -186,6 +266,12 @@ export async function handleToolCall( case 'ralph_validate': return await handleValidate(args); + case 'ralph_list_presets': + return await handleListPresets(args); + + case 'ralph_fetch_spec': + return await handleFetchSpec(args); + default: return { content: [ @@ -344,6 +430,74 @@ async function handleValidate( }; } +async function handleListPresets( + args: Record | undefined +): Promise<{ content: Array<{ type: 'text'; text: string }> }> { + const parsed = toolSchemas.ralph_list_presets.parse(args); + + const { getPresetsByCategory } = await import('../presets/index.js'); + + const allCategories = getPresetsByCategory(); + const filterCategory = parsed.category?.toLowerCase(); + + const result: Record< + string, + Array<{ + name: string; + description: string; + maxIterations: number; + validate: boolean; + commit: boolean; + }> + > = {}; + + for (const [category, presets] of Object.entries(allCategories)) { + if (filterCategory && !category.toLowerCase().includes(filterCategory)) { + continue; + } + result[category] = presets.map((p) => ({ + name: p.name, + description: p.description, + maxIterations: p.maxIterations, + validate: p.validate, + commit: p.commit, + })); + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; +} + +async function handleFetchSpec( + args: Record | undefined +): Promise<{ content: Array<{ type: 'text'; text: string }> }> { + const parsed = toolSchemas.ralph_fetch_spec.parse(args); + + const { fetchFromIntegration } = await import('../integrations/index.js'); + + const options: Record = {}; + if (parsed.mode) options.mode = parsed.mode; + if (parsed.project) options.project = parsed.project; + if (parsed.label) options.label = parsed.label; + + const result = await fetchFromIntegration(parsed.source, parsed.identifier, options); + + return { + content: [ + { + type: 'text', + text: typeof result === 'string' ? result : JSON.stringify(result, null, 2), + }, + ], + }; +} + function formatInitResult(result: InitCoreResult): string { if (result.success) { return `Successfully initialized Ralph Playbook at ${result.path}\n\nFiles created:\n${result.filesCreated.map((f) => `- ${f}`).join('\n')}`; From aaf1eae6ab9253b9d434e1e3b863030edce475e9 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:13:36 +0000 Subject: [PATCH 15/35] feat: add figma_to_code and batch_issues MCP prompts - Add figma_to_code prompt for Figma design-to-code workflow with framework and mode selection (spec, tokens, components, content) - Add batch_issues prompt for processing multiple GitHub/Linear issues automatically with auto mode - Update fetch_and_build prompt to include Figma as a source option - Update fetch_and_build to use ralph_fetch_spec for preview before building Co-Authored-By: Claude Opus 4.6 --- src/mcp/prompts.ts | 130 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 123 insertions(+), 7 deletions(-) diff --git a/src/mcp/prompts.ts b/src/mcp/prompts.ts index a01c84ec..235a910a 100644 --- a/src/mcp/prompts.ts +++ b/src/mcp/prompts.ts @@ -45,16 +45,17 @@ export function getPrompts(): Prompt[] { }, { name: 'fetch_and_build', - description: 'Fetch a spec from a source and start building', + description: + 'Fetch a spec from an external source (GitHub, Linear, Notion, Figma) and start building', arguments: [ { name: 'source', - description: 'Source to fetch from (url, github, todoist, linear, notion)', + description: 'Source to fetch from (url, github, linear, notion, figma)', required: true, }, { name: 'identifier', - description: 'Source identifier (URL, project name, etc.)', + description: 'Source identifier (URL, project name, issue number, Figma file URL, etc.)', required: true, }, { @@ -64,6 +65,62 @@ export function getPrompts(): Prompt[] { }, ], }, + { + name: 'figma_to_code', + description: + 'Extract a Figma design and build it as code. Supports design specs, tokens, components, and content extraction.', + arguments: [ + { + name: 'figma_url', + description: 'Figma file or frame URL', + required: true, + }, + { + name: 'framework', + description: + 'Target framework: react, vue, svelte, astro, nextjs, nuxt, html (default: react)', + required: false, + }, + { + name: 'mode', + description: + 'Extraction mode: spec (full design spec), tokens (design tokens as CSS/Tailwind), components (component code), content (text/IA extraction)', + required: false, + }, + { + name: 'path', + description: 'Project directory to build in', + required: false, + }, + ], + }, + { + name: 'batch_issues', + description: + 'Process multiple GitHub or Linear issues automatically in sequence. Each issue becomes a task with its own branch, commits, and PR.', + arguments: [ + { + name: 'source', + description: 'Issue source: github or linear', + required: true, + }, + { + name: 'project', + description: 'GitHub repo (owner/repo) or Linear project name', + required: true, + }, + { + name: 'label', + description: 'Filter issues by label (e.g., "good first issue", "bug", "enhancement")', + required: false, + }, + { + name: 'path', + description: 'Project directory path', + required: false, + }, + ], + }, ]; } @@ -150,13 +207,14 @@ Show me where we are!`, type: 'text', text: `Please fetch a spec and build a project from it. -Source: ${args?.source || '(specify source)'} +Source: ${args?.source || '(specify source: github, linear, notion, or figma)'} Identifier: ${args?.identifier || '(specify identifier)'} Path: ${cwd} -1. First, initialize Ralph Playbook at the path if needed -2. Use ralph_run with the --from option to fetch the spec and start building -3. Monitor progress and help resolve any issues +1. First, use ralph_fetch_spec to preview the spec content +2. Initialize Ralph Playbook at the path if needed +3. Use ralph_run with the --from option to fetch the spec and start building +4. Monitor progress and help resolve any issues Let's build it!`, }, @@ -164,6 +222,64 @@ Let's build it!`, ], }; + case 'figma_to_code': + return { + description: 'Convert Figma design to code', + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please extract a Figma design and build it as code. + +Figma URL: ${args?.figma_url || '(specify Figma file URL)'} +Framework: ${args?.framework || 'react'} +Mode: ${args?.mode || 'spec'} +Path: ${cwd} + +1. First, use ralph_fetch_spec with source "figma" to extract the design: + - Use mode "${args?.mode || 'spec'}" to get ${args?.mode === 'tokens' ? 'design tokens' : args?.mode === 'components' ? 'component structure' : args?.mode === 'content' ? 'text content and IA' : 'the full design specification'} +2. Review the extracted spec — check colors, typography, spacing, and component structure +3. Initialize Ralph Playbook if needed, then use ralph_run to build the ${args?.framework || 'React'} implementation +4. The agent will iterate until the UI matches the design spec, running validation between iterations + +Let's bring this design to life!`, + }, + }, + ], + }; + + case 'batch_issues': + return { + description: 'Process multiple issues automatically', + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process multiple issues from ${args?.source || '(github or linear)'} automatically. + +Source: ${args?.source || '(specify: github or linear)'} +Project: ${args?.project || '(specify repo or project name)'} +${args?.label ? `Label filter: ${args.label}` : 'Label: (all issues)'} +Path: ${cwd} + +1. First, use ralph_fetch_spec to preview the available issues from ${args?.source || 'the source'} + - Project: ${args?.project || '(specify)'} + ${args?.label ? `- Filter by label: "${args.label}"` : ''} +2. Review the issues and confirm which ones to process +3. Use ralph_run with auto mode enabled to process each issue: + - Each issue gets its own branch + - Code changes are validated and auto-committed + - A PR is created for each completed issue +4. Monitor progress across all issues + +Let's batch process these issues!`, + }, + }, + ], + }; + default: throw new Error(`Unknown prompt: ${name}`); } From 6e41ab16be7dfa6bbb56750b24a968783d8941cc Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:14:12 +0000 Subject: [PATCH 16/35] feat: expose activity log as MCP resource Add .ralph/activity.md as a readable MCP resource so Claude Desktop and other MCP clients can access loop execution history, timing data, and cost information. Co-Authored-By: Claude Opus 4.6 --- src/mcp/resources.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/mcp/resources.ts b/src/mcp/resources.ts index 25fa021a..dfbd521a 100644 --- a/src/mcp/resources.ts +++ b/src/mcp/resources.ts @@ -67,6 +67,18 @@ export async function getResources(): Promise { } } + // Activity log + const activityPath = join(cwd, '.ralph', 'activity.md'); + if (existsSync(activityPath)) { + resources.push({ + uri: 'ralph://project/activity', + name: 'Activity Log', + description: + 'Loop execution history with timing, cost data, and task outcomes (.ralph/activity.md)', + mimeType: 'text/markdown', + }); + } + return resources; } @@ -105,6 +117,10 @@ export async function handleResourceRead(uri: string): Promise<{ filePath = join(cwd, 'PROMPT_plan.md'); break; + case 'activity': + filePath = join(cwd, '.ralph', 'activity.md'); + break; + default: // Handle specs if (resourcePath.startsWith('specs/')) { From b200041fa8fe86520399d8b1e0c20d0713201e4f Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:28:23 +0000 Subject: [PATCH 17/35] feat: add smart context windowing to reduce input tokens per iteration Introduces a context builder that progressively narrows the prompt sent to agents across loop iterations. Iteration 1 gets full context (spec + skills + plan), iterations 2-3 get trimmed plan context, and iterations 4+ get minimal context with just the current task. Validation feedback is compressed to reduce token waste. Adds --context-budget flag for optional token budget enforcement. Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 4 + src/commands/run.ts | 2 + src/loop/context-builder.ts | 229 ++++++++++++++++++++++++++++++++++++ src/loop/executor.ts | 54 ++++----- 4 files changed, 257 insertions(+), 32 deletions(-) create mode 100644 src/loop/context-builder.ts diff --git a/src/cli.ts b/src/cli.ts index f4105608..777d12a5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -81,6 +81,10 @@ program .option('--no-track-cost', 'Disable cost tracking') .option('--circuit-breaker-failures ', 'Max consecutive failures before stopping (default: 3)') .option('--circuit-breaker-errors ', 'Max same error occurrences before stopping (default: 5)') + .option( + '--context-budget ', + 'Max input tokens per iteration for smart context trimming (0 = unlimited)' + ) // Figma integration options .option('--figma-mode ', 'Figma mode: spec, tokens, components, assets, content') .option( diff --git a/src/commands/run.ts b/src/commands/run.ts index 3a84cff0..b4baeb69 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -215,6 +215,7 @@ export interface RunCommandOptions { trackCost?: boolean; circuitBreakerFailures?: number; circuitBreakerErrors?: number; + contextBudget?: number; // Figma options figmaMode?: 'spec' | 'tokens' | 'components' | 'assets' | 'content'; figmaFramework?: 'react' | 'vue' | 'svelte' | 'astro' | 'nextjs' | 'nuxt' | 'html'; @@ -584,6 +585,7 @@ Focus on one task at a time. After completing a task, update IMPLEMENTATION_PLAN trackCost: options.trackCost ?? true, // Default to true model: agent.type === 'claude-code' ? 'claude-3-sonnet' : 'default', checkFileCompletion: true, // Always check for file-based completion + contextBudget: options.contextBudget ? Number(options.contextBudget) : undefined, circuitBreaker: preset?.circuitBreaker ? { maxConsecutiveFailures: diff --git a/src/loop/context-builder.ts b/src/loop/context-builder.ts new file mode 100644 index 00000000..36267eb8 --- /dev/null +++ b/src/loop/context-builder.ts @@ -0,0 +1,229 @@ +/** + * Context builder for intelligent prompt trimming across loop iterations. + * + * Reduces input tokens by progressively narrowing the context sent to the agent: + * - Iteration 1: Full spec + skills + full implementation plan + * - Iterations 2-3: Abbreviated spec + current task only + compressed feedback + * - Iterations 4+: Current task only + error summary + */ + +import { estimateTokens } from './cost-tracker.js'; +import type { PlanTask, TaskCount } from './task-counter.js'; + +export interface ContextBuildOptions { + /** The full task/spec content (original prompt) */ + fullTask: string; + /** Task with skills appended */ + taskWithSkills: string; + /** Current task from IMPLEMENTATION_PLAN.md */ + currentTask: PlanTask | null; + /** Task count info */ + taskInfo: TaskCount; + /** Current iteration number (1-based) */ + iteration: number; + /** Max iterations for this loop */ + maxIterations: number; + /** Validation feedback from previous iteration */ + validationFeedback?: string; + /** Maximum input tokens budget (0 = unlimited) */ + maxInputTokens?: number; +} + +export interface BuiltContext { + /** The assembled prompt to send to the agent */ + prompt: string; + /** Estimated token count */ + estimatedTokens: number; + /** Whether the context was trimmed */ + wasTrimmed: boolean; + /** Debug info about what was included/excluded */ + debugInfo: string; +} + +/** + * Strip ANSI escape codes from text + */ +function stripAnsi(text: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape codes requires control chars + return text.replace(/\x1b\[[0-9;]*m/g, ''); +} + +/** + * Compress validation feedback to reduce token usage. + * Keeps only the failing command names and truncated error output. + */ +export function compressValidationFeedback(feedback: string, maxChars: number = 2000): string { + if (!feedback) return ''; + + const stripped = stripAnsi(feedback); + + // Already under budget + if (stripped.length <= maxChars) return stripped; + + const lines = stripped.split('\n'); + const compressed: string[] = ['## Validation Failed\n']; + let currentLength = compressed[0].length; + + for (const line of lines) { + // Always include headers (### command name) + if (line.startsWith('### ') || line.startsWith('## ')) { + compressed.push(line); + currentLength += line.length + 1; + continue; + } + + // Include error lines up to budget + if (currentLength + line.length + 1 <= maxChars - 50) { + compressed.push(line); + currentLength += line.length + 1; + } + } + + compressed.push('\nPlease fix the above issues before continuing.'); + return compressed.join('\n'); +} + +/** + * Build a trimmed implementation plan context showing only the current task + * with a summary of completed and pending tasks. + */ +export function buildTrimmedPlanContext(currentTask: PlanTask, taskInfo: TaskCount): string { + const completedCount = taskInfo.completed; + const pendingCount = taskInfo.pending; + const taskNum = completedCount + 1; + + const subtasksList = + currentTask.subtasks + ?.map((st) => { + const checkbox = st.completed ? '[x]' : '[ ]'; + return `- ${checkbox} ${st.name}`; + }) + .join('\n') || ''; + + const lines: string[] = []; + + if (completedCount > 0) { + lines.push(`> ${completedCount} task(s) already completed.`); + } + + lines.push(`\n## Current Task (${taskNum}/${taskInfo.total}): ${currentTask.name}\n`); + + if (subtasksList) { + lines.push('Subtasks:'); + lines.push(subtasksList); + } + + if (pendingCount > 1) { + lines.push(`\n> ${pendingCount - 1} more task(s) remaining after this one.`); + } + + lines.push( + '\nComplete these subtasks, then mark them done in IMPLEMENTATION_PLAN.md by changing [ ] to [x].' + ); + + return lines.join('\n'); +} + +/** + * Build the iteration context with intelligent trimming. + */ +export function buildIterationContext(opts: ContextBuildOptions): BuiltContext { + const { + fullTask, + taskWithSkills, + currentTask, + taskInfo, + iteration, + validationFeedback, + maxInputTokens = 0, + } = opts; + + const totalTasks = taskInfo.total; + const completedTasks = taskInfo.completed; + const debugParts: string[] = []; + let prompt: string; + + // No structured tasks — just pass the task as-is + if (!currentTask || totalTasks === 0) { + prompt = taskWithSkills; + if (validationFeedback) { + const compressed = compressValidationFeedback(validationFeedback); + prompt = `${prompt}\n\n${compressed}`; + } + debugParts.push('mode=raw (no structured tasks)'); + } else if (iteration === 1) { + // Iteration 1: Full context — spec + skills + full current task details + const taskNum = completedTasks + 1; + const subtasksList = currentTask.subtasks?.map((st) => `- [ ] ${st.name}`).join('\n') || ''; + + prompt = `${taskWithSkills} + +## Current Task (${taskNum}/${totalTasks}): ${currentTask.name} + +Subtasks: +${subtasksList} + +Complete these subtasks, then mark them done in IMPLEMENTATION_PLAN.md by changing [ ] to [x].`; + + debugParts.push('mode=full (iteration 1)'); + debugParts.push(`included: full spec + skills + task ${taskNum}/${totalTasks}`); + } else if (iteration <= 3) { + // Iterations 2-3: Trimmed plan context + abbreviated spec reference + const planContext = buildTrimmedPlanContext(currentTask, taskInfo); + + prompt = `Continue working on the project. Check IMPLEMENTATION_PLAN.md for full progress. + +${planContext}`; + + // Add compressed validation feedback if present + if (validationFeedback) { + const compressed = compressValidationFeedback(validationFeedback, 2000); + prompt = `${prompt}\n\n${compressed}`; + debugParts.push('included: compressed validation feedback'); + } + + debugParts.push(`mode=trimmed (iteration ${iteration})`); + debugParts.push(`excluded: full spec, skills`); + } else { + // Iterations 4+: Minimal context — just current task + const planContext = buildTrimmedPlanContext(currentTask, taskInfo); + + prompt = `Continue working on the project. + +${planContext}`; + + // Add heavily compressed validation feedback if present + if (validationFeedback) { + const compressed = compressValidationFeedback(validationFeedback, 500); + prompt = `${prompt}\n\n${compressed}`; + debugParts.push('included: minimal validation feedback (500 chars)'); + } + + debugParts.push(`mode=minimal (iteration ${iteration})`); + debugParts.push('excluded: spec, skills, plan history'); + } + + // Apply token budget if set + let wasTrimmed = iteration > 1 && currentTask !== null && totalTasks > 0; + const estimatedTokens = estimateTokens(prompt); + + if (maxInputTokens > 0 && estimatedTokens > maxInputTokens) { + // Aggressively trim: truncate the prompt to fit budget + const targetChars = maxInputTokens * 3.5; // rough chars-per-token + if (prompt.length > targetChars) { + prompt = `${prompt.slice(0, targetChars)}\n\n[Context truncated to fit ${maxInputTokens} token budget]`; + wasTrimmed = true; + debugParts.push(`truncated: ${estimatedTokens} -> ~${maxInputTokens} tokens`); + } + } + + const finalTokens = estimateTokens(prompt); + debugParts.push(`tokens: ~${finalTokens}`); + + return { + prompt, + estimatedTokens: finalTokens, + wasTrimmed, + debugInfo: debugParts.join(' | '), + }; +} diff --git a/src/loop/executor.ts b/src/loop/executor.ts index ebd8eb6e..d2b8fc9a 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -15,6 +15,7 @@ import { import { ProgressRenderer } from '../ui/progress-renderer.js'; import { type Agent, type AgentRunOptions, runAgent } from './agents.js'; import { CircuitBreaker, type CircuitBreakerConfig } from './circuit-breaker.js'; +import { buildIterationContext, compressValidationFeedback } from './context-builder.js'; import { CostTracker, type CostTrackerStats, formatCost } from './cost-tracker.js'; import { estimateLoop, formatEstimateDetailed } from './estimator.js'; import { checkFileBasedCompletion, createProgressTracker, type ProgressEntry } from './progress.js'; @@ -135,6 +136,7 @@ export interface LoopOptions { checkFileCompletion?: boolean; // Check for RALPH_COMPLETE file trackCost?: boolean; // Track token usage and cost model?: string; // Model name for cost estimation + contextBudget?: number; // Max input tokens per iteration (0 = unlimited) } export interface LoopResult { @@ -542,36 +544,23 @@ export async function runLoop(options: LoopOptions): Promise { const iterProgress = new ProgressRenderer(); iterProgress.start('Working...'); - // Build iteration-specific task with current task context - let iterationTask: string; - if (currentTask && totalTasks > 0) { - const taskNum = completedTasks + 1; - // Get subtasks for current task - const subtasksList = currentTask.subtasks?.map((st) => `- [ ] ${st.name}`).join('\n') || ''; - - if (i === 1) { - // First iteration: include full context - iterationTask = `${taskWithSkills} - -## Current Task (${taskNum}/${totalTasks}): ${currentTask.name} - -Subtasks: -${subtasksList} - -Complete these subtasks, then mark them done in IMPLEMENTATION_PLAN.md by changing [ ] to [x].`; - } else { - // Subsequent iterations: focused task only (context already established) - iterationTask = `Continue working on the project. Check IMPLEMENTATION_PLAN.md for progress. - -## Current Task (${taskNum}/${totalTasks}): ${currentTask.name} - -Subtasks: -${subtasksList} - -Complete these subtasks, then mark them done in IMPLEMENTATION_PLAN.md by changing [ ] to [x].`; - } - } else { - iterationTask = taskWithSkills; + // Build iteration-specific task with smart context windowing + const builtContext = buildIterationContext({ + fullTask: options.task, + taskWithSkills, + currentTask, + taskInfo, + iteration: i, + maxIterations, + validationFeedback: undefined, // Validation feedback handled separately below + maxInputTokens: options.contextBudget || 0, + }); + const iterationTask = builtContext.prompt; + + // Debug: log context builder output + if (process.env.RALPH_DEBUG) { + console.error(`[DEBUG] Context: ${builtContext.debugInfo}`); + console.error(`[DEBUG] Trimmed: ${builtContext.wasTrimmed}`); } // Debug: log the prompt being sent @@ -774,8 +763,9 @@ Complete these subtasks, then mark them done in IMPLEMENTATION_PLAN.md by changi await progressTracker.appendEntry(progressEntry); } - // Continue loop with validation feedback - taskWithSkills = `${taskWithSkills}\n\n${feedback}`; + // Continue loop with compressed validation feedback + const compressedFeedback = compressValidationFeedback(feedback); + taskWithSkills = `${taskWithSkills}\n\n${compressedFeedback}`; continue; // Go to next iteration to fix issues } else { // Validation passed - record success From 53b518d6e3d8976dd06022af18828ba3474b3f12 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:31:47 +0000 Subject: [PATCH 18/35] feat: add Anthropic SDK with prompt caching support Replaces raw fetch calls to Anthropic API with the official @anthropic-ai/sdk, enabling prompt caching via cache_control on system messages. Cache reads are 90% cheaper than regular input tokens. Adds cache-aware pricing to cost tracker with savings metrics displayed in CLI output and activity summaries. Also adds system message support and usage tracking for OpenAI/OpenRouter providers. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 49 ++++++++++++++++++ package.json | 5 +- src/llm/api.ts | 107 ++++++++++++++++++++++++++++----------- src/loop/cost-tracker.ts | 80 ++++++++++++++++++++++++++++- 4 files changed, 207 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6d0c5483..c4d7db86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.1-beta.15", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.73.0", "@modelcontextprotocol/sdk": "^1.0.0", "chalk": "^5.3.0", "chalk-animation": "^2.0.3", @@ -57,6 +58,26 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", + "integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -106,6 +127,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -4993,6 +5023,19 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -6933,6 +6976,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 8adfdeb7..b4b4d58f 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "access": "public" }, "dependencies": { + "@anthropic-ai/sdk": "^0.73.0", "@modelcontextprotocol/sdk": "^1.0.0", "chalk": "^5.3.0", "chalk-animation": "^2.0.3", @@ -83,13 +84,13 @@ "@commitlint/config-conventional": "^20.3.1", "@types/inquirer": "^9.0.7", "@types/node": "^25.1.0", + "@vitest/coverage-v8": "^2.1.8", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", "husky": "^9.1.0", "lint-staged": "^16.2.7", "typescript": "^5.7.3", - "vitest": "^2.1.8", - "@vitest/coverage-v8": "^2.1.8" + "vitest": "^2.1.8" }, "lint-staged": { "src/**/*.ts": [ diff --git a/src/llm/api.ts b/src/llm/api.ts index 23fe6e43..e0f7825b 100644 --- a/src/llm/api.ts +++ b/src/llm/api.ts @@ -1,8 +1,9 @@ /** * Unified LLM API for ralph-starter - * Supports Anthropic, OpenAI, and OpenRouter + * Supports Anthropic (via SDK with prompt caching), OpenAI, and OpenRouter */ +import Anthropic from '@anthropic-ai/sdk'; import { getProviderKeyFromEnv, type LLMProvider, PROVIDERS } from './providers.js'; // Timeout for API calls (30 seconds) @@ -10,14 +11,24 @@ const API_TIMEOUT_MS = 30000; export interface LLMRequest { prompt: string; + /** Optional system message (will be cached for Anthropic) */ + system?: string; maxTokens?: number; model?: string; } +export interface LLMUsage { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; +} + export interface LLMResponse { content: string; model: string; provider: LLMProvider; + usage?: LLMUsage; } /** @@ -47,48 +58,73 @@ async function fetchWithTimeout( } } +// Singleton Anthropic client (reused for connection pooling and caching) +let anthropicClient: Anthropic | null = null; + +function getAnthropicClient(apiKey: string): Anthropic { + if (!anthropicClient) { + anthropicClient = new Anthropic({ apiKey, timeout: API_TIMEOUT_MS }); + } + return anthropicClient; +} + /** - * Call Anthropic API + * Call Anthropic API using the official SDK with prompt caching support. + * + * When a `system` message is provided, it is marked with `cache_control` + * so that repeated calls with the same system prompt benefit from + * Anthropic's prompt caching (90% cheaper on cache reads). */ async function callAnthropic(apiKey: string, request: LLMRequest): Promise { const config = PROVIDERS.anthropic; const model = request.model || config.defaultModel; + const client = getAnthropicClient(apiKey); - const response = await fetchWithTimeout(config.apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model, - max_tokens: request.maxTokens || 1024, - messages: [ + // Build system message with cache control if provided + const system: Anthropic.Messages.TextBlockParam[] | undefined = request.system + ? [ { - role: 'user', - content: request.prompt, + type: 'text' as const, + text: request.system, + cache_control: { type: 'ephemeral' as const }, }, - ], - }), - }); + ] + : undefined; - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Anthropic API error ${response.status}: ${errorText}`); - } + const response = await client.messages.create({ + model, + max_tokens: request.maxTokens || 1024, + system, + messages: [ + { + role: 'user', + content: request.prompt, + }, + ], + }); - const data = await response.json(); - const content = data.content?.[0]?.text; + const textBlock = response.content.find((block) => block.type === 'text'); + const content = textBlock && 'text' in textBlock ? textBlock.text : undefined; if (!content) { throw new Error('No response content from Anthropic'); } + // Extract usage including cache metrics + // Cache fields may exist on the usage object but aren't in the base type + const rawUsage = response.usage as unknown as Record; + const usage: LLMUsage = { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + cacheCreationInputTokens: rawUsage.cache_creation_input_tokens, + cacheReadInputTokens: rawUsage.cache_read_input_tokens, + }; + return { content, model, provider: 'anthropic', + usage, }; } @@ -114,18 +150,20 @@ async function callOpenAICompatible( headers['X-Title'] = 'ralph-starter'; } + // Build messages array, optionally including system message + const messages: { role: string; content: string }[] = []; + if (request.system) { + messages.push({ role: 'system', content: request.system }); + } + messages.push({ role: 'user', content: request.prompt }); + const response = await fetchWithTimeout(config.apiUrl, { method: 'POST', headers, body: JSON.stringify({ model, max_tokens: request.maxTokens || 1024, - messages: [ - { - role: 'user', - content: request.prompt, - }, - ], + messages, }), }); @@ -141,10 +179,19 @@ async function callOpenAICompatible( throw new Error(`No response content from ${config.displayName}`); } + // Extract usage if available + const usage: LLMUsage | undefined = data.usage + ? { + inputTokens: data.usage.prompt_tokens || 0, + outputTokens: data.usage.completion_tokens || 0, + } + : undefined; + return { content, model, provider, + usage, }; } diff --git a/src/loop/cost-tracker.ts b/src/loop/cost-tracker.ts index 6261e7fa..97700af8 100644 --- a/src/loop/cost-tracker.ts +++ b/src/loop/cost-tracker.ts @@ -7,6 +7,8 @@ export interface ModelPricing { name: string; inputPricePerMillion: number; // USD per 1M input tokens outputPricePerMillion: number; // USD per 1M output tokens + cacheWritePricePerMillion?: number; // USD per 1M cache write tokens (1.25x input) + cacheReadPricePerMillion?: number; // USD per 1M cache read tokens (0.1x input) } // Pricing as of January 2026 (approximate) @@ -15,16 +17,22 @@ export const MODEL_PRICING: Record = { name: 'Claude 3 Opus', inputPricePerMillion: 15, outputPricePerMillion: 75, + cacheWritePricePerMillion: 18.75, // 1.25x input + cacheReadPricePerMillion: 1.5, // 0.1x input }, 'claude-3-sonnet': { name: 'Claude 3.5 Sonnet', inputPricePerMillion: 3, outputPricePerMillion: 15, + cacheWritePricePerMillion: 3.75, // 1.25x input + cacheReadPricePerMillion: 0.3, // 0.1x input }, 'claude-3-haiku': { name: 'Claude 3.5 Haiku', inputPricePerMillion: 0.25, outputPricePerMillion: 1.25, + cacheWritePricePerMillion: 0.3125, // 1.25x input + cacheReadPricePerMillion: 0.025, // 0.1x input }, 'gpt-4': { name: 'GPT-4', @@ -56,10 +64,17 @@ export interface CostEstimate { totalCost: number; } +export interface CacheMetrics { + cacheCreationTokens: number; + cacheReadTokens: number; + cacheSavings: number; // USD saved by cache reads vs full-price input +} + export interface IterationCost { iteration: number; tokens: TokenEstimate; cost: CostEstimate; + cache?: CacheMetrics; timestamp: Date; } @@ -70,6 +85,7 @@ export interface CostTrackerStats { avgTokensPerIteration: TokenEstimate; avgCostPerIteration: CostEstimate; projectedCost?: CostEstimate; // Projected cost for remaining iterations + totalCacheSavings: number; // USD saved by prompt caching iterations: IterationCost[]; } @@ -146,7 +162,7 @@ export class CostTracker { } /** - * Record an iteration's token usage + * Record an iteration's token usage (estimated from text) */ recordIteration(input: string, output: string): IterationCost { const inputTokens = estimateTokens(input); @@ -171,6 +187,54 @@ export class CostTracker { return iterationCost; } + /** + * Record an iteration with actual API usage data (includes cache metrics) + */ + recordIterationWithUsage(usage: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }): IterationCost { + const tokens: TokenEstimate = { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens: usage.inputTokens + usage.outputTokens, + }; + + const cost = calculateCost(tokens, this.pricing); + + // Calculate cache metrics if available + let cache: CacheMetrics | undefined; + if (usage.cacheCreationInputTokens || usage.cacheReadInputTokens) { + const cacheCreationTokens = usage.cacheCreationInputTokens || 0; + const cacheReadTokens = usage.cacheReadInputTokens || 0; + + // Cache savings = what those cache-read tokens would have cost at full price minus cache price + const fullPriceCost = (cacheReadTokens / 1_000_000) * this.pricing.inputPricePerMillion; + const cachedCost = this.pricing.cacheReadPricePerMillion + ? (cacheReadTokens / 1_000_000) * this.pricing.cacheReadPricePerMillion + : fullPriceCost; + + cache = { + cacheCreationTokens, + cacheReadTokens, + cacheSavings: fullPriceCost - cachedCost, + }; + } + + const iterationCost: IterationCost = { + iteration: this.iterations.length + 1, + tokens, + cost, + cache, + timestamp: new Date(), + }; + + this.iterations.push(iterationCost); + return iterationCost; + } + /** * Get current statistics */ @@ -184,6 +248,7 @@ export class CostTracker { totalCost: { inputCost: 0, outputCost: 0, totalCost: 0 }, avgTokensPerIteration: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, avgCostPerIteration: { inputCost: 0, outputCost: 0, totalCost: 0 }, + totalCacheSavings: 0, iterations: [], }; } @@ -225,6 +290,12 @@ export class CostTracker { } } + // Sum cache savings across all iterations + const totalCacheSavings = this.iterations.reduce( + (sum, i) => sum + (i.cache?.cacheSavings || 0), + 0 + ); + return { totalIterations, totalTokens, @@ -232,6 +303,7 @@ export class CostTracker { avgTokensPerIteration, avgCostPerIteration, projectedCost, + totalCacheSavings, iterations: this.iterations, }; } @@ -251,6 +323,10 @@ export class CostTracker { `Cost: ${formatCost(stats.totalCost.totalCost)} (${formatCost(stats.avgCostPerIteration.totalCost)}/iteration avg)`, ]; + if (stats.totalCacheSavings > 0) { + lines.push(`Cache savings: ${formatCost(stats.totalCacheSavings)}`); + } + if (stats.projectedCost) { lines.push(`Projected max cost: ${formatCost(stats.projectedCost.totalCost)}`); } @@ -279,7 +355,7 @@ export class CostTracker { | Output Tokens | ${formatTokens(stats.totalTokens.outputTokens)} | | Total Cost | ${formatCost(stats.totalCost.totalCost)} | | Avg Cost/Iteration | ${formatCost(stats.avgCostPerIteration.totalCost)} | -${stats.projectedCost ? `| Projected Max Cost | ${formatCost(stats.projectedCost.totalCost)} |` : ''} +${stats.totalCacheSavings > 0 ? `| Cache Savings | ${formatCost(stats.totalCacheSavings)} |\n` : ''}${stats.projectedCost ? `| Projected Max Cost | ${formatCost(stats.projectedCost.totalCost)} |` : ''} `; } From 912ab71402db76339482983db891f71a806eba6b Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 06:35:34 +0000 Subject: [PATCH 19/35] feat: add Anthropic Batch API support for 50% cost reduction Adds --batch flag to ralph-starter auto command that submits tasks via the Anthropic Batch API instead of running agent loops. Batch requests are processed asynchronously at 50% cost reduction. Includes polling with exponential backoff, progress display, and cost savings summary. Note: batch mode uses the API directly (no tool use), best for planning, code generation, and review tasks. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 49 +++++++++++ package.json | 5 +- src/cli.ts | 4 + src/commands/auto.ts | 201 ++++++++++++++++++++++++++++++++++++++++++- src/llm/batch.ts | 192 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 src/llm/batch.ts diff --git a/package-lock.json b/package-lock.json index 6d0c5483..c4d7db86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.1-beta.15", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.73.0", "@modelcontextprotocol/sdk": "^1.0.0", "chalk": "^5.3.0", "chalk-animation": "^2.0.3", @@ -57,6 +58,26 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", + "integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -106,6 +127,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -4993,6 +5023,19 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -6933,6 +6976,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 8adfdeb7..b4b4d58f 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "access": "public" }, "dependencies": { + "@anthropic-ai/sdk": "^0.73.0", "@modelcontextprotocol/sdk": "^1.0.0", "chalk": "^5.3.0", "chalk-animation": "^2.0.3", @@ -83,13 +84,13 @@ "@commitlint/config-conventional": "^20.3.1", "@types/inquirer": "^9.0.7", "@types/node": "^25.1.0", + "@vitest/coverage-v8": "^2.1.8", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", "husky": "^9.1.0", "lint-staged": "^16.2.7", "typescript": "^5.7.3", - "vitest": "^2.1.8", - "@vitest/coverage-v8": "^2.1.8" + "vitest": "^2.1.8" }, "lint-staged": { "src/**/*.ts": [ diff --git a/src/cli.ts b/src/cli.ts index f4105608..66d92922 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -235,6 +235,8 @@ program .option('--validate', 'Run validation after each task', true) .option('--no-validate', 'Skip validation') .option('--max-iterations ', 'Max iterations per task (default: 15)') + .option('--batch', 'Use Anthropic Batch API for 50% cost reduction (no tool use)') + .option('--model ', 'Model to use in batch mode') .action(async (options) => { await autoCommand({ source: options.source, @@ -246,6 +248,8 @@ program agent: options.agent, validate: options.validate, maxIterations: options.maxIterations ? parseInt(options.maxIterations, 10) : undefined, + batch: options.batch, + model: options.model, }); }); diff --git a/src/commands/auto.ts b/src/commands/auto.ts index 44c4a042..f9badbe4 100644 --- a/src/commands/auto.ts +++ b/src/commands/auto.ts @@ -8,6 +8,14 @@ import chalk from 'chalk'; import ora from 'ora'; import { hasUncommittedChanges, isGitRepo } from '../automation/git.js'; +import { + type BatchRequest, + type BatchResult, + getBatchResults, + submitBatch, + waitForBatch, +} from '../llm/batch.js'; +import { getProviderKeyFromEnv } from '../llm/providers.js'; import { detectBestAgent } from '../loop/agents.js'; import { type BatchTask, completeTask, fetchBatchTasks } from '../loop/batch-fetcher.js'; import { executeTaskBatch } from '../loop/task-executor.js'; @@ -31,6 +39,10 @@ export interface AutoModeOptions { validate?: boolean; /** Max iterations per task */ maxIterations?: number; + /** Use Anthropic Batch API for 50% cost reduction (no tool use) */ + batch?: boolean; + /** Model to use for batch mode */ + model?: string; } /** @@ -139,7 +151,13 @@ export async function autoCommand(options: AutoModeOptions): Promise { return; } - // Execute tasks + // Batch API mode: submit all tasks to Anthropic Batch API + if (options.batch) { + await executeBatchApi(tasks, options); + return; + } + + // Execute tasks (standard agent mode) console.log(chalk.bold('Starting batch execution...')); console.log(); @@ -209,3 +227,184 @@ export async function autoCommand(options: AutoModeOptions): Promise { console.log(); } + +/** + * Execute tasks via Anthropic Batch API for 50% cost reduction. + * + * NOTE: Batch mode uses the API directly (no tool use). Best for + * planning, code generation, and review — not full agent loops. + */ +async function executeBatchApi(tasks: BatchTask[], options: AutoModeOptions): Promise { + const spinner = ora(); + + // Check for Anthropic API key + const apiKey = getProviderKeyFromEnv('anthropic'); + if (!apiKey) { + console.log(chalk.red('Error: ANTHROPIC_API_KEY is required for batch mode')); + console.log(chalk.dim('Set ANTHROPIC_API_KEY environment variable')); + process.exit(1); + } + + console.log(chalk.bold('Batch API mode (50% cost reduction)')); + console.log( + chalk.yellow('Note: Batch mode uses the API directly — no tool use or file editing.') + ); + console.log(chalk.yellow('Best for: planning, code generation, and review tasks.')); + console.log(); + + // Build batch requests + const batchRequests: BatchRequest[] = tasks.map((task) => ({ + customId: `${task.source}-${task.id}`, + system: + 'You are an expert software engineer. Analyze the task and provide a detailed implementation plan with code snippets. Do NOT use tools — provide all code inline.', + prompt: buildBatchTaskPrompt(task), + model: options.model, + maxTokens: 4096, + })); + + // Submit batch + spinner.start(`Submitting ${batchRequests.length} tasks to Anthropic Batch API...`); + let batchId: string; + try { + batchId = await submitBatch(apiKey, batchRequests); + spinner.succeed(`Batch submitted: ${chalk.cyan(batchId)}`); + } catch (error) { + spinner.fail( + `Failed to submit batch: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + process.exit(1); + } + + // Poll for completion + console.log(); + console.log(chalk.dim('Waiting for batch to complete (this can take up to 24 hours)...')); + console.log(chalk.dim('You can safely Ctrl+C and check later with the batch ID above.')); + console.log(); + + try { + const finalStatus = await waitForBatch(apiKey, batchId, { + onProgress: (status) => { + const progress = + status.totalRequests > 0 + ? Math.round((status.completedRequests / status.totalRequests) * 100) + : 0; + process.stdout.write( + `\r ${chalk.cyan(`${progress}%`)} completed (${status.completedRequests}/${status.totalRequests} requests) ` + ); + }, + initialIntervalMs: 10000, // Batch jobs take time, no need to poll fast + maxIntervalMs: 120000, + }); + + console.log(); + console.log(); + console.log(chalk.green.bold('Batch completed!')); + console.log( + chalk.dim( + `Completed: ${finalStatus.completedRequests}, Failed: ${finalStatus.failedRequests}` + ) + ); + console.log(); + + // Retrieve results + spinner.start('Retrieving results...'); + const results = await getBatchResults(apiKey, batchId); + spinner.succeed(`Retrieved ${results.length} results`); + console.log(); + + // Display results + let totalInputTokens = 0; + let totalOutputTokens = 0; + + for (const result of results) { + const task = tasks.find((t) => `${t.source}-${t.id}` === result.customId); + const taskTitle = task?.title || result.customId; + + if (result.success) { + console.log(chalk.green(` ${taskTitle}`)); + if (result.usage) { + totalInputTokens += result.usage.inputTokens; + totalOutputTokens += result.usage.outputTokens; + console.log( + chalk.dim( + ` Tokens: ${result.usage.inputTokens} in / ${result.usage.outputTokens} out` + ) + ); + } + // Show first 200 chars of content as preview + if (result.content) { + const preview = result.content.slice(0, 200).replace(/\n/g, ' '); + console.log(chalk.dim(` Preview: ${preview}...`)); + } + } else { + console.log(chalk.red(` ${taskTitle}: ${result.error}`)); + } + } + + // Cost summary (batch API is 50% off) + const successful = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + + console.log(); + console.log(chalk.bold('Summary:')); + console.log(` ${chalk.green('Successful:')} ${successful}`); + console.log(` ${chalk.red('Failed:')} ${failed}`); + + if (totalInputTokens > 0 || totalOutputTokens > 0) { + // Approximate cost at Sonnet pricing with 50% batch discount + const inputCost = (totalInputTokens / 1_000_000) * 3 * 0.5; + const outputCost = (totalOutputTokens / 1_000_000) * 15 * 0.5; + const totalCost = inputCost + outputCost; + const fullPriceCost = inputCost * 2 + outputCost * 2; + + console.log(` ${chalk.dim('Tokens:')} ${totalInputTokens} in / ${totalOutputTokens} out`); + console.log( + ` ${chalk.dim('Cost:')} $${totalCost.toFixed(4)} (saved $${(fullPriceCost - totalCost).toFixed(4)} vs standard pricing)` + ); + } + + console.log(); + } catch (error) { + console.log(); + console.log( + chalk.red(`Batch polling failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + ); + console.log( + chalk.dim( + `You can check the batch status later using the Anthropic API with batch ID: ${batchId}` + ) + ); + process.exit(1); + } +} + +/** + * Build a prompt for batch API mode (no tool use). + */ +function buildBatchTaskPrompt(task: BatchTask): string { + const lines: string[] = []; + + lines.push(`# Task: ${task.title}`); + lines.push(''); + lines.push(`Source: ${task.url}`); + lines.push(''); + + if (task.labels?.length) { + lines.push(`Labels: ${task.labels.join(', ')}`); + lines.push(''); + } + + lines.push('## Description'); + lines.push(''); + lines.push(task.description || '*No description provided*'); + lines.push(''); + lines.push('## Instructions'); + lines.push(''); + lines.push('Analyze the task above and provide:'); + lines.push('1. A clear implementation plan'); + lines.push('2. Complete code for all files that need to be created or modified'); + lines.push('3. Any test code needed'); + lines.push('4. Brief notes on potential edge cases'); + + return lines.join('\n'); +} diff --git a/src/llm/batch.ts b/src/llm/batch.ts new file mode 100644 index 00000000..477d4266 --- /dev/null +++ b/src/llm/batch.ts @@ -0,0 +1,192 @@ +/** + * Anthropic Batch API client for ralph-starter. + * + * Submits multiple requests as a batch for 50% cost reduction. + * Batch requests are processed asynchronously (up to 24 hours). + */ + +import Anthropic from '@anthropic-ai/sdk'; + +export interface BatchRequest { + /** Unique identifier for this request within the batch */ + customId: string; + /** System message (project context, specs, etc.) */ + system?: string; + /** User message (the task prompt) */ + prompt: string; + /** Model to use */ + model?: string; + /** Max tokens for the response */ + maxTokens?: number; +} + +export interface BatchResult { + /** The custom_id from the request */ + customId: string; + /** Whether this individual request succeeded */ + success: boolean; + /** The response content (if successful) */ + content?: string; + /** Error message (if failed) */ + error?: string; + /** Token usage */ + usage?: { + inputTokens: number; + outputTokens: number; + }; +} + +export interface BatchStatus { + /** The batch ID */ + batchId: string; + /** Current processing status */ + status: 'in_progress' | 'canceling' | 'ended'; + /** Number of requests in the batch */ + totalRequests: number; + /** Number of completed requests */ + completedRequests: number; + /** Number of failed requests */ + failedRequests: number; + /** When the batch was created */ + createdAt: string; + /** When the batch finished (if ended) */ + endedAt?: string; +} + +const DEFAULT_MODEL = 'claude-sonnet-4-20250514'; + +/** + * Submit a batch of requests to the Anthropic Batch API. + * Returns the batch ID for polling. + */ +export async function submitBatch(apiKey: string, requests: BatchRequest[]): Promise { + const client = new Anthropic({ apiKey }); + + const batchRequests = requests.map((req) => ({ + custom_id: req.customId, + params: { + model: req.model || DEFAULT_MODEL, + max_tokens: req.maxTokens || 4096, + system: req.system || undefined, + messages: [ + { + role: 'user' as const, + content: req.prompt, + }, + ], + }, + })); + + const batch = await client.messages.batches.create({ + requests: batchRequests, + }); + + return batch.id; +} + +/** + * Poll a batch for its current status. + */ +export async function getBatchStatus(apiKey: string, batchId: string): Promise { + const client = new Anthropic({ apiKey }); + const batch = await client.messages.batches.retrieve(batchId); + + return { + batchId: batch.id, + status: batch.processing_status, + totalRequests: + batch.request_counts.processing + + batch.request_counts.succeeded + + batch.request_counts.errored + + batch.request_counts.canceled + + batch.request_counts.expired, + completedRequests: batch.request_counts.succeeded, + failedRequests: + batch.request_counts.errored + batch.request_counts.canceled + batch.request_counts.expired, + createdAt: batch.created_at, + endedAt: batch.ended_at ?? undefined, + }; +} + +/** + * Retrieve results for a completed batch. + */ +export async function getBatchResults(apiKey: string, batchId: string): Promise { + const client = new Anthropic({ apiKey }); + const results: BatchResult[] = []; + + const decoder = await client.messages.batches.results(batchId); + for await (const result of decoder) { + if (result.result.type === 'succeeded') { + const message = result.result.message; + const textBlock = message.content.find((block: { type: string }) => block.type === 'text') as + | { type: 'text'; text: string } + | undefined; + + results.push({ + customId: result.custom_id, + success: true, + content: textBlock?.text, + usage: { + inputTokens: message.usage.input_tokens, + outputTokens: message.usage.output_tokens, + }, + }); + } else { + let errorMsg = `Request ${result.result.type}`; + if (result.result.type === 'errored') { + const errResp = result.result.error; + errorMsg = `${errResp.type}: ${JSON.stringify(errResp.error)}`; + } + + results.push({ + customId: result.custom_id, + success: false, + error: errorMsg, + }); + } + } + + return results; +} + +/** + * Poll a batch until it completes, with exponential backoff. + * Calls onProgress on each poll for status updates. + */ +export async function waitForBatch( + apiKey: string, + batchId: string, + options?: { + /** Callback on each poll */ + onProgress?: (status: BatchStatus) => void; + /** Maximum wait time in ms (default: 24 hours) */ + maxWaitMs?: number; + /** Initial poll interval in ms (default: 5 seconds) */ + initialIntervalMs?: number; + /** Maximum poll interval in ms (default: 60 seconds) */ + maxIntervalMs?: number; + } +): Promise { + const maxWaitMs = options?.maxWaitMs ?? 24 * 60 * 60 * 1000; + const initialIntervalMs = options?.initialIntervalMs ?? 5000; + const maxIntervalMs = options?.maxIntervalMs ?? 60000; + + const startTime = Date.now(); + let intervalMs = initialIntervalMs; + + while (Date.now() - startTime < maxWaitMs) { + const status = await getBatchStatus(apiKey, batchId); + options?.onProgress?.(status); + + if (status.status === 'ended') { + return status; + } + + // Wait with exponential backoff (capped) + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + intervalMs = Math.min(intervalMs * 1.5, maxIntervalMs); + } + + throw new Error(`Batch ${batchId} did not complete within ${maxWaitMs / 1000}s`); +} From b6d9bd2824413a6ba1a59ba6b81a0f350d07d4cd Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 15:54:52 +0000 Subject: [PATCH 20/35] fix: address CodeRabbit review feedback on MCP tools and prompts - Fix batch_issues prompt: use ralph_run with auto mode instead of incorrectly referencing ralph_fetch_spec for listing issues - Fix handleListPresets category filter: use strict equality instead of substring match to prevent unintended matches - handleFetchSpec already passes path to fetchFromIntegration (linter fix) Co-Authored-By: Claude Opus 4.6 --- src/mcp/prompts.ts | 10 +++++----- src/mcp/tools.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/mcp/prompts.ts b/src/mcp/prompts.ts index 235a910a..3fa8afff 100644 --- a/src/mcp/prompts.ts +++ b/src/mcp/prompts.ts @@ -264,15 +264,15 @@ Project: ${args?.project || '(specify repo or project name)'} ${args?.label ? `Label filter: ${args.label}` : 'Label: (all issues)'} Path: ${cwd} -1. First, use ralph_fetch_spec to preview the available issues from ${args?.source || 'the source'} - - Project: ${args?.project || '(specify)'} +1. Use ralph_run with auto mode to batch-process issues from ${args?.source || 'the source'}: + - Set from="${args?.source || '(github or linear)'}" and project="${args?.project || '(specify)'}" ${args?.label ? `- Filter by label: "${args.label}"` : ''} -2. Review the issues and confirm which ones to process -3. Use ralph_run with auto mode enabled to process each issue: + - Enable auto=true, commit=true, and validate=true - Each issue gets its own branch - Code changes are validated and auto-committed - A PR is created for each completed issue -4. Monitor progress across all issues +2. Monitor progress across all issues +3. If an individual issue fails, note the failure and continue to the next one Let's batch process these issues!`, }, diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 92dae463..dddbf4be 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -452,7 +452,7 @@ async function handleListPresets( > = {}; for (const [category, presets] of Object.entries(allCategories)) { - if (filterCategory && !category.toLowerCase().includes(filterCategory)) { + if (filterCategory && category.toLowerCase() !== filterCategory) { continue; } result[category] = presets.map((p) => ({ From 342f7d1902e11d664e425eff87b759f57b428d37 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Fri, 6 Feb 2026 16:19:07 +0000 Subject: [PATCH 21/35] fix: address CodeRabbit review round 2 - Normalize framework casing in figma_to_code prompt (consistent display name) - Guard undefined args in handleListPresets with fallback to empty object - Enforce non-empty path in ralph_fetch_spec schema (.min(1)) and always assign path to options instead of truthiness check Co-Authored-By: Claude Opus 4.6 --- src/mcp/prompts.ts | 9 ++++++--- src/mcp/tools.ts | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/mcp/prompts.ts b/src/mcp/prompts.ts index 3fa8afff..277da6ee 100644 --- a/src/mcp/prompts.ts +++ b/src/mcp/prompts.ts @@ -222,7 +222,9 @@ Let's build it!`, ], }; - case 'figma_to_code': + case 'figma_to_code': { + const framework = args?.framework || 'react'; + const displayFramework = framework.charAt(0).toUpperCase() + framework.slice(1); return { description: 'Convert Figma design to code', messages: [ @@ -233,14 +235,14 @@ Let's build it!`, text: `Please extract a Figma design and build it as code. Figma URL: ${args?.figma_url || '(specify Figma file URL)'} -Framework: ${args?.framework || 'react'} +Framework: ${framework} Mode: ${args?.mode || 'spec'} Path: ${cwd} 1. First, use ralph_fetch_spec with source "figma" to extract the design: - Use mode "${args?.mode || 'spec'}" to get ${args?.mode === 'tokens' ? 'design tokens' : args?.mode === 'components' ? 'component structure' : args?.mode === 'content' ? 'text content and IA' : 'the full design specification'} 2. Review the extracted spec — check colors, typography, spacing, and component structure -3. Initialize Ralph Playbook if needed, then use ralph_run to build the ${args?.framework || 'React'} implementation +3. Initialize Ralph Playbook if needed, then use ralph_run to build the ${displayFramework} implementation 4. The agent will iterate until the UI matches the design spec, running validation between iterations Let's bring this design to life!`, @@ -248,6 +250,7 @@ Let's bring this design to life!`, }, ], }; + } case 'batch_issues': return { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index dddbf4be..049a2cca 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -50,7 +50,7 @@ const toolSchemas = { }), ralph_fetch_spec: z.object({ - path: z.string().describe('Project directory path'), + path: z.string().min(1).describe('Project directory path'), source: z .enum(['github', 'linear', 'notion', 'figma']) .describe('Integration source to fetch from'), @@ -433,7 +433,7 @@ async function handleValidate( async function handleListPresets( args: Record | undefined ): Promise<{ content: Array<{ type: 'text'; text: string }> }> { - const parsed = toolSchemas.ralph_list_presets.parse(args); + const parsed = toolSchemas.ralph_list_presets.parse(args ?? {}); const { getPresetsByCategory } = await import('../presets/index.js'); @@ -481,7 +481,7 @@ async function handleFetchSpec( const { fetchFromIntegration } = await import('../integrations/index.js'); - const options: Record = {}; + const options: Record = { path: parsed.path }; if (parsed.mode) options.mode = parsed.mode; if (parsed.project) options.project = parsed.project; if (parsed.label) options.label = parsed.label; From 24183d3c3534208967bddad64e54689cb5c977a2 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Tue, 10 Feb 2026 00:57:11 +0000 Subject: [PATCH 22/35] fix: resolve build errors from PR merges - Restore drawSeparator import in executor.ts - Remove conflicting getPackageVersion import in server.ts Co-Authored-By: Claude Opus 4.6 --- src/loop/executor.ts | 2 +- src/mcp/server.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/loop/executor.ts b/src/loop/executor.ts index 219afdb8..1ae3ac50 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -12,7 +12,7 @@ import { type IssueRef, type SemanticPrType, } from '../automation/git.js'; -import { drawBox, getTerminalWidth } from '../ui/box.js'; +import { drawBox, drawSeparator, getTerminalWidth } from '../ui/box.js'; import { ProgressRenderer } from '../ui/progress-renderer.js'; import { type Agent, type AgentRunOptions, runAgent } from './agents.js'; import { CircuitBreaker, type CircuitBreakerConfig } from './circuit-breaker.js'; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3e34b628..71212e9e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -11,7 +11,6 @@ import { ListToolsRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; -import { getPackageVersion } from '../utils/version.js'; import { getPrompts, handleGetPrompt } from './prompts.js'; import { getResources, handleResourceRead } from './resources.js'; import { getTools, handleToolCall } from './tools.js'; From 2e9ab957aaf1bb3ebbbe24553b962901633f85c3 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Tue, 10 Feb 2026 01:17:43 +0000 Subject: [PATCH 23/35] fix: address CodeRabbit review issues in batch API - Add empty requests guard in submitBatch - Add retry logic for transient errors in polling loop - Extract taskCustomId helper to avoid duplicated pattern - Add pricing caveat for non-Sonnet models Co-Authored-By: Claude Opus 4.6 --- src/commands/auto.ts | 11 ++++++++--- src/llm/batch.ts | 24 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/commands/auto.ts b/src/commands/auto.ts index f9badbe4..5c028050 100644 --- a/src/commands/auto.ts +++ b/src/commands/auto.ts @@ -20,6 +20,10 @@ import { detectBestAgent } from '../loop/agents.js'; import { type BatchTask, completeTask, fetchBatchTasks } from '../loop/batch-fetcher.js'; import { executeTaskBatch } from '../loop/task-executor.js'; +function taskCustomId(task: BatchTask): string { + return `${task.source}-${task.id}`; +} + export interface AutoModeOptions { /** Source to fetch tasks from */ source: 'github' | 'linear'; @@ -254,7 +258,7 @@ async function executeBatchApi(tasks: BatchTask[], options: AutoModeOptions): Pr // Build batch requests const batchRequests: BatchRequest[] = tasks.map((task) => ({ - customId: `${task.source}-${task.id}`, + customId: taskCustomId(task), system: 'You are an expert software engineer. Analyze the task and provide a detailed implementation plan with code snippets. Do NOT use tools — provide all code inline.', prompt: buildBatchTaskPrompt(task), @@ -317,7 +321,7 @@ async function executeBatchApi(tasks: BatchTask[], options: AutoModeOptions): Pr let totalOutputTokens = 0; for (const result of results) { - const task = tasks.find((t) => `${t.source}-${t.id}` === result.customId); + const task = tasks.find((t) => taskCustomId(t) === result.customId); const taskTitle = task?.title || result.customId; if (result.success) { @@ -352,6 +356,7 @@ async function executeBatchApi(tasks: BatchTask[], options: AutoModeOptions): Pr if (totalInputTokens > 0 || totalOutputTokens > 0) { // Approximate cost at Sonnet pricing with 50% batch discount + // Note: actual cost varies by model (Haiku is ~10x cheaper, Opus ~5x more) const inputCost = (totalInputTokens / 1_000_000) * 3 * 0.5; const outputCost = (totalOutputTokens / 1_000_000) * 15 * 0.5; const totalCost = inputCost + outputCost; @@ -359,7 +364,7 @@ async function executeBatchApi(tasks: BatchTask[], options: AutoModeOptions): Pr console.log(` ${chalk.dim('Tokens:')} ${totalInputTokens} in / ${totalOutputTokens} out`); console.log( - ` ${chalk.dim('Cost:')} $${totalCost.toFixed(4)} (saved $${(fullPriceCost - totalCost).toFixed(4)} vs standard pricing)` + ` ${chalk.dim('Est. cost (Sonnet):')} $${totalCost.toFixed(4)} (saved $${(fullPriceCost - totalCost).toFixed(4)} vs standard pricing)` ); } diff --git a/src/llm/batch.ts b/src/llm/batch.ts index 477d4266..cafee27d 100644 --- a/src/llm/batch.ts +++ b/src/llm/batch.ts @@ -60,6 +60,10 @@ const DEFAULT_MODEL = 'claude-sonnet-4-20250514'; * Returns the batch ID for polling. */ export async function submitBatch(apiKey: string, requests: BatchRequest[]): Promise { + if (requests.length === 0) { + throw new Error('Cannot submit an empty batch — at least one request is required.'); + } + const client = new Anthropic({ apiKey }); const batchRequests = requests.map((req) => ({ @@ -175,8 +179,26 @@ export async function waitForBatch( const startTime = Date.now(); let intervalMs = initialIntervalMs; + let consecutiveErrors = 0; + const maxRetries = 3; + while (Date.now() - startTime < maxWaitMs) { - const status = await getBatchStatus(apiKey, batchId); + let status: BatchStatus; + try { + status = await getBatchStatus(apiKey, batchId); + consecutiveErrors = 0; + } catch (err) { + consecutiveErrors++; + if (consecutiveErrors >= maxRetries) { + throw new Error( + `Batch polling failed after ${maxRetries} consecutive errors: ${err instanceof Error ? err.message : String(err)}` + ); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + intervalMs = Math.min(intervalMs * 1.5, maxIntervalMs); + continue; + } + options?.onProgress?.(status); if (status.status === 'ended') { From 5b433774610a8c4fe2018c745f34f3a340301a4d Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Tue, 10 Feb 2026 01:25:29 +0000 Subject: [PATCH 24/35] feat: add stable-release label for non-beta releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `stable-release` label support to prepare-release workflow. When a PR with this label is merged to main, the workflow strips the prerelease suffix and applies a proper semver bump instead of incrementing the beta number. Example: 0.1.1-beta.16 + feat PR + stable-release → 0.2.0 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/prepare-release.yml | 82 ++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 13 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index a1822232..6cc2e26d 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -12,15 +12,22 @@ permissions: jobs: # NOTE: Auto-labeling (including candidate-release) is handled by auto-label.yml - # This workflow aggregates ALL merged candidate-release PRs since the last release + # This workflow aggregates ALL merged candidate-release/stable-release PRs since the last release + # + # Labels: + # candidate-release → beta bump (0.1.1-beta.16 → 0.1.1-beta.17) + # stable-release → stable bump (0.1.1-beta.16 → 0.2.0) - # Create/update release PR when a PR with candidate-release label is merged + # Create/update release PR when a PR with candidate-release or stable-release label is merged create-release-pr: name: Create Release PR if: | github.event.action == 'closed' && github.event.pull_request.merged == true && - contains(github.event.pull_request.labels.*.name, 'candidate-release') && + ( + contains(github.event.pull_request.labels.*.name, 'candidate-release') || + contains(github.event.pull_request.labels.*.name, 'stable-release') + ) && !startsWith(github.event.pull_request.head.ref, 'release/') runs-on: ubuntu-latest steps: @@ -57,9 +64,9 @@ jobs: echo "No release tags found, collecting all candidate-release PRs" fi - # Query all merged PRs with candidate-release label since the tag date + # Query all merged PRs with candidate-release or stable-release label since the tag date # Exclude release branch PRs - PR_JSON=$(gh pr list \ + CANDIDATE_JSON=$(gh pr list \ --state merged \ --label "candidate-release" \ --base main \ @@ -67,6 +74,16 @@ jobs: --limit 100 \ --jq "[.[] | select(.mergedAt > \"$TAG_DATE\" and (.headRefName | startswith(\"release/\") | not))]" ) + STABLE_JSON=$(gh pr list \ + --state merged \ + --label "stable-release" \ + --base main \ + --json number,title,mergedAt,headRefName \ + --limit 100 \ + --jq "[.[] | select(.mergedAt > \"$TAG_DATE\" and (.headRefName | startswith(\"release/\") | not))]" + ) + # Merge and deduplicate by PR number + PR_JSON=$(echo "$CANDIDATE_JSON $STABLE_JSON" | jq -s 'add | unique_by(.number)') PR_COUNT=$(echo "$PR_JSON" | jq length) echo "Found $PR_COUNT merged PRs since $LATEST_TAG" @@ -125,24 +142,49 @@ jobs: echo "Version bump: $BUMP (current: $CURRENT_VERSION)" echo "Changes: $(echo "$CHANGES" | jq -r '.[] | " - #\(.pr): \(.rawTitle)"')" + - name: Check if stable release + if: steps.collect.outputs.skip != 'true' + id: stable + run: | + IS_STABLE="${{ contains(github.event.pull_request.labels.*.name, 'stable-release') }}" + echo "is_stable=$IS_STABLE" >> $GITHUB_OUTPUT + echo "Stable release: $IS_STABLE" + - name: Calculate new version if: steps.collect.outputs.skip != 'true' id: version env: CURRENT: ${{ steps.bump.outputs.current }} BUMP: ${{ steps.bump.outputs.bump }} + IS_STABLE: ${{ steps.stable.outputs.is_stable }} run: | + if [ "$IS_STABLE" = "true" ]; then + # Stable release: strip prerelease suffix, apply semver bump to base + BASE=$(echo "$CURRENT" | sed 's/-.*$//') + IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE" - # Parse version (handle prerelease) - if [[ "$CURRENT" == *"-"* ]]; then - # It's a prerelease, just bump the prerelease number + case "$BUMP" in + major) + NEW_VERSION="$((MAJOR + 1)).0.0" + ;; + minor) + NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" + ;; + patch) + NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" + ;; + esac + + echo "Stable release: $CURRENT -> $NEW_VERSION (bump: $BUMP)" + elif [[ "$CURRENT" == *"-"* ]]; then + # Beta release: bump the prerelease number BASE=$(echo "$CURRENT" | sed 's/-.*$//') PRE_TYPE=$(echo "$CURRENT" | sed 's/.*-\([a-z]*\).*/\1/') PRE_NUM=$(echo "$CURRENT" | sed 's/.*\.\([0-9]*\)$/\1/') NEW_PRE_NUM=$((PRE_NUM + 1)) NEW_VERSION="${BASE}-${PRE_TYPE}.${NEW_PRE_NUM}" else - # Parse semver + # Already stable, apply semver bump IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" case "$BUMP" in @@ -202,6 +244,7 @@ jobs: EXISTING_NUMBER: ${{ steps.check.outputs.number }} EXISTING_BRANCH: ${{ steps.check.outputs.branch }} EXISTING_VERSION: ${{ steps.check.outputs.version }} + IS_STABLE: ${{ steps.stable.outputs.is_stable }} run: | TODAY=$(date +%Y-%m-%d) RELEASE_BRANCH="release/v${NEW_VERSION}" @@ -257,10 +300,18 @@ jobs: # Build PR body with all included changes PR_CHANGES_LIST=$(echo "$CHANGES_JSON" | jq -r '.[] | "- #\(.pr): \(.rawTitle)"') + if [ "$IS_STABLE" = "true" ]; then + RELEASE_LABEL="Stable Release" + NPM_TAG_INFO="Package will be published to npm with \`latest\` tag" + else + RELEASE_LABEL="Release" + NPM_TAG_INFO="Package will be published to npm" + fi + PR_BODY=$(cat < Date: Tue, 10 Feb 2026 01:30:12 +0000 Subject: [PATCH 25/35] fix: resolve CI failures on staging PR - Update pnpm-lock.yaml to include @anthropic-ai/sdk dependency - Remove duplicate rate-limits.md doc (rate-limiting.md already exists) - Use 'release' label instead of 'stable-release' in workflow Co-Authored-By: Claude Opus 4.6 --- .github/workflows/prepare-release.yml | 14 +-- docs/docs/advanced/rate-limits.md | 156 -------------------------- pnpm-lock.yaml | 38 +++++++ 3 files changed, 45 insertions(+), 163 deletions(-) delete mode 100644 docs/docs/advanced/rate-limits.md diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 6cc2e26d..3db69e1d 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -12,13 +12,13 @@ permissions: jobs: # NOTE: Auto-labeling (including candidate-release) is handled by auto-label.yml - # This workflow aggregates ALL merged candidate-release/stable-release PRs since the last release + # This workflow aggregates ALL merged candidate-release/release PRs since the last release # # Labels: # candidate-release → beta bump (0.1.1-beta.16 → 0.1.1-beta.17) - # stable-release → stable bump (0.1.1-beta.16 → 0.2.0) + # release → stable bump (0.1.1-beta.16 → 0.2.0) - # Create/update release PR when a PR with candidate-release or stable-release label is merged + # Create/update release PR when a PR with candidate-release or release label is merged create-release-pr: name: Create Release PR if: | @@ -26,7 +26,7 @@ jobs: github.event.pull_request.merged == true && ( contains(github.event.pull_request.labels.*.name, 'candidate-release') || - contains(github.event.pull_request.labels.*.name, 'stable-release') + contains(github.event.pull_request.labels.*.name, 'release') ) && !startsWith(github.event.pull_request.head.ref, 'release/') runs-on: ubuntu-latest @@ -64,7 +64,7 @@ jobs: echo "No release tags found, collecting all candidate-release PRs" fi - # Query all merged PRs with candidate-release or stable-release label since the tag date + # Query all merged PRs with candidate-release or release label since the tag date # Exclude release branch PRs CANDIDATE_JSON=$(gh pr list \ --state merged \ @@ -76,7 +76,7 @@ jobs: ) STABLE_JSON=$(gh pr list \ --state merged \ - --label "stable-release" \ + --label "release" \ --base main \ --json number,title,mergedAt,headRefName \ --limit 100 \ @@ -146,7 +146,7 @@ jobs: if: steps.collect.outputs.skip != 'true' id: stable run: | - IS_STABLE="${{ contains(github.event.pull_request.labels.*.name, 'stable-release') }}" + IS_STABLE="${{ contains(github.event.pull_request.labels.*.name, 'release') }}" echo "is_stable=$IS_STABLE" >> $GITHUB_OUTPUT echo "Stable release: $IS_STABLE" diff --git a/docs/docs/advanced/rate-limits.md b/docs/docs/advanced/rate-limits.md deleted file mode 100644 index 4529bc5f..00000000 --- a/docs/docs/advanced/rate-limits.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -sidebar_position: 4 -title: Rate Limits -description: Understanding and handling API rate limits -keywords: [rate limits, throttling, API limits, tokens, reset time] ---- - -# Rate Limits - -ralph-starter helps you manage API rate limits when running autonomous coding loops. This guide explains how rate limiting works and how to handle it effectively. - -## Built-in Rate Limiter - -Control the frequency of API calls with the `--rate-limit` flag: - -```bash -# Limit to 50 API calls per hour -ralph-starter run --rate-limit 50 "build X" - -# Limit to 100 calls per hour (default if set) -ralph-starter run --rate-limit 100 "implement feature" -``` - -The rate limiter: -- Tracks calls per minute and per hour -- Automatically waits when limits are approached -- Shows countdown timer during wait periods -- Warns at 80% capacity - -## When Rate Limits Are Reached - -When Claude or another AI agent hits a rate limit, ralph-starter displays detailed information: - -``` -⚠ Claude rate limit reached - -Rate Limit Stats: - • Session usage: 100% (50K / 50K tokens) - • Requests made: 127 this hour - • Time until reset: ~47 minutes (resets at 04:30 UTC) - -Session Progress: - • Tasks completed: 3/5 - • Current task: "Add swarm mode CLI flags" - • Branch: auto/github-54 - • Iterations completed: 12 - -To resume when limit resets: - ralph-starter run - -Tip: Check your limits at https://claude.ai/settings -``` - -### What's Displayed - -| Field | Description | -|-------|-------------| -| **Session usage** | Percentage of token quota used | -| **Requests made** | Number of API calls this hour | -| **Time until reset** | Estimated time when you can resume | -| **Tasks completed** | Progress through your implementation plan | -| **Current task** | What was being worked on when limited | -| **Branch** | Git branch for context | -| **Iterations** | Number of loop iterations completed | - -## Handling Rate Limits - -### 1. Wait and Resume - -The simplest approach is to wait for the reset time shown: - -```bash -# Wait for the indicated time, then run again -ralph-starter run -``` - -### 2. Use Lower Rate Limits - -Prevent hitting limits by setting a conservative rate: - -```bash -# Use a lower rate to stay under limits -ralph-starter run --rate-limit 30 "build feature" -``` - -### 3. Check Your Limits - -Different Claude plans have different limits: -- **Free tier**: Limited requests per day -- **Pro**: Higher limits, faster resets -- **Team/Enterprise**: Custom limits - -Check your current usage at [claude.ai/settings](https://claude.ai/settings). - -## Rate Limit Detection - -ralph-starter detects rate limits through: - -1. **Output analysis**: Parsing agent output for rate limit messages -2. **API headers**: Extracting `x-ratelimit-*` headers when available -3. **Error patterns**: Recognizing common rate limit error messages - -Detected patterns include: -- "rate limit" or "usage limit" messages -- "100%" usage indicators -- "exceeded" or "too many requests" errors -- HTTP 429 responses - -## Best Practices - -### For Long Tasks - -```bash -# Use rate limiting for multi-hour tasks -ralph-starter run --rate-limit 40 --max-iterations 20 "refactor auth system" -``` - -### For Batch Processing - -```bash -# Lower rate for batch processing multiple issues -ralph-starter auto --source github --project owner/repo --limit 5 -``` - -### For Cost Control - -Combine rate limiting with cost tracking: - -```bash -ralph-starter run --rate-limit 50 --track-cost "build feature" -``` - -## Troubleshooting - -### "Rate limit reached" immediately - -- You may have hit limits in a previous session -- Wait for the displayed reset time -- Check [claude.ai/settings](https://claude.ai/settings) for current usage - -### Loop stops unexpectedly - -Rate limits from the AI agent (like Claude Code) are separate from ralph-starter's built-in rate limiter. Both can cause stops: - -- **Built-in rate limiter**: Shows waiting countdown, then continues -- **AI agent rate limit**: Shows detailed stats and stops - -### Inconsistent reset times - -Reset times are estimated based on available information. If the AI agent doesn't provide exact headers, ralph-starter estimates based on typical reset windows (usually hourly). - -## Related Features - -- [Cost Tracking](/docs/cli/run#cost-tracking) - Monitor token usage and costs -- [Validation](/docs/advanced/validation) - Ensure quality while managing rate limits -- [Circuit Breaker](/docs/cli/run#circuit-breaker) - Stop loops that are stuck diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01eb6a71..608f3425 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.73.0 + version: 0.73.0(zod@4.3.6) '@modelcontextprotocol/sdk': specifier: ^1.0.0 version: 1.26.0(zod@4.3.6) @@ -87,6 +90,15 @@ importers: packages: + '@anthropic-ai/sdk@0.73.0': + resolution: {integrity: sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -104,6 +116,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -1573,6 +1589,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -2155,6 +2175,9 @@ packages: resolution: {integrity: sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==} engines: {node: '>=12'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2348,6 +2371,12 @@ packages: snapshots: + '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.3.6 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -2362,6 +2391,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.28.6': {} + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -3725,6 +3756,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.6 + ts-algebra: 2.0.0 + json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} @@ -4322,6 +4358,8 @@ snapshots: trim-newlines@4.1.1: {} + ts-algebra@2.0.0: {} + tslib@2.8.1: {} type-fest@0.21.3: {} From a521503811749c3a52379686d2bc5bcb6b5aa4aa Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Tue, 10 Feb 2026 01:34:28 +0000 Subject: [PATCH 26/35] fix(security): resolve CodeQL alerts - Loop HTML tag removal to prevent incomplete sanitization bypass - Replace TOCTOU existsSync+readFileSync with try/catch - Remove unused fullTask variable in context-builder Co-Authored-By: Claude Opus 4.6 --- src/loop/context-builder.ts | 2 +- src/loop/executor.ts | 12 ++++++++++-- src/loop/skills.ts | 6 ++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/loop/context-builder.ts b/src/loop/context-builder.ts index 36267eb8..2504d0f0 100644 --- a/src/loop/context-builder.ts +++ b/src/loop/context-builder.ts @@ -129,7 +129,7 @@ export function buildTrimmedPlanContext(currentTask: PlanTask, taskInfo: TaskCou */ export function buildIterationContext(opts: ContextBuildOptions): BuiltContext { const { - fullTask, + fullTask: _fullTask, taskWithSkills, currentTask, taskInfo, diff --git a/src/loop/executor.ts b/src/loop/executor.ts index 206cdfad..d9d309a0 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -81,16 +81,24 @@ function getSourceIcon(source?: string): string { * Strip markdown and list formatting from task names */ function cleanTaskName(name: string): string { - return name + let cleaned = name .replace(/\*\*/g, '') // Remove bold ** .replace(/\*/g, '') // Remove italic * .replace(/`/g, '') // Remove code backticks - .replace(/<[^>]+>/g, '') // Remove HTML tags .replace(/^\d+\.\s+/, '') // Remove numbered list prefix (1. ) .replace(/^[-*]\s+/, '') // Remove bullet list prefix (- or * ) .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Convert [text](url) to text .replace(/\s+/g, ' ') // Collapse whitespace .trim(); + + // Loop HTML tag removal to handle nested/incomplete tags like ipt> + let prev: string; + do { + prev = cleaned; + cleaned = cleaned.replace(/<[^>]+>/g, ''); + } while (cleaned !== prev); + + return cleaned; } /** diff --git a/src/loop/skills.ts b/src/loop/skills.ts index a57fb2e1..1bbf42cf 100644 --- a/src/loop/skills.ts +++ b/src/loop/skills.ts @@ -97,9 +97,9 @@ function scanSkillsDir(dir: string, source: ClaudeSkill['source']): ClaudeSkill[ source, }); } else if (stats.isDirectory()) { - // Check for SKILL.md inside subdirectory + // Try reading SKILL.md inside subdirectory const skillMdPath = join(fullPath, 'SKILL.md'); - if (existsSync(skillMdPath)) { + try { const content = readFileSync(skillMdPath, 'utf-8'); skills.push({ name: extractName(content, entry), @@ -107,6 +107,8 @@ function scanSkillsDir(dir: string, source: ClaudeSkill['source']): ClaudeSkill[ description: extractDescription(content), source, }); + } catch { + // SKILL.md not found or unreadable, skip } } } catch { From f5cfb0aad6c9c4057ce99484ebd873e88a302831 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Tue, 10 Feb 2026 01:38:10 +0000 Subject: [PATCH 27/35] fix(security): eliminate file system race condition in skills detection Remove statSync+readFileSync TOCTOU pattern entirely. Instead of checking file type then reading, try reading directly and catch errors. This eliminates the race window between stat and read. Co-Authored-By: Claude Opus 4.6 --- src/loop/skills.ts | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/src/loop/skills.ts b/src/loop/skills.ts index 1bbf42cf..84896006 100644 --- a/src/loop/skills.ts +++ b/src/loop/skills.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -84,11 +84,9 @@ function scanSkillsDir(dir: string, source: ClaudeSkill['source']): ClaudeSkill[ for (const entry of entries) { const fullPath = join(dir, entry); - try { - const stats = statSync(fullPath); - - if (stats.isFile() && entry.endsWith('.md')) { - // Flat .md skill file + if (entry.endsWith('.md')) { + // Try reading as a flat .md skill file + try { const content = readFileSync(fullPath, 'utf-8'); skills.push({ name: extractName(content, entry.replace('.md', '')), @@ -96,23 +94,23 @@ function scanSkillsDir(dir: string, source: ClaudeSkill['source']): ClaudeSkill[ description: extractDescription(content), source, }); - } else if (stats.isDirectory()) { - // Try reading SKILL.md inside subdirectory - const skillMdPath = join(fullPath, 'SKILL.md'); - try { - const content = readFileSync(skillMdPath, 'utf-8'); - skills.push({ - name: extractName(content, entry), - path: skillMdPath, - description: extractDescription(content), - source, - }); - } catch { - // SKILL.md not found or unreadable, skip - } + } catch { + // File unreadable, skip + } + } else { + // Try reading SKILL.md inside subdirectory + const skillMdPath = join(fullPath, 'SKILL.md'); + try { + const content = readFileSync(skillMdPath, 'utf-8'); + skills.push({ + name: extractName(content, entry), + path: skillMdPath, + description: extractDescription(content), + source, + }); + } catch { + // Not a skill directory or unreadable, skip } - } catch { - // Skip unreadable entries } } } catch { From 73dfbccd34c4941b1d498670888d84173f2e5c9b Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Wed, 11 Feb 2026 12:14:48 +0000 Subject: [PATCH 28/35] fix(ci): fetch remote branch before force-push in docs SEO sync The --force-with-lease push fails when the automation branch already exists remotely because the local checkout has no knowledge of the remote ref. Adding a fetch first resolves the stale info rejection. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docs-seo-aeo.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docs-seo-aeo.yml b/.github/workflows/docs-seo-aeo.yml index 21a787ea..3a44e735 100644 --- a/.github/workflows/docs-seo-aeo.yml +++ b/.github/workflows/docs-seo-aeo.yml @@ -72,6 +72,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin "${BRANCH_NAME}" || true git checkout -B "${BRANCH_NAME}" git add docs/static/sitemap.xml docs/static/llms.txt docs/static/llms-full.txt docs/static/docs.json docs/static/docs-urls.txt docs/static/ai-index.json docs/static/sidebar.json git commit -m "chore(docs): sync generated SEO/AEO assets [skip ci]" From 7860ce4820805ce2fdd979d20bbd33ed82d39225 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Wed, 11 Feb 2026 12:17:25 +0000 Subject: [PATCH 29/35] Revert "fix(ci): fetch remote branch before force-push in docs SEO sync" This reverts commit 73dfbccd34c4941b1d498670888d84173f2e5c9b. --- .github/workflows/docs-seo-aeo.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/docs-seo-aeo.yml b/.github/workflows/docs-seo-aeo.yml index 3a44e735..21a787ea 100644 --- a/.github/workflows/docs-seo-aeo.yml +++ b/.github/workflows/docs-seo-aeo.yml @@ -72,7 +72,6 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git fetch origin "${BRANCH_NAME}" || true git checkout -B "${BRANCH_NAME}" git add docs/static/sitemap.xml docs/static/llms.txt docs/static/llms-full.txt docs/static/docs.json docs/static/docs-urls.txt docs/static/ai-index.json docs/static/sidebar.json git commit -m "chore(docs): sync generated SEO/AEO assets [skip ci]" From d4656450d175b9e06a8fb3395a092c6a831a1354 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Wed, 11 Feb 2026 12:23:24 +0000 Subject: [PATCH 30/35] docs: add branch naming convention with staging/v rule Staging branches must use semver version numbers (e.g. staging/v0.2.0) instead of arbitrary names. Co-Authored-By: Claude Opus 4.6 --- CONTRIBUTING.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1005435..8ab1e3da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,12 +63,23 @@ pnpm build ### 1. Create a Branch +Follow the branch naming convention: + ```bash -git checkout -b feature/my-feature -# or -git checkout -b fix/my-fix +git checkout -b feature/my-feature # New features +git checkout -b fix/my-fix # Bug fixes +git checkout -b staging/v0.2.0 # Staging branches (use target version) +git checkout -b release/v0.2.0 # Release branches (automated) ``` +**Branch naming rules:** +- `feature/` — New features +- `fix/` — Bug fixes +- `docs/` — Documentation changes +- `chore/` — Maintenance tasks +- `staging/v` — Pre-release staging (must use target semver) +- `release/v` — Release branches (created by CI) + ### 2. Make Your Changes - Keep changes focused - one feature or fix per PR From 5d8733197efbb3c3898db6fe8919c516e31e888f Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Wed, 11 Feb 2026 12:52:03 +0000 Subject: [PATCH 31/35] fix: resolve wizard hanging on "Setting up project..." initCommand has interactive prompts (git init confirmation, agent selection) and agent detection that blocks when called from the wizard since the wizard's spinner consumes stdout. Added nonInteractive flag to skip prompts, auto-init git, and skip agent detection when called from the wizard context. Co-Authored-By: Claude Opus 4.6 --- src/commands/init.ts | 137 ++++++++++++++++++++++++++----------------- src/wizard/index.ts | 2 +- 2 files changed, 84 insertions(+), 55 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index be91e2d9..ec3754c2 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -9,6 +9,8 @@ import { type Agent, detectAvailableAgents, printAgentStatus } from '../loop/age interface InitOptions { name?: string; + /** Skip interactive prompts and agent detection (used when called from wizard) */ + nonInteractive?: boolean; } export type ProjectType = 'nodejs' | 'python' | 'rust' | 'go' | 'unknown'; @@ -251,79 +253,97 @@ Add discoveries and learnings here as you work. export async function initCommand(_options: InitOptions): Promise { const cwd = process.cwd(); const spinner = ora(); + const nonInteractive = _options.nonInteractive ?? false; - console.log(); - console.log(chalk.cyan.bold('Initialize Ralph Wiggum')); - console.log(chalk.dim('Set up autonomous AI coding loops (Ralph Playbook)')); - console.log(); + if (!nonInteractive) { + console.log(); + console.log(chalk.cyan.bold('Initialize Ralph Wiggum')); + console.log(chalk.dim('Set up autonomous AI coding loops (Ralph Playbook)')); + console.log(); + } // Check if already initialized if (existsSync(join(cwd, 'AGENTS.md'))) { - console.log(chalk.yellow('Ralph Playbook files already exist.')); - console.log(chalk.dim('Files: AGENTS.md, PROMPT_*.md, specs/')); + if (!nonInteractive) { + console.log(chalk.yellow('Ralph Playbook files already exist.')); + console.log(chalk.dim('Files: AGENTS.md, PROMPT_*.md, specs/')); + } return; } // Detect project const project = detectProject(cwd); - console.log(chalk.dim(`Detected: ${project.type === 'unknown' ? 'New project' : project.type}`)); - console.log(); + if (!nonInteractive) { + console.log( + chalk.dim(`Detected: ${project.type === 'unknown' ? 'New project' : project.type}`) + ); + console.log(); + } // Check git const hasGit = await isGitRepo(cwd); if (!hasGit) { - const { initGit } = await inquirer.prompt([ - { - type: 'confirm', - name: 'initGit', - message: 'No git repo found. Initialize one?', - default: true, - }, - ]); - - if (initGit) { + if (nonInteractive) { + // Auto-init git when called from wizard await initGitRepo(cwd); - console.log(chalk.green('Git repository initialized')); + } else { + const { initGit } = await inquirer.prompt([ + { + type: 'confirm', + name: 'initGit', + message: 'No git repo found. Initialize one?', + default: true, + }, + ]); + + if (initGit) { + await initGitRepo(cwd); + console.log(chalk.green('Git repository initialized')); + } } } - // Detect available agents - spinner.start('Detecting available agents...'); - const agents = await detectAvailableAgents(); - const availableAgents = agents.filter((a) => a.available); - spinner.stop(); + // Detect available agents (skip in non-interactive mode — wizard handles this) + let selectedAgent: Agent | undefined; + if (!nonInteractive) { + spinner.start('Detecting available agents...'); + const agents = await detectAvailableAgents(); + const availableAgents = agents.filter((a) => a.available); + spinner.stop(); + + if (availableAgents.length === 0) { + console.log(chalk.red('No coding agents found!')); + printAgentStatus(agents); + console.log(chalk.yellow('Please install one of the agents above first.')); + return; + } - if (availableAgents.length === 0) { - console.log(chalk.red('No coding agents found!')); printAgentStatus(agents); - console.log(chalk.yellow('Please install one of the agents above first.')); - return; - } - printAgentStatus(agents); - - // Select default agent - let selectedAgent: Agent; - if (availableAgents.length === 1) { - selectedAgent = availableAgents[0]; - console.log(chalk.dim(`Using: ${selectedAgent.name}`)); - } else { - const { agent } = await inquirer.prompt([ - { - type: 'list', - name: 'agent', - message: 'Select default coding agent:', - choices: availableAgents.map((a) => ({ - name: a.name, - value: a, - })), - }, - ]); - selectedAgent = agent; + // Select default agent + if (availableAgents.length === 1) { + selectedAgent = availableAgents[0]; + console.log(chalk.dim(`Using: ${selectedAgent.name}`)); + } else { + const { agent } = await inquirer.prompt([ + { + type: 'list', + name: 'agent', + message: 'Select default coding agent:', + choices: availableAgents.map((a) => ({ + name: a.name, + value: a, + })), + }, + ]); + selectedAgent = agent; + } } // Create Ralph Playbook files - spinner.start('Creating Ralph Playbook files...'); + if (!nonInteractive) { + spinner.start('Creating Ralph Playbook files...'); + } // AGENTS.md writeFileSync(join(cwd, 'AGENTS.md'), generateAgentsMd(project)); @@ -341,14 +361,17 @@ export async function initCommand(_options: InitOptions): Promise { mkdirSync(specsDir, { recursive: true }); } - spinner.succeed('Ralph Playbook files created'); + if (!nonInteractive) { + spinner.succeed('Ralph Playbook files created'); + } // Create .ralph config + const agentType = selectedAgent?.type ?? 'claude-code'; const ralphDir = join(cwd, '.ralph'); if (!existsSync(ralphDir)) { mkdirSync(ralphDir, { recursive: true }); const config = { - agent: selectedAgent.type, + agent: agentType, auto_commit: true, max_iterations: 50, validation: { @@ -361,7 +384,7 @@ export async function initCommand(_options: InitOptions): Promise { } // Create .claude/CLAUDE.md if using Claude Code - if (selectedAgent.type === 'claude-code') { + if (agentType === 'claude-code') { const claudeDir = join(cwd, '.claude'); if (!existsSync(claudeDir)) { mkdirSync(claudeDir, { recursive: true }); @@ -393,7 +416,13 @@ ${project.lintCmd ? `- \`${project.lintCmd}\`` : ''} `; writeFileSync(join(claudeDir, 'CLAUDE.md'), claudeMd); - console.log(chalk.dim('Created .claude/CLAUDE.md')); + if (!nonInteractive) { + console.log(chalk.dim('Created .claude/CLAUDE.md')); + } + } + + if (nonInteractive) { + return; } console.log(); diff --git a/src/wizard/index.ts b/src/wizard/index.ts index a3fdd7c7..e88a88fb 100644 --- a/src/wizard/index.ts +++ b/src/wizard/index.ts @@ -493,7 +493,7 @@ Provide a prioritized list of suggestions with explanations.`; // Step 1: Initialize Ralph Playbook spinner.start('Setting up project...'); try { - await initCommand({ name: answers.projectName }); + await initCommand({ name: answers.projectName, nonInteractive: true }); spinner.succeed('Project initialized'); } catch (error) { spinner.fail('Failed to initialize project'); From 2d1ac74fe8f941dc987825c9f50d1cd2901c5eb4 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Wed, 11 Feb 2026 15:07:33 +0000 Subject: [PATCH 32/35] fix: fixes --- docs/docs/wizard/overview.md | 4 +- src/loop/estimator.ts | 2 +- src/loop/executor.ts | 37 ++++++++---- src/loop/task-counter.ts | 26 +++++++-- src/wizard/index.ts | 109 ++++++++++++++++++++--------------- src/wizard/prompts.ts | 27 +++++++-- 6 files changed, 136 insertions(+), 69 deletions(-) diff --git a/docs/docs/wizard/overview.md b/docs/docs/wizard/overview.md index 5a044c61..7406dabc 100644 --- a/docs/docs/wizard/overview.md +++ b/docs/docs/wizard/overview.md @@ -83,7 +83,7 @@ $ ralph-starter ❯ Yes, I know what I want to build No, help me brainstorm ideas -? What's your idea for today? +? Which idea do you want to build? (e.g., "a habit tracker app" or "an API for managing recipes") > a personal finance tracker @@ -107,7 +107,7 @@ $ ralph-starter Complexity: Working MVP -? Does this look right? +? Is this the right specs? ❯ Yes, let's build it! I want to change something Start over with a different idea diff --git a/src/loop/estimator.ts b/src/loop/estimator.ts index 13c13049..6f1fb645 100644 --- a/src/loop/estimator.ts +++ b/src/loop/estimator.ts @@ -101,7 +101,7 @@ export function estimateLoop(taskCount: TaskCount): LoopEstimate { let confidence: LoopEstimate['confidence'] = 'medium'; if (pendingTasks <= 3) { confidence = 'high'; - } else if (pendingTasks >= 10) { + } else if (pendingTasks >= 18) { confidence = 'low'; } diff --git a/src/loop/executor.ts b/src/loop/executor.ts index d9d309a0..747d40e7 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -586,24 +586,28 @@ export async function runLoop(options: LoopOptions): Promise { // Show loop header with task info const sourceIcon = getSourceIcon(options.sourceType); const headerLines: string[] = []; + const boxWidth = Math.min(60, getTerminalWidth() - 4); + const innerWidth = boxWidth - 2; if (currentTask && totalTasks > 0) { const taskNum = completedTasks + 1; const cleanName = cleanTaskName(currentTask.name); - const tw = getTerminalWidth(); - const maxNameLen = Math.max(20, tw - 30); - const taskName = - cleanName.length > maxNameLen ? `${cleanName.slice(0, maxNameLen - 3)}...` : cleanName; + const prefix = ` ${sourceIcon} Task ${taskNum}/${totalTasks} │ `; + const available = innerWidth - prefix.length; + if (available > 0) { + const taskName = truncateToFit(cleanName, Math.max(8, available)); + headerLines.push(`${prefix}${chalk.white.bold(taskName)}`); + } else { + headerLines.push(truncateToFit(`${prefix}${cleanName}`, innerWidth)); + } headerLines.push( - ` ${sourceIcon} Task ${taskNum}/${totalTasks} │ ${chalk.white.bold(taskName)}` + chalk.dim(truncateToFit(` ${options.agent.name} │ Iter ${i}/${maxIterations}`, innerWidth)) ); - headerLines.push(chalk.dim(` ${options.agent.name} │ Iter ${i}/${maxIterations}`)); } else { - headerLines.push( - ` ${sourceIcon} Loop ${i}/${maxIterations} │ ${chalk.white.bold(`Running ${options.agent.name}`)}` - ); + const fallbackLine = ` ${sourceIcon} Loop ${i}/${maxIterations} │ Running ${options.agent.name}`; + headerLines.push(chalk.white.bold(truncateToFit(fallbackLine, innerWidth))); } console.log(); - console.log(drawBox(headerLines, { color: chalk.cyan })); + console.log(drawBox(headerLines, { color: chalk.cyan, width: boxWidth })); console.log(); // Create progress renderer for this iteration @@ -697,6 +701,19 @@ export async function runLoop(options: LoopOptions): Promise { } } + // In build mode, don't allow completion while plan tasks remain + if (status === 'done' && options.task.includes('IMPLEMENTATION_PLAN.md')) { + const latestTaskInfo = parsePlanTasks(options.cwd); + if (latestTaskInfo.pending > 0) { + console.log( + chalk.yellow( + ` Agent reported done but ${latestTaskInfo.pending} task(s) remain - continuing...` + ) + ); + status = 'continue'; + } + } + if (status === 'blocked') { // Detect specific block reasons for better user feedback const output = result.output.toLowerCase(); diff --git a/src/loop/task-counter.ts b/src/loop/task-counter.ts index 74abaafc..ff928d64 100644 --- a/src/loop/task-counter.ts +++ b/src/loop/task-counter.ts @@ -34,18 +34,23 @@ export function parsePlanTasks(cwd: string): TaskCount { let currentTask: PlanTask | null = null; let taskIndex = 0; + let hasHeaders = false; - // First pass: look for "### Task N:" headers (hierarchical format) + // First pass: look for "### Phase N:" or "### Task N:" headers (hierarchical format) for (const line of lines) { - // Match "### Task N: Description" - const taskHeaderMatch = line.match(/^#{2,3}\s*Task\s*\d+[:\s]+(.+)/i); - if (taskHeaderMatch) { + const phaseHeaderMatch = line.match(/^#{2,3}\s*Phase\s*\d+[:\s-]+(.+)/i); + const taskHeaderMatch = line.match(/^#{2,3}\s*Task\s*\d+[:\s-]+(.+)/i); + const headingMatch = line.match(/^#{1,6}\s+/); + + if (phaseHeaderMatch || taskHeaderMatch) { + hasHeaders = true; // Save previous task if exists if (currentTask) { tasks.push(currentTask); } + const headerText = (phaseHeaderMatch?.[1] || taskHeaderMatch?.[1] || '').trim(); currentTask = { - name: taskHeaderMatch[1].trim(), + name: headerText || `Task ${taskIndex + 1}`, completed: false, // Will be determined by subtasks index: taskIndex++, subtasks: [], @@ -53,6 +58,13 @@ export function parsePlanTasks(cwd: string): TaskCount { continue; } + // If we hit another heading, close out the current task + if (headingMatch && currentTask) { + tasks.push(currentTask); + currentTask = null; + continue; + } + // Collect subtasks under current task if (currentTask) { const checkboxMatch = line.match(/^\s*[-*]\s*\[([xX ])\]\s*(.+)/); @@ -70,11 +82,13 @@ export function parsePlanTasks(cwd: string): TaskCount { } // If hierarchical format found, determine task completion from subtasks - if (tasks.length > 0 && tasks.some((t) => t.subtasks && t.subtasks.length > 0)) { + if (hasHeaders) { for (const task of tasks) { if (task.subtasks && task.subtasks.length > 0) { // Task is complete when ALL subtasks are complete task.completed = task.subtasks.every((st) => st.completed); + } else { + task.completed = false; } } } else { diff --git a/src/wizard/index.ts b/src/wizard/index.ts index e88a88fb..a7563c4b 100644 --- a/src/wizard/index.ts +++ b/src/wizard/index.ts @@ -225,40 +225,40 @@ async function runWizardFlow(spinner: Ora): Promise { let continueWizard = true; while (continueWizard) { - // Ask if user has an idea, needs help, or wants to improve existing - const hasIdea = await askHasIdea({ - isExistingProject: cwdIsExistingProject, - isRalphProject: cwdIsRalphProject, - }); + let idea: string; - // Handle "improve existing project" flow - if (hasIdea === 'improve_existing') { - const improveAction = await askImproveAction(); + if (cwdIsExistingProject) { + // Existing project: show list with improve_existing option + const hasIdea = await askHasIdea({ + isExistingProject: true, + isRalphProject: cwdIsRalphProject, + }); - if (improveAction === 'prompt') { - // User gives specific instructions - const improvementPrompt = await askImprovementPrompt(); + // Handle "improve existing project" flow + if (hasIdea === 'improve_existing') { + const improveAction = await askImproveAction(); - console.log(); - console.log(chalk.cyan.bold(' Starting improvement loop...')); - console.log(); + if (improveAction === 'prompt') { + const improvementPrompt = await askImprovementPrompt(); - // Run with the improvement as the task - await runCommand(improvementPrompt, { - auto: true, - commit: false, - validate: true, - }); + console.log(); + console.log(chalk.cyan.bold(' Starting improvement loop...')); + console.log(); - showSuccess('Improvement complete!'); - return; - } else { - // Analyze and suggest improvements - console.log(); - console.log(chalk.cyan.bold(' Analyzing project...')); - console.log(); + await runCommand(improvementPrompt, { + auto: true, + commit: false, + validate: true, + }); + + showSuccess('Improvement complete!'); + return; + } else { + console.log(); + console.log(chalk.cyan.bold(' Analyzing project...')); + console.log(); - const analysisPrompt = `Analyze this codebase and suggest improvements. Look at: + const analysisPrompt = `Analyze this codebase and suggest improvements. Look at: 1. Code quality and best practices 2. Missing features or incomplete implementations 3. Performance opportunities @@ -267,31 +267,44 @@ async function runWizardFlow(spinner: Ora): Promise { Provide a prioritized list of suggestions with explanations.`; - await runCommand(analysisPrompt, { - auto: true, - commit: false, - validate: false, - maxIterations: 5, - }); + await runCommand(analysisPrompt, { + auto: true, + commit: false, + validate: false, + maxIterations: 5, + }); - showSuccess('Analysis complete!'); - return; + showSuccess('Analysis complete!'); + return; + } } - } - let idea: string; - if (hasIdea === 'need_help') { - // Launch idea mode - const selectedIdea = await runIdeaMode(); - if (selectedIdea === null) { - // User wants to describe their own after browsing ideas - idea = await askForIdea(); + if (hasIdea === 'need_help') { + const selectedIdea = await runIdeaMode(); + if (selectedIdea === null) { + idea = await askForIdea(); + } else { + idea = selectedIdea; + } } else { - idea = selectedIdea; + idea = await askForIdea(); } } else { - idea = await askForIdea(); + // New project: ask if they have an idea or want help + const hasIdea = await askHasIdea(); + + if (hasIdea === 'need_help') { + const selectedIdea = await runIdeaMode(); + if (selectedIdea === null) { + idea = await askForIdea(); + } else { + idea = selectedIdea; + } + } else { + idea = await askForIdea(); + } } + answers.rawIdea = idea; // Refine with LLM - pass spinner and agent to avoid conflicts and double detection @@ -372,6 +385,10 @@ Provide a prioritized list of suggestions with explanations.`; answers.complexity = await askForComplexity(answers.complexity); break; } + } else { + console.log(chalk.dim(' Continuing with the current specs...')); + refining = false; + continueWizard = false; } } } diff --git a/src/wizard/prompts.ts b/src/wizard/prompts.ts index ad68b9aa..81fd1712 100644 --- a/src/wizard/prompts.ts +++ b/src/wizard/prompts.ts @@ -99,12 +99,31 @@ export async function askForIdea(): Promise { { type: 'input', name: 'idea', - message: "What's your idea for today?", + message: 'Which idea do you want to build?', suffix: '\n (e.g., "a habit tracker app" or "an API for managing recipes")\n >', - validate: (input: string) => (input.trim().length > 0 ? true : 'Please describe your idea'), + validate: (input: string) => + normalizeIdeaInput(input).length > 0 ? true : 'Please describe your idea', }, ]); - return idea.trim(); + return normalizeIdeaInput(idea); +} + +function normalizeIdeaInput(input: string): string { + let trimmed = input.trim(); + + const yesPrefix = trimmed.match(/^(?:y|yes|yeah|yep|sure|ok|okay)[\s,.:;!-]+(.+)/i); + if (yesPrefix?.[1]) { + trimmed = yesPrefix[1].trim(); + } + + const buildPrefix = trimmed.match( + /^(?:i\s*(?:want|wanna|would\s+like)\s*to\s*)?build[\s,.:;!-]+(.+)/i + ); + if (buildPrefix?.[1]) { + trimmed = buildPrefix[1].trim(); + } + + return trimmed.trim(); } /** @@ -338,7 +357,7 @@ export async function confirmPlan(): Promise<'proceed' | 'modify' | 'restart'> { { type: 'list', name: 'action', - message: 'Does this look right?', + message: 'Is this the right specs?', choices: [ { name: "Yes, let's build it!", value: 'proceed' }, { name: 'I want to change something', value: 'modify' }, From ac28984a63d93a1344512ec852a68f2f481d8c7b Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Wed, 11 Feb 2026 17:27:26 +0000 Subject: [PATCH 33/35] feat: improve skills --- docs/docs/cli/skill.md | 10 ++ src/commands/run.ts | 4 + src/loop/executor.ts | 2 +- src/loop/skills.ts | 40 +++++++- src/skills/auto-install.ts | 183 +++++++++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 src/skills/auto-install.ts diff --git a/docs/docs/cli/skill.md b/docs/docs/cli/skill.md index 3b15bc69..ea286a54 100644 --- a/docs/docs/cli/skill.md +++ b/docs/docs/cli/skill.md @@ -123,6 +123,16 @@ installed skills from three locations: Detected skills are matched against the project's tech stack and included in the agent's prompt context when relevant. +## Auto Skill Discovery + +When running a task, ralph-starter can also query the skills.sh +registry to find and install relevant skills automatically. +If you want to disable this behavior, set: + +```bash +RALPH_DISABLE_SKILL_AUTO_INSTALL=1 +``` + ## Behavior - The `add` action uses `npx add-skill` under the hood. diff --git a/src/commands/run.ts b/src/commands/run.ts index 8555196c..c4575024 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -17,6 +17,7 @@ import { type LoopOptions, runLoop } from '../loop/executor.js'; import { formatPrdPrompt, getPrdStats, parsePrdFile } from '../loop/prd-parser.js'; import { calculateOptimalIterations } from '../loop/task-counter.js'; import { formatPresetsHelp, getPreset, type PresetConfig } from '../presets/index.js'; +import { autoInstallSkillsFromTask } from '../skills/auto-install.js'; import { getSourceDefaults } from '../sources/config.js'; import { fetchFromSource } from '../sources/index.js'; @@ -538,6 +539,9 @@ Focus on one task at a time. After completing a task, update IMPLEMENTATION_PLAN return; } + // Auto-install relevant skills from skills.sh (if available) + await autoInstallSkillsFromTask(finalTask, cwd); + // Apply preset if specified let preset: PresetConfig | undefined; if (options.preset) { diff --git a/src/loop/executor.ts b/src/loop/executor.ts index 747d40e7..6d35e631 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -426,7 +426,7 @@ export async function runLoop(options: LoopOptions): Promise { const detectedSkills = detectClaudeSkills(options.cwd); let taskWithSkills = options.task; if (detectedSkills.length > 0) { - const skillsPrompt = formatSkillsForPrompt(detectedSkills); + const skillsPrompt = formatSkillsForPrompt(detectedSkills, options.task); taskWithSkills = `${options.task}\n\n${skillsPrompt}`; } diff --git a/src/loop/skills.ts b/src/loop/skills.ts index 84896006..c2214614 100644 --- a/src/loop/skills.ts +++ b/src/loop/skills.ts @@ -245,7 +245,35 @@ export function getRelevantSkills( /** * Format skills for inclusion in agent prompt */ -export function formatSkillsForPrompt(skills: ClaudeSkill[]): string { +function shouldAutoApplySkill(skill: ClaudeSkill, task: string): boolean { + const name = skill.name.toLowerCase(); + const desc = (skill.description || '').toLowerCase(); + const text = `${name} ${desc}`; + const taskLower = task.toLowerCase(); + + const taskIsWeb = + taskLower.includes('web') || + taskLower.includes('website') || + taskLower.includes('landing') || + taskLower.includes('frontend') || + taskLower.includes('ui') || + taskLower.includes('ux'); + + const isDesignSkill = + text.includes('design') || + text.includes('ui') || + text.includes('ux') || + text.includes('frontend'); + + if (taskIsWeb && isDesignSkill) return true; + if (taskLower.includes('astro') && text.includes('astro')) return true; + if (taskLower.includes('tailwind') && text.includes('tailwind')) return true; + if (taskLower.includes('seo') && text.includes('seo')) return true; + + return false; +} + +export function formatSkillsForPrompt(skills: ClaudeSkill[], task?: string): string { if (skills.length === 0) return ''; const lines = ['## Available Claude Code Skills', '']; @@ -255,6 +283,16 @@ export function formatSkillsForPrompt(skills: ClaudeSkill[]): string { } lines.push(''); + + if (task) { + const autoApply = skills.filter((skill) => shouldAutoApplySkill(skill, task)); + if (autoApply.length > 0) { + const skillList = autoApply.map((skill) => `/${skill.name}`).join(', '); + lines.push(`Auto-apply these skills: ${skillList}`); + lines.push(''); + } + } + lines.push('Use these skills when appropriate by invoking them with /skill-name.'); return lines.join('\n'); diff --git a/src/skills/auto-install.ts b/src/skills/auto-install.ts new file mode 100644 index 00000000..1f8a76ab --- /dev/null +++ b/src/skills/auto-install.ts @@ -0,0 +1,183 @@ +import chalk from 'chalk'; +import { execa } from 'execa'; +import ora from 'ora'; +import { findSkill } from '../loop/skills.js'; + +export interface SkillCandidate { + fullName: string; // owner/repo@skill + repo: string; + skill: string; + score: number; +} + +const MAX_SKILLS_TO_INSTALL = 2; +const SKILLS_CLI = 'skills'; + +function buildSkillQueries(task: string): string[] { + const queries = new Set(); + const text = task.toLowerCase(); + + if (text.includes('astro')) queries.add('astro'); + if (text.includes('react')) queries.add('react'); + if (text.includes('next')) queries.add('nextjs'); + if (text.includes('tailwind')) queries.add('tailwind'); + if (text.includes('seo')) queries.add('seo'); + if (text.includes('accessibility') || text.includes('a11y')) queries.add('accessibility'); + + if ( + text.includes('landing') || + text.includes('website') || + text.includes('web app') || + text.includes('portfolio') || + text.includes('marketing') + ) { + queries.add('frontend design'); + queries.add('web design'); + } + + if (text.includes('design') || text.includes('ui') || text.includes('ux')) { + queries.add('ui design'); + } + + if (queries.size === 0) { + queries.add('web design'); + } + + return Array.from(queries); +} + +function parseSkillLine(line: string): SkillCandidate | null { + const match = line.match(/([a-z0-9_.-]+\/[a-z0-9_.-]+@[a-z0-9_.-]+)/i); + if (!match) return null; + + const fullName = match[1]; + const [repo, skill] = fullName.split('@'); + if (!repo || !skill) return null; + + return { + fullName, + repo, + skill, + score: 0, + }; +} + +function scoreCandidate(candidate: SkillCandidate, task: string): number { + const text = `${candidate.fullName}`.toLowerCase(); + const taskLower = task.toLowerCase(); + let score = 0; + + const boost = (keyword: string, weight: number) => { + if (text.includes(keyword)) score += weight; + }; + + boost('frontend', 3); + boost('design', 3); + boost('ui', 2); + boost('ux', 2); + boost('landing', 2); + boost('astro', taskLower.includes('astro') ? 3 : 1); + boost('react', taskLower.includes('react') ? 2 : 0); + boost('next', taskLower.includes('next') ? 2 : 0); + boost('tailwind', taskLower.includes('tailwind') ? 2 : 0); + boost('seo', taskLower.includes('seo') ? 2 : 0); + + return score; +} + +function rankCandidates(candidates: SkillCandidate[], task: string): SkillCandidate[] { + for (const candidate of candidates) { + candidate.score = scoreCandidate(candidate, task); + } + + return candidates.sort((a, b) => b.score - a.score); +} + +async function findSkillsByQuery(query: string): Promise { + try { + const result = await execa('npx', [SKILLS_CLI, 'find', query], { + stdio: 'pipe', + }); + + const lines = result.stdout.split('\n').map((line) => line.trim()); + const candidates: SkillCandidate[] = []; + + for (const line of lines) { + const candidate = parseSkillLine(line); + if (candidate) { + candidates.push(candidate); + } + } + + return candidates; + } catch { + return []; + } +} + +async function installSkill(candidate: SkillCandidate, globalInstall: boolean): Promise { + const args = [SKILLS_CLI, 'add', candidate.fullName, '-y']; + if (globalInstall) args.push('-g'); + + try { + await execa('npx', args, { stdio: 'inherit' }); + return true; + } catch { + return false; + } +} + +export async function autoInstallSkillsFromTask(task: string, cwd: string): Promise { + if (!task.trim()) return []; + if (process.env.RALPH_DISABLE_SKILL_AUTO_INSTALL === '1') return []; + + const queries = buildSkillQueries(task); + if (queries.length === 0) return []; + + const spinner = ora('Searching skills.sh for relevant skills...').start(); + const allCandidates = new Map(); + + for (const query of queries) { + const candidates = await findSkillsByQuery(query); + for (const candidate of candidates) { + if (!allCandidates.has(candidate.fullName)) { + allCandidates.set(candidate.fullName, candidate); + } + } + } + + if (allCandidates.size === 0) { + spinner.warn('No skills found from skills.sh'); + return []; + } + + const ranked = rankCandidates(Array.from(allCandidates.values()), task); + const toInstall = ranked + .filter((candidate) => !findSkill(cwd, candidate.skill)) + .slice(0, MAX_SKILLS_TO_INSTALL); + + if (toInstall.length === 0) { + spinner.succeed('Relevant skills already installed'); + return []; + } + + spinner.stop(); + console.log(chalk.cyan('Installing recommended skills from skills.sh...')); + + const installed: string[] = []; + for (const candidate of toInstall) { + console.log(chalk.dim(` • ${candidate.fullName}`)); + const ok = await installSkill(candidate, true); + if (ok) { + installed.push(candidate.skill); + } + } + + if (installed.length > 0) { + console.log(chalk.green(`Installed skills: ${installed.join(', ')}`)); + } else { + console.log(chalk.yellow('No skills were installed.')); + } + + return installed; +} From 0aaf8cdb72e421faea469b705d1c5a2f8b6ba3bd Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Wed, 11 Feb 2026 17:38:48 +0000 Subject: [PATCH 34/35] fix: wizard fixes --- src/cli.ts | 25 ++++++----- src/commands/run.ts | 8 +++- src/loop/step-detector.ts | 10 +++++ src/wizard/index.ts | 48 +++++++++++++++++---- src/wizard/prompts.ts | 83 +++++++++++++++++++++++------------- src/wizard/spec-generator.ts | 15 ++++++- src/wizard/ui.ts | 49 ++++++++++++++++++--- 7 files changed, 182 insertions(+), 56 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 2ac472b3..f5bac3a6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,7 @@ import { sourceCommand } from './commands/source.js'; import { templateCommand } from './commands/template.js'; import { startMcpServer } from './mcp/server.js'; import { formatPresetsHelp, getPresetNames } from './presets/index.js'; +import { drawBox, getTerminalWidth } from './ui/box.js'; import { getPackageVersion } from './utils/version.js'; import { runIdeaMode, runWizard } from './wizard/index.js'; @@ -25,16 +26,20 @@ const VERSION = getPackageVersion(); const program = new Command(); -const banner = ` - ${chalk.cyan('╭─────────────────────────────────────────────────────────────╮')} - ${chalk.cyan('│')} ${chalk.cyan('│')} - ${chalk.cyan('│')} ${chalk.bold.white('ralph-starter')} ${chalk.gray(`v${VERSION}`)} ${chalk.cyan('│')} - ${chalk.cyan('│')} ${chalk.cyan('│')} - ${chalk.cyan('│')} ${chalk.dim('Ralph Wiggum made easy.')} ${chalk.cyan('│')} - ${chalk.cyan('│')} ${chalk.dim('One command to run autonomous AI coding loops.')} ${chalk.cyan('│')} - ${chalk.cyan('│')} ${chalk.cyan('│')} - ${chalk.cyan('╰─────────────────────────────────────────────────────────────╯')} -`; +function stripAnsi(text: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence detection requires control characters + return text.replace(/\u001b\[[0-9;]*m/g, ''); +} + +const bannerLines = [ + ` ${chalk.bold.white('ralph-starter')} ${chalk.gray(`v${VERSION}`)}`, + ` ${chalk.dim('Ralph Wiggum made easy.')}`, + ` ${chalk.dim('One command to run autonomous AI coding loops.')}`, +]; + +const maxLineLen = Math.max(...bannerLines.map((line) => stripAnsi(line).length)); +const bannerWidth = Math.min(getTerminalWidth() - 4, Math.max(40, maxLineLen + 2)); +const banner = `\n${drawBox(bannerLines, { color: chalk.cyan, width: bannerWidth })}\n`; program .name('ralph-starter') diff --git a/src/commands/run.ts b/src/commands/run.ts index c4575024..9f788ccf 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -24,6 +24,12 @@ import { fetchFromSource } from '../sources/index.js'; /** Default fallback repo for GitHub issues when no project is specified */ const DEFAULT_GITHUB_ISSUES_REPO = 'rubenmarcus/ralph-ideas'; +function formatDurationSeconds(durationSec: number): string { + const minutes = Math.floor(durationSec / 60); + const seconds = durationSec % 60; + return `${minutes}m ${seconds}s`; +} + /** * Detect how to run the project based on package.json scripts or common patterns */ @@ -619,7 +625,7 @@ Focus on one task at a time. After completing a task, update IMPLEMENTATION_PLAN } if (result.stats) { const durationSec = Math.round(result.stats.totalDuration / 1000); - console.log(chalk.dim(`Total duration: ${durationSec}s`)); + console.log(chalk.dim(`Total duration: ${formatDurationSeconds(durationSec)}`)); if (result.stats.validationFailures > 0) { console.log(chalk.dim(`Validation failures: ${result.stats.validationFailures}`)); } diff --git a/src/loop/step-detector.ts b/src/loop/step-detector.ts index 94b45940..06f37d59 100644 --- a/src/loop/step-detector.ts +++ b/src/loop/step-detector.ts @@ -54,6 +54,10 @@ export function detectStepFromOutput(line: string): string | null { const isReadOperation = ['read', 'glob', 'grep'].includes(toolName); const isWriteOperation = ['write', 'edit'].includes(toolName); + if (toolName.includes('todowrite') || toolName.includes('todo_write')) { + return 'Updating task checklist...'; + } + // Reading code - check this early if (isReadOperation) { return 'Reading code...'; @@ -241,6 +245,9 @@ export function detectStepFromOutput(line: string): string | null { } // Generic tool + if (blockToolName.includes('todowrite') || blockToolName.includes('todo_write')) { + return 'Updating task checklist...'; + } return `Using ${block.name}...`; } @@ -280,6 +287,9 @@ export function detectStepFromOutput(line: string): string | null { if (blockToolName === 'bash') return 'Running command...'; if (blockToolName === 'glob') return 'Searching files...'; if (blockToolName === 'grep') return 'Searching code...'; + if (blockToolName.includes('todowrite') || blockToolName.includes('todo_write')) { + return 'Updating task checklist...'; + } return `Using ${block.name}...`; } } diff --git a/src/wizard/index.ts b/src/wizard/index.ts index a7563c4b..68f0daa1 100644 --- a/src/wizard/index.ts +++ b/src/wizard/index.ts @@ -13,6 +13,7 @@ import { runSetupWizard } from '../setup/wizard.js'; import { runIdeaMode } from './ideas.js'; import { isLlmAvailable, refineIdea } from './llm.js'; import { + askBrainstormConfirm, askContinueAction, askExecutionOptions, askExistingProjectAction, @@ -47,6 +48,25 @@ import { // Global spinner reference for cleanup on exit let activeSpinner: Ora | null = null; +function normalizeTechStackValue(value?: string | null): string | undefined { + if (!value) return undefined; + const trimmed = String(value).trim(); + if (!trimmed) return undefined; + const lower = trimmed.toLowerCase(); + if (lower === 'null' || lower === 'none' || lower === 'undefined') return undefined; + return trimmed; +} + +function normalizeTechStack(stack: WizardAnswers['techStack']): WizardAnswers['techStack'] { + return { + frontend: normalizeTechStackValue(stack.frontend), + backend: normalizeTechStackValue(stack.backend), + database: normalizeTechStackValue(stack.database), + styling: normalizeTechStackValue(stack.styling), + language: normalizeTechStackValue(stack.language), + }; +} + /** * Handle graceful exit on Ctrl+C */ @@ -280,11 +300,16 @@ Provide a prioritized list of suggestions with explanations.`; } if (hasIdea === 'need_help') { - const selectedIdea = await runIdeaMode(); - if (selectedIdea === null) { - idea = await askForIdea(); + const shouldBrainstorm = await askBrainstormConfirm(); + if (shouldBrainstorm) { + const selectedIdea = await runIdeaMode(); + if (selectedIdea === null) { + idea = await askForIdea(); + } else { + idea = selectedIdea; + } } else { - idea = selectedIdea; + idea = await askForIdea(); } } else { idea = await askForIdea(); @@ -294,11 +319,16 @@ Provide a prioritized list of suggestions with explanations.`; const hasIdea = await askHasIdea(); if (hasIdea === 'need_help') { - const selectedIdea = await runIdeaMode(); - if (selectedIdea === null) { - idea = await askForIdea(); + const shouldBrainstorm = await askBrainstormConfirm(); + if (shouldBrainstorm) { + const selectedIdea = await runIdeaMode(); + if (selectedIdea === null) { + idea = await askForIdea(); + } else { + idea = selectedIdea; + } } else { - idea = selectedIdea; + idea = await askForIdea(); } } else { idea = await askForIdea(); @@ -334,7 +364,7 @@ Provide a prioritized list of suggestions with explanations.`; answers.projectName = refinedIdea.projectName; answers.projectDescription = refinedIdea.projectDescription; answers.projectType = refinedIdea.projectType; - answers.techStack = refinedIdea.suggestedStack; + answers.techStack = normalizeTechStack(refinedIdea.suggestedStack); answers.suggestedFeatures = refinedIdea.suggestedFeatures; answers.complexity = refinedIdea.estimatedComplexity; diff --git a/src/wizard/prompts.ts b/src/wizard/prompts.ts index 81fd1712..d5bbb3f9 100644 --- a/src/wizard/prompts.ts +++ b/src/wizard/prompts.ts @@ -11,10 +11,9 @@ export async function askHasIdea(options?: { isExistingProject?: boolean; isRalphProject?: boolean; }): Promise<'has_idea' | 'need_help' | 'improve_existing'> { - const choices: Array<{ name: string; value: string }> = []; - - // If in an existing project, show improve option first + // If in an existing project, keep the multi-option list if (options?.isExistingProject) { + const choices: Array<{ name: string; value: string }> = []; const projectLabel = options.isRalphProject ? 'Improve this Ralph project' : 'Improve this existing project'; @@ -22,25 +21,33 @@ export async function askHasIdea(options?: { name: `${projectLabel} → (add features, fix issues, or get suggestions)`, value: 'improve_existing', }); - } + choices.push( + { name: 'Yes, I know what I want to build', value: 'has_idea' }, + { name: 'No, help me brainstorm ideas', value: 'need_help' } + ); - choices.push( - { name: 'Yes, I know what I want to build', value: 'has_idea' }, - { name: 'No, help me brainstorm ideas', value: 'need_help' } - ); + const { hasIdea } = await inquirer.prompt([ + { + type: 'list', + name: 'hasIdea', + message: 'What would you like to do?', + choices, + }, + ]); + return hasIdea; + } + // New project: simple Y/N prompt const { hasIdea } = await inquirer.prompt([ { - type: 'list', + type: 'confirm', name: 'hasIdea', - message: options?.isExistingProject - ? 'What would you like to do?' - : 'Do you have a project idea?', - choices, + message: 'Do you have a project idea?', + default: true, }, ]); - return hasIdea; + return hasIdea ? 'has_idea' : 'need_help'; } /** @@ -108,6 +115,21 @@ export async function askForIdea(): Promise { return normalizeIdeaInput(idea); } +/** + * Ask if user wants to brainstorm ideas + */ +export async function askBrainstormConfirm(): Promise { + const { brainstorm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'brainstorm', + message: "Let's brainstorm some ideas?", + default: true, + }, + ]); + + return brainstorm; +} function normalizeIdeaInput(input: string): string { let trimmed = input.trim(); @@ -353,13 +375,25 @@ export async function askForComplexity(suggestedComplexity?: Complexity): Promis * Confirm the refined plan */ export async function confirmPlan(): Promise<'proceed' | 'modify' | 'restart'> { + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message: 'Is this the right specs?', + default: true, + }, + ]); + + if (confirmed) { + return 'proceed'; + } + const { action } = await inquirer.prompt([ { type: 'list', name: 'action', - message: 'Is this the right specs?', + message: 'What would you like to do?', choices: [ - { name: "Yes, let's build it!", value: 'proceed' }, { name: 'I want to change something', value: 'modify' }, { name: 'Start over with a different idea', value: 'restart' }, ], @@ -401,21 +435,10 @@ export async function askExecutionOptions(): Promise<{ const { autoRun } = await inquirer.prompt([ { - type: 'list', + type: 'confirm', name: 'autoRun', - message: 'How should we proceed?', - choices: [ - { - name: 'Start building automatically → (AI runs immediately after setup)', - short: 'Build now', - value: true, - }, - { - name: 'Just create the plan → (run "ralph-starter run" later)', - short: 'Plan only', - value: false, - }, - ], + message: 'Start building automatically?', + default: true, }, ]); diff --git a/src/wizard/spec-generator.ts b/src/wizard/spec-generator.ts index 32155333..66283b4c 100644 --- a/src/wizard/spec-generator.ts +++ b/src/wizard/spec-generator.ts @@ -36,6 +36,12 @@ export function generateSpec(answers: WizardAnswers): string { if (answers.techStack.database) { sections.push(`- **Database:** ${formatTech(answers.techStack.database)}`); } + if (answers.techStack.styling) { + sections.push(`- **Styling:** ${formatTech(answers.techStack.styling)}`); + } + if (answers.techStack.language) { + sections.push(`- **Language:** ${formatTech(answers.techStack.language)}`); + } sections.push(''); } @@ -184,7 +190,7 @@ export function generateAgentsMd(answers: WizardAnswers): string { * Check if tech stack has any values */ function hasTechStack(stack: TechStack): boolean { - return !!(stack.frontend || stack.backend || stack.database); + return !!(stack.frontend || stack.backend || stack.database || stack.styling || stack.language); } /** @@ -192,6 +198,7 @@ function hasTechStack(stack: TechStack): boolean { */ function formatTech(tech: string): string { const names: Record = { + astro: 'Astro', react: 'React', nextjs: 'Next.js', vue: 'Vue.js', @@ -205,6 +212,12 @@ function formatTech(tech: string): string { sqlite: 'SQLite', postgres: 'PostgreSQL', mongodb: 'MongoDB', + tailwind: 'Tailwind CSS', + css: 'CSS', + scss: 'SCSS', + 'styled-components': 'styled-components', + typescript: 'TypeScript', + javascript: 'JavaScript', }; return names[tech] || tech; } diff --git a/src/wizard/ui.ts b/src/wizard/ui.ts index d0f8424a..af285861 100644 --- a/src/wizard/ui.ts +++ b/src/wizard/ui.ts @@ -59,10 +59,42 @@ export function showWelcomeCompact(): void { export function showRefinedSummary( projectName: string, projectType: string, - stack: { frontend?: string; backend?: string; database?: string }, + stack: { + frontend?: string; + backend?: string; + database?: string; + styling?: string; + language?: string; + }, features: string[], complexity: string ): void { + const formatTechLabel = (tech: string): string => { + const names: Record = { + astro: 'Astro', + react: 'React', + nextjs: 'Next.js', + vue: 'Vue.js', + svelte: 'Svelte', + vanilla: 'Vanilla JavaScript', + 'react-native': 'React Native', + expo: 'Expo (React Native)', + nodejs: 'Node.js', + python: 'Python', + go: 'Go', + sqlite: 'SQLite', + postgres: 'PostgreSQL', + mongodb: 'MongoDB', + tailwind: 'Tailwind CSS', + css: 'CSS', + scss: 'SCSS', + 'styled-components': 'styled-components', + typescript: 'TypeScript', + javascript: 'JavaScript', + }; + return names[tech] || tech; + }; + console.log(); console.log(chalk.cyan.bold(" Here's what I understand:")); console.log(chalk.gray(' ────────────────────────────────────────')); @@ -71,11 +103,18 @@ export function showRefinedSummary( console.log(` ${chalk.white('Type:')} ${projectType}`); console.log(); - if (stack.frontend || stack.backend || stack.database) { + if (stack.frontend || stack.backend || stack.database || stack.styling || stack.language) { console.log(` ${chalk.white('Tech Stack:')}`); - if (stack.frontend) console.log(` ${chalk.dim('Frontend:')} ${stack.frontend}`); - if (stack.backend) console.log(` ${chalk.dim('Backend:')} ${stack.backend}`); - if (stack.database) console.log(` ${chalk.dim('Database:')} ${stack.database}`); + if (stack.frontend) + console.log(` ${chalk.dim('Frontend:')} ${formatTechLabel(stack.frontend)}`); + if (stack.backend) + console.log(` ${chalk.dim('Backend:')} ${formatTechLabel(stack.backend)}`); + if (stack.database) + console.log(` ${chalk.dim('Database:')} ${formatTechLabel(stack.database)}`); + if (stack.styling) + console.log(` ${chalk.dim('Styling:')} ${formatTechLabel(stack.styling)}`); + if (stack.language) + console.log(` ${chalk.dim('Language:')} ${formatTechLabel(stack.language)}`); console.log(); } From a460cad7bd0dd35b90ea751e2a95cf893543e342 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Thu, 12 Feb 2026 02:59:10 +0000 Subject: [PATCH 35/35] feat: prompt --- src/wizard/index.ts | 29 +++++++++++++++++++++++++++++ src/wizard/prompts.ts | 21 ++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/wizard/index.ts b/src/wizard/index.ts index 68f0daa1..1ba48400 100644 --- a/src/wizard/index.ts +++ b/src/wizard/index.ts @@ -26,6 +26,7 @@ import { askImproveAction, askImprovementPrompt, askRalphPlaybookAction, + askSpecChangePrompt, askWhatToModify, askWorkingDirectory, confirmPlan, @@ -392,6 +393,34 @@ Provide a prioritized list of suggestions with explanations.`; } else if (action === 'restart') { refining = false; // Will loop back to get new idea + } else if (action === 'prompt') { + const changeRequest = await askSpecChangePrompt(); + const updatedIdea = `${answers.rawIdea}\n\nChange request: ${changeRequest}`; + + spinner.start('Updating specs...'); + try { + refinedIdea = await refineIdea(updatedIdea, spinner, agent); + } catch (_error) { + spinner.fail('Could not update specs'); + refinedIdea = { + projectName: answers.projectName || 'my-project', + projectDescription: answers.projectDescription || updatedIdea, + projectType: answers.projectType || 'web', + suggestedStack: answers.techStack, + coreFeatures: refinedIdea.coreFeatures, + suggestedFeatures: refinedIdea.suggestedFeatures, + estimatedComplexity: answers.complexity, + }; + } + + answers.rawIdea = updatedIdea; + answers.projectName = refinedIdea.projectName; + answers.projectDescription = refinedIdea.projectDescription; + answers.projectType = refinedIdea.projectType; + answers.techStack = normalizeTechStack(refinedIdea.suggestedStack); + answers.suggestedFeatures = refinedIdea.suggestedFeatures; + answers.selectedFeatures = []; + answers.complexity = refinedIdea.estimatedComplexity; } else if (action === 'modify') { const modifyWhat = await askWhatToModify(); diff --git a/src/wizard/prompts.ts b/src/wizard/prompts.ts index d5bbb3f9..747159a9 100644 --- a/src/wizard/prompts.ts +++ b/src/wizard/prompts.ts @@ -374,7 +374,7 @@ export async function askForComplexity(suggestedComplexity?: Complexity): Promis /** * Confirm the refined plan */ -export async function confirmPlan(): Promise<'proceed' | 'modify' | 'restart'> { +export async function confirmPlan(): Promise<'proceed' | 'modify' | 'restart' | 'prompt'> { const { confirmed } = await inquirer.prompt([ { type: 'confirm', @@ -394,6 +394,7 @@ export async function confirmPlan(): Promise<'proceed' | 'modify' | 'restart'> { name: 'action', message: 'What would you like to do?', choices: [ + { name: 'Describe changes in plain language', value: 'prompt' }, { name: 'I want to change something', value: 'modify' }, { name: 'Start over with a different idea', value: 'restart' }, ], @@ -403,6 +404,24 @@ export async function confirmPlan(): Promise<'proceed' | 'modify' | 'restart'> { return action; } +/** + * Ask for a plain-language change request + */ +export async function askSpecChangePrompt(): Promise { + const { change } = await inquirer.prompt([ + { + type: 'input', + name: 'change', + message: 'What should be changed?', + suffix: '\n (e.g., "use Next.js only, no separate backend", "switch to Tailwind")\n >', + validate: (input: string) => + input.trim().length > 0 ? true : 'Please describe the change you want', + }, + ]); + + return change.trim(); +} + /** * Ask what to modify */