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/cli/src/cli/__tests__/args.test.ts b/cli/src/cli/__tests__/args.test.ts new file mode 100644 index 00000000..f1c2f91d --- /dev/null +++ b/cli/src/cli/__tests__/args.test.ts @@ -0,0 +1,94 @@ +import { beforeAll, describe, expect, it, mock, spyOn } 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 between 1 and 10000", + ); + }); + + it("throws on --repeat -1", () => { + expect(() => parseCliArgs(["--repeat", "-1", "task"])).toThrow( + "--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 between 1 and 10000", + ); + }); + + it("throws on --repeat 1.5", () => { + expect(() => parseCliArgs(["--repeat", "1.5", "task"])).toThrow( + "--repeat must be an integer between 1 and 10000", + ); + }); + + 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); + 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("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", + ); + }); + + 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..2de4ab86 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") @@ -62,6 +64,11 @@ export function createProgram(): Command { return program; } +function resolveBrowserEnabled(flag: boolean | undefined): "true" | "false" { + if (flag === true) return "true"; + return "false"; +} + /** * Parse command line arguments into RuntimeOptions */ @@ -88,7 +95,33 @@ export function parseArgs(args: string[]): { const opts = program.opts(); const [task] = program.args; - // Determine AI engine (--sonnet implies --claude) + // --prd has a commander default, so opts.prd alone cannot detect explicit usage + const taskSourceFlags = ["--prd", "--yaml", "--json", "--github"]; + const hasExplicitTaskSourceFlag = ralphyArgs.some((arg) => + taskSourceFlags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)), + ); + + const repeatProvided = opts.repeat !== undefined; + const repeatCount = repeatProvided ? Number(opts.repeat) : 1; + if (repeatProvided && (!/^[1-9][0-9]*$/.test(opts.repeat) || repeatCount > 10_000)) { + throw new Error("--repeat must be an integer between 1 and 10000"); + } + + const continueOnFailure = opts.continueOnFailure || false; + if (continueOnFailure && !repeatProvided && task) { + console.warn("Warning: --continue-on-failure has no effect without --repeat"); + } + const hasRepeatOptions = repeatProvided || continueOnFailure; + 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"; if (opts.sonnet) aiEngine = "claude"; else if (opts.opencode) aiEngine = "opencode"; @@ -140,6 +173,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 || "", @@ -154,7 +189,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/single-task-loop.test.ts b/cli/src/cli/commands/single-task-loop.test.ts new file mode 100644 index 00000000..551749ae --- /dev/null +++ b/cli/src/cli/commands/single-task-loop.test.ts @@ -0,0 +1,99 @@ +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); + }); + + 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); + }); +}); 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..140f3858 --- /dev/null +++ b/cli/src/cli/commands/single-task-loop.ts @@ -0,0 +1,64 @@ +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; + + // 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; + + 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) { + 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 }; +} diff --git a/cli/src/cli/commands/task.ts b/cli/src/cli/commands/task.ts index 312977e7..e3005123 100644 --- a/cli/src/cli/commands/task.ts +++ b/cli/src/cli/commands/task.ts @@ -1,44 +1,44 @@ -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); - // Check engine availability const engine = createEngine(options.aiEngine as AIEngineName); const available = await isEngineAvailable(options.aiEngine as AIEngineName); if (!available) { - logError(`${engine.name} CLI not found. Make sure '${engine.cliCommand}' is in your PATH.`); - process.exit(1); + const error = `${engine.name} CLI not found. Make sure '${engine.cliCommand}' is in your PATH.`; + logError(error); + logTaskProgress(task, "failed", workDir); + return { success: false, fatal: true, error }; } logInfo(`Running task with ${engine.name}...`); - // Check browser availability if (isBrowserAvailable(options.browserEnabled)) { logInfo("Browser automation enabled (agent-browser)"); } - // Build prompt const prompt = buildPrompt({ task, autoCommit: options.autoCommit, @@ -48,17 +48,26 @@ 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, ); } @@ -96,24 +101,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); - await sendNotifications(config, "completed", { - tasksCompleted: 1, - tasksFailed: 0, - }); - notifyTaskComplete(task); + if (notifySingleTask) { + notifyTaskComplete(task); + } - // Show response summary if (result.response && result.response !== "Task completed") { console.log("\nResult:"); console.log(result.response.slice(0, 500)); @@ -121,25 +120,11 @@ export async function runTask(task: string, options: RuntimeOptions): 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[]; diff --git a/cli/src/index.ts b/cli/src/index.ts index 5ac9431f..75f94c5c 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -3,9 +3,12 @@ import { parseArgs } from "./cli/args.ts"; import { addRule, showConfig } from "./cli/commands/config.ts"; import { runInit } from "./cli/commands/init.ts"; import { runLoop } from "./cli/commands/run.ts"; -import { runTask } from "./cli/commands/task.ts"; +import { runSingleTaskLoop } from "./cli/commands/single-task-loop.ts"; +import { loadConfig } from "./config/loader.ts"; import { flushAllProgressWrites } from "./config/writer.ts"; +import { sendNotifications } from "./notifications/webhook.ts"; import { logError } from "./ui/logger.ts"; +import { notify } from "./ui/notify.ts"; async function main(): Promise { try { @@ -37,7 +40,35 @@ 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) { + 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${skippedSuffix}`, + ); + } else { + notify( + "Ralphy", + `Repeated task completed: ${result.completed}/${result.total} succeeded${skippedSuffix}`, + ); + } + } + } + + 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; } diff --git a/ralphy.sh b/ralphy.sh index 12bd5da1..fd4a3fba 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,16 +47,19 @@ 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 +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 @@ -75,6 +81,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 +122,18 @@ log_debug() { fi } +is_positive_integer() { + local value="$1" + [[ "$value" =~ ^[1-9][0-9]*$ ]] +} + +is_fatal_error_output() { + local file="$1" + # 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 slugify() { echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g' | sed -E 's/^-|-$//g' | cut -c1-50 @@ -133,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 @@ -330,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 @@ -370,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 } @@ -599,6 +618,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}" @@ -609,6 +629,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) @@ -618,44 +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" - ;; - codex) - codex exec --full-auto \ - --json \ - "$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=$? @@ -664,6 +706,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 +738,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 +751,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 +771,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 +782,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 +800,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 +811,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 +824,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 +890,10 @@ parse_args() { AI_ENGINE="copilot" shift ;; + --gemini) + AI_ENGINE="gemini" + shift + ;; --model) MODEL_OVERRIDE="$2" shift 2 @@ -840,6 +902,20 @@ parse_args() { DRY_RUN=true shift ;; + --repeat) + [[ -z "${2:-}" ]] && { log_error "--repeat requires a value"; exit 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" + REPEAT_FLAG_USED=true + shift 2 + ;; + --continue-on-failure) + CONTINUE_ON_FAILURE=true + shift + ;; --max-iterations) MAX_ITERATIONS="${2:-0}" shift 2 @@ -860,6 +936,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 +959,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) @@ -930,10 +1019,12 @@ parse_args() { ;; --browser) BROWSER_ENABLED="true" + BROWSER_FLAG_USED=true shift ;; --no-browser) BROWSER_ENABLED="false" + BROWSER_FLAG_USED=true shift ;; -*) @@ -983,6 +1074,22 @@ check_requirements() { exit 1 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" + 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 +1144,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 +1347,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 +1443,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 +1452,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 +1461,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 +1470,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 +1480,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 +1755,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 +1799,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,11 +1871,19 @@ 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 \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ --output-last-message "$CODEX_LAST_MESSAGE_FILE" \ "$prompt" > "$output_file" 2>&1 & ;; @@ -2279,13 +2451,23 @@ 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 \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ --output-last-message "$CODEX_LAST_MESSAGE_FILE" \ "$prompt" ) > "$tmpfile" 2>>"$log_file" @@ -2424,11 +2606,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 +2624,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 +2791,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 +2842,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 +2917,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,9 +3085,16 @@ 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 \ + ${MODEL_OVERRIDE:+--model "$MODEL_OVERRIDE"} \ "$resolve_prompt" > "$resolve_tmpfile" 2>&1 ;; *) @@ -2993,8 +3209,23 @@ show_summary() { main() { parse_args "$@" + # Repeat options are only valid in single-task mode. + 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 + 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 + if [[ -z "$SINGLE_TASK" ]]; then + log_error "--repeat and --continue-on-failure require a task argument" + exit 1 + fi + 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 @@ -3031,6 +3262,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 +3281,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 +3290,65 @@ 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" + + # 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 + + 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 + local skipped=$(( total - completed - failed )) + 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${skipped_suffix}" + else + notify_done "Repeated task completed: $completed/$total succeeded${skipped_suffix}" + fi + fi + + if [[ "$failed" -gt 0 ]]; then + exit 1 + fi + exit 0 fi if [[ "$DRY_RUN" == true ]] && [[ "$MAX_ITERATIONS" -eq 0 ]]; then @@ -3085,6 +3373,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" 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