From a063b27855a045d33126795463040027f51fc6f2 Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Mon, 9 Mar 2026 07:39:17 +0100 Subject: [PATCH 01/20] feat: add single-task repeat mode with --repeat and --continue-on-failure Co-Authored-By: Claude Opus 4.6 --- cli/src/cli/__tests__/args.test.ts | 77 ++++++++++++++++++ cli/src/cli/args.ts | 28 +++++++ cli/src/cli/commands/single-task-loop.test.ts | 80 +++++++++++++++++++ cli/src/cli/commands/single-task-loop.ts | 54 +++++++++++++ cli/src/cli/commands/task.ts | 58 +++++++------- cli/src/config/types.ts | 6 ++ cli/src/index.ts | 33 +++++++- cli/src/ui/settings.test.ts | 21 +++++ cli/src/ui/settings.ts | 1 + 9 files changed, 328 insertions(+), 30 deletions(-) create mode 100644 cli/src/cli/__tests__/args.test.ts create mode 100644 cli/src/cli/commands/single-task-loop.test.ts create mode 100644 cli/src/cli/commands/single-task-loop.ts create mode 100644 cli/src/ui/settings.test.ts diff --git a/cli/src/cli/__tests__/args.test.ts b/cli/src/cli/__tests__/args.test.ts new file mode 100644 index 00000000..128f0ee5 --- /dev/null +++ b/cli/src/cli/__tests__/args.test.ts @@ -0,0 +1,77 @@ +import { beforeAll, describe, expect, it, mock } from "bun:test"; + +let parseArgs: typeof import("../args.ts").parseArgs; + +beforeAll(async () => { + mock.module("../../version.ts", () => ({ + VERSION: "test", + })); + ({ parseArgs } = await import("../args.ts")); +}); + +function parseCliArgs(args: string[]) { + return parseArgs(["bun", "ralphy", ...args]); +} + +describe("parseArgs repeat options", () => { + it("parses --repeat 5 with task", () => { + const { options, task } = parseCliArgs(["--repeat", "5", "do something"]); + expect(task).toBe("do something"); + expect(options.repeatCount).toBe(5); + expect(options.continueOnFailure).toBe(false); + }); + + it("throws on --repeat 0", () => { + expect(() => parseCliArgs(["--repeat", "0", "task"])).toThrow( + "--repeat must be an integer >= 1", + ); + }); + + it("throws on --repeat -1", () => { + expect(() => parseCliArgs(["--repeat", "-1", "task"])).toThrow( + "--repeat must be an integer >= 1", + ); + }); + + it("throws on --repeat abc", () => { + expect(() => parseCliArgs(["--repeat", "abc", "task"])).toThrow( + "--repeat must be an integer >= 1", + ); + }); + + it("throws on --repeat 1.5", () => { + expect(() => parseCliArgs(["--repeat", "1.5", "task"])).toThrow( + "--repeat must be an integer >= 1", + ); + }); + + it("parses --repeat with --continue-on-failure", () => { + const { options } = parseCliArgs(["--repeat", "3", "--continue-on-failure", "task"]); + expect(options.repeatCount).toBe(3); + expect(options.continueOnFailure).toBe(true); + }); + + it("throws when --repeat is used without task", () => { + expect(() => parseCliArgs(["--repeat", "3"])).toThrow( + "--repeat and --continue-on-failure require a task argument", + ); + }); + + it("throws when --continue-on-failure is used without task", () => { + expect(() => parseCliArgs(["--continue-on-failure"])).toThrow( + "--repeat and --continue-on-failure require a task argument", + ); + }); + + it("throws when repeat options are combined with task source flags", () => { + expect(() => parseCliArgs(["--repeat", "3", "--yaml", "tasks.yaml", "task"])).toThrow( + "--repeat and --continue-on-failure cannot be used with --prd, --yaml, --json, or --github", + ); + }); + + it("defaults to repeatCount 1", () => { + const { options } = parseCliArgs(["task"]); + expect(options.repeatCount).toBe(1); + expect(options.continueOnFailure).toBe(false); + }); +}); diff --git a/cli/src/cli/args.ts b/cli/src/cli/args.ts index beadad1d..700a9e48 100644 --- a/cli/src/cli/args.ts +++ b/cli/src/cli/args.ts @@ -31,6 +31,8 @@ export function createProgram(): Command { .option("--copilot", "Use GitHub Copilot") .option("--gemini", "Use Gemini CLI") .option("--dry-run", "Show what would be done without executing") + .option("--repeat ", "Repeat single task N times") + .option("--continue-on-failure", "Continue repeat loop on task failure") .option("--max-iterations ", "Maximum iterations (0 = unlimited)", "0") .option("--max-retries ", "Maximum retries per task", "3") .option("--retry-delay ", "Delay between retries in seconds", "5") @@ -88,6 +90,30 @@ export function parseArgs(args: string[]): { const opts = program.opts(); const [task] = program.args; + // Detect explicit PRD/task source flags from raw args. + // --prd has a commander default, so opts.prd cannot be used for this. + const hasExplicitTaskSourceFlag = ralphyArgs.some((arg) => + ["--prd", "--yaml", "--json", "--github", "--prd=", "--yaml=", "--json=", "--github="].some( + (flag) => (flag.endsWith("=") ? arg.startsWith(flag) : arg === flag), + ), + ); + + const repeatProvided = opts.repeat !== undefined; + const repeatCount = repeatProvided ? Number(opts.repeat) : 1; + if (repeatProvided && (!Number.isInteger(repeatCount) || repeatCount < 1)) { + throw new Error("--repeat must be an integer >= 1"); + } + + const continueOnFailure = opts.continueOnFailure || false; + if ((repeatProvided || continueOnFailure) && !task) { + throw new Error("--repeat and --continue-on-failure require a task argument"); + } + if ((repeatProvided || continueOnFailure) && hasExplicitTaskSourceFlag) { + throw new Error( + "--repeat and --continue-on-failure cannot be used with --prd, --yaml, --json, or --github", + ); + } + // Determine AI engine (--sonnet implies --claude) let aiEngine = "claude"; if (opts.sonnet) aiEngine = "claude"; @@ -140,6 +166,8 @@ export function parseArgs(args: string[]): { maxIterations: Number.parseInt(opts.maxIterations, 10) || 0, maxRetries: Number.parseInt(opts.maxRetries, 10) || 3, retryDelay: Number.parseInt(opts.retryDelay, 10) || 5, + repeatCount, + continueOnFailure, verbose: opts.verbose || false, branchPerTask: opts.branchPerTask || false, baseBranch: opts.baseBranch || "", diff --git a/cli/src/cli/commands/single-task-loop.test.ts b/cli/src/cli/commands/single-task-loop.test.ts new file mode 100644 index 00000000..b2a204e5 --- /dev/null +++ b/cli/src/cli/commands/single-task-loop.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "bun:test"; +import { DEFAULT_OPTIONS } from "../../config/types.ts"; +import { runSingleTaskLoop } from "./single-task-loop.ts"; + +describe("runSingleTaskLoop", () => { + it("stops on first non-fatal failure in fail-fast mode", async () => { + let calls = 0; + const result = await runSingleTaskLoop( + "task", + { + ...DEFAULT_OPTIONS, + repeatCount: 3, + continueOnFailure: false, + }, + { + runTaskFn: async () => { + calls++; + return { success: false, fatal: false, error: "boom" }; + }, + logInfoFn: () => {}, + }, + ); + + expect(calls).toBe(1); + expect(result.completed).toBe(0); + expect(result.failed).toBe(1); + expect(result.total).toBe(3); + }); + + it("continues on non-fatal failures when continue-on-failure is enabled", async () => { + let call = 0; + const sequence = [ + { success: false, fatal: false, error: "first" }, + { success: true, fatal: false }, + { success: false, fatal: false, error: "last" }, + ] as const; + + const result = await runSingleTaskLoop( + "task", + { + ...DEFAULT_OPTIONS, + repeatCount: 3, + continueOnFailure: true, + }, + { + runTaskFn: async () => sequence[call++] ?? sequence[sequence.length - 1], + logInfoFn: () => {}, + }, + ); + + expect(call).toBe(3); + expect(result.completed).toBe(1); + expect(result.failed).toBe(2); + expect(result.total).toBe(3); + }); + + it("always stops on fatal failures", async () => { + let calls = 0; + const result = await runSingleTaskLoop( + "task", + { + ...DEFAULT_OPTIONS, + repeatCount: 5, + continueOnFailure: true, + }, + { + runTaskFn: async () => { + calls++; + return { success: false, fatal: true, error: "auth failed" }; + }, + logInfoFn: () => {}, + }, + ); + + expect(calls).toBe(1); + expect(result.completed).toBe(0); + expect(result.failed).toBe(1); + expect(result.total).toBe(5); + }); +}); diff --git a/cli/src/cli/commands/single-task-loop.ts b/cli/src/cli/commands/single-task-loop.ts new file mode 100644 index 00000000..710b825f --- /dev/null +++ b/cli/src/cli/commands/single-task-loop.ts @@ -0,0 +1,54 @@ +import type { RuntimeOptions } from "../../config/types.ts"; +import { logInfo } from "../../ui/logger.ts"; +import { type TaskRunResult, runTask } from "./task.ts"; + +type TaskRunner = (task: string, options: RuntimeOptions) => Promise; +type InfoLogger = (message: string) => void; + +export interface SingleTaskLoopResult { + total: number; + completed: number; + failed: number; +} + +/** + * Run the single-task flow with optional repeat behavior. + */ +export async function runSingleTaskLoop( + task: string, + options: RuntimeOptions, + deps?: { + runTaskFn?: TaskRunner; + logInfoFn?: InfoLogger; + }, +): Promise { + const runTaskFn = deps?.runTaskFn ?? runTask; + const logInfoFn = deps?.logInfoFn ?? logInfo; + + const total = options.repeatCount; + let completed = 0; + let failed = 0; + + for (let i = 1; i <= total; i++) { + if (total > 1) { + logInfoFn(`[${i}/${total}] Executing: ${task}`); + } + + const result = await runTaskFn(task, options); + if (result.success) { + completed++; + continue; + } + + failed++; + if (result.fatal || !options.continueOnFailure) { + break; + } + } + + if (total > 1) { + logInfoFn(`Done: ${completed} succeeded, ${failed} failed of ${total}`); + } + + return { total, completed, failed }; +} diff --git a/cli/src/cli/commands/task.ts b/cli/src/cli/commands/task.ts index 312977e7..496715b5 100644 --- a/cli/src/cli/commands/task.ts +++ b/cli/src/cli/commands/task.ts @@ -1,23 +1,26 @@ -import { loadConfig } from "../../config/loader.ts"; import type { RuntimeOptions } from "../../config/types.ts"; import { logTaskProgress } from "../../config/writer.ts"; import { createEngine, isEngineAvailable } from "../../engines/index.ts"; import type { AIEngineName } from "../../engines/types.ts"; import { isBrowserAvailable } from "../../execution/browser.ts"; import { buildPrompt } from "../../execution/prompt.ts"; -import { isRetryableError, withRetry } from "../../execution/retry.ts"; -import { sendNotifications } from "../../notifications/webhook.ts"; +import { isFatalError, isRetryableError, withRetry } from "../../execution/retry.ts"; import { formatTokens, logError, logInfo, setVerbose } from "../../ui/logger.ts"; import { notifyTaskComplete, notifyTaskFailed } from "../../ui/notify.ts"; import { buildActiveSettings } from "../../ui/settings.ts"; import { ProgressSpinner } from "../../ui/spinner.ts"; +export interface TaskRunResult { + success: boolean; + fatal: boolean; + error?: string; +} + /** * Run a single task (brownfield mode) */ -export async function runTask(task: string, options: RuntimeOptions): Promise { +export async function runTask(task: string, options: RuntimeOptions): Promise { const workDir = process.cwd(); - const config = loadConfig(workDir); // Set verbose mode setVerbose(options.verbose); @@ -27,8 +30,9 @@ export async function runTask(task: string, options: RuntimeOptions): Promise { try { @@ -37,7 +40,33 @@ async function main(): Promise { // Single task mode (brownfield) if (task) { - await runTask(task, options); + const result = await runSingleTaskLoop(task, options); + + if (!options.dryRun) { + const config = loadConfig(process.cwd()); + await sendNotifications(config, result.failed > 0 ? "failed" : "completed", { + tasksCompleted: result.completed, + tasksFailed: result.failed, + }); + + if (options.repeatCount > 1) { + if (result.failed > 0) { + notify( + "Ralphy - Error", + `Repeated task finished: ${result.completed}/${result.total} succeeded, ${result.failed} failed`, + ); + } else { + notify( + "Ralphy", + `Repeated task completed: ${result.completed}/${result.total} succeeded`, + ); + } + } + } + + if (result.failed > 0) { + process.exitCode = 1; + } return; } diff --git a/cli/src/ui/settings.test.ts b/cli/src/ui/settings.test.ts new file mode 100644 index 00000000..7d769f88 --- /dev/null +++ b/cli/src/ui/settings.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "bun:test"; +import { DEFAULT_OPTIONS } from "../config/types.ts"; +import { buildActiveSettings } from "./settings.ts"; + +describe("buildActiveSettings", () => { + it("includes repeat setting when repeatCount > 1", () => { + const settings = buildActiveSettings({ + ...DEFAULT_OPTIONS, + repeatCount: 3, + }); + expect(settings).toContain("repeat 3"); + }); + + it("does not include repeat setting when repeatCount is 1", () => { + const settings = buildActiveSettings({ + ...DEFAULT_OPTIONS, + repeatCount: 1, + }); + expect(settings).not.toContain("repeat 1"); + }); +}); diff --git a/cli/src/ui/settings.ts b/cli/src/ui/settings.ts index eebfdc51..550fe9cb 100644 --- a/cli/src/ui/settings.ts +++ b/cli/src/ui/settings.ts @@ -20,6 +20,7 @@ export function buildActiveSettings(options: RuntimeOptions): string[] { if (options.createPr) activeSettings.push("pr"); if (options.parallel) activeSettings.push("parallel"); if (!options.autoCommit) activeSettings.push("no-commit"); + if (options.repeatCount > 1) activeSettings.push(`repeat ${options.repeatCount}`); return activeSettings; } From 837bf8f8b23f23c0a8ed7fb4e402e5b9bfa79f8f Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Mon, 9 Mar 2026 07:39:24 +0100 Subject: [PATCH 02/20] fix: adjust codex engine for repeat mode compatibility Co-Authored-By: Claude Opus 4.6 --- cli/src/engines/codex.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cli/src/engines/codex.ts b/cli/src/engines/codex.ts index 9674ea0f..8eddd361 100644 --- a/cli/src/engines/codex.ts +++ b/cli/src/engines/codex.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, rmSync, unlinkSync } from "node:fs"; +import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { BaseAIEngine, execCommand, formatCommandError } from "./base.ts"; import type { AIResult, EngineOptions } from "./types.ts"; @@ -17,7 +17,13 @@ export class CodexEngine extends BaseAIEngine { const lastMessageFile = join(workDir, `.codex-last-message-${Date.now()}-${process.pid}.txt`); try { - const args = ["exec", "--full-auto", "--json", "--output-last-message", lastMessageFile]; + const args = [ + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--json", + "--output-last-message", + lastMessageFile, + ]; if (options?.modelOverride) { args.push("--model", options.modelOverride); } From 39a8303bc7ddd866fd597d7412e2aace55f8218f Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Mon, 9 Mar 2026 07:39:29 +0100 Subject: [PATCH 03/20] feat: align bash CLI parity with npm CLI Co-Authored-By: Claude Opus 4.6 --- ralphy.sh | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 268 insertions(+), 20 deletions(-) diff --git a/ralphy.sh b/ralphy.sh index 12bd5da1..85f7b2cd 100755 --- a/ralphy.sh +++ b/ralphy.sh @@ -2,7 +2,7 @@ # ============================================ # Ralphy - Autonomous AI Coding Loop -# Supports Claude Code, OpenCode, Codex, Cursor, Qwen-Code and Factory Droid +# Supports Claude Code, OpenCode, Codex, Cursor, Qwen-Code, Factory Droid, GitHub Copilot, and Gemini CLI # Runs until PRD is complete # ============================================ @@ -12,7 +12,7 @@ set -euo pipefail # CONFIGURATION & DEFAULTS # ============================================ -VERSION="4.3.0" +VERSION="4.7.2" # Ralphy config directory RALPHY_DIR=".ralphy" @@ -27,12 +27,15 @@ AUTO_COMMIT=true # Runtime options SKIP_TESTS=false SKIP_LINT=false -AI_ENGINE="claude" # claude, opencode, cursor, codex, qwen, droid, or copilot +AI_ENGINE="claude" # claude, opencode, cursor, codex, qwen, droid, copilot, or gemini MODEL_OVERRIDE="" # Override default model for any engine (e.g., "sonnet", "gpt-4o-mini") DRY_RUN=false MAX_ITERATIONS=0 # 0 = unlimited MAX_RETRIES=3 RETRY_DELAY=5 +REPEAT_COUNT=1 +CONTINUE_ON_FAILURE=false +REPEAT_FLAG_USED=false VERBOSE=false # Git branch options @@ -44,13 +47,15 @@ PR_DRAFT=false # Parallel execution PARALLEL=false MAX_PARALLEL=3 +NO_MERGE=false # PRD source options -PRD_SOURCE="markdown" # markdown, yaml, github +PRD_SOURCE="markdown" # markdown, yaml, json, github PRD_FILE="PRD.md" GITHUB_REPO="" GITHUB_LABEL="" SYNC_ISSUE="" # GitHub issue number to sync PRD with +TASK_SOURCE_FLAG_USED=false # Browser automation (agent-browser) BROWSER_ENABLED="auto" # auto, true, false @@ -75,6 +80,7 @@ ai_pid="" monitor_pid="" tmpfile="" CODEX_LAST_MESSAGE_FILE="" +LAST_TASK_FATAL=false current_step="Thinking" total_input_tokens=0 total_output_tokens=0 @@ -115,6 +121,18 @@ log_debug() { fi } +is_positive_integer() { + local value="$1" + [[ "$value" =~ ^[1-9][0-9]*$ ]] +} + +is_fatal_error_output() { + local file="$1" + grep -Eqi \ + 'not authenticated|no authentication|authentication failed|invalid[^[:space:]]*token|invalid[^[:space:]]*api.?key|unauthorized|(^|[^0-9])401([^0-9]|$)|(^|[^0-9])403([^0-9]|$)|command not found|not installed|is not recognized' \ + "$file" +} + # Slugify text for branch names slugify() { echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g' | sed -E 's/^-|-$//g' | cut -c1-50 @@ -599,6 +617,7 @@ Keep changes focused and minimal. Do not refactor unrelated code." # Run a single brownfield task run_brownfield_task() { local task="$1" + LAST_TASK_FATAL=false echo "" echo "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" @@ -650,8 +669,14 @@ run_brownfield_task() { ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ 2>&1 | tee "$output_file" ;; + gemini) + gemini --output-format stream-json \ + --yolo \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ + -p "$prompt" 2>&1 | tee "$output_file" + ;; codex) - codex exec --full-auto \ + codex exec --dangerously-bypass-approvals-and-sandbox \ --json \ "$prompt" 2>&1 | tee "$output_file" ;; @@ -664,6 +689,10 @@ run_brownfield_task() { log_task_history "$task" "completed" log_success "Task completed" else + if is_fatal_error_output "$output_file"; then + LAST_TASK_FATAL=true + log_error "Fatal error detected (auth/config/CLI)." + fi log_task_history "$task" "failed" log_error "Task failed" fi @@ -692,6 +721,9 @@ ${BOLD}CONFIG & SETUP:${RESET} ${BOLD}SINGLE TASK MODE:${RESET} "task description" Run a single task without PRD (quotes required) + --repeat N Repeat single task N times + --continue-on-failure + Continue repeat loop on failure (non-fatal only) --no-commit Don't auto-commit after task completion ${BOLD}AI ENGINE OPTIONS:${RESET} @@ -702,6 +734,7 @@ ${BOLD}AI ENGINE OPTIONS:${RESET} --qwen Use Qwen-Code --droid Use Factory Droid --copilot Use GitHub Copilot + --gemini Use Gemini CLI --model Override default model for any engine Claude: sonnet, haiku, opus OpenCode: gpt-4o, gpt-4o-mini, o1, o3-mini @@ -721,6 +754,7 @@ ${BOLD}EXECUTION OPTIONS:${RESET} ${BOLD}PARALLEL EXECUTION:${RESET} --parallel Run independent tasks in parallel --max-parallel N Max concurrent tasks (default: 3) + --no-merge Keep parallel branches without auto-merge ${BOLD}GIT BRANCH OPTIONS:${RESET} --branch-per-task Create a new git branch for each task @@ -731,6 +765,7 @@ ${BOLD}GIT BRANCH OPTIONS:${RESET} ${BOLD}PRD SOURCE OPTIONS:${RESET} --prd FILE PRD file path (default: PRD.md) --yaml FILE Use YAML task file instead of markdown + --json FILE Use JSON task file --github REPO Fetch tasks from GitHub issues (e.g., owner/repo) --github-label TAG Filter GitHub issues by label --sync-issue NUM Sync PRD file to GitHub issue body on each iteration @@ -748,6 +783,8 @@ ${BOLD}EXAMPLES:${RESET} # Brownfield mode (single tasks in existing projects) ./ralphy.sh --init # Initialize config ./ralphy.sh "add dark mode toggle" # Run single task + ./ralphy.sh --repeat 3 "find and fix bugs" + ./ralphy.sh --repeat 5 --continue-on-failure "harden edge cases" ./ralphy.sh "fix the login bug" --cursor # Single task with Cursor ./ralphy.sh "test the login flow" --browser # Task with browser automation @@ -757,6 +794,7 @@ ${BOLD}EXAMPLES:${RESET} ./ralphy.sh --branch-per-task --create-pr # Feature branch workflow ./ralphy.sh --parallel --max-parallel 4 # Run 4 tasks concurrently ./ralphy.sh --yaml tasks.yaml # Use YAML task file + ./ralphy.sh --json tasks.json # Use JSON task file ./ralphy.sh --github owner/repo # Fetch from GitHub issues ${BOLD}PRD FORMATS:${RESET} @@ -769,6 +807,9 @@ ${BOLD}PRD FORMATS:${RESET} completed: false parallel_group: 1 # Optional: tasks with same group run in parallel + JSON (tasks.json): + { "tasks": [{ "title": "Task description", "completed": false, "parallel_group": 1 }] } + GitHub Issues: Uses open issues from the specified repository @@ -832,6 +873,10 @@ parse_args() { AI_ENGINE="copilot" shift ;; + --gemini) + AI_ENGINE="gemini" + shift + ;; --model) MODEL_OVERRIDE="$2" shift 2 @@ -840,6 +885,20 @@ parse_args() { DRY_RUN=true shift ;; + --repeat) + [[ -z "${2:-}" ]] && { log_error "--repeat requires a value"; exit 1; } + if ! is_positive_integer "$2"; then + log_error "--repeat must be an integer >= 1" + exit 1 + fi + REPEAT_COUNT="$2" + REPEAT_FLAG_USED=true + shift 2 + ;; + --continue-on-failure) + CONTINUE_ON_FAILURE=true + shift + ;; --max-iterations) MAX_ITERATIONS="${2:-0}" shift 2 @@ -860,6 +919,10 @@ parse_args() { MAX_PARALLEL="${2:-3}" shift 2 ;; + --no-merge) + NO_MERGE=true + shift + ;; --branch-per-task) BRANCH_PER_TASK=true shift @@ -879,16 +942,25 @@ parse_args() { --prd) PRD_FILE="${2:-PRD.md}" PRD_SOURCE="markdown" + TASK_SOURCE_FLAG_USED=true shift 2 ;; --yaml) PRD_FILE="${2:-tasks.yaml}" PRD_SOURCE="yaml" + TASK_SOURCE_FLAG_USED=true + shift 2 + ;; + --json) + PRD_FILE="${2:-tasks.json}" + PRD_SOURCE="json" + TASK_SOURCE_FLAG_USED=true shift 2 ;; --github) GITHUB_REPO="${2:-}" PRD_SOURCE="github" + TASK_SOURCE_FLAG_USED=true shift 2 ;; --github-label) @@ -983,6 +1055,18 @@ check_requirements() { exit 1 fi ;; + json) + if [[ ! -f "$PRD_FILE" ]]; then + log_error "$PRD_FILE not found in current directory" + log_info "Create a tasks.json file with a top-level tasks array" + log_info "Or use: --prd PRD.md for Markdown task files" + exit 1 + fi + if ! jq -e '.tasks and (.tasks | type == "array")' "$PRD_FILE" >/dev/null 2>&1; then + log_error "Invalid JSON task file: top-level 'tasks' array is required" + exit 1 + fi + ;; github) if [[ -z "$GITHUB_REPO" ]]; then log_error "GitHub repository not specified. Use --github owner/repo" @@ -1037,11 +1121,17 @@ check_requirements() { exit 1 fi ;; + gemini) + if ! command -v gemini &>/dev/null; then + log_error "Gemini CLI not found. Install from https://github.com/google-gemini/gemini-cli" + exit 1 + fi + ;; *) if ! command -v claude &>/dev/null; then log_error "Claude Code CLI not found." log_info "Install from: https://github.com/anthropics/claude-code" - log_info "Or use another engine: --cursor, --opencode, --codex, --qwen, --copilot" + log_info "Or use another engine: --cursor, --opencode, --codex, --qwen, --droid, --copilot, --gemini" exit 1 fi ;; @@ -1234,6 +1324,45 @@ get_tasks_in_group_yaml() { yq -r ".tasks[] | select(.completed != true and (.parallel_group // 0) == $group) | .title" "$PRD_FILE" 2>/dev/null || true } +# ============================================ +# TASK SOURCES - JSON +# ============================================ + +get_tasks_json() { + jq -r '.tasks[] | select(.completed != true) | .title' "$PRD_FILE" 2>/dev/null || true +} + +get_next_task_json() { + jq -r '.tasks[] | select(.completed != true) | .title' "$PRD_FILE" 2>/dev/null | head -1 | cut -c1-50 || echo "" +} + +count_remaining_json() { + jq -r '[.tasks[] | select(.completed != true)] | length' "$PRD_FILE" 2>/dev/null || echo "0" +} + +count_completed_json() { + jq -r '[.tasks[] | select(.completed == true)] | length' "$PRD_FILE" 2>/dev/null || echo "0" +} + +mark_task_complete_json() { + local task=$1 + local tmp_file + tmp_file=$(mktemp) + if jq --arg task "$task" \ + '(.tasks[] | select(.title == $task) | .completed) = true' \ + "$PRD_FILE" > "$tmp_file" 2>/dev/null; then + mv "$tmp_file" "$PRD_FILE" + else + rm -f "$tmp_file" + return 1 + fi +} + +get_tasks_in_group_json() { + local group=$1 + jq -r ".tasks[] | select(.completed != true and (.parallel_group // 0) == $group) | .title" "$PRD_FILE" 2>/dev/null || true +} + # ============================================ # TASK SOURCES - GITHUB ISSUES # ============================================ @@ -1291,6 +1420,7 @@ get_next_task() { case "$PRD_SOURCE" in markdown) get_next_task_markdown ;; yaml) get_next_task_yaml ;; + json) get_next_task_json ;; github) get_next_task_github ;; esac } @@ -1299,6 +1429,7 @@ get_all_tasks() { case "$PRD_SOURCE" in markdown) get_tasks_markdown ;; yaml) get_tasks_yaml ;; + json) get_tasks_json ;; github) get_tasks_github ;; esac } @@ -1307,6 +1438,7 @@ count_remaining_tasks() { case "$PRD_SOURCE" in markdown) count_remaining_markdown ;; yaml) count_remaining_yaml ;; + json) count_remaining_json ;; github) count_remaining_github ;; esac } @@ -1315,6 +1447,7 @@ count_completed_tasks() { case "$PRD_SOURCE" in markdown) count_completed_markdown ;; yaml) count_completed_yaml ;; + json) count_completed_json ;; github) count_completed_github ;; esac } @@ -1324,6 +1457,7 @@ mark_task_complete() { case "$PRD_SOURCE" in markdown) mark_task_complete_markdown "$task" ;; yaml) mark_task_complete_yaml "$task" ;; + json) mark_task_complete_json "$task" ;; github) mark_task_complete_github "$task" ;; esac } @@ -1598,6 +1732,9 @@ $never_touch yaml) prompt="@${PRD_FILE} @$PROGRESS_FILE" ;; + json) + prompt="@${PRD_FILE} @$PROGRESS_FILE" + ;; github) # For GitHub issues, we include the issue body local issue_body="" @@ -1639,6 +1776,10 @@ $step. Update the PRD to mark the task as complete (change '- [ ]' to '- [x]')." ;; yaml) prompt="$prompt +$step. Update ${PRD_FILE} to mark the task as completed (set completed: true)." + ;; + json) + prompt="$prompt $step. Update ${PRD_FILE} to mark the task as completed (set completed: true)." ;; github) @@ -1707,10 +1848,17 @@ run_ai_command() { ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ > "$output_file" 2>&1 & ;; + gemini) + # Gemini CLI: stream-json output with yolo approval mode + gemini --output-format stream-json \ + --yolo \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ + -p "$prompt" > "$output_file" 2>&1 & + ;; codex) CODEX_LAST_MESSAGE_FILE="${output_file}.last" rm -f "$CODEX_LAST_MESSAGE_FILE" - codex exec --full-auto \ + codex exec --dangerously-bypass-approvals-and-sandbox \ --json \ --output-last-message "$CODEX_LAST_MESSAGE_FILE" \ "$prompt" > "$output_file" 2>&1 & @@ -2279,12 +2427,21 @@ Focus only on implementing: $task_name" ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} ) > "$tmpfile" 2>>"$log_file" ;; + gemini) + ( + cd "$worktree_dir" + gemini --output-format stream-json \ + --yolo \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ + -p "$prompt" + ) > "$tmpfile" 2>>"$log_file" + ;; codex) ( cd "$worktree_dir" CODEX_LAST_MESSAGE_FILE="$tmpfile.last" rm -f "$CODEX_LAST_MESSAGE_FILE" - codex exec --full-auto \ + codex exec --dangerously-bypass-approvals-and-sandbox \ --json \ --output-last-message "$CODEX_LAST_MESSAGE_FILE" \ "$prompt" @@ -2424,11 +2581,17 @@ run_parallel_tasks() { local completed_branches=() local groups=("all") - if [[ "$PRD_SOURCE" == "yaml" ]]; then + if [[ "$PRD_SOURCE" == "yaml" || "$PRD_SOURCE" == "json" ]]; then groups=() - while IFS= read -r group; do - [[ -n "$group" ]] && groups+=("$group") - done < <(yq -r '.tasks[] | select(.completed != true) | (.parallel_group // 0)' "$PRD_FILE" 2>/dev/null | sort -n | uniq) + if [[ "$PRD_SOURCE" == "yaml" ]]; then + while IFS= read -r group; do + [[ -n "$group" ]] && groups+=("$group") + done < <(yq -r '.tasks[] | select(.completed != true) | (.parallel_group // 0)' "$PRD_FILE" 2>/dev/null | sort -n | uniq) + else + while IFS= read -r group; do + [[ -n "$group" ]] && groups+=("$group") + done < <(jq -r '.tasks[] | select(.completed != true) | (.parallel_group // 0)' "$PRD_FILE" 2>/dev/null | sort -n | uniq) + fi fi for group in "${groups[@]}"; do @@ -2436,10 +2599,16 @@ run_parallel_tasks() { local group_label="" local group_completed_branches=() # Track branches completed in this group - if [[ "$PRD_SOURCE" == "yaml" ]]; then - while IFS= read -r task; do - [[ -n "$task" ]] && tasks+=("$task") - done < <(get_tasks_in_group_yaml "$group") + if [[ "$PRD_SOURCE" == "yaml" || "$PRD_SOURCE" == "json" ]]; then + if [[ "$PRD_SOURCE" == "yaml" ]]; then + while IFS= read -r task; do + [[ -n "$task" ]] && tasks+=("$task") + done < <(get_tasks_in_group_yaml "$group") + else + while IFS= read -r task; do + [[ -n "$task" ]] && tasks+=("$task") + done < <(get_tasks_in_group_json "$group") + fi [[ ${#tasks[@]} -eq 0 ]] && continue group_label=" (group $group)" else @@ -2597,6 +2766,8 @@ run_parallel_tasks() { mark_task_complete_markdown "$task" elif [[ "$PRD_SOURCE" == "yaml" ]]; then mark_task_complete_yaml "$task" + elif [[ "$PRD_SOURCE" == "json" ]]; then + mark_task_complete_json "$task" elif [[ "$PRD_SOURCE" == "github" ]]; then mark_task_complete_github "$task" fi @@ -2646,7 +2817,7 @@ run_parallel_tasks() { # After each parallel_group completes, merge branches into integration branch # so the next group sees the completed work (fixes issue #13) # NOTE: Uses git branch instead of git checkout to avoid changing HEAD while worktrees are active (Greptile review) - if [[ "$PRD_SOURCE" == "yaml" ]] && [[ ${#group_completed_branches[@]} -gt 0 ]] && [[ ${#groups[@]} -gt 1 ]]; then + if [[ "$PRD_SOURCE" == "yaml" || "$PRD_SOURCE" == "json" ]] && [[ ${#group_completed_branches[@]} -gt 0 ]] && [[ ${#groups[@]} -gt 1 ]]; then local integration_branch="ralphy/integration-group-$group" log_info "Creating integration branch for group $group: $integration_branch" @@ -2721,6 +2892,19 @@ run_parallel_tasks() { for branch in "${completed_branches[@]}"; do echo " ${CYAN}•${RESET} $branch" done + elif [[ "$NO_MERGE" == true ]]; then + # Keep branches as-is for manual follow-up. + echo "${BOLD}Auto-merge skipped (--no-merge). Branches kept:${RESET}" + for branch in "${completed_branches[@]}"; do + echo " ${CYAN}•${RESET} $branch" + done + if [[ ${#integration_branches[@]} -gt 0 ]]; then + echo "" + echo "${BOLD}Integration branches kept:${RESET}" + for int_branch in "${integration_branches[@]}"; do + echo " ${CYAN}•${RESET} $int_branch" + done + fi else # Auto-merge branches into ORIGINAL base branch (not integration branches) # This addresses Greptile review: final merge should use original base, not integration branch @@ -2876,8 +3060,14 @@ Be careful to preserve functionality from BOTH branches. The goal is to integrat ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ > "$resolve_tmpfile" 2>&1 ;; + gemini) + gemini --output-format stream-json \ + --yolo \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ + -p "$resolve_prompt" > "$resolve_tmpfile" 2>&1 + ;; codex) - codex exec --full-auto \ + codex exec --dangerously-bypass-approvals-and-sandbox \ --json \ "$resolve_prompt" > "$resolve_tmpfile" 2>&1 ;; @@ -2993,6 +3183,18 @@ show_summary() { main() { parse_args "$@" + # Repeat options are only valid in single-task mode. + if [[ "$REPEAT_FLAG_USED" == true || "$CONTINUE_ON_FAILURE" == true ]]; then + if [[ -z "$SINGLE_TASK" ]]; then + log_error "--repeat and --continue-on-failure require a task argument" + exit 1 + fi + if [[ "$TASK_SOURCE_FLAG_USED" == true ]]; then + log_error "--repeat and --continue-on-failure cannot be used with --prd, --yaml, --json, or --github" + exit 1 + fi + fi + # Load browser setting from config (if not overridden by CLI flag) if [[ "$BROWSER_ENABLED" == "auto" ]] && [[ -f "$CONFIG_FILE" ]]; then BROWSER_ENABLED=$(load_browser_setting) @@ -3031,6 +3233,7 @@ main() { qwen) command -v qwen &>/dev/null || { log_error "Qwen-Code CLI not found"; exit 1; } ;; droid) command -v droid &>/dev/null || { log_error "Factory Droid CLI not found"; exit 1; } ;; copilot) command -v copilot &>/dev/null || { log_error "GitHub Copilot CLI not found"; exit 1; } ;; + gemini) command -v gemini &>/dev/null || { log_error "Gemini CLI not found"; exit 1; } ;; esac if ! git rev-parse --git-dir >/dev/null 2>&1; then @@ -3049,6 +3252,7 @@ main() { qwen) engine_display="${GREEN}Qwen-Code${RESET}" ;; droid) engine_display="${MAGENTA}Factory Droid${RESET}" ;; copilot) engine_display="${BLUE}GitHub Copilot${RESET}" ;; + gemini) engine_display="${CYAN}Gemini CLI${RESET}" ;; *) engine_display="${MAGENTA}Claude Code${RESET}" ;; esac echo "Engine: $engine_display" @@ -3057,10 +3261,53 @@ main() { else echo "Config: ${DIM}none (run --init to configure)${RESET}" fi + if [[ "$REPEAT_COUNT" -gt 1 ]]; then + local repeat_mode_label="repeat:$REPEAT_COUNT" + if [[ "$CONTINUE_ON_FAILURE" == true ]]; then + repeat_mode_label="$repeat_mode_label continue-on-failure" + fi + echo "Mode: ${YELLOW}$repeat_mode_label${RESET}" + fi echo "${BOLD}============================================${RESET}" - run_brownfield_task "$SINGLE_TASK" - exit $? + local total="$REPEAT_COUNT" + local completed=0 + local failed=0 + local run_idx + + for ((run_idx=1; run_idx<=total; run_idx++)); do + if [[ "$total" -gt 1 ]]; then + log_info "[$run_idx/$total] Executing: $SINGLE_TASK" + fi + + if run_brownfield_task "$SINGLE_TASK"; then + ((completed++)) || true + continue + fi + + ((failed++)) || true + if [[ "$LAST_TASK_FATAL" == true ]]; then + log_error "Aborting repeat loop due to fatal error." + break + fi + if [[ "$CONTINUE_ON_FAILURE" != true ]]; then + break + fi + done + + if [[ "$total" -gt 1 ]]; then + log_info "Done: $completed succeeded, $failed failed of $total" + if [[ "$failed" -gt 0 ]]; then + notify_error "Repeated task finished: $completed/$total succeeded, $failed failed" + else + notify_done "Repeated task completed: $completed/$total succeeded" + fi + fi + + if [[ "$failed" -gt 0 ]]; then + exit 1 + fi + exit 0 fi if [[ "$DRY_RUN" == true ]] && [[ "$MAX_ITERATIONS" -eq 0 ]]; then @@ -3085,6 +3332,7 @@ main() { qwen) engine_display="${GREEN}Qwen-Code${RESET}" ;; droid) engine_display="${MAGENTA}Factory Droid${RESET}" ;; copilot) engine_display="${BLUE}GitHub Copilot${RESET}" ;; + gemini) engine_display="${CYAN}Gemini CLI${RESET}" ;; *) engine_display="${MAGENTA}Claude Code${RESET}" ;; esac echo "Engine: $engine_display" From e984745a79a743b6852c902482ec7d5d4f7ee0dc Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Mon, 9 Mar 2026 07:39:35 +0100 Subject: [PATCH 04/20] docs: update README and add example PRD/tasks files Co-Authored-By: Claude Opus 4.6 --- PRD.example.md | 27 +++++++++++++++++++++++++++ README.md | 7 ++++++- cli/README.md | 4 ++++ tasks.example.yaml | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 PRD.example.md create mode 100644 tasks.example.yaml diff --git a/PRD.example.md b/PRD.example.md new file mode 100644 index 00000000..d00a129f --- /dev/null +++ b/PRD.example.md @@ -0,0 +1,27 @@ +# Example Project PRD + +Ein einfaches Beispiel-Projekt, um Ralphy zu demonstrieren. + +## Kontext + +Wir bauen eine kleine CLI-Anwendung, die Markdown-Dateien in HTML konvertiert. + +## Tasks + +- [ ] Projekt-Setup: package.json erstellen mit TypeScript und Vitest +- [ ] Funktion schreiben, die Markdown-Headings (#, ##, ###) in HTML-Tags konvertiert +- [ ] Funktion schreiben, die **bold** und *italic* Text konvertiert +- [ ] Funktion schreiben, die Listen (- item) in HTML-Listen konvertiert +- [ ] CLI-Entry-Point erstellen, der eine Datei einliest und konvertiert +- [ ] README.md mit Nutzungsanleitung schreiben + +## Akzeptanzkriterien + +- Alle Tests gruen +- TypeScript kompiliert ohne Fehler +- CLI kann mit `npx ts-node src/index.ts input.md` ausgefuehrt werden + +## Notizen + +- Keine externen Markdown-Libraries verwenden (Lernzweck) +- Einfache Regex-basierte Konvertierung reicht aus diff --git a/README.md b/README.md index 91c074a0..ce8fe538 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ cd ralphy && chmod +x ralphy.sh ./ralphy.sh --prd PRD.md ``` -Both versions have identical features. Examples below use `ralphy` (npm) - substitute `./ralphy.sh` if using the bash script. +Examples below use `ralphy` (npm). Most commands also work with `./ralphy.sh`, but newer npm CLI features may land there first. ## Two Modes @@ -36,6 +36,8 @@ Both versions have identical features. Examples below use `ralphy` (npm) - subst ```bash ralphy "add dark mode" ralphy "fix the auth bug" +ralphy --repeat 3 "find and fix bugs" +ralphy --repeat 5 --continue-on-failure "harden edge cases" ``` **Task list** - work through a PRD: @@ -319,6 +321,8 @@ ralphy --parallel --sandbox | `--max-retries N` | retries per task (default: 3) | | `--retry-delay N` | seconds between retries | | `--dry-run` | preview only | +| `--repeat N` | repeat a single task N times (requires task argument) | +| `--continue-on-failure` | in repeat mode, continue after non-fatal task failures | | `--browser` | enable browser automation | | `--no-browser` | disable browser automation | | `-v, --verbose` | debug output | @@ -363,6 +367,7 @@ When an engine exits non-zero, ralphy includes the last lines of CLI output in t ## Changelog ### v4.7.2 +- **Single-task repeat mode**: added `--repeat ` with `--continue-on-failure` and fail-fast defaults; fatal errors still abort immediately - **Improved auth error detection**: simplified `extractAuthenticationError` function with better edge case handling (e.g., JSON dumps during login) - **Added project standards**: `CLAUDE.md`, `.cursorrules`, `CONTRIBUTING.md` for consistent AI-assisted development - **Enhanced default prompts**: enforce concise, focused code changes diff --git a/cli/README.md b/cli/README.md index e4a2e199..1dbdd3bc 100644 --- a/cli/README.md +++ b/cli/README.md @@ -24,6 +24,8 @@ ralphy --prd PRD.md ```bash ralphy "add dark mode" ralphy "fix the auth bug" +ralphy --repeat 3 "find and fix bugs" +ralphy --repeat 5 --continue-on-failure "harden edge cases" ``` **Task list** - work through a PRD: @@ -307,6 +309,8 @@ ralphy --parallel --sandbox | `--max-retries N` | retries per task (default: 3) | | `--retry-delay N` | seconds between retries | | `--dry-run` | preview only | +| `--repeat N` | repeat a single task N times (requires task argument) | +| `--continue-on-failure` | in repeat mode, continue after non-fatal task failures | | `--browser` | enable browser automation | | `--no-browser` | disable browser automation | | `-v, --verbose` | debug output | diff --git a/tasks.example.yaml b/tasks.example.yaml new file mode 100644 index 00000000..ecaa4f1a --- /dev/null +++ b/tasks.example.yaml @@ -0,0 +1,35 @@ +# Example Tasks for Ralphy (YAML Format) +# +# Vorteile gegenueber Markdown: +# - parallel_group: Kontrolliert, welche Tasks gleichzeitig laufen duerfen +# - Strukturierte Daten fuer komplexere Workflows +# +# Nutzung: ./ralphy.sh --yaml tasks.example.yaml --parallel + +tasks: + # Gruppe 0: Setup (muss zuerst fertig sein) + - title: "Projekt-Setup: package.json erstellen mit TypeScript und Vitest" + completed: false + parallel_group: 0 + + # Gruppe 1: Kern-Funktionen (koennen parallel laufen) + - title: "Funktion schreiben, die Markdown-Headings (#, ##, ###) in HTML-Tags konvertiert" + completed: false + parallel_group: 1 + + - title: "Funktion schreiben, die **bold** und *italic* Text konvertiert" + completed: false + parallel_group: 1 + + - title: "Funktion schreiben, die Listen (- item) in HTML-Listen konvertiert" + completed: false + parallel_group: 1 + + # Gruppe 2: Integration (braucht Gruppe 1) + - title: "CLI-Entry-Point erstellen, der eine Datei einliest und konvertiert" + completed: false + parallel_group: 2 + + - title: "README.md mit Nutzungsanleitung schreiben" + completed: false + parallel_group: 2 From 05ade13b36a7d99a0a295b207c4f8bada4a42c92 Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Mon, 9 Mar 2026 10:56:46 +0100 Subject: [PATCH 05/20] refactor: simplify args parsing and deduplicate task error handling - Extract failWith() to eliminate duplicated error handling in task.ts - Extract resolveBrowserEnabled() to replace nested ternary in args.ts - Extract taskSourceFlags and hasRepeatOptions to reduce repetition - Preserve --sonnet priority guard over other engine flags Co-Authored-By: Claude Opus 4.6 --- cli/src/cli/args.ts | 23 +++++++++------- cli/src/cli/commands/task.ts | 52 ++++++++++++------------------------ 2 files changed, 31 insertions(+), 44 deletions(-) diff --git a/cli/src/cli/args.ts b/cli/src/cli/args.ts index 700a9e48..d5b6db64 100644 --- a/cli/src/cli/args.ts +++ b/cli/src/cli/args.ts @@ -64,6 +64,12 @@ export function createProgram(): Command { return program; } +function resolveBrowserEnabled(flag: boolean | undefined): "auto" | "true" | "false" { + if (flag === true) return "true"; + if (flag === false) return "false"; + return "auto"; +} + /** * Parse command line arguments into RuntimeOptions */ @@ -90,12 +96,10 @@ export function parseArgs(args: string[]): { const opts = program.opts(); const [task] = program.args; - // Detect explicit PRD/task source flags from raw args. - // --prd has a commander default, so opts.prd cannot be used for this. + // --prd has a commander default, so opts.prd alone cannot detect explicit usage + const taskSourceFlags = ["--prd", "--yaml", "--json", "--github"]; const hasExplicitTaskSourceFlag = ralphyArgs.some((arg) => - ["--prd", "--yaml", "--json", "--github", "--prd=", "--yaml=", "--json=", "--github="].some( - (flag) => (flag.endsWith("=") ? arg.startsWith(flag) : arg === flag), - ), + taskSourceFlags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)), ); const repeatProvided = opts.repeat !== undefined; @@ -105,16 +109,17 @@ export function parseArgs(args: string[]): { } const continueOnFailure = opts.continueOnFailure || false; - if ((repeatProvided || continueOnFailure) && !task) { + const hasRepeatOptions = repeatProvided || continueOnFailure; + if (hasRepeatOptions && !task) { throw new Error("--repeat and --continue-on-failure require a task argument"); } - if ((repeatProvided || continueOnFailure) && hasExplicitTaskSourceFlag) { + if (hasRepeatOptions && hasExplicitTaskSourceFlag) { throw new Error( "--repeat and --continue-on-failure cannot be used with --prd, --yaml, --json, or --github", ); } - // Determine AI engine (--sonnet implies --claude) + // --sonnet implies --claude and takes priority over other engine flags let aiEngine = "claude"; if (opts.sonnet) aiEngine = "claude"; else if (opts.opencode) aiEngine = "opencode"; @@ -182,7 +187,7 @@ export function parseArgs(args: string[]): { githubLabel: opts.githubLabel || "", syncIssue: opts.syncIssue ? Number.parseInt(opts.syncIssue, 10) || undefined : undefined, autoCommit: opts.commit !== false, - browserEnabled: opts.browser === true ? "true" : opts.browser === false ? "false" : "auto", + browserEnabled: resolveBrowserEnabled(opts.browser), modelOverride, skipMerge: opts.merge === false, useSandbox: opts.sandbox || false, diff --git a/cli/src/cli/commands/task.ts b/cli/src/cli/commands/task.ts index 496715b5..2484f3c6 100644 --- a/cli/src/cli/commands/task.ts +++ b/cli/src/cli/commands/task.ts @@ -21,11 +21,8 @@ export interface TaskRunResult { */ export async function runTask(task: string, options: RuntimeOptions): Promise { const workDir = process.cwd(); - - // Set verbose mode setVerbose(options.verbose); - // Check engine availability const engine = createEngine(options.aiEngine as AIEngineName); const available = await isEngineAvailable(options.aiEngine as AIEngineName); @@ -37,12 +34,10 @@ export async function runTask(task: string, options: RuntimeOptions): Promise { spinner.updateStep("Working"); - // Build engine options const engineOptions = { ...(options.modelOverride && { modelOverride: options.modelOverride }), ...(options.engineArgs && options.engineArgs.length > 0 && { engineArgs: options.engineArgs }), }; - // Use streaming if available if (engine.executeStreaming) { return await engine.executeStreaming( prompt, workDir, - (step) => { - spinner.updateStep(step); - }, + (step) => spinner.updateStep(step), engineOptions, ); } @@ -100,22 +100,18 @@ export async function runTask(task: string, options: RuntimeOptions): Promise { - spinner.updateStep(`Retry ${attempt}`); - }, + onRetry: (attempt) => spinner.updateStep(`Retry ${attempt}`), }, ); if (result.success) { const tokens = formatTokens(result.inputTokens, result.outputTokens); spinner.success(`Done ${tokens}`); - logTaskProgress(task, "completed", workDir); - if (options.repeatCount === 1) { + if (notifySingleTask) { notifyTaskComplete(task); } - // Show response summary if (result.response && result.response !== "Task completed") { console.log("\nResult:"); console.log(result.response.slice(0, 500)); @@ -126,22 +122,8 @@ export async function runTask(task: string, options: RuntimeOptions): Promise Date: Mon, 9 Mar 2026 21:42:06 +0100 Subject: [PATCH 06/20] fix: address PR review feedback - codex.ts: document --dangerously-bypass-approvals-and-sandbox flag origin - args.ts: warn when --continue-on-failure is used without --repeat - ralphy.sh: limit fatal error scan to last 20 lines to prevent false positives - ralphy.sh: check jq availability before JSON validation Co-Authored-By: Claude Opus 4.6 --- cli/src/cli/args.ts | 3 +++ cli/src/engines/codex.ts | 2 +- ralphy.sh | 10 +++++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/cli/src/cli/args.ts b/cli/src/cli/args.ts index d5b6db64..e082c5d3 100644 --- a/cli/src/cli/args.ts +++ b/cli/src/cli/args.ts @@ -109,6 +109,9 @@ export function parseArgs(args: string[]): { } const continueOnFailure = opts.continueOnFailure || false; + if (continueOnFailure && !repeatProvided) { + console.warn("Warning: --continue-on-failure has no effect without --repeat"); + } const hasRepeatOptions = repeatProvided || continueOnFailure; if (hasRepeatOptions && !task) { throw new Error("--repeat and --continue-on-failure require a task argument"); diff --git a/cli/src/engines/codex.ts b/cli/src/engines/codex.ts index 8eddd361..7deab643 100644 --- a/cli/src/engines/codex.ts +++ b/cli/src/engines/codex.ts @@ -19,7 +19,7 @@ export class CodexEngine extends BaseAIEngine { try { const args = [ "exec", - "--dangerously-bypass-approvals-and-sandbox", + "--dangerously-bypass-approvals-and-sandbox", // replaces deprecated --full-auto; required for unattended execution "--json", "--output-last-message", lastMessageFile, diff --git a/ralphy.sh b/ralphy.sh index 85f7b2cd..aa9be729 100755 --- a/ralphy.sh +++ b/ralphy.sh @@ -128,9 +128,9 @@ is_positive_integer() { is_fatal_error_output() { local file="$1" - grep -Eqi \ - 'not authenticated|no authentication|authentication failed|invalid[^[:space:]]*token|invalid[^[:space:]]*api.?key|unauthorized|(^|[^0-9])401([^0-9]|$)|(^|[^0-9])403([^0-9]|$)|command not found|not installed|is not recognized' \ - "$file" + # Only check the last 20 lines to avoid false positives from AI-generated narrative + tail -20 "$file" | grep -Eqi \ + 'not authenticated|no authentication|authentication failed|invalid[^[:space:]]*token|invalid[^[:space:]]*api.?key|unauthorized|(^|[^0-9])401([^0-9]|$)|(^|[^0-9])403([^0-9]|$)|command not found|not installed|is not recognized' } # Slugify text for branch names @@ -1056,6 +1056,10 @@ check_requirements() { fi ;; json) + if ! command -v jq &>/dev/null; then + log_error "jq is required for JSON parsing. Install from https://github.com/jqlang/jq" + exit 1 + fi if [[ ! -f "$PRD_FILE" ]]; then log_error "$PRD_FILE not found in current directory" log_info "Create a tasks.json file with a top-level tasks array" From a0bf4677224d0abf8c3e4ffd1d159573bb2441d7 Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Tue, 10 Mar 2026 03:55:23 +0100 Subject: [PATCH 07/20] fix: add --repeat upper bound and bash CLI parity warning - args.ts: cap --repeat at 10000 to prevent near-infinite loops (e.g. --repeat 1e100) - args.test.ts: update error message expectations for new bound - ralphy.sh: warn when --continue-on-failure is used without --repeat (matches TS CLI) Co-Authored-By: Claude Opus 4.6 --- cli/src/cli/__tests__/args.test.ts | 8 ++++---- cli/src/cli/args.ts | 4 ++-- ralphy.sh | 3 +++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/cli/src/cli/__tests__/args.test.ts b/cli/src/cli/__tests__/args.test.ts index 128f0ee5..dffc1504 100644 --- a/cli/src/cli/__tests__/args.test.ts +++ b/cli/src/cli/__tests__/args.test.ts @@ -23,25 +23,25 @@ describe("parseArgs repeat options", () => { it("throws on --repeat 0", () => { expect(() => parseCliArgs(["--repeat", "0", "task"])).toThrow( - "--repeat must be an integer >= 1", + "--repeat must be an integer between 1 and 10000", ); }); it("throws on --repeat -1", () => { expect(() => parseCliArgs(["--repeat", "-1", "task"])).toThrow( - "--repeat must be an integer >= 1", + "--repeat must be an integer between 1 and 10000", ); }); it("throws on --repeat abc", () => { expect(() => parseCliArgs(["--repeat", "abc", "task"])).toThrow( - "--repeat must be an integer >= 1", + "--repeat must be an integer between 1 and 10000", ); }); it("throws on --repeat 1.5", () => { expect(() => parseCliArgs(["--repeat", "1.5", "task"])).toThrow( - "--repeat must be an integer >= 1", + "--repeat must be an integer between 1 and 10000", ); }); diff --git a/cli/src/cli/args.ts b/cli/src/cli/args.ts index e082c5d3..670f96c5 100644 --- a/cli/src/cli/args.ts +++ b/cli/src/cli/args.ts @@ -104,8 +104,8 @@ export function parseArgs(args: string[]): { const repeatProvided = opts.repeat !== undefined; const repeatCount = repeatProvided ? Number(opts.repeat) : 1; - if (repeatProvided && (!Number.isInteger(repeatCount) || repeatCount < 1)) { - throw new Error("--repeat must be an integer >= 1"); + if (repeatProvided && (!Number.isInteger(repeatCount) || repeatCount < 1 || repeatCount > 10_000)) { + throw new Error("--repeat must be an integer between 1 and 10000"); } const continueOnFailure = opts.continueOnFailure || false; diff --git a/ralphy.sh b/ralphy.sh index aa9be729..0eb2da96 100755 --- a/ralphy.sh +++ b/ralphy.sh @@ -3188,6 +3188,9 @@ main() { parse_args "$@" # Repeat options are only valid in single-task mode. + if [[ "$CONTINUE_ON_FAILURE" == true && "$REPEAT_FLAG_USED" != true ]]; then + log_warn "--continue-on-failure has no effect without --repeat" + fi if [[ "$REPEAT_FLAG_USED" == true || "$CONTINUE_ON_FAILURE" == true ]]; then if [[ -z "$SINGLE_TASK" ]]; then log_error "--repeat and --continue-on-failure require a task argument" From e6a3e22d65178bd7e3173220bc09e9ab4d3b9e05 Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Tue, 10 Mar 2026 03:56:53 +0100 Subject: [PATCH 08/20] fix: enforce --repeat upper bound in bash CLI and add boundary test - ralphy.sh: reject --repeat > 10000 (matches TS CLI validation) - args.test.ts: add test for --repeat 10001 boundary case Co-Authored-By: Claude Opus 4.6 --- cli/src/cli/__tests__/args.test.ts | 6 ++++++ ralphy.sh | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cli/src/cli/__tests__/args.test.ts b/cli/src/cli/__tests__/args.test.ts index dffc1504..6f098286 100644 --- a/cli/src/cli/__tests__/args.test.ts +++ b/cli/src/cli/__tests__/args.test.ts @@ -45,6 +45,12 @@ describe("parseArgs repeat options", () => { ); }); + it("throws on --repeat 10001", () => { + expect(() => parseCliArgs(["--repeat", "10001", "task"])).toThrow( + "--repeat must be an integer between 1 and 10000", + ); + }); + it("parses --repeat with --continue-on-failure", () => { const { options } = parseCliArgs(["--repeat", "3", "--continue-on-failure", "task"]); expect(options.repeatCount).toBe(3); diff --git a/ralphy.sh b/ralphy.sh index 0eb2da96..145d9a56 100755 --- a/ralphy.sh +++ b/ralphy.sh @@ -887,8 +887,8 @@ parse_args() { ;; --repeat) [[ -z "${2:-}" ]] && { log_error "--repeat requires a value"; exit 1; } - if ! is_positive_integer "$2"; then - log_error "--repeat must be an integer >= 1" + if ! is_positive_integer "$2" || [[ "$2" -gt 10000 ]]; then + log_error "--repeat must be an integer between 1 and 10000" exit 1 fi REPEAT_COUNT="$2" From a5f2cdd171359ab352228c4c969cdfd98dbd2f5d Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Tue, 10 Mar 2026 16:28:12 +0100 Subject: [PATCH 09/20] fix: address PR review feedback - args.ts: only warn about --continue-on-failure no-op when task is present - ralphy.sh: forward --model override to codex engine in brownfield mode - ralphy.sh: suppress misleading warning when task argument is missing Co-Authored-By: Claude Opus 4.6 --- cli/src/cli/args.ts | 2 +- ralphy.sh | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/src/cli/args.ts b/cli/src/cli/args.ts index 670f96c5..5391482a 100644 --- a/cli/src/cli/args.ts +++ b/cli/src/cli/args.ts @@ -109,7 +109,7 @@ export function parseArgs(args: string[]): { } const continueOnFailure = opts.continueOnFailure || false; - if (continueOnFailure && !repeatProvided) { + if (continueOnFailure && !repeatProvided && task) { console.warn("Warning: --continue-on-failure has no effect without --repeat"); } const hasRepeatOptions = repeatProvided || continueOnFailure; diff --git a/ralphy.sh b/ralphy.sh index 145d9a56..f98b9c46 100755 --- a/ralphy.sh +++ b/ralphy.sh @@ -678,6 +678,7 @@ run_brownfield_task() { codex) codex exec --dangerously-bypass-approvals-and-sandbox \ --json \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ "$prompt" 2>&1 | tee "$output_file" ;; esac @@ -3188,7 +3189,7 @@ main() { parse_args "$@" # Repeat options are only valid in single-task mode. - if [[ "$CONTINUE_ON_FAILURE" == true && "$REPEAT_FLAG_USED" != true ]]; then + if [[ "$CONTINUE_ON_FAILURE" == true && "$REPEAT_FLAG_USED" != true && -n "$SINGLE_TASK" ]]; then log_warn "--continue-on-failure has no effect without --repeat" fi if [[ "$REPEAT_FLAG_USED" == true || "$CONTINUE_ON_FAILURE" == true ]]; then From 71f5ca20e9807b9d55b8ec6c9db3ffa73d04b12c Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Tue, 10 Mar 2026 16:39:58 +0100 Subject: [PATCH 10/20] fix: show skipped count in repeat loop summary When the loop exits early (fail-fast or fatal), the summary now shows "Done: 0 succeeded, 1 failed, 9 skipped of 10" instead of hiding skipped iterations. Co-Authored-By: Claude Opus 4.6 --- cli/src/cli/commands/single-task-loop.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/src/cli/commands/single-task-loop.ts b/cli/src/cli/commands/single-task-loop.ts index 710b825f..c779e9d3 100644 --- a/cli/src/cli/commands/single-task-loop.ts +++ b/cli/src/cli/commands/single-task-loop.ts @@ -47,7 +47,10 @@ export async function runSingleTaskLoop( } if (total > 1) { - logInfoFn(`Done: ${completed} succeeded, ${failed} failed of ${total}`); + const skipped = total - completed - failed; + const parts = [`${completed} succeeded`, `${failed} failed`]; + if (skipped > 0) parts.push(`${skipped} skipped`); + logInfoFn(`Done: ${parts.join(", ")} of ${total}`); } return { total, completed, failed }; From 1bc3373fd56e43256d789def964875855434784c Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Tue, 10 Mar 2026 17:58:01 +0100 Subject: [PATCH 11/20] fix: show skipped count in bash repeat loop summary Aligns bash CLI output with TypeScript version when loop exits early. Co-Authored-By: Claude Opus 4.6 --- ralphy.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ralphy.sh b/ralphy.sh index f98b9c46..cfd7acd4 100755 --- a/ralphy.sh +++ b/ralphy.sh @@ -3304,7 +3304,10 @@ main() { done if [[ "$total" -gt 1 ]]; then - log_info "Done: $completed succeeded, $failed failed of $total" + local skipped=$(( total - completed - failed )) + local summary="Done: $completed succeeded, $failed failed" + [[ $skipped -gt 0 ]] && summary="$summary, $skipped skipped" + log_info "$summary of $total" if [[ "$failed" -gt 0 ]]; then notify_error "Repeated task finished: $completed/$total succeeded, $failed failed" else From a20b9c57f60b241c6b1fa2efc5618b3f719ea232 Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Tue, 10 Mar 2026 18:51:20 +0100 Subject: [PATCH 12/20] fix: add warning test and forward --model to all codex invocations - args.test.ts: test that --continue-on-failure warns without --repeat - ralphy.sh: add --model override to parallel, worktree, and conflict-resolution codex calls Co-Authored-By: Claude Opus 4.6 --- cli/src/cli/__tests__/args.test.ts | 13 ++++++++++++- ralphy.sh | 3 +++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cli/src/cli/__tests__/args.test.ts b/cli/src/cli/__tests__/args.test.ts index 6f098286..f1c2f91d 100644 --- a/cli/src/cli/__tests__/args.test.ts +++ b/cli/src/cli/__tests__/args.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it, mock } from "bun:test"; +import { beforeAll, describe, expect, it, mock, spyOn } from "bun:test"; let parseArgs: typeof import("../args.ts").parseArgs; @@ -69,6 +69,17 @@ describe("parseArgs repeat options", () => { ); }); + it("warns when --continue-on-failure is used without --repeat but with a task", () => { + const warnSpy = spyOn(console, "warn"); + const { options } = parseCliArgs(["--continue-on-failure", "do something"]); + expect(options.continueOnFailure).toBe(true); + expect(options.repeatCount).toBe(1); + expect(warnSpy).toHaveBeenCalledWith( + "Warning: --continue-on-failure has no effect without --repeat", + ); + warnSpy.mockRestore(); + }); + it("throws when repeat options are combined with task source flags", () => { expect(() => parseCliArgs(["--repeat", "3", "--yaml", "tasks.yaml", "task"])).toThrow( "--repeat and --continue-on-failure cannot be used with --prd, --yaml, --json, or --github", diff --git a/ralphy.sh b/ralphy.sh index cfd7acd4..f2725c08 100755 --- a/ralphy.sh +++ b/ralphy.sh @@ -1865,6 +1865,7 @@ run_ai_command() { rm -f "$CODEX_LAST_MESSAGE_FILE" codex exec --dangerously-bypass-approvals-and-sandbox \ --json \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ --output-last-message "$CODEX_LAST_MESSAGE_FILE" \ "$prompt" > "$output_file" 2>&1 & ;; @@ -2448,6 +2449,7 @@ Focus only on implementing: $task_name" rm -f "$CODEX_LAST_MESSAGE_FILE" codex exec --dangerously-bypass-approvals-and-sandbox \ --json \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ --output-last-message "$CODEX_LAST_MESSAGE_FILE" \ "$prompt" ) > "$tmpfile" 2>>"$log_file" @@ -3074,6 +3076,7 @@ Be careful to preserve functionality from BOTH branches. The goal is to integrat codex) codex exec --dangerously-bypass-approvals-and-sandbox \ --json \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ "$resolve_prompt" > "$resolve_tmpfile" 2>&1 ;; *) From f387ef0cd1124140a986324930af5f0d210f9f6d Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Wed, 11 Mar 2026 07:28:40 +0100 Subject: [PATCH 13/20] fix: show skipped count in desktop notification for repeat mode Co-Authored-By: Claude Opus 4.6 --- cli/src/index.ts | 6 ++++-- ralphy.sh | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index ec23bee9..75f94c5c 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -50,15 +50,17 @@ async function main(): Promise { }); if (options.repeatCount > 1) { + const skipped = result.total - result.completed - result.failed; + const skippedSuffix = skipped > 0 ? `, ${skipped} skipped` : ""; if (result.failed > 0) { notify( "Ralphy - Error", - `Repeated task finished: ${result.completed}/${result.total} succeeded, ${result.failed} failed`, + `Repeated task finished: ${result.completed}/${result.total} succeeded, ${result.failed} failed${skippedSuffix}`, ); } else { notify( "Ralphy", - `Repeated task completed: ${result.completed}/${result.total} succeeded`, + `Repeated task completed: ${result.completed}/${result.total} succeeded${skippedSuffix}`, ); } } diff --git a/ralphy.sh b/ralphy.sh index f2725c08..d822ee0c 100755 --- a/ralphy.sh +++ b/ralphy.sh @@ -3311,10 +3311,12 @@ main() { local summary="Done: $completed succeeded, $failed failed" [[ $skipped -gt 0 ]] && summary="$summary, $skipped skipped" log_info "$summary of $total" + local skipped_suffix="" + [[ $skipped -gt 0 ]] && skipped_suffix=", $skipped skipped" if [[ "$failed" -gt 0 ]]; then - notify_error "Repeated task finished: $completed/$total succeeded, $failed failed" + notify_error "Repeated task finished: $completed/$total succeeded, $failed failed${skipped_suffix}" else - notify_done "Repeated task completed: $completed/$total succeeded" + notify_done "Repeated task completed: $completed/$total succeeded${skipped_suffix}" fi fi From 44f6a839d71951baf39fe83f267077464d78e0b1 Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Wed, 11 Mar 2026 14:15:18 +0100 Subject: [PATCH 14/20] fix: run dry-run only once regardless of --repeat count Prevents printing the prompt N times and misleading "N succeeded" summary when --dry-run is combined with --repeat. Co-Authored-By: Claude Opus 4.6 --- cli/src/cli/commands/single-task-loop.ts | 7 +++++++ ralphy.sh | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/cli/src/cli/commands/single-task-loop.ts b/cli/src/cli/commands/single-task-loop.ts index c779e9d3..140f3858 100644 --- a/cli/src/cli/commands/single-task-loop.ts +++ b/cli/src/cli/commands/single-task-loop.ts @@ -26,6 +26,13 @@ export async function runSingleTaskLoop( const logInfoFn = deps?.logInfoFn ?? logInfo; const total = options.repeatCount; + + // Dry-run: show prompt once, skip repeat iterations + if (options.dryRun) { + await runTaskFn(task, options); + return { total, completed: 0, failed: 0 }; + } + let completed = 0; let failed = 0; diff --git a/ralphy.sh b/ralphy.sh index d822ee0c..9802a8c1 100755 --- a/ralphy.sh +++ b/ralphy.sh @@ -3282,6 +3282,13 @@ main() { echo "${BOLD}============================================${RESET}" local total="$REPEAT_COUNT" + + # Dry-run: show prompt once, skip repeat iterations + if [[ "$DRY_RUN" == true ]]; then + run_brownfield_task "$SINGLE_TASK" + return $? + fi + local completed=0 local failed=0 local run_idx From baf3f567d0f2b1c073000e7afc2d013ad5eeacd0 Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Wed, 11 Mar 2026 15:01:46 +0100 Subject: [PATCH 15/20] fix: add dry-run guard to bash brownfield task execution Prevents run_brownfield_task from invoking the AI engine when --dry-run is set, matching the TypeScript task.ts behavior. Co-Authored-By: Claude Opus 4.6 --- ralphy.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ralphy.sh b/ralphy.sh index 9802a8c1..f91fddc8 100755 --- a/ralphy.sh +++ b/ralphy.sh @@ -628,6 +628,12 @@ run_brownfield_task() { local prompt prompt=$(build_brownfield_prompt "$task") + if [[ "$DRY_RUN" == true ]]; then + log_info "DRY RUN - Would execute:" + echo "${DIM}$prompt${RESET}" + return 0 + fi + # Create temp file for output local output_file output_file=$(mktemp) From 4e05f85dc1c089320cd4b953a610ddd23236fc7a Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Fri, 13 Mar 2026 09:09:32 +0100 Subject: [PATCH 16/20] fix: reject scientific notation in --repeat and add dry-run test - args.ts: use regex /^[1-9][0-9]*$/ to reject inputs like "1e4" (matches bash is_positive_integer behavior) - single-task-loop.test.ts: add test for dry-run early-return path Co-Authored-By: Claude Opus 4.6 --- cli/src/cli/args.ts | 2 +- cli/src/cli/commands/single-task-loop.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/cli/src/cli/args.ts b/cli/src/cli/args.ts index 5391482a..9f5c2c98 100644 --- a/cli/src/cli/args.ts +++ b/cli/src/cli/args.ts @@ -104,7 +104,7 @@ export function parseArgs(args: string[]): { const repeatProvided = opts.repeat !== undefined; const repeatCount = repeatProvided ? Number(opts.repeat) : 1; - if (repeatProvided && (!Number.isInteger(repeatCount) || repeatCount < 1 || repeatCount > 10_000)) { + if (repeatProvided && (!/^[1-9][0-9]*$/.test(opts.repeat) || repeatCount > 10_000)) { throw new Error("--repeat must be an integer between 1 and 10000"); } diff --git a/cli/src/cli/commands/single-task-loop.test.ts b/cli/src/cli/commands/single-task-loop.test.ts index b2a204e5..551749ae 100644 --- a/cli/src/cli/commands/single-task-loop.test.ts +++ b/cli/src/cli/commands/single-task-loop.test.ts @@ -77,4 +77,23 @@ describe("runSingleTaskLoop", () => { expect(result.failed).toBe(1); expect(result.total).toBe(5); }); + + it("calls runTaskFn exactly once in dry-run mode regardless of repeatCount", async () => { + let calls = 0; + const result = await runSingleTaskLoop( + "task", + { ...DEFAULT_OPTIONS, dryRun: true, repeatCount: 5 }, + { + runTaskFn: async () => { + calls++; + return { success: true, fatal: false }; + }, + logInfoFn: () => {}, + }, + ); + expect(calls).toBe(1); + expect(result.total).toBe(5); + expect(result.completed).toBe(0); + expect(result.failed).toBe(0); + }); }); From f7474df1128b1a1d4609265c337f45fdede568c7 Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Sun, 15 Mar 2026 11:23:09 +0100 Subject: [PATCH 17/20] fix: disable browser automation by default (opt-in via --browser) The auto-detection injected a large prompt block whenever agent-browser was installed, even when not needed. Browser is now opt-in only. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/cli/args.ts | 5 ++--- cli/src/config/types.ts | 6 +++--- cli/src/execution/browser.ts | 21 ++++++++------------- cli/src/execution/parallel.ts | 4 ++-- cli/src/execution/prompt.ts | 8 ++++---- cli/src/execution/sequential.ts | 2 +- 6 files changed, 20 insertions(+), 26 deletions(-) diff --git a/cli/src/cli/args.ts b/cli/src/cli/args.ts index 9f5c2c98..d359c77d 100644 --- a/cli/src/cli/args.ts +++ b/cli/src/cli/args.ts @@ -64,10 +64,9 @@ export function createProgram(): Command { return program; } -function resolveBrowserEnabled(flag: boolean | undefined): "auto" | "true" | "false" { +function resolveBrowserEnabled(flag: boolean | undefined): "true" | "false" { if (flag === true) return "true"; - if (flag === false) return "false"; - return "auto"; + return "false"; } /** diff --git a/cli/src/config/types.ts b/cli/src/config/types.ts index a93eaab9..41dbf344 100644 --- a/cli/src/config/types.ts +++ b/cli/src/config/types.ts @@ -109,8 +109,8 @@ export interface RuntimeOptions { syncIssue?: number; /** Auto-commit changes */ autoCommit: boolean; - /** Browser automation mode: 'auto' | 'true' | 'false' */ - browserEnabled: "auto" | "true" | "false"; + /** Browser automation mode: 'true' | 'false' */ + browserEnabled: "true" | "false"; /** Override default model for the engine */ modelOverride?: string; /** Skip automatic branch merging after parallel execution */ @@ -147,5 +147,5 @@ export const DEFAULT_OPTIONS: RuntimeOptions = { githubRepo: "", githubLabel: "", autoCommit: true, - browserEnabled: "auto", + browserEnabled: "false", }; diff --git a/cli/src/execution/browser.ts b/cli/src/execution/browser.ts index e772b8ab..3e4ef323 100644 --- a/cli/src/execution/browser.ts +++ b/cli/src/execution/browser.ts @@ -17,25 +17,20 @@ export function isAgentBrowserInstalled(): boolean { /** * Check if browser automation should be enabled - * @param browserEnabled - CLI flag value: 'auto' | 'true' | 'false' + * @param browserEnabled - CLI flag value: 'true' | 'false' * @returns true if browser should be enabled */ -export function isBrowserAvailable(browserEnabled: "auto" | "true" | "false"): boolean { - if (browserEnabled === "false") { +export function isBrowserAvailable(browserEnabled: "true" | "false"): boolean { + if (browserEnabled !== "true") { return false; } - if (browserEnabled === "true") { - if (!isAgentBrowserInstalled()) { - logWarn("--browser flag used but agent-browser CLI not found"); - logWarn("Install from: https://agent-browser.dev"); - return false; - } - return true; + if (!isAgentBrowserInstalled()) { + logWarn("--browser flag used but agent-browser CLI not found"); + logWarn("Install from: https://agent-browser.dev"); + return false; } - - // auto mode: check if available - return isAgentBrowserInstalled(); + return true; } /** diff --git a/cli/src/execution/parallel.ts b/cli/src/execution/parallel.ts index 5318088e..aa834045 100644 --- a/cli/src/execution/parallel.ts +++ b/cli/src/execution/parallel.ts @@ -58,7 +58,7 @@ async function runAgentInWorktree( retryDelay: number, skipTests: boolean, skipLint: boolean, - browserEnabled: "auto" | "true" | "false", + browserEnabled: "true" | "false", modelOverride?: string, engineArgs?: string[], ): Promise { @@ -152,7 +152,7 @@ async function runAgentInSandbox( retryDelay: number, skipTests: boolean, skipLint: boolean, - browserEnabled: "auto" | "true" | "false", + browserEnabled: "true" | "false", modelOverride?: string, engineArgs?: string[], ): Promise { diff --git a/cli/src/execution/prompt.ts b/cli/src/execution/prompt.ts index 19690778..ecc8d3ba 100644 --- a/cli/src/execution/prompt.ts +++ b/cli/src/execution/prompt.ts @@ -7,7 +7,7 @@ interface PromptOptions { task: string; autoCommit?: boolean; workDir?: string; - browserEnabled?: "auto" | "true" | "false"; + browserEnabled?: "true" | "false"; skipTests?: boolean; skipLint?: boolean; prdFile?: string; @@ -37,7 +37,7 @@ export function buildPrompt(options: PromptOptions): string { task, autoCommit = true, workDir = process.cwd(), - browserEnabled = "auto", + browserEnabled = "false", skipTests = false, skipLint = false, prdFile, @@ -141,7 +141,7 @@ interface ParallelPromptOptions { workDir?: string; skipTests?: boolean; skipLint?: boolean; - browserEnabled?: "auto" | "true" | "false"; + browserEnabled?: "true" | "false"; allowCommit?: boolean; } @@ -156,7 +156,7 @@ export function buildParallelPrompt(options: ParallelPromptOptions): string { workDir = process.cwd(), skipTests = false, skipLint = false, - browserEnabled = "auto", + browserEnabled = "false", allowCommit = true, } = options; diff --git a/cli/src/execution/sequential.ts b/cli/src/execution/sequential.ts index 813bc859..4a68e0f4 100644 --- a/cli/src/execution/sequential.ts +++ b/cli/src/execution/sequential.ts @@ -26,7 +26,7 @@ export interface ExecutionOptions { createPr: boolean; draftPr: boolean; autoCommit: boolean; - browserEnabled: "auto" | "true" | "false"; + browserEnabled: "true" | "false"; prdFile?: string; /** Active settings to display in spinner */ activeSettings?: string[]; From 76a326d0fadbeb0f4bf4214316da4ba5e92182bf Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Mon, 16 Mar 2026 20:58:38 +0100 Subject: [PATCH 18/20] fix: align bash browser default with TS CLI (remove auto mode) - BROWSER_ENABLED defaults to "false" instead of "auto" - load_browser_setting returns "false" when no config exists - is_browser_available treats unknown values as disabled - Matches types.ts change removing "auto" from browserEnabled type Co-Authored-By: Claude Opus 4.6 (1M context) --- ralphy.sh | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/ralphy.sh b/ralphy.sh index f91fddc8..ff217ac4 100755 --- a/ralphy.sh +++ b/ralphy.sh @@ -58,7 +58,8 @@ SYNC_ISSUE="" # GitHub issue number to sync PRD with TASK_SOURCE_FLAG_USED=false # Browser automation (agent-browser) -BROWSER_ENABLED="auto" # auto, true, false +BROWSER_ENABLED="false" # true, false; pass --browser to enable +BROWSER_FLAG_USED=false # Colors (detect if terminal supports colors) if [[ -t 1 ]] && command -v tput &>/dev/null && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then @@ -151,8 +152,8 @@ is_browser_available() { fi return 0 fi - # auto mode: check if available - command -v agent-browser &>/dev/null + # Any other value (including legacy "auto"): treat as disabled + return 1 } # Get browser instructions for prompt injection @@ -348,8 +349,8 @@ boundaries: # Capabilities - optional tool integrations capabilities: # Browser automation via agent-browser (https://agent-browser.dev) - # Values: "auto" (detect), "true" (force enable), "false" (disable) - browser: "auto" + # Values: "true" (enable), "false" (disable) + browser: "false" EOF # Create progress.txt @@ -388,14 +389,14 @@ load_ralphy_boundaries() { # Load browser setting from config.yaml load_browser_setting() { - [[ ! -f "$CONFIG_FILE" ]] && echo "auto" && return + [[ ! -f "$CONFIG_FILE" ]] && echo "false" && return if command -v yq &>/dev/null; then local setting - setting=$(yq -r '.capabilities.browser // "auto"' "$CONFIG_FILE" 2>/dev/null || echo "auto") + setting=$(yq -r '.capabilities.browser // "false"' "$CONFIG_FILE" 2>/dev/null || echo "false") echo "$setting" else - echo "auto" + echo "false" fi } @@ -1009,10 +1010,12 @@ parse_args() { ;; --browser) BROWSER_ENABLED="true" + BROWSER_FLAG_USED=true shift ;; --no-browser) BROWSER_ENABLED="false" + BROWSER_FLAG_USED=true shift ;; -*) @@ -3213,7 +3216,7 @@ main() { fi # Load browser setting from config (if not overridden by CLI flag) - if [[ "$BROWSER_ENABLED" == "auto" ]] && [[ -f "$CONFIG_FILE" ]]; then + if [[ "$BROWSER_FLAG_USED" != true ]] && [[ -f "$CONFIG_FILE" ]]; then BROWSER_ENABLED=$(load_browser_setting) fi From a77835bcd409f44a91c1bc37faec54f249efe8d9 Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Mon, 16 Mar 2026 21:21:41 +0100 Subject: [PATCH 19/20] fix: suppress streaming output in repeat mode for cleaner terminal Extract run_engine() to avoid duplicating engine commands. In repeat mode (--repeat N > 1), output goes only to file instead of streaming through tee, preventing a wall of AI text. Co-Authored-By: Claude Opus 4.6 (1M context) --- ralphy.sh | 99 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 54 insertions(+), 45 deletions(-) diff --git a/ralphy.sh b/ralphy.sh index ff217ac4..cd892de9 100755 --- a/ralphy.sh +++ b/ralphy.sh @@ -644,51 +644,60 @@ run_brownfield_task() { log_info "Browser automation enabled (agent-browser)" fi - # Run the AI engine (tee to show output while saving for parsing) - case "$AI_ENGINE" in - claude) - claude --dangerously-skip-permissions \ - ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ - -p "$prompt" 2>&1 | tee "$output_file" - ;; - opencode) - opencode --output-format stream-json \ - --approval-mode full-auto \ - ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ - "$prompt" 2>&1 | tee "$output_file" - ;; - cursor) - agent --dangerously-skip-permissions \ - -p "$prompt" 2>&1 | tee "$output_file" - ;; - qwen) - qwen --output-format stream-json \ - --approval-mode yolo \ - -p "$prompt" 2>&1 | tee "$output_file" - ;; - droid) - droid exec --output-format stream-json \ - --auto medium \ - "$prompt" 2>&1 | tee "$output_file" - ;; - copilot) - copilot -p "$prompt" \ - ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ - 2>&1 | tee "$output_file" - ;; - gemini) - gemini --output-format stream-json \ - --yolo \ - ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ - -p "$prompt" 2>&1 | tee "$output_file" - ;; - codex) - codex exec --dangerously-bypass-approvals-and-sandbox \ - --json \ - ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ - "$prompt" 2>&1 | tee "$output_file" - ;; - esac + # Run the AI engine + run_engine() { + case "$AI_ENGINE" in + claude) + claude --dangerously-skip-permissions \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ + -p "$prompt" 2>&1 + ;; + opencode) + opencode --output-format stream-json \ + --approval-mode full-auto \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ + "$prompt" 2>&1 + ;; + cursor) + agent --dangerously-skip-permissions \ + -p "$prompt" 2>&1 + ;; + qwen) + qwen --output-format stream-json \ + --approval-mode yolo \ + -p "$prompt" 2>&1 + ;; + droid) + droid exec --output-format stream-json \ + --auto medium \ + "$prompt" 2>&1 + ;; + copilot) + copilot -p "$prompt" \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ + 2>&1 + ;; + gemini) + gemini --output-format stream-json \ + --yolo \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ + -p "$prompt" 2>&1 + ;; + codex) + codex exec --dangerously-bypass-approvals-and-sandbox \ + --json \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ + "$prompt" 2>&1 + ;; + esac + } + + # In repeat mode, suppress streaming output to avoid a wall of text + if [[ "$REPEAT_COUNT" -gt 1 ]]; then + run_engine > "$output_file" + else + run_engine | tee "$output_file" + fi local exit_code=$? From 66ceb9f37b66b6ea0b3ed08e04d127e8f372c221 Mon Sep 17 00:00:00 2001 From: Fabian Kliebhan Date: Mon, 16 Mar 2026 21:33:42 +0100 Subject: [PATCH 20/20] fix: log failed progress on engine unavailability and reorder validation - task.ts: call logTaskProgress on engine-not-found path for consistent task history tracking - args.ts: check task source flag conflict before missing task argument so users get the more specific error message first Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/cli/args.ts | 6 +++--- cli/src/cli/commands/task.ts | 1 + ralphy.sh | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cli/src/cli/args.ts b/cli/src/cli/args.ts index d359c77d..2de4ab86 100644 --- a/cli/src/cli/args.ts +++ b/cli/src/cli/args.ts @@ -112,14 +112,14 @@ export function parseArgs(args: string[]): { console.warn("Warning: --continue-on-failure has no effect without --repeat"); } const hasRepeatOptions = repeatProvided || continueOnFailure; - if (hasRepeatOptions && !task) { - throw new Error("--repeat and --continue-on-failure require a task argument"); - } if (hasRepeatOptions && hasExplicitTaskSourceFlag) { throw new Error( "--repeat and --continue-on-failure cannot be used with --prd, --yaml, --json, or --github", ); } + if (hasRepeatOptions && !task) { + throw new Error("--repeat and --continue-on-failure require a task argument"); + } // --sonnet implies --claude and takes priority over other engine flags let aiEngine = "claude"; diff --git a/cli/src/cli/commands/task.ts b/cli/src/cli/commands/task.ts index 2484f3c6..e3005123 100644 --- a/cli/src/cli/commands/task.ts +++ b/cli/src/cli/commands/task.ts @@ -29,6 +29,7 @@ export async function runTask(task: string, options: RuntimeOptions): Promise