diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index a182223..3db69e1 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/release PRs since the last release + # + # Labels: + # candidate-release → beta bump (0.1.1-beta.16 → 0.1.1-beta.17) + # 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 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, '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 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 "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, '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 <` — 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 diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index c38a0f5..ca53bcd 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -39,6 +39,12 @@ - [ ] Add `--figma-preview` to show changes without applying - [ ] Add `--figma-mapping ` for custom content mapping +### Session Management +- [x] Create `src/loop/session.ts` for pause/resume support +- [x] Add `ralph-starter pause` command +- [x] Add `ralph-starter resume` command +- [ ] Store session state in `.ralph-session.json` + ### Task 6: Documentation - [ ] Add content mode section to README.md diff --git a/README.md b/README.md index fc06e7c..5813da9 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,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/cli/skill.md b/docs/docs/cli/skill.md index 3b15bc6..ea286a5 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/docs/docs/wizard/overview.md b/docs/docs/wizard/overview.md index 5a044c6..7406dab 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/package.json b/package.json index 9b1081b..057846d 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "access": "public" }, "dependencies": { + "@anthropic-ai/sdk": "^0.73.0", "@modelcontextprotocol/sdk": "^1.0.0", "chalk": "^5.3.0", "chalk-animation": "^2.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01eb6a7..608f342 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: {} diff --git a/src/cli.ts b/src/cli.ts index 5e239e9..f5bac3a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,7 +8,9 @@ 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 { resumeCommand } from './commands/resume.js'; import { runCommand } from './commands/run.js'; import { setupCommand } from './commands/setup.js'; import { skillCommand } from './commands/skill.js'; @@ -16,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'; @@ -23,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') @@ -75,6 +82,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( @@ -229,6 +240,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, @@ -240,6 +253,30 @@ program agent: options.agent, validate: options.validate, maxIterations: options.maxIterations ? parseInt(options.maxIterations, 10) : undefined, + batch: options.batch, + model: options.model, + }); + }); + +// 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 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, }); }); diff --git a/src/commands/auto.ts b/src/commands/auto.ts index 44c4a04..5c02805 100644 --- a/src/commands/auto.ts +++ b/src/commands/auto.ts @@ -8,10 +8,22 @@ 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'; +function taskCustomId(task: BatchTask): string { + return `${task.source}-${task.id}`; +} + export interface AutoModeOptions { /** Source to fetch tasks from */ source: 'github' | 'linear'; @@ -31,6 +43,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 +155,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 +231,185 @@ 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: 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), + 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) => taskCustomId(t) === 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 + // 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; + const fullPriceCost = inputCost * 2 + outputCost * 2; + + console.log(` ${chalk.dim('Tokens:')} ${totalInputTokens} in / ${totalOutputTokens} out`); + console.log( + ` ${chalk.dim('Est. cost (Sonnet):')} $${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/commands/init.ts b/src/commands/init.ts index be91e2d..ec3754c 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/commands/pause.ts b/src/commands/pause.ts new file mode 100644 index 0000000..b82e9d0 --- /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/commands/resume.ts b/src/commands/resume.ts new file mode 100644 index 0000000..efc8446 --- /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(); +} diff --git a/src/commands/run.ts b/src/commands/run.ts index 3a84cff..9f788cc 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -17,12 +17,19 @@ 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'; /** 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 */ @@ -215,6 +222,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'; @@ -537,6 +545,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) { @@ -576,6 +587,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, @@ -584,6 +596,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: @@ -612,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/commands/skill.ts b/src/commands/skill.ts index 8b034aa..ecfdf58 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')); } diff --git a/src/llm/api.ts b/src/llm/api.ts index 23fe6e4..e0f7825 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/llm/batch.ts b/src/llm/batch.ts new file mode 100644 index 0000000..cafee27 --- /dev/null +++ b/src/llm/batch.ts @@ -0,0 +1,214 @@ +/** + * 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 { + 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) => ({ + 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; + + let consecutiveErrors = 0; + const maxRetries = 3; + + while (Date.now() - startTime < maxWaitMs) { + 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') { + 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`); +} diff --git a/src/loop/context-builder.ts b/src/loop/context-builder.ts new file mode 100644 index 0000000..2504d0f --- /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: _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/cost-tracker.ts b/src/loop/cost-tracker.ts index 6261e7f..97700af 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)} |` : ''} `; } diff --git a/src/loop/estimator.ts b/src/loop/estimator.ts index 13c1304..6f1fb64 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 ebd8eb6..6d35e63 100644 --- a/src/loop/executor.ts +++ b/src/loop/executor.ts @@ -6,15 +6,24 @@ import { createPullRequest, formatPrBody, generateSemanticPrTitle, + getCurrentBranch, gitCommit, gitPush, hasUncommittedChanges, type IssueRef, type SemanticPrType, } from '../automation/git.js'; +import { drawBox, drawSeparator, getTerminalWidth } from '../ui/box.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 { 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'; @@ -38,14 +47,58 @@ function sleep(ms: number): Promise { } /** - * Strip markdown formatting from task names + * 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)}...`; +} + +/** + * 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 */ function cleanTaskName(name: string): string { - return name + let cleaned = name .replace(/\*\*/g, '') // Remove bold ** .replace(/\*/g, '') // Remove italic * .replace(/`/g, '') // Remove code backticks + .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; } /** @@ -125,6 +178,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 @@ -135,6 +189,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 { @@ -253,7 +308,7 @@ function detectCompletion( * Get human-readable reason for completion (UX 3) */ function getCompletionReason(output: string, options: CompletionOptions): string { - const { completionPromise, requireExitSignal } = options; + const { completionPromise } = options; // Check explicit completion promise first if (completionPromise && output.includes(completionPromise)) { @@ -371,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}`; } @@ -385,44 +440,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 @@ -511,12 +570,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 > 25 ? `${clean.slice(0, 22)}...` : clean; - }); + .map((t) => truncateToFit(cleanTaskName(t.name), maxNameWidth)); if (completedNames.length > 0) { console.log( @@ -527,51 +584,54 @@ export async function runLoop(options: LoopOptions): Promise { previousCompletedTasks = completedTasks; // Show loop header with task info - console.log(chalk.cyan(`\n═══════════════════════════════════════════════════════════════`)); + 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 taskName = cleanName.length > 40 ? `${cleanName.slice(0, 37)}...` : cleanName; - console.log(chalk.cyan.bold(` Task ${taskNum}/${totalTasks} │ ${taskName}`)); + 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( + chalk.dim(truncateToFit(` ${options.agent.name} │ Iter ${i}/${maxIterations}`, innerWidth)) + ); } else { - console.log(chalk.cyan.bold(` Loop ${i}/${maxIterations} │ Running ${options.agent.name}`)); + const fallbackLine = ` ${sourceIcon} Loop ${i}/${maxIterations} │ Running ${options.agent.name}`; + headerLines.push(chalk.white.bold(truncateToFit(fallbackLine, innerWidth))); } - console.log(chalk.cyan(`═══════════════════════════════════════════════════════════════\n`)); + console.log(); + console.log(drawBox(headerLines, { color: chalk.cyan, width: boxWidth })); + console.log(); // Create progress renderer for this iteration 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; + iterProgress.updateProgress(i, maxIterations, costTracker?.getStats()?.totalCost?.totalCost); + + // 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 @@ -641,6 +701,19 @@ Complete these subtasks, then mark them done in IMPLEMENTATION_PLAN.md by changi } } + // 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(); @@ -655,13 +728,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(); @@ -724,23 +813,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 @@ -774,8 +863,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 @@ -833,27 +923,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; @@ -861,6 +947,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)); } diff --git a/src/loop/session.ts b/src/loop/session.ts new file mode 100644 index 0000000..be8429a --- /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/loop/skills.ts b/src/loop/skills.ts index 49950cf..c221461 100644 --- a/src/loop/skills.ts +++ b/src/loop/skills.ts @@ -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); + + if (entry.endsWith('.md')) { + // Try reading as a flat .md skill file + try { + 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, }); + } catch { + // File unreadable, skip } - } - } catch { - // Directory not readable - } - } - - // 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'); + } else { + // Try reading SKILL.md inside subdirectory + const skillMdPath = join(fullPath, 'SKILL.md'); + try { + const content = readFileSync(skillMdPath, 'utf-8'); skills.push({ - name: file.replace('.md', ''), - path: skillPath, + name: extractName(content, entry), + path: skillMdPath, description: extractDescription(content), - source: 'project', + source, }); + } catch { + // Not a skill directory or unreadable, skip } } - } catch { - // Directory not readable } + } catch { + // Directory not readable } - // 3. Check for skills.sh script (common pattern for skill installation) + 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/) + skills.push(...scanSkillsDir(join(cwd, '.claude', 'skills'), 'project')); + + // 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 } @@ -182,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', '']; @@ -192,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'); @@ -203,3 +304,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()); +} diff --git a/src/loop/step-detector.ts b/src/loop/step-detector.ts index 94b4594..06f37d5 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/loop/task-counter.ts b/src/loop/task-counter.ts index 74abaaf..ff928d6 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/mcp/server.ts b/src/mcp/server.ts index ac04994..71212e9 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 { @@ -8,11 +11,21 @@ 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'; +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 */ diff --git a/src/skills/auto-install.ts b/src/skills/auto-install.ts new file mode 100644 index 0000000..1f8a76a --- /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; +} diff --git a/src/ui/box.ts b/src/ui/box.ts new file mode 100644 index 0000000..12b693b --- /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 21f6ef8..c1fbc2d 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 = ''; } diff --git a/src/ui/shimmer.ts b/src/ui/shimmer.ts index 43449e6..51c121a 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); } /** diff --git a/src/utils/rate-limit-display.ts b/src/utils/rate-limit-display.ts new file mode 100644 index 0000000..824119d --- /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(' | '); +} diff --git a/src/wizard/index.ts b/src/wizard/index.ts index a3fdd7c..1ba4840 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, @@ -25,6 +26,7 @@ import { askImproveAction, askImprovementPrompt, askRalphPlaybookAction, + askSpecChangePrompt, askWhatToModify, askWorkingDirectory, confirmPlan, @@ -47,6 +49,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 */ @@ -225,40 +246,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 +288,54 @@ 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 shouldBrainstorm = await askBrainstormConfirm(); + if (shouldBrainstorm) { + const selectedIdea = await runIdeaMode(); + if (selectedIdea === null) { + idea = await askForIdea(); + } else { + idea = selectedIdea; + } + } else { + idea = await askForIdea(); + } } 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 shouldBrainstorm = await askBrainstormConfirm(); + if (shouldBrainstorm) { + const selectedIdea = await runIdeaMode(); + if (selectedIdea === null) { + idea = await askForIdea(); + } else { + idea = selectedIdea; + } + } else { + idea = await askForIdea(); + } + } else { + idea = await askForIdea(); + } } + answers.rawIdea = idea; // Refine with LLM - pass spinner and agent to avoid conflicts and double detection @@ -321,7 +365,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; @@ -349,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(); @@ -372,6 +444,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; } } } @@ -493,7 +569,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'); diff --git a/src/wizard/prompts.ts b/src/wizard/prompts.ts index ad68b9a..747159a 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'; } /** @@ -99,12 +106,46 @@ 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); +} + +/** + * 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(); + + 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(); } /** @@ -333,14 +374,27 @@ 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', + name: 'confirmed', + message: 'Is this the right specs?', + default: true, + }, + ]); + + if (confirmed) { + return 'proceed'; + } + const { action } = await inquirer.prompt([ { type: 'list', name: 'action', - message: 'Does this look right?', + message: 'What would you like to do?', choices: [ - { name: "Yes, let's build it!", value: 'proceed' }, + { 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' }, ], @@ -350,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 */ @@ -382,21 +454,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 3215533..66283b4 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 d0f8424..af28586 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(); }