diff --git a/AGENTS.md b/AGENTS.md index dd9f056..35afb83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ No build step needed — Pi loads extensions via [jiti](https://github.com/unjs/ ## External dependencies The extension imports from Pi SDK packages (not in `package.json` — provided by the host): + - `@earendil-works/pi-coding-agent` — `ExtensionAPI`, `ExtensionContext`, `createAgentSession`, etc. - `@earendil-works/pi-tui` — `Box`, `Text` for custom message renderer @@ -44,6 +45,7 @@ The only real npm dependency is `yaml` (^2.4.0). ## Runtime state All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory): + - `.ralpi/progress.json` — execution progress, supports multiple PRDs - `.ralpi/reflections/` — per-task reflection JSON files - `.ralpi/prompts/` — generated prompts (timestamped, for debugging) @@ -59,4 +61,13 @@ Task IDs are zero-padded strings (`"01"`, `"02"`, etc.). The parser prepends `0` ## Config -Read from `.ralpi/config.yaml` in project directory. Falls back to `DEFAULT_CONFIG` in `src/types.ts` when file is missing. Config is loaded at `projectDir` level, not extension level. +Read from `.ralpi/config.yaml` in project directory (and global `~/.pi/ralpi/config.yaml`). Falls back to `DEFAULT_CONFIG` in `src/types.ts` when files are missing. Config is loaded at `projectDir` level, not extension level. + +Key config fields in `execution`: + +- `autoCommit` / `autoReview` — toggle follow-up commit and review agent sessions (also selectable at loop startup via `selectLoopOptions`) +- `models` — round-robin model list for parallel mode +- `implModel` / `commitModel` / `reviewModel` — `/` strings resolved via `resolveModelSpec` in `utils.ts` +- `commitTimeoutMs` / `reviewTimeoutMs` — timeouts for follow-up sessions +- `loopTimeoutMs` — max total loop duration in ms (0 = no limit; checked between batches in `executePlanBatches`) +- `timeoutMs` — per-task execution timeout diff --git a/README.md b/README.md index f5e3688..5e94ec2 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,15 @@ execution: models: # round-robin in / format - google/gemini-3.5-flash # 1st and 3rd task in parallel - openai/gpt-5.5 # 2nd task in parallel + autoCommit: true # spawn a commit agent after each task completes + autoReview: false # spawn a review agent to review each commit + implModel: "" # model for task impl (sequential mode, empty = inherit parent) + commitModel: "" # model for commit sessions (empty = inherit task model) + reviewModel: "" # model for review sessions (empty = inherit task model) + timeoutMs: 0 # per-task timeout in ms (0 = inherit Pi's defaults) + commitTimeoutMs: 60000 # timeout for auto-commit agent sessions + reviewTimeoutMs: 120000 # timeout for auto-review agent sessions + loopTimeoutMs: 0 # max total loop duration in ms (0 = no limit) prompts: projectContext: "Additional context for all tasks" ``` @@ -134,6 +143,20 @@ prompts: > **NOTE**: this is only used in parallel execution, in sequential mode the > parent pi session's model is used +#### Auto-commit and Auto-review + +When `autoCommit` is enabled (default), a follow-up agent session is spawned +after each task to stage and commit uncommitted changes. When `autoReview` is +enabled, a second follow-up session reviews the latest commit against the task +description. Both options can be overridden at loop startup via a selection +prompt. + +`commitModel` and `reviewModel` accept `/` strings (e.g. +`anthropic/claude-sonnet-4`) resolved via the model registry. When empty, the +task's model is inherited. `implModel` sets the model for task implementation +in sequential mode (overridden by `execution.models` round-robin in parallel +mode). + ## State Files - `.ralpi/progress.json` - Execution progress diff --git a/index.ts b/index.ts index 7a6371a..ae7db0a 100644 --- a/index.ts +++ b/index.ts @@ -29,6 +29,8 @@ import { deleteLoopActive, readLoopActive, findRalpiDir, + listPRDsSorted, + formatDuration, } from "./src/utils"; const COMMANDS = ["plan", "resume", "reset"] as const; @@ -120,6 +122,96 @@ function buildPlanByMode( : buildSequentialPlan(project, completed); } +/** + * Prompt the user to select auto-commit and auto-review options for this loop. + * Defaults are taken from config; the user can override at loop startup. + * Fields explicitly set in the config YAML are skipped (no prompt). + * Returns the selected options (or config defaults if cancelled). + */ +async function selectLoopOptions( + ctx: ExtensionContext, + config: import("./src/types").RalpiConfig, +): Promise<{ autoCommit: boolean; autoReview: boolean }> { + const explicit = config.execution.explicitKeys; + + // Skip the commit prompt when the YAML explicitly sets it. + let autoCommit: boolean; + if (explicit?.has("autoCommit")) { + autoCommit = config.execution.autoCommit; + } else { + const commitChoice = await ctx.ui.select("Auto-commit after each task?", [ + "Yes — stage and commit changes automatically", + "No — skip auto-commit", + ]); + autoCommit = commitChoice + ? commitChoice.startsWith("Yes") + : config.execution.autoCommit; + } + + let autoReview = false; + if (autoCommit) { + // Skip the review prompt when the YAML explicitly sets it. + if (explicit?.has("autoReview")) { + autoReview = config.execution.autoReview; + } else { + const reviewChoice = await ctx.ui.select( + "Auto-review each commit against the task?", + ["Yes — spawn a review agent after each commit", "No — skip review"], + ); + autoReview = reviewChoice + ? reviewChoice.startsWith("Yes") + : config.execution.autoReview; + } + } + + return { autoCommit, autoReview }; +} + +/** + * When multiple PRD loops have progress, prompt the user to select which one + * to resume. Returns the selected PRD key and sourcePath. + * If only one PRD exists, returns it without prompting. + * Returns null if no PRDs exist. + */ +async function selectPRDToResume( + ctx: ExtensionContext, + found: NonNullable>, +): Promise<{ prdKey: string; sourcePath: string } | null> { + const prds = listPRDsSorted(found.state); + if (prds.length === 0) return null; + if (prds.length === 1) { + return { prdKey: prds[0].key, sourcePath: prds[0].prd.sourcePath }; + } + + // Multiple PRDs — show selection sorted by most recent first + const options = prds.map((entry) => { + const tasks = entry.prd.tasks; + const total = Object.keys(tasks).length; + const completed = Object.values(tasks).filter( + (t) => t.status === "completed", + ).length; + const failed = Object.values(tasks).filter( + (t) => t.status === "failed", + ).length; + const relPath = path.relative(process.cwd(), entry.prd.sourcePath); + const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString(); + return `${relPath} — ${completed}/${total} done${failed ? `, ${failed} failed` : ""} · ${updated}`; + }); + + const selected = await ctx.ui.select( + "Multiple loops found. Which to resume?", + options, + ); + if (!selected) return null; + + const idx = options.indexOf(selected); + if (idx === -1) return null; + return { + prdKey: prds[idx].key, + sourcePath: prds[idx].prd.sourcePath, + }; +} + /** Run all batches in a plan, updating the task file after each batch. */ async function executePlanBatches( plan: ReturnType, @@ -147,8 +239,20 @@ async function executePlanBatches( // Track failed task IDs across batches to block downstream tasks const failedTaskIds = new Set(progress.getFailedTaskIds()); + // Loop-level execution timeout: stop starting new batches once elapsed. + // In-progress tasks finish naturally; we just skip remaining batches. + const loopStart = Date.now(); + const loopTimeoutMs = config.execution.loopTimeoutMs; + let loopTimedOut = false; + try { for (const batch of plan.batches) { + // Check loop timeout before starting a new batch + if (loopTimeoutMs > 0 && Date.now() - loopStart > loopTimeoutMs) { + loopTimedOut = true; + break; + } + if (progress.getState().paused) { ctx.ui.notify( "Execution paused. Use /ralpi resume to continue.", @@ -223,6 +327,13 @@ async function executePlanBatches( if (projectDir) { deleteLoopActive(projectDir); } + if (loopTimedOut) { + const elapsed = formatDuration(Date.now() - loopStart); + ctx.ui.notify( + `Loop execution timeout reached (${elapsed}). Remaining tasks skipped. Use /ralpi resume to continue.`, + "warning", + ); + } } } @@ -784,6 +895,9 @@ async function handleRun( const completed = buildCompletedSet(progress, project); const mode = await selectExecutionMode(ctx, project, taskFile, config); + const { autoCommit, autoReview } = await selectLoopOptions(ctx, config); + config.execution.autoCommit = autoCommit; + config.execution.autoReview = autoReview; const plan = buildPlanByMode(mode, project, completed); // Show dependency chain + execution plan before starting @@ -839,11 +953,11 @@ async function handleResume( ): Promise { let taskFile: string; let projectDir: string; - let found: ReturnType; + let prdKey: string | undefined; if (args[0]) { taskFile = resolveTaskArg(args[0], process.cwd()); - found = findProgressFile(process.cwd(), taskFile); + const found = findProgressFile(process.cwd(), taskFile); if (!found) { ctx.ui.notify( `No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`, @@ -852,8 +966,9 @@ async function handleResume( return; } projectDir = path.dirname(path.dirname(found.path)); + prdKey = found.prdKey; } else { - found = findProgressFile(process.cwd()); + const found = findProgressFile(process.cwd()); if (!found) { ctx.ui.notify( "No .ralpi/progress.json found. Start with /ralpi run [task-file]", @@ -862,10 +977,16 @@ async function handleResume( return; } projectDir = path.dirname(path.dirname(found.path)); - // For no-arg resume, use the first PRD's source path or legacy sourcePath - taskFile = found.state.prds - ? Object.values(found.state.prds)[0].sourcePath - : found.state.sourcePath; + + // When no specific task file is given, let the user select which loop + // to resume from multiple PRDs (sorted by most recent first). + const selected = await selectPRDToResume(ctx, found); + if (!selected) { + ctx.ui.notify("Resume cancelled.", "info"); + return; + } + taskFile = selected.sourcePath; + prdKey = selected.prdKey; } const project = parseTaskFile(taskFile); @@ -877,21 +998,21 @@ async function handleResume( const config = loadConfig(projectDir); config.model = parentModel ?? ctx.model; config.thinkingLevel = parentThinkingLevel; - const progress = new ProgressTracker(projectDir, taskFile, found.prdKey); + const progress = new ProgressTracker(projectDir, taskFile, prdKey); progress.setPaused(false); const completed = buildCompletedSet(progress, project); const mode = await selectExecutionMode(ctx, project, taskFile, config); + const { autoCommit, autoReview } = await selectLoopOptions(ctx, config); + config.execution.autoCommit = autoCommit; + config.execution.autoReview = autoReview; const plan = buildPlanByMode(mode, project, completed); // Print remaining batches before executing const formattedPlan = formatExecutionPlan(plan); if (mode === "parallel") { - ctx.ui.notify( - `${formattedPlan}\n\nResuming parallel execution...`, - "info", - ); + ctx.ui.notify(`${formattedPlan}\n\nResuming parallel execution...`, "info"); } else { ctx.ui.notify( `${formattedPlan}\n\nResuming sequential execution...`, @@ -941,12 +1062,11 @@ async function handleReset( return; } const projectDir = path.dirname(path.dirname(found.path)); - const progress = new ProgressTracker( - projectDir, - found.state.prds - ? Object.values(found.state.prds)[0].sourcePath - : found.state.sourcePath, - ); + // Use the most recently updated PRD (first in sorted order) + const prds = listPRDsSorted(found.state); + const sourcePath = + prds.length > 0 ? prds[0].prd.sourcePath : found.state.sourcePath; + const progress = new ProgressTracker(projectDir, sourcePath); progress.reset(); } diff --git a/src/executor.ts b/src/executor.ts index 505646a..654ac79 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -4,7 +4,7 @@ import type { Task, Project, Reflection, ToolUsage } from "./types"; import type { RalpiConfig } from "./types"; import type { ProgressTracker } from "./progress"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { buildTaskPrompt } from "./prompts"; +import { buildTaskPrompt, buildReviewPrompt, MAX_DIFF_BYTES } from "./prompts"; import { extractReflection } from "./reflection"; import { runAgentSession, @@ -14,6 +14,8 @@ import { hasUncommittedChanges, getGitStatusPorcelain, getGitDiff, + getLatestCommitDiff, + resolveModelSpec, formatDuration, } from "./utils"; import { updateTaskInFile } from "./parser"; @@ -74,6 +76,11 @@ class ModelRoundRobin { return this.models.length; } + /** All resolved models in the pool (for follow-up session failover). */ + get allModels(): unknown[] { + return this.models; + } + assign(taskId: string): unknown { let index: number; if (this.freeSlots.length > 0) { @@ -627,14 +634,19 @@ async function executeTask( roundRobin?: ModelRoundRobin | null, batchRender?: () => void, ): Promise { - const maxRetries = config.execution.maxRetries; - // Model failover: when a provider/API is down, cycle through available models. - // result.success === false always means an agent-session failure (API error, - // provider unreachable, etc.), not a task-work error. + // Pi's built-in retry (via SettingsManager) handles transient errors with + // exponential backoff within each model. Ralpi only handles model cycling. const maxModelAttempts = roundRobin ? roundRobin.length : 1; let modelAttempt = 0; - let currentModel: unknown = assignedModel ?? config.model; + // Resolve implModel from config (used in sequential mode when no round-robin assignment). + // In parallel mode, the round-robin assignedModel takes precedence. + const implModel = resolveModelSpec( + ctx.modelRegistry as { find(p: string, m: string): unknown } | undefined, + config.execution.implModel, + (msg) => ctx.ui.notify(msg, "warning"), + ); + let currentModel: unknown = assignedModel ?? implModel ?? config.model; while (modelAttempt < maxModelAttempts) { // On subsequent model attempts, advance to the next model. @@ -644,46 +656,53 @@ async function executeTask( currentModel = roundRobin.advance(task.id); } - let retries = 0; - while (retries <= maxRetries) { + try { + // Mark as in progress + progress.markInProgress(task.id); + // Auto-update the PRD source file checkbox try { - // Mark as in progress - progress.markInProgress(task.id); - // Auto-update the PRD source file checkbox - try { - updateTaskInFile(project.sourcePath, task.id, "in_progress"); - } catch { - // Best-effort: don't fail the task over a checkbox update - } + updateTaskInFile(project.sourcePath, task.id, "in_progress"); + } catch { + // Best-effort: don't fail the task over a checkbox update + } - // Get dependency reflections - const depReflections = progress.getDependencyReflections( - task.dependencies || [], - ); + // Get dependency reflections + const depReflections = progress.getDependencyReflections( + task.dependencies || [], + ); - // Run the task - const result = await runTask( - task, - project, - config, - depReflections, - ctx, - sendChatMessage, - projectDir, - parallelState, - currentModel, - batchRender, - ); + // Run the task + const result = await runTask( + task, + project, + config, + depReflections, + ctx, + sendChatMessage, + projectDir, + parallelState, + currentModel, + batchRender, + ); - if (result.success) { - // ── Auto-Commit: Trigger follow-up agent session for uncommitted changes ── - let finalCommitMessages = result.commitMessages ?? []; - let finalCommitSummary = result.commitSummary ?? ""; + if (result.success) { + // ── Auto-Commit: optionally trigger follow-up agent session for uncommitted changes ── + let finalCommitMessages = result.commitMessages ?? []; + let finalCommitSummary = result.commitSummary ?? ""; + if (config.execution.autoCommit) { try { if (hasUncommittedChanges(projectDir)) { const status = getGitStatusPorcelain(projectDir); - const diff = getGitDiff(projectDir); + let diff = getGitDiff(projectDir); + let diffNote = ""; + if (diff.length > MAX_DIFF_BYTES) { + diffNote = + "\n\n... (diff truncated: omitted " + + (diff.length - MAX_DIFF_BYTES).toLocaleString() + + " bytes; run `git diff` to view the full diff)"; + diff = diff.slice(0, MAX_DIFF_BYTES); + } const commitPrompt = [ `## Auto-Commit for Task ${task.id}: ${task.title}`, "", @@ -703,118 +722,34 @@ async function executeTask( "### Current Tracked Diff (git diff)", "```diff", diff || "(no tracked diff output)", + diffNote, "```", ].join("\n"); - // ── Commit widget setup ── - const commitWidgetKey = `ralpi-commit-${task.id}`; - let commitFrameIndex = 0; - const commitToolCalls: ToolCallEntry[] = []; - let commitWidgetTui: { requestRender(): void } | null = null; + // Resolve commit model (fall back to current task model) + const commitModel = + resolveModelSpec( + ctx.modelRegistry as + | { find(p: string, m: string): unknown } + | undefined, + config.execution.commitModel, + (msg) => ctx.ui.notify(msg, "warning"), + ) ?? currentModel; - const commitHeader = `commit for ${task.id} · ${task.title}`; + // Build failover list: primary model first, then the rest of the pool. + const commitModels = buildFailoverModels(commitModel, roundRobin); - const buildCommitLines = ( - t: typeof ctx.ui.theme, - width?: number, - ): string[] => { - const effectiveWidth = width || 74; - const frame = t.fg( - "accent", - SPINNER_FRAMES[commitFrameIndex % SPINNER_FRAMES.length], - ); - const lines = [ - truncateToWidth(`~ ${frame} ${commitHeader}`, effectiveWidth), - ]; - - if (commitToolCalls.length > 0) { - if (commitToolCalls.length <= MAX_COLLAPSED) { - for (let i = 0; i < commitToolCalls.length; i++) { - const entry = commitToolCalls[i]; - const isLast = i === commitToolCalls.length - 1; - const branch = isLast ? " └── " : " ├── "; - const tag = t.fg("accent", `[${entry.name}]`); - lines.push( - truncateToWidth( - `${branch}${tag} ${entry.label}`, - effectiveWidth, - ), - ); - } - } else { - const shown = commitToolCalls.slice(-MAX_COLLAPSED); - const remaining = commitToolCalls.length - shown.length; - lines.push( - truncateToWidth( - t.fg("dim", ` ├── …${remaining} earlier`), - effectiveWidth, - ), - ); - for (let i = 0; i < shown.length; i++) { - const entry = shown[i]; - const isLast = i === shown.length - 1; - const branch = isLast ? " └── " : " ├── "; - const tag = t.fg("accent", `[${entry.name}]`); - lines.push( - truncateToWidth( - `${branch}${tag} ${entry.label}`, - effectiveWidth, - ), - ); - } - } - } - return lines; - }; - - ctx.ui.setWidget(commitWidgetKey, (tui, t) => { - commitWidgetTui = tui; - return { - render: (width?: number) => buildCommitLines(t, width), - invalidate: () => commitWidgetTui?.requestRender(), - }; - }); - - const requestCommitRender = () => - commitWidgetTui?.requestRender(); - - const commitSpinnerTimer = setInterval(() => { - commitFrameIndex = - (commitFrameIndex + 1) % SPINNER_FRAMES.length; - requestCommitRender(); - }, 100); - - // Use a short timeout for the commit session (60s should be enough) - const commitTimeout = Math.min( - 60_000, - config.execution.timeoutMs, - ); - - let commitResult: Awaited>; - - try { - commitResult = await runAgentSession( + const { result: commitResult, toolCalls: commitToolCalls } = + await runFollowUpSession( + ctx, + config, commitPrompt, projectDir, - commitTimeout, - (event) => { - if (event.type === "tool_execution_start") { - const label = formatToolArg(event.toolName, event.args); - commitToolCalls.push({ - name: event.toolName, - label, - }); - requestCommitRender(); - } - }, - undefined, - currentModel, - config.thinkingLevel, + `commit for ${task.id} · ${task.title}`, + `commit-${task.id}`, + config.execution.commitTimeoutMs, + commitModels, ); - } finally { - clearInterval(commitSpinnerTimer); - ctx.ui.setWidget(commitWidgetKey, undefined); - } if (commitResult.success) { // Re-capture commits made during this follow-up session @@ -846,94 +781,152 @@ async function executeTask( }`, ); } + } - // Save reflection - if (result.reflection) { - saveReflectionToFile(projectDir, config, result.reflection); - } - - // Mark completed with all metadata - progress.markCompleted( - task.id, - result.durationMs, - result.reflection, - result.toolUsage, - result.outputPreview, - finalCommitMessages, - finalCommitSummary, - ); - // Auto-update the PRD source file checkbox + // ── Auto-Review: optionally spawn a review agent to review the latest commit ── + if (config.execution.autoReview) { try { - updateTaskInFile(project.sourcePath, task.id, "completed"); - } catch { - // Best-effort: don't fail the task over a checkbox update + const commitInfo = getLatestCommitDiff(projectDir); + if (commitInfo && commitInfo.diff) { + const reviewPrompt = buildReviewPrompt( + task, + project, + commitInfo.hash, + commitInfo.subject, + commitInfo.diff, + config.prompts.projectContext, + ); + + // Resolve review model (fall back to current task model) + const reviewModel = + resolveModelSpec( + ctx.modelRegistry as + | { find(p: string, m: string): unknown } + | undefined, + config.execution.reviewModel, + (msg) => ctx.ui.notify(msg, "warning"), + ) ?? currentModel; + + // Build failover list: primary model first, then the rest of the pool. + const reviewModels = buildFailoverModels(reviewModel, roundRobin); + + const { result: reviewResult, toolCalls: reviewToolCalls } = + await runFollowUpSession( + ctx, + config, + reviewPrompt, + projectDir, + `review for ${task.id} · ${task.title}`, + `review-${task.id}`, + config.execution.reviewTimeoutMs, + reviewModels, + ); + + if (reviewResult.success) { + const reviewText = reviewResult.text.trim(); + // Post review as a chat message with tool calls + const preview = + reviewText.length > 500 + ? reviewText.slice(0, 500) + "\n... (truncated)" + : reviewText; + sendChatMessage?.( + `⚑ review for ${task.id} · ${task.title}\n${preview}`, + { toolCalls: reviewToolCalls }, + ); + } else { + sendChatMessage?.( + `~ review for ${task.id} · ${task.title} — review session failed: ${reviewResult.error}`, + { toolCalls: reviewToolCalls }, + ); + } + } + } catch (error) { + // Don't fail the task if auto-review fails + sendChatMessage?.( + `~ review for ${task.id} · ${task.title} — auto-review error: ${ + error instanceof Error ? error.message : String(error) + }`, + ); } - roundRobin?.release(task.id); - return; } - // Agent session failed (provider error). - // If we have more models, cycle immediately — don't waste retries. - if (roundRobin && modelAttempt < maxModelAttempts - 1) { - // Don't release — advance() already handles the transition. - // release() would put the slot in freeSlots, then assign() - // would pick it right back up, getting stuck on the same model. - modelAttempt++; - sendChatMessage?.( - `~ ${task.id} · ${task.title} — trying model ${modelAttempt + 1}/${maxModelAttempts} (previous: ${result.error})`, + // Save reflection + if (result.reflection) { + saveReflectionToFile( + projectDir, + config, + result.reflection, + progress.getKey(), ); - break; // exit retry loop, cycle to next model } - // No more models — use normal retry logic - if (retries < maxRetries) { - retries = progress.incrementRetry(task.id); - sendChatMessage?.( - `~ ${task.id} · ${task.title} — retrying (${retries}/${maxRetries}): ${result.error}`, - ); - - // Exponential backoff - const delay = config.execution.retryDelayMs * 2 ** (retries - 1); - await sleep(delay); - } else { - // Max retries exceeded - progress.markFailed(task.id, result.error || "Unknown error"); - // Don't update PRD — retry exhaustion is transient, not terminal - sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${result.error}`); - ctx.ui.notify( - `Task ${task.id} failed after ${maxRetries} retries: ${ - result.error || "Unknown error" - }`, - "error", - ); - return; - } - } catch (error) { - roundRobin?.release(task.id); - batchRender?.(); - const errorMsg = error instanceof Error ? error.message : String(error); - progress.markFailed(task.id, errorMsg); + // Mark completed with all metadata + progress.markCompleted( + task.id, + result.durationMs, + result.reflection, + result.toolUsage, + result.outputPreview, + finalCommitMessages, + finalCommitSummary, + ); // Auto-update the PRD source file checkbox try { - updateTaskInFile(project.sourcePath, task.id, "failed"); + updateTaskInFile(project.sourcePath, task.id, "completed"); } catch { - // Best-effort + // Best-effort: don't fail the task over a checkbox update } - sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`); - ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); + roundRobin?.release(task.id); return; } - } - // If we broke out (model cycling), continue the outer loop - modelAttempt++; + // Agent session failed (provider error). + // Pi's built-in retry already exhausted for this model. Cycle to the next. + if (roundRobin && modelAttempt < maxModelAttempts - 1) { + modelAttempt++; + sendChatMessage?.( + `~ ${task.id} · ${task.title} — cycling to model ${modelAttempt + 1}/${maxModelAttempts} (previous: ${result.error})`, + ); + continue; // next model in the outer while loop + } + + // All models exhausted. + progress.markFailed(task.id, result.error || "Unknown error"); + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${result.error}`); + ctx.ui.notify( + `Task ${task.id} failed across ${maxModelAttempts} models: ${ + result.error || "Unknown error" + }`, + "error", + ); + roundRobin?.release(task.id); + return; + } catch (error) { + roundRobin?.release(task.id); + batchRender?.(); + const errorMsg = error instanceof Error ? error.message : String(error); + progress.markFailed(task.id, errorMsg); + // Auto-update the PRD source file checkbox + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`); + ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); + return; + } } // All models exhausted — release the slot roundRobin?.release(task.id); batchRender?.(); progress.markFailed(task.id, "All configured models exhausted"); - // Don't update PRD — model exhaustion is transient, not terminal sendChatMessage?.( `✗ ${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`, ); @@ -949,17 +942,167 @@ function saveReflectionToFile( sourceDir: string, config: RalpiConfig, reflection: Reflection, + prdKey: string, ): void { - const reflectionsDir = path.join(sourceDir, config.paths.reflectionsDir); + const reflectionsDir = path.join( + sourceDir, + config.paths.reflectionsDir, + prdKey, + ); ensureDir(reflectionsDir); const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`); writeFileSafe(filePath, JSON.stringify(reflection, null, 2)); } +// ─── Follow-Up Sessions (Commit / Review) ───────────────────────────────────── + +/** + * Run a follow-up agent session (commit, review, etc.) with a live spinner + * widget. Handles widget setup, spinner animation, session execution, and + * cleanup. Cycles through `models` on connection failure so a flaky provider + * doesn't kill the commit/review step. Returns the session result and + * captured tool calls. + */ +async function runFollowUpSession( + ctx: ExtensionContext, + config: RalpiConfig, + prompt: string, + projectDir: string, + header: string, + widgetKeySuffix: string, + timeoutMs: number, + models: unknown[], +): Promise<{ + result: Awaited>; + toolCalls: ToolCallEntry[]; +}> { + const toolCalls: ToolCallEntry[] = []; + let frameIndex = 0; + let widgetTui: { requestRender(): void } | null = null; + const widgetKey = `ralpi-${widgetKeySuffix}-${Date.now()}`; + + const truncateWidth = 74; + + const buildLines = (t: typeof ctx.ui.theme, width?: number): string[] => { + const effectiveWidth = width + ? Math.min(width, truncateWidth) + : truncateWidth; + const frame = t.fg( + "accent", + SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length], + ); + const lines = [truncateToWidth(`~ ${frame} ${header}`, effectiveWidth)]; + + if (toolCalls.length > 0) { + if (toolCalls.length <= MAX_COLLAPSED) { + for (let i = 0; i < toolCalls.length; i++) { + const entry = toolCalls[i]; + const isLast = i === toolCalls.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = t.fg("accent", `[${entry.name}]`); + lines.push( + truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth), + ); + } + } else { + const shown = toolCalls.slice(-MAX_COLLAPSED); + const remaining = toolCalls.length - shown.length; + lines.push( + truncateToWidth( + t.fg("dim", ` ├── …${remaining} earlier`), + effectiveWidth, + ), + ); + for (let i = 0; i < shown.length; i++) { + const entry = shown[i]; + const isLast = i === shown.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = t.fg("accent", `[${entry.name}]`); + lines.push( + truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth), + ); + } + } + } + return lines; + }; + + ctx.ui.setWidget(widgetKey, (tui, t) => { + widgetTui = tui; + return { + render: (width?: number) => buildLines(t, width), + invalidate: () => widgetTui?.requestRender(), + }; + }); + + const requestRender = () => widgetTui?.requestRender(); + + const spinnerTimer = setInterval(() => { + frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length; + requestRender(); + }, 100); + + let result: Awaited> | undefined; + try { + for (let attempt = 0; attempt < models.length; attempt++) { + const model = models[attempt]; + result = await runAgentSession( + prompt, + projectDir, + timeoutMs, + (event) => { + if (event.type === "tool_execution_start") { + const label = formatToolArg(event.toolName, event.args); + toolCalls.push({ name: event.toolName, label }); + requestRender(); + } + }, + undefined, + model, + config.thinkingLevel, + true, // noSkills — follow-up sessions don't need the skills catalog + ); + + if (result.success) break; + + // If there's a next model to try, cycle; otherwise give up. + if (attempt < models.length - 1) { + // Clear partial tool calls from the failed attempt so the widget + // reflects only the successful (or final) attempt. + toolCalls.length = 0; + requestRender(); + } + } + } finally { + clearInterval(spinnerTimer); + ctx.ui.setWidget(widgetKey, undefined); + } + + // result is always set — the loop runs at least once (models.length >= 1) + return { result: result!, toolCalls }; +} + // ─── Helpers ───────────────────────────────────────────────────────────────── -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +/** + * Build a model failover list for a follow-up session. + * + * The primary model goes first; the remaining models from the round-robin + * pool are appended (deduped) so a flaky provider doesn't kill the commit + * or review step. When there's no round-robin (sequential mode), the + * primary model is returned as a single-element list. + */ +function buildFailoverModels( + primary: unknown, + roundRobin: ModelRoundRobin | null | undefined, +): unknown[] { + const models: unknown[] = [primary]; + if (roundRobin) { + for (const m of roundRobin.allModels) { + if (m !== primary) models.push(m); + } + } + return models; } // ─── Tool Call Formatting ──────────────────────────────────────────────── diff --git a/src/progress.ts b/src/progress.ts index 9e0f821..b77b77c 100644 --- a/src/progress.ts +++ b/src/progress.ts @@ -237,15 +237,6 @@ export class ProgressTracker { .filter((r): r is Reflection => r !== undefined); } - /** Increment retry count */ - incrementRetry(taskId: string): number { - const prd = this.getPRD(); - this.ensureTask(prd, taskId); - prd.tasks[taskId].retries++; - this.save(); - return prd.tasks[taskId].retries; - } - /** Set paused state */ setPaused(paused: boolean): void { const prd = this.getPRD(); @@ -277,7 +268,7 @@ export class ProgressTracker { private ensureTask(prd: PRDProgress, taskId: string): void { if (!prd.tasks[taskId]) { - prd.tasks[taskId] = { status: "pending", retries: 0 }; + prd.tasks[taskId] = { status: "pending" }; } } } diff --git a/src/prompts.ts b/src/prompts.ts index 577a162..c2b43a5 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -1,6 +1,29 @@ import type { Task, Project, Reflection } from "./types"; import { readTaskSpec } from "./parser"; +/** Maximum bytes of a commit diff embedded in a review/commit prompt. + * Diffs larger than this are truncated to avoid blowing past the model's + * context window. The agent can always run `git show HEAD` itself to + * inspect the full diff when it needs more detail. + * + * ~50 KB ≈ 12.5K tokens — comfortably fits even on models with a 128K + * context window once system-prompt overhead is accounted for. */ +export const MAX_DIFF_BYTES = 50_000; + +/** + * Truncate a diff to MAX_DIFF_BYTES, appending a clear notice when truncated. + */ +function truncateDiff(diff: string): string { + if (diff.length <= MAX_DIFF_BYTES) return diff; + const omitted = diff.length - MAX_DIFF_BYTES; + return ( + diff.slice(0, MAX_DIFF_BYTES) + + "\n\n... (diff truncated: omitted " + + omitted.toLocaleString() + + " bytes; run `git show HEAD` to view the full diff)" + ); +} + // ─── Task Prompt ───────────────────────────────────────────────────────────── /** @@ -136,7 +159,91 @@ export function buildTaskPrompt( return parts.join("\n"); } -// ─── Plan Prompt ───────────────────────────────────────────────────────────── +// ─── Review Prompt ─────────────────────────────────────────────────────────── + +/** + * Build the prompt for the auto-review agent. + * Includes the task description and the latest commit diff so the reviewer + * can assess whether the commit fulfills the task requirements. + */ +export function buildReviewPrompt( + task: Task, + project: Project, + commitHash: string, + commitSubject: string, + commitDiff: string, + projectContext?: string, +): string { + const parts: string[] = []; + + parts.push(`# Code Review: Task ${task.id}: ${task.title}`); + parts.push(""); + + // ── Task Description ── + + parts.push("## Task Description"); + if (task.description) { + parts.push(task.description); + } else { + parts.push(task.title); + } + parts.push(""); + + // ── Task Specification ── + + if (task.file) { + const spec = readTaskSpec(project.sourceDir, task.file); + if (spec) { + parts.push("## Task Specification"); + parts.push(`Full details from \`${task.file}\`:`); + parts.push(""); + parts.push(spec); + parts.push(""); + } + } + + // ── Commit Under Review ── + + parts.push("## Commit Under Review"); + parts.push(`Commit: ${commitHash} — ${commitSubject}`); + parts.push(""); + parts.push("### Diff"); + parts.push("```diff"); + parts.push(truncateDiff(commitDiff)); + parts.push("```"); + parts.push(""); + + // ── Project Context ── + + if (projectContext) { + parts.push("## Additional Context"); + parts.push(projectContext); + parts.push(""); + } + + // ── Review Instructions ── + + parts.push("## Review Instructions"); + parts.push( + "Review the commit above against the task description. Check for:", + ); + parts.push( + "- **Correctness**: Does the implementation fulfill the task requirements?", + ); + parts.push("- **Completeness**: Are all aspects of the task addressed?"); + parts.push( + "- **Code quality**: Are there obvious bugs, anti-patterns, or issues?", + ); + parts.push( + "- **Missing changes**: Are there files that should have been modified but weren't?", + ); + parts.push(""); + parts.push( + "Provide a concise review with any issues found. If the commit looks good, say so explicitly.", + ); + + return parts.join("\n"); +} /** * Build the prompt for a dry-run / plan display @@ -155,9 +262,10 @@ export function buildPlanPrompt(project: Project): string { lines.push("## Tasks"); for (const task of project.tasks) { - const deps = task.dependencies.length > 0 - ? ` (depends on: ${task.dependencies.join(", ")})` - : ""; + const deps = + task.dependencies.length > 0 + ? ` (depends on: ${task.dependencies.join(", ")})` + : ""; lines.push(`- [ ] ${task.id}: ${task.title}${deps}`); } lines.push(""); diff --git a/src/types.ts b/src/types.ts index 714f7a6..565b872 100644 --- a/src/types.ts +++ b/src/types.ts @@ -115,7 +115,6 @@ export interface TaskProgressInfo { status: Task["status"]; startedAt?: string; completedAt?: string; - retries: number; durationMs?: number; reflection?: Reflection; error?: string; @@ -167,16 +166,31 @@ export interface RalpiConfig { reflectionsDir: string; }; execution: { - /** Maximum retries per task */ - maxRetries: number; - /** Delay between retries in milliseconds */ - retryDelayMs: number; /** Task execution timeout in milliseconds */ timeoutMs: number; /** Maximum parallel tasks (0 = unlimited) */ maxParallel: number; /** Round-robin model list for parallel tasks (empty = inherit parent model) */ models: string[]; + /** Spawn a follow-up agent to commit changes after each task completes */ + autoCommit: boolean; + /** Spawn a review agent to review the commit against the task description */ + autoReview: boolean; + /** Keys under `execution:` explicitly present in a loaded config YAML. + * Used to skip interactive prompts for fields the user already set. */ + explicitKeys?: Set; + /** Model for commit sessions in / format (empty = inherit task model) */ + commitModel: string; + /** Model for review sessions in / format (empty = inherit task model) */ + reviewModel: string; + /** Model for task implementation in / format (empty = inherit parent model; only used in sequential mode when models is empty) */ + implModel: string; + /** Timeout for auto-commit agent sessions in milliseconds */ + commitTimeoutMs: number; + /** Timeout for auto-review agent sessions in milliseconds */ + reviewTimeoutMs: number; + /** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */ + loopTimeoutMs: number; }; prompts: { /** Additional context injected into every task prompt */ @@ -196,11 +210,17 @@ export const DEFAULT_CONFIG: RalpiConfig = { reflectionsDir: ".ralpi/reflections", }, execution: { - maxRetries: 0, - retryDelayMs: 0, timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout) maxParallel: 3, models: [], + autoCommit: true, + autoReview: false, + commitModel: "", + reviewModel: "", + implModel: "", + commitTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout) + reviewTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout) + loopTimeoutMs: 0, // 0 = no limit }, prompts: { projectContext: "", diff --git a/src/utils.ts b/src/utils.ts index 9619b4f..1b6ac23 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -13,6 +13,7 @@ import { DefaultResourceLoader, getAgentDir, SessionManager, + SettingsManager, } from "@earendil-works/pi-coding-agent"; // ─── Directory Helpers ─────────────────────────────────────────────────────── @@ -152,6 +153,70 @@ export function findProgressFile( return null; } +/** + * List all PRDs from a ProgressState, sorted by lastUpdatedAt descending + * (most recent first). Used by resume to offer a selection when multiple + * loops have progress simultaneously. + */ +export function listPRDsSorted( + state: ProgressState, +): Array<{ key: string; prd: PRDProgress }> { + const entries: Array<{ key: string; prd: PRDProgress }> = []; + + if (state.prds) { + for (const [key, prd] of Object.entries(state.prds)) { + entries.push({ key, prd }); + } + } else { + // Legacy flat mode — single PRD + entries.push({ + key: "legacy", + prd: { + sourcePath: state.sourcePath, + tasks: state.tasks, + startedAt: state.startedAt, + lastUpdatedAt: state.lastUpdatedAt, + paused: state.paused, + }, + }); + } + + entries.sort((a, b) => { + return ( + new Date(b.prd.lastUpdatedAt).getTime() - + new Date(a.prd.lastUpdatedAt).getTime() + ); + }); + + return entries; +} + +// ─── Model Resolution ─────────────────────────────────────────────────────── + +/** + * Resolve a "/" spec string via the model registry. + * Returns undefined if spec is empty, malformed, or not found. + */ +export function resolveModelSpec( + modelRegistry: + | { find(provider: string, modelId: string): unknown } + | undefined, + spec: string, + onWarning?: (msg: string) => void, +): unknown | undefined { + if (!spec) return undefined; + const slashIdx = spec.indexOf("/"); + if (slashIdx === -1) { + onWarning?.( + `ralpi config: skipping model "${spec}" — expected / format`, + ); + return undefined; + } + const provider = spec.slice(0, slashIdx); + const modelId = spec.slice(slashIdx + 1); + return modelRegistry?.find(provider, modelId); +} + // ─── Config ────────────────────────────────────────────────────────────────── /** Try to use the `yaml` package (real dependency in package.json). @@ -239,6 +304,15 @@ export function loadConfig(projectDir: string): RalpiConfig { const content = fs.readFileSync(filePath, "utf-8"); const parsed = parseSimpleYaml(content); Object.assign(acc, mergeConfig(acc, parsed)); + // Track which execution keys were explicitly set in this YAML so the + // loop-startup prompts can be skipped for fields the user already set. + const exec = parsed?.execution; + if (exec && typeof exec === "object" && !Array.isArray(exec)) { + acc.execution.explicitKeys ??= new Set(); + for (const key of Object.keys(exec)) { + acc.execution.explicitKeys.add(key); + } + } } catch { // Malformed config — skip silently } @@ -434,6 +508,10 @@ export async function runAgentSession( signal?: AbortSignal, model?: unknown, thinkingLevel?: unknown, + /** When true, skip loading the skills catalog for this session. Used by + * focused follow-up sessions (commit/review) that don't need skills — + * keeps the context lean and avoids dragging in unrelated overhead. */ + noSkills = false, ): Promise<{ success: boolean; text: string; @@ -466,7 +544,7 @@ export async function runAgentSession( cwd, agentDir: getAgentDir(), noExtensions: true, - noSkills: false, + noSkills, noPromptTemplates: true, noThemes: true, noContextFiles: true, @@ -477,6 +555,7 @@ export async function runAgentSession( cwd, sessionManager: SessionManager.inMemory(), resourceLoader: loader, + settingsManager: SettingsManager.create(cwd, getAgentDir()), tools: ["read", "bash", "edit", "write", "grep", "find", "ls"], model: model as any, thinkingLevel: thinkingLevel as any, @@ -675,3 +754,43 @@ export function captureGitCommits(projectDir: string): { return { commitMessages, commitSummary }; } + +/** + * Get the diff of the latest commit (HEAD). + * Returns the short hash, subject, and full diff (stat + patch). + * Used by the auto-review agent to review a commit against the task. + */ +export function getLatestCommitDiff( + projectDir: string, +): { hash: string; subject: string; diff: string } | null { + const { execSync } = require("node:child_process"); + + try { + execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" }); + } catch { + return null; + } + + try { + const hash = execSync("git rev-parse --short HEAD", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + + const subject = execSync("git log -1 --format=%s", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + + // Full diff of the latest commit: stat overview + patch + const diff = execSync("git show HEAD --stat --patch", { + cwd: projectDir, + encoding: "utf-8", + maxBuffer: 1024 * 1024, + }).trim(); + + return { hash, subject, diff }; + } catch { + return null; + } +}