diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index ed2bc7520..aa61ac603 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -27,6 +27,7 @@ Here are the general guidelines for determining whether something is a bug and s 6. The bug does not rely on unstated assumptions about the codebase or author's intent. 7. It is not enough to speculate that a change may disrupt another part of the codebase, to be considered a bug, one must identify the other parts of the code that are provably affected. 8. The bug is clearly not just an intentional change by the original author. +9. Use the repository's `AGENTS.md` and/or `CLAUDE.md` files (if present) for guidance on style, conventions, testing expectations, and architectural patterns. Your review should respect these project-level norms — flag deviations only when they conflict with correctness or security, not personal preference. When flagging a bug, you will also provide an accompanying comment. Once again, these guidelines are not the final word on how to construct a comment -- defer to any subsequent guidelines that you encounter. diff --git a/.github/agents/reviewer.md b/.github/agents/reviewer.md index bf8dcd9fe..748e402e5 100644 --- a/.github/agents/reviewer.md +++ b/.github/agents/reviewer.md @@ -24,6 +24,7 @@ Here are the general guidelines for determining whether something is a bug and s 6. The bug does not rely on unstated assumptions about the codebase or author's intent. 7. It is not enough to speculate that a change may disrupt another part of the codebase, to be considered a bug, one must identify the other parts of the code that are provably affected. 8. The bug is clearly not just an intentional change by the original author. +9. Use the repository's `AGENTS.md` and/or `CLAUDE.md` files (if present) for guidance on style, conventions, testing expectations, and architectural patterns. Your review should respect these project-level norms — flag deviations only when they conflict with correctness or security, not personal preference. When flagging a bug, you will also provide an accompanying comment. Once again, these guidelines are not the final word on how to construct a comment -- defer to any subsequent guidelines that you encounter. diff --git a/.opencode/agents/reviewer.md b/.opencode/agents/reviewer.md index a99878540..d3ea5cf8b 100644 --- a/.opencode/agents/reviewer.md +++ b/.opencode/agents/reviewer.md @@ -32,6 +32,7 @@ Here are the general guidelines for determining whether something is a bug and s 6. The bug does not rely on unstated assumptions about the codebase or author's intent. 7. It is not enough to speculate that a change may disrupt another part of the codebase, to be considered a bug, one must identify the other parts of the code that are provably affected. 8. The bug is clearly not just an intentional change by the original author. +9. Use the repository's `AGENTS.md` and/or `CLAUDE.md` files (if present) for guidance on style, conventions, testing expectations, and architectural patterns. Your review should respect these project-level norms — flag deviations only when they conflict with correctness or security, not personal preference. When flagging a bug, you will also provide an accompanying comment. Once again, these guidelines are not the final word on how to construct a comment -- defer to any subsequent guidelines that you encounter. diff --git a/src/sdk/workflows/builtin/ralph/claude/index.ts b/src/sdk/workflows/builtin/ralph/claude/index.ts index 03eee1b1d..0ecd68968 100644 --- a/src/sdk/workflows/builtin/ralph/claude/index.ts +++ b/src/sdk/workflows/builtin/ralph/claude/index.ts @@ -5,26 +5,35 @@ * so users can see each iteration's progress in real time. The loop * terminates when: * - {@link MAX_LOOPS} iterations have completed, OR - * - Two consecutive reviewer passes return zero findings. + * - Two parallel reviewer passes both return zero findings. + * + * The reviewer stages use the Claude Agent SDK's structured output + * (`outputFormat`) to guarantee the review result matches the + * {@link ReviewResultSchema} — no manual JSON parsing required. * * Run: atomic workflow -n ralph -a claude "" */ import { defineWorkflow } from "../../../index.ts"; +import { query as claudeSdkQuery } from "@anthropic-ai/claude-agent-sdk"; import { buildPlannerPrompt, buildOrchestratorPrompt, + buildInfraDiscoveryPrompts, buildReviewPrompt, buildDebuggerReportPrompt, - parseReviewResult, extractMarkdownBlock, + filterActionable, + mergeReviewResults, + REVIEW_RESULT_JSON_SCHEMA, + type ReviewResult, + type StructuredReviewResult, } from "../helpers/prompts.ts"; import { hasActionableFindings } from "../helpers/review.ts"; -import { safeGitStatusS } from "../helpers/git.ts"; +import { captureBranchChangeset } from "../helpers/git.ts"; const MAX_LOOPS = 10; -const CONSECUTIVE_CLEAN_THRESHOLD = 2; // The orchestrator stage implements the actual code changes and can run for // a very long time on large tasks. 24 hours prevents premature timeout. @@ -35,24 +44,56 @@ function asAgentCall(agentName: string, prompt: string): string { return `@"${agentName} (agent)" ${prompt}`; } +/** + * Run the Claude Agent SDK's `query()` with structured output and collect + * the result. Returns a {@link StructuredReviewResult} with the SDK-validated + * structured output (when available) and the raw text fallback. + */ +async function queryWithStructuredOutput( + prompt: string, +): Promise { + let structured: ReviewResult | null = null; + let raw = ""; + + for await (const msg of claudeSdkQuery({ + prompt, + options: { + outputFormat: { + type: "json_schema", + schema: REVIEW_RESULT_JSON_SCHEMA, + }, + }, + })) { + if (msg.type === "result") { + raw = String((msg as Record).output ?? ""); + if ( + msg.subtype === "success" && + (msg as Record).structured_output + ) { + structured = (msg as Record).structured_output as ReviewResult; + } + } + } + + return { + structured: structured ? filterActionable(structured) : null, + raw, + }; +} + export default defineWorkflow<"claude">({ name: "ralph", description: "Plan → orchestrate → review → debug loop with bounded iteration", }) .run(async (ctx) => { - // Free-form workflows receive their positional prompt under - // `inputs.prompt`; destructure once so every stage below can close - // over a bare `prompt` string without re-reaching into ctx.inputs. const prompt = ctx.inputs.prompt ?? ""; - let consecutiveClean = 0; let debuggerReport = ""; for (let iteration = 1; iteration <= MAX_LOOPS; iteration++) { // ── Plan ──────────────────────────────────────────────────────────── - const plannerName = `planner-${iteration}`; await ctx.stage( - { name: plannerName }, + { name: `planner-${iteration}` }, {}, {}, async (s) => { @@ -69,95 +110,110 @@ export default defineWorkflow<"claude">({ }, ); - // ── Orchestrate ───────────────────────────────────────────────────── - const orchName = `orchestrator-${iteration}`; await ctx.stage( - { name: orchName }, + { name: `orchestrator-${iteration}` }, {}, {}, async (s) => { await s.session.query( - asAgentCall( - "orchestrator", - buildOrchestratorPrompt(prompt), - ), + asAgentCall("orchestrator", buildOrchestratorPrompt(prompt)), { timeoutMs: ORCHESTRATOR_TIMEOUT_MS }, ); s.save(s.sessionId); }, ); + // ── Infrastructure Discovery (three parallel sub-agent stages) ──── + const changeset = await captureBranchChangeset(); + const discoveryPrompts = buildInfraDiscoveryPrompts(); - // ── Review (first pass) ───────────────────────────────────────────── - let gitStatus = await safeGitStatusS(); - const reviewerName = `reviewer-${iteration}`; - const review = await ctx.stage( - { name: reviewerName }, - {}, - {}, - async (s) => { - const result = await s.session.query( - asAgentCall( - "reviewer", - buildReviewPrompt(prompt, { gitStatus, iteration }), - ), - ); - s.save(s.sessionId); - return result.output; - }, - ); - - - let reviewRaw = review.result; - let parsed = parseReviewResult(reviewRaw); - - if (!hasActionableFindings(parsed, reviewRaw)) { - consecutiveClean += 1; - if (consecutiveClean >= CONSECUTIVE_CLEAN_THRESHOLD) break; - - // Confirmation pass — re-run reviewer only - gitStatus = await safeGitStatusS(); - const confirmName = `reviewer-${iteration}-confirm`; - const confirm = await ctx.stage( - { name: confirmName }, + const [locatorResult, analyzerResult, patternResult] = await Promise.all([ + ctx.stage( + { name: `infra-locate-${iteration}` }, {}, {}, async (s) => { const result = await s.session.query( - asAgentCall( - "reviewer", - buildReviewPrompt(prompt, { - gitStatus, - iteration, - isConfirmationPass: true, - }), - ), + asAgentCall("codebase-locator", discoveryPrompts.locator), ); s.save(s.sessionId); - return result.output; + return String(result.output ?? ""); }, - ); - + ), + ctx.stage( + { name: `infra-analyze-${iteration}` }, + {}, + {}, + async (s) => { + const result = await s.session.query( + asAgentCall("codebase-analyzer", discoveryPrompts.analyzer), + ); + s.save(s.sessionId); + return String(result.output ?? ""); + }, + ), + ctx.stage( + { name: `infra-patterns-${iteration}` }, + {}, + {}, + async (s) => { + const result = await s.session.query( + asAgentCall("codebase-pattern-finder", discoveryPrompts.patternFinder), + ); + s.save(s.sessionId); + return String(result.output ?? ""); + }, + ), + ]); + + const discoveryContext = [ + "### Infrastructure Files (codebase-locator)\n\n" + locatorResult.result, + "### Infrastructure Analysis (codebase-analyzer)\n\n" + analyzerResult.result, + "### Build & Test Patterns (codebase-pattern-finder)\n\n" + patternResult.result, + ].join("\n\n---\n\n"); + + // ── Review (two parallel passes) ──────────────────────────────────── + const reviewPrompt = buildReviewPrompt(prompt, { + changeset, + iteration, + discoveryContext, + }); + + const [reviewA, reviewB] = await Promise.all([ + ctx.stage( + { name: `reviewer-${iteration}-a` }, + {}, + {}, + async (s) => { + const result = await queryWithStructuredOutput(reviewPrompt); + s.save(s.sessionId); + return result; + }, + ), + ctx.stage( + { name: `reviewer-${iteration}-b` }, + {}, + {}, + async (s) => { + const result = await queryWithStructuredOutput(reviewPrompt); + s.save(s.sessionId); + return result; + }, + ), + ]); - reviewRaw = confirm.result; - parsed = parseReviewResult(reviewRaw); + const merged = mergeReviewResults(reviewA.result, reviewB.result); + const parsed = merged.structured; + const reviewRaw = merged.raw; - if (!hasActionableFindings(parsed, reviewRaw)) { - consecutiveClean += 1; - if (consecutiveClean >= CONSECUTIVE_CLEAN_THRESHOLD) break; - } else { - consecutiveClean = 0; - } - } else { - consecutiveClean = 0; - } + // Both reviewers agree the code is clean → done + if (!hasActionableFindings(parsed, reviewRaw)) break; - // ── Debug (only if findings remain AND another iteration is allowed) ─ - if (hasActionableFindings(parsed, reviewRaw) && iteration < MAX_LOOPS) { - const debuggerName = `debugger-${iteration}`; + // ── Debug (only if another iteration is allowed) ──────────────────── + if (iteration < MAX_LOOPS) { const debugger_ = await ctx.stage( - { name: debuggerName }, + { name: `debugger-${iteration}` }, {}, {}, async (s) => { @@ -166,7 +222,7 @@ export default defineWorkflow<"claude">({ "debugger", buildDebuggerReportPrompt(parsed, reviewRaw, { iteration, - gitStatus, + changeset, }), ), ); diff --git a/src/sdk/workflows/builtin/ralph/copilot/index.ts b/src/sdk/workflows/builtin/ralph/copilot/index.ts index 0221a30ee..2abf54901 100644 --- a/src/sdk/workflows/builtin/ralph/copilot/index.ts +++ b/src/sdk/workflows/builtin/ralph/copilot/index.ts @@ -5,27 +5,42 @@ * so users can see each iteration's progress in real time. The loop * terminates when: * - {@link MAX_LOOPS} iterations have completed, OR - * - Two consecutive reviewer passes return zero findings. + * - Two parallel reviewer passes both return zero findings. + * + * The reviewer stages use a `submit_review` custom tool (defined via + * `defineTool` with Zod schema validation) to guarantee the review result + * matches the {@link ReviewResultSchema}. The Copilot SDK validates tool + * call arguments against the Zod schema before the handler fires. * * Run: atomic workflow -n ralph -a copilot "" */ import { defineWorkflow } from "../../../index.ts"; +import { defineTool } from "@github/copilot-sdk"; import type { SessionEvent } from "@github/copilot-sdk"; import { buildPlannerPrompt, buildOrchestratorPrompt, + buildInfraDiscoveryPrompts, buildReviewPrompt, buildDebuggerReportPrompt, - parseReviewResult, extractMarkdownBlock, + filterActionable, + mergeReviewResults, + ReviewResultSchema, + type ReviewResult, + type StructuredReviewResult, } from "../helpers/prompts.ts"; import { hasActionableFindings } from "../helpers/review.ts"; -import { safeGitStatusS } from "../helpers/git.ts"; +import { captureBranchChangeset } from "../helpers/git.ts"; const MAX_LOOPS = 10; -const CONSECUTIVE_CLEAN_THRESHOLD = 2; + +const SUBMIT_REVIEW_DESCRIPTION = + "Submit the structured code review result. You MUST call this tool " + + "exactly once with your complete review. Do not output the review as " + + "plain text — use this tool."; /** * Concatenate the text content of every top-level assistant message in the @@ -65,20 +80,13 @@ export default defineWorkflow<"copilot">({ "Plan → orchestrate → review → debug loop with bounded iteration", }) .run(async (ctx) => { - // Free-form workflows receive their positional prompt under - // `inputs.prompt`; destructure once so every stage below can close - // over a bare `userPromptText` without re-reaching into ctx.inputs. - // (Named `userPromptText` rather than `prompt` to avoid confusion - // with the `prompt:` object key used in the Copilot send call.) const userPromptText = ctx.inputs.prompt ?? ""; - let consecutiveClean = 0; let debuggerReport = ""; for (let iteration = 1; iteration <= MAX_LOOPS; iteration++) { // ── Plan ────────────────────────────────────────────────────────── - const plannerName = `planner-${iteration}`; const planner = await ctx.stage( - { name: plannerName }, + { name: `planner-${iteration}` }, {}, { agent: "planner" }, async (s) => { @@ -94,11 +102,9 @@ export default defineWorkflow<"copilot">({ }, ); - // ── Orchestrate ─────────────────────────────────────────────────── - const orchName = `orchestrator-${iteration}`; await ctx.stage( - { name: orchName }, + { name: `orchestrator-${iteration}` }, {}, { agent: "orchestrator" }, async (s) => { @@ -111,82 +117,134 @@ export default defineWorkflow<"copilot">({ }, ); + // ── Infrastructure Discovery (three parallel sub-agent stages) ── + const changeset = await captureBranchChangeset(); + const discoveryPrompts = buildInfraDiscoveryPrompts(); - // ── Review (first pass) ─────────────────────────────────────────── - let gitStatus = await safeGitStatusS(); - const reviewerName = `reviewer-${iteration}`; - const review = await ctx.stage( - { name: reviewerName }, - {}, - { agent: "reviewer" }, - async (s) => { - await s.session.send({ - prompt: buildReviewPrompt(userPromptText, { - gitStatus, - iteration, - }), - }); - const messages = await s.session.getMessages(); - s.save(messages); - return getAssistantText(messages); - }, - ); + const [locatorResult, analyzerResult, patternResult] = await Promise.all([ + ctx.stage( + { name: `infra-locate-${iteration}` }, + {}, + { agent: "codebase-locator" }, + async (s) => { + await s.session.send({ prompt: discoveryPrompts.locator }); + const messages = await s.session.getMessages(); + s.save(messages); + return getAssistantText(messages); + }, + ), + ctx.stage( + { name: `infra-analyze-${iteration}` }, + {}, + { agent: "codebase-analyzer" }, + async (s) => { + await s.session.send({ prompt: discoveryPrompts.analyzer }); + const messages = await s.session.getMessages(); + s.save(messages); + return getAssistantText(messages); + }, + ), + ctx.stage( + { name: `infra-patterns-${iteration}` }, + {}, + { agent: "codebase-pattern-finder" }, + async (s) => { + await s.session.send({ prompt: discoveryPrompts.patternFinder }); + const messages = await s.session.getMessages(); + s.save(messages); + return getAssistantText(messages); + }, + ), + ]); + + const discoveryContext = [ + "### Infrastructure Files (codebase-locator)\n\n" + locatorResult.result, + "### Infrastructure Analysis (codebase-analyzer)\n\n" + analyzerResult.result, + "### Build & Test Patterns (codebase-pattern-finder)\n\n" + patternResult.result, + ].join("\n\n---\n\n"); + + // ── Review (two parallel passes) ────────────────────────────────── + const reviewPrompt = buildReviewPrompt(userPromptText, { + changeset, + iteration, + useSubmitTool: true, + discoveryContext, + }); + // Each parallel reviewer gets its own tool + capture ref so they + // don't race on a shared mutable. + let captureA: ReviewResult | null = null; + let captureB: ReviewResult | null = null; - let reviewRaw = review.result; - let parsed = parseReviewResult(reviewRaw); + const toolA = defineTool("submit_review", { + description: SUBMIT_REVIEW_DESCRIPTION, + parameters: ReviewResultSchema, + skipPermission: true, + handler: async (data: ReviewResult) => { + captureA = filterActionable(data); + return "Review submitted successfully."; + }, + }); - if (!hasActionableFindings(parsed, reviewRaw)) { - consecutiveClean += 1; - if (consecutiveClean >= CONSECUTIVE_CLEAN_THRESHOLD) break; + const toolB = defineTool("submit_review", { + description: SUBMIT_REVIEW_DESCRIPTION, + parameters: ReviewResultSchema, + skipPermission: true, + handler: async (data: ReviewResult) => { + captureB = filterActionable(data); + return "Review submitted successfully."; + }, + }); - // Confirmation pass — re-run reviewer only - gitStatus = await safeGitStatusS(); - const confirmName = `reviewer-${iteration}-confirm`; - const confirm = await ctx.stage( - { name: confirmName }, + const [reviewA, reviewB] = await Promise.all([ + ctx.stage( + { name: `reviewer-${iteration}-a` }, {}, - { agent: "reviewer" }, + { agent: "reviewer", tools: [toolA] }, async (s) => { - await s.session.send({ - prompt: buildReviewPrompt(userPromptText, { - gitStatus, - iteration, - isConfirmationPass: true, - }), - }); + await s.session.sendAndWait({ prompt: reviewPrompt }); const messages = await s.session.getMessages(); s.save(messages); - return getAssistantText(messages); + return { + structured: captureA, + raw: getAssistantText(messages), + } as StructuredReviewResult; }, - ); - + ), + ctx.stage( + { name: `reviewer-${iteration}-b` }, + {}, + { agent: "reviewer", tools: [toolB] }, + async (s) => { + await s.session.sendAndWait({ prompt: reviewPrompt }); + const messages = await s.session.getMessages(); + s.save(messages); + return { + structured: captureB, + raw: getAssistantText(messages), + } as StructuredReviewResult; + }, + ), + ]); - reviewRaw = confirm.result; - parsed = parseReviewResult(reviewRaw); + const merged = mergeReviewResults(reviewA.result, reviewB.result); + const parsed = merged.structured; + const reviewRaw = merged.raw; - if (!hasActionableFindings(parsed, reviewRaw)) { - consecutiveClean += 1; - if (consecutiveClean >= CONSECUTIVE_CLEAN_THRESHOLD) break; - } else { - consecutiveClean = 0; - } - } else { - consecutiveClean = 0; - } + // Both reviewers agree the code is clean → done + if (!hasActionableFindings(parsed, reviewRaw)) break; - // ── Debug (only if findings remain AND another iteration is allowed) ─ - if (hasActionableFindings(parsed, reviewRaw) && iteration < MAX_LOOPS) { - const debuggerName = `debugger-${iteration}`; + // ── Debug (only if another iteration is allowed) ────────────────── + if (iteration < MAX_LOOPS) { const debugger_ = await ctx.stage( - { name: debuggerName }, + { name: `debugger-${iteration}` }, {}, { agent: "debugger" }, async (s) => { await s.session.send({ prompt: buildDebuggerReportPrompt(parsed, reviewRaw, { iteration, - gitStatus, + changeset, }), }); const messages = await s.session.getMessages(); diff --git a/src/sdk/workflows/builtin/ralph/helpers/git.ts b/src/sdk/workflows/builtin/ralph/helpers/git.ts index 503c29942..98ab863ff 100644 --- a/src/sdk/workflows/builtin/ralph/helpers/git.ts +++ b/src/sdk/workflows/builtin/ralph/helpers/git.ts @@ -1,34 +1,201 @@ /** - * Deterministic working-tree probes used by the Ralph loop. + * Deterministic changeset probes used by the Ralph loop. * * The reviewer and debugger sub-agents both benefit from knowing exactly which - * files were touched in the current iteration. Asking an LLM to figure that - * out via tool calls is expensive and lossy, so we capture `git status -s` - * directly from the workflow runner and inject it into prompts. + * files were touched by the current work. We compute a diff relative to the + * parent branch (auto-discovered, defaulting to main) so the changeset + * includes BOTH committed and uncommitted changes — not just the working tree. * - * Failures (no git binary, not a repo, command non-zero) collapse to "" so - * the prompts can fall back to the "working tree clean" branch. + * Git command failures are captured — not swallowed — so downstream agents + * can distinguish "nothing changed" from "git broke" and course-correct. */ +// ─── Internals ────────────────────────────────────────────────────────────── + +/** Result of running a single git command. */ +interface GitResult { + /** Trimmed stdout on success, "" on failure. */ + stdout: string; + /** True when the command exited with code 0. */ + ok: boolean; + /** Human-readable error context when ok is false. */ + error?: string; +} + /** - * Run `git status -s` from the given cwd. Returns trimmed stdout, or "" on - * any error. Never throws. + * Run a git command and return both the output and error context. + * + * Never throws — call-site code can check `.ok` and propagate `.error` + * to downstream agents instead of silently producing empty strings. */ -export async function safeGitStatusS( +async function git( + args: string[], cwd: string = process.cwd(), -): Promise { +): Promise { try { const proc = Bun.spawn({ - cmd: ["git", "status", "-s"], + cmd: ["git", ...args], cwd, stdout: "pipe", - stderr: "ignore", + stderr: "pipe", }); - const stdout = await new Response(proc.stdout).text(); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); const code = await proc.exited; - if (code !== 0) return ""; - return stdout; - } catch { - return ""; + if (code !== 0) { + return { + stdout: "", + ok: false, + error: `\`git ${args.join(" ")}\` exited with code ${code}${stderr.trim() ? ": " + stderr.trim() : ""}`, + }; + } + return { stdout: stdout.trim(), ok: true }; + } catch (err) { + return { + stdout: "", + ok: false, + error: `\`git ${args.join(" ")}\` failed to spawn: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +// ─── Branch discovery ─────────────────────────────────────────────────────── + +/** Well-known default branch names, tried in order. */ +const DEFAULT_BRANCH_CANDIDATES = ["main", "master", "develop"] as const; + +/** + * Discover the parent (base) branch for the current HEAD. + * + * Strategy: + * 1. Find the merge-base between HEAD and each default-branch candidate. + * The candidate whose merge-base is closest to HEAD (fewest commits away) + * is the winner. + * 2. If no candidate exists locally, fall back to "main". + * + * This handles the common case of feature branches off main/master without + * requiring configuration. + */ +export async function discoverBaseBranch( + cwd: string = process.cwd(), +): Promise { + const branchResult = await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd); + const currentBranch = branchResult.stdout; + + let bestCandidate = "main"; + let bestDistance = Infinity; + + for (const candidate of DEFAULT_BRANCH_CANDIDATES) { + // Skip if we ARE the candidate (reviewing main against itself is useless) + if (currentBranch === candidate) continue; + + // Check the ref exists + const refExists = await git(["rev-parse", "--verify", `refs/heads/${candidate}`], cwd); + if (!refExists.ok) continue; + + const mergeBase = await git(["merge-base", "HEAD", candidate], cwd); + if (!mergeBase.ok) continue; + + // Count commits from merge-base to HEAD — fewer = closer = better match + const countResult = await git(["rev-list", "--count", `${mergeBase.stdout}..HEAD`], cwd); + const distance = parseInt(countResult.stdout, 10); + if (!Number.isNaN(distance) && distance < bestDistance) { + bestDistance = distance; + bestCandidate = candidate; + } } + + return bestCandidate; +} + +// ─── Changeset capture ────────────────────────────────────────────────────── + +/** The result of capturing the full changeset for a review. */ +export interface BranchChangeset { + /** The base branch the diff is relative to (e.g. "main") */ + baseBranch: string; + /** + * `git diff --stat` output showing files changed, insertions, deletions + * relative to the merge-base with the parent branch. Includes both + * committed AND uncommitted changes. + */ + diffStat: string; + /** + * Short list of uncommitted working-tree changes (`git status -s`). + * Useful for the reviewer to distinguish "already committed" from + * "still in-flight". + */ + uncommitted: string; + /** + * `git diff --name-status` output listing each changed file with its + * status (A=added, M=modified, D=deleted, R=renamed). + */ + nameStatus: string; + /** + * Human-readable error messages for any git commands that failed during + * changeset capture. Empty when everything succeeded. Downstream prompts + * surface these so the reviewing agent can course-correct (e.g. run the + * git commands itself, or flag the gap as a finding). + */ + errors: string[]; } + +/** + * Capture the full changeset for the current branch relative to its parent. + * + * Combines: + * - `git diff ..HEAD --stat` (committed changes) + * - `git diff --stat` (uncommitted staged+unstaged changes) + * - `git status -s` (working tree snapshot) + * + * Into a single {@link BranchChangeset} that gives the reviewer complete + * visibility into everything this branch has done. + * + * Git failures are collected in {@link BranchChangeset.errors} rather than + * swallowed, so downstream agents see exactly what broke and can compensate. + */ +export async function captureBranchChangeset( + cwd: string = process.cwd(), +): Promise { + const errors: string[] = []; + + const baseBranch = await discoverBaseBranch(cwd); + const mergeBaseResult = await git(["merge-base", "HEAD", baseBranch], cwd); + + if (!mergeBaseResult.ok && mergeBaseResult.error) { + errors.push(mergeBaseResult.error); + } + + // Compute the merge-base ref, falling back to baseBranch if merge-base fails + const baseRef = mergeBaseResult.stdout || baseBranch; + + // Full diff stat: committed changes from branch point through HEAD, + // plus any uncommitted working-tree changes (the combined diff captures + // the complete picture) + const diffStatResult = await git(["diff", `${baseRef}...HEAD`, "--stat"], cwd); + const uncommittedStatResult = await git(["diff", "--stat"], cwd); + const uncommittedResult = await git(["status", "-s"], cwd); + const nameStatusResult = await git(["diff", `${baseRef}...HEAD`, "--name-status"], cwd); + + // Collect errors from each command + if (!diffStatResult.ok && diffStatResult.error) errors.push(diffStatResult.error); + if (!uncommittedStatResult.ok && uncommittedStatResult.error) errors.push(uncommittedStatResult.error); + if (!uncommittedResult.ok && uncommittedResult.error) errors.push(uncommittedResult.error); + if (!nameStatusResult.ok && nameStatusResult.error) errors.push(nameStatusResult.error); + + // Merge committed + uncommitted stats for a complete picture + const combinedStat = [diffStatResult.stdout, uncommittedStatResult.stdout] + .filter(Boolean) + .join("\n"); + + return { + baseBranch, + diffStat: combinedStat, + uncommitted: uncommittedResult.stdout, + nameStatus: nameStatusResult.stdout, + errors, + }; +} + diff --git a/src/sdk/workflows/builtin/ralph/helpers/prompts.ts b/src/sdk/workflows/builtin/ralph/helpers/prompts.ts index a3cffc950..5aef48ac9 100644 --- a/src/sdk/workflows/builtin/ralph/helpers/prompts.ts +++ b/src/sdk/workflows/builtin/ralph/helpers/prompts.ts @@ -2,16 +2,108 @@ * Ralph Prompt Utilities * * Prompts used by the Ralph plan → orchestrate → review → debug loop: - * - buildPlannerPrompt: initial planning OR re-planning from a debugger report - * - buildOrchestratorPrompt: spawn workers to execute the task list - * - buildReviewPrompt: structured code review with injected git status - * - buildDebuggerReportPrompt: diagnose review findings, produce a re-plan brief + * - buildPlannerPrompt: initial planning OR re-planning from a debugger report + * - buildOrchestratorPrompt: spawn workers to execute the task list + * - buildInfraDiscoveryPrompts: prompts for parallel sub-agent infrastructure discovery + * - buildReviewPrompt: structured code review with injected changeset + discovery context + * - buildDebuggerReportPrompt: diagnose review findings, produce a re-plan brief * - * Plus parsing helpers for the reviewer JSON output and the debugger markdown - * report. + * Plus Zod schemas for structured output, parsing helpers for the reviewer + * JSON output, and the debugger markdown report. + */ + +import { z } from "zod"; + +// ============================================================================ +// STRUCTURED OUTPUT SCHEMAS +// ============================================================================ + +/** Zod schema for a single review finding. */ +export const ReviewFindingSchema = z.object({ + title: z.string().describe("Brief title prefixed with priority, e.g. '[P0] Missing null check'"), + body: z.string().describe("Detailed explanation of the issue, its impact, and a suggested fix"), + confidence_score: z.number().min(0).max(1).optional().describe("Confidence in the finding (0.0–1.0)"), + priority: z.number().int().min(0).max(3).optional().describe("Severity: 0=P0 critical, 1=P1 important, 2=P2 moderate, 3=P3 minor"), + code_location: z.object({ + absolute_file_path: z.string().describe("Absolute path to the file containing the issue"), + line_range: z.object({ + start: z.number().int().describe("Start line number"), + end: z.number().int().describe("End line number"), + }), + }).optional().describe("Location of the issue in the codebase"), +}); + +/** Zod schema for the full structured review output. */ +export const ReviewResultSchema = z.object({ + findings: z.array(ReviewFindingSchema).describe("List of review findings, ordered by priority"), + overall_correctness: z.string().describe("'patch is correct' or 'patch is incorrect'"), + overall_explanation: z.string().describe("Summary of overall quality and correctness"), + overall_confidence_score: z.number().min(0).max(1).optional().describe("Overall confidence in the review (0.0–1.0)"), +}); + +/** JSON Schema derived from the Zod schema — used by Claude and OpenCode SDKs. */ +export const REVIEW_RESULT_JSON_SCHEMA = z.toJSONSchema(ReviewResultSchema); + +/** Result from a reviewer stage with structured output support. */ +export interface StructuredReviewResult { + /** Parsed and filtered review from SDK structured output, or null if unavailable */ + structured: ReviewResult | null; + /** Raw text output for fallback parsing and debugger input */ + raw: string; +} + +/** + * Merge two parallel reviewer results into one. * - * Zero-dependency: no imports from the Atomic runtime. + * Two independent reviewers run the same prompt simultaneously. This function + * unions their findings and picks the more conservative overall_correctness. + * When either reviewer's structured output is unavailable, it falls back to + * text parsing ({@link parseReviewResult}) before merging. */ +export function mergeReviewResults( + a: StructuredReviewResult, + b: StructuredReviewResult, +): StructuredReviewResult { + const rawCombined = [a.raw, b.raw].filter(Boolean).join("\n\n---\n\n"); + + // Resolve: prefer structured output, fall back to text parsing + const parsedA = a.structured ?? (a.raw.trim() ? parseReviewResult(a.raw) : null); + const parsedB = b.structured ?? (b.raw.trim() ? parseReviewResult(b.raw) : null); + + if (!parsedA && !parsedB) { + return { structured: null, raw: rawCombined }; + } + + const findingsA = parsedA?.findings ?? []; + const findingsB = parsedB?.findings ?? []; + + const correctnessA = parsedA?.overall_correctness ?? "patch is correct"; + const correctnessB = parsedB?.overall_correctness ?? "patch is correct"; + const isIncorrect = + correctnessA === "patch is incorrect" || + correctnessB === "patch is incorrect"; + + const explanations = [ + parsedA?.overall_explanation, + parsedB?.overall_explanation, + ].filter(Boolean) as string[]; + + const confidences = [ + parsedA?.overall_confidence_score, + parsedB?.overall_confidence_score, + ].filter((c): c is number => c !== undefined); + + return { + structured: { + findings: [...findingsA, ...findingsB], + overall_correctness: isIncorrect ? "patch is incorrect" : "patch is correct", + overall_explanation: explanations.join(" | "), + overall_confidence_score: + confidences.length > 0 ? Math.max(...confidences) : undefined, + }, + raw: rawCombined, + }; +} // ============================================================================ // PLANNER @@ -239,6 +331,135 @@ Update task statuses **immediately** at every transition via TaskUpdate. - Mark previous tasks "completed" before marking new ones "in_progress".`; } +// ============================================================================ +// INFRASTRUCTURE DISCOVERY +// ============================================================================ + +/** Prompts for the three parallel infrastructure-discovery sub-agents. */ +export interface InfraDiscoveryPrompts { + /** Prompt for the codebase-locator sub-agent. */ + locator: string; + /** Prompt for the codebase-analyzer sub-agent. */ + analyzer: string; + /** Prompt for the codebase-pattern-finder sub-agent. */ + patternFinder: string; +} + +/** + * Build prompts for three parallel sub-agent stages that discover the + * repository's build, test, lint, and CI infrastructure. Each sub-agent + * explores the codebase dynamically — no hard-coded file lists or patterns. + * + * Inspired by the deep-research-codebase workflow which dispatches + * codebase-locator, codebase-analyzer, and codebase-pattern-finder + * sub-agents in parallel for exploratory research. + */ +export function buildInfraDiscoveryPrompts(): InfraDiscoveryPrompts { + return { + locator: `# Locate Build & Test Infrastructure Files + +Find ALL files in this repository that define or configure the build, test, +lint, type-check, and CI/CD infrastructure. Report their paths and a +one-line description of each. + +## What to look for + +- **Package manifest**: package.json, Cargo.toml, go.mod, pyproject.toml, etc. +- **Lockfiles**: bun.lockb, bun.lock, package-lock.json, yarn.lock, pnpm-lock.yaml, etc. +- **Build config**: tsconfig.json, webpack.config.*, vite.config.*, esbuild.*, rollup.config.*, Makefile, etc. +- **Test config**: jest.config.*, vitest.config.*, playwright.config.*, .mocharc.*, pytest.ini, etc. +- **Lint / format config**: .eslintrc.*, eslint.config.*, biome.json, .prettierrc.*, oxlint.json, etc. +- **CI/CD workflows**: .github/workflows/*.yml, .gitlab-ci.yml, Jenkinsfile, .circleci/config.yml, etc. +- **Agent config files**: CLAUDE.md, AGENTS.md, .claude/*, .github/copilot-instructions.md (these often document project commands) + +## Output format + +Respond with a flat list: + +\`\`\` + +\`\`\` + +Be exhaustive. Do NOT skip files just because they seem minor — CI configs +and agent instruction files often contain the authoritative command list. +End with a brief trailing summary (1-2 sentences) of what you found.`, + + analyzer: `# Analyze Build & Test Infrastructure + +Examine this repository's build, test, lint, and type-check infrastructure. +Your goal is to produce a concise reference that tells a reviewer exactly +which commands to run to verify an implementation. + +## Investigation steps + +1. Read the package manifest (package.json, Cargo.toml, go.mod, etc.) and + list every script/target related to building, testing, linting, + type-checking, or formatting. +2. Identify the package manager (bun, npm, yarn, pnpm, cargo, go, make) + from lockfiles or config. +3. Read CI workflow files (.github/workflows/*.yml, etc.) and extract the + key \`run:\` commands — these are the authoritative "what CI actually + executes" list. +4. Read CLAUDE.md / AGENTS.md if present — they often document the + canonical commands for contributors. +5. Identify the test framework(s) in use and how to invoke them. + +## Output format + +\`\`\` +## Package Manager + + +## Build Commands +- \`\` — + +## Test Commands +- \`\` — + +## Lint / Type-check Commands +- \`\` — + +## CI Commands (from workflow files) +- \`\` — +\`\`\` + +Be specific — include the exact invocation string (e.g. \`bun test\`, not +just "run tests"). If a command has variants (e.g. test:unit, test:e2e), +list each separately. End with a brief trailing summary.`, + + patternFinder: `# Find Build & Test Patterns + +Search this repository for existing patterns that show how code is built, +tested, and validated. A reviewer needs to know not just WHAT commands exist, +but HOW they are used in practice. + +## What to find + +1. **Test file patterns**: Where do tests live? What naming convention + (*.test.ts, *.spec.ts, *_test.go, etc.)? Show 2-3 example paths. +2. **Test execution patterns**: How are tests actually run? Find examples in + CI configs, scripts, or documentation. Note any environment variables or + flags that are standard (e.g. CLAUDECODE=1, --coverage). +3. **Build patterns**: How is the project built? Is there a multi-step build + (e.g. codegen → compile → bundle)? What order matters? +4. **Quality gate patterns**: What checks gate a merge? Look at CI workflows, + pre-commit hooks, and PR check configurations. List the commands in the + order CI runs them. +5. **Dependency install pattern**: How are dependencies installed before + build/test (e.g. \`bun install\`, \`npm ci\`)? + +## Output format + +For each pattern found, report: +- The pattern name +- The concrete command or file path +- A brief explanation of when/how it's used + +End with a brief trailing summary of the overall build/test workflow order +(e.g. "install → typecheck → lint → test → build").`, + }; +} + // ============================================================================ // REVIEWER // ============================================================================ @@ -264,52 +485,164 @@ export interface ReviewResult { } export interface ReviewContext { - /** Output of `git status -s` captured immediately before the review. */ - gitStatus: string; + /** + * Full branch changeset captured by {@link captureBranchChangeset}. + * Contains diff stat, name-status, and uncommitted changes relative to + * the parent branch — giving the reviewer complete visibility into every + * change this branch introduces. + */ + changeset: { + baseBranch: string; + diffStat: string; + uncommitted: string; + nameStatus: string; + errors: string[]; + }; /** 1-indexed loop iteration, used in the prompt header. */ iteration?: number; /** - * Whether this is the second consecutive review pass within the same loop - * iteration (i.e. the previous pass had zero findings and we are - * confirming before counting two clean reviews in a row). + * When true, instructs the reviewer to call the `submit_review` tool + * instead of outputting JSON directly. Used by the Copilot SDK which + * achieves structured output through tool definitions. */ - isConfirmationPass?: boolean; + useSubmitTool?: boolean; + /** + * Raw output from the parallel infrastructure-discovery sub-agents + * (codebase-locator, codebase-analyzer, codebase-pattern-finder). + * When present, the reviewer uses this to identify and run the + * repository's build/test/lint commands as part of verification. + */ + discoveryContext?: string; } /** - * Build the reviewer prompt. Injects deterministic `git status -s` so the - * reviewer doesn't have to re-discover what changed. + * Build the reviewer prompt. Injects a deterministic branch-relative + * changeset so the reviewer sees every file this branch has touched — + * both committed and uncommitted — without expensive tool calls. */ export function buildReviewPrompt( spec: string, context: ReviewContext, ): string { - const gitStatus = context.gitStatus.trim(); - const gitSection = - gitStatus.length > 0 - ? `## Working Tree (\`git status -s\`) + const { changeset } = context; + const hasChanges = + changeset.diffStat.length > 0 || + changeset.uncommitted.length > 0; + const hasErrors = changeset.errors.length > 0; -These files have uncommitted changes — they are the files actually touched in -this iteration. Use them to focus your review: + // ── Changeset section ────────────────────────────────────────────────── -\`\`\` -${gitStatus} -\`\`\`` - : `## Working Tree (\`git status -s\`) + let changesetSection: string; -The working tree is clean. Either nothing was implemented this iteration or -all changes were already committed. Cross-check the task list to verify -whether the implementation actually ran.`; + if (hasChanges || hasErrors) { + const parts: string[] = []; + + parts.push( + `## Branch Changeset (relative to \`${changeset.baseBranch}\`)`, + ); + + // Surface git errors first — the agent needs to know the data is partial + if (hasErrors) { + parts.push( + "", + "### Git Errors", + "", + "The following git commands failed during changeset capture. The data", + "below may be **incomplete**. You should re-run the failed commands", + "yourself to get the full picture, or flag the gap as a finding.", + "", + ...changeset.errors.map((e) => `- ${e}`), + ); + } + + if (hasChanges) { + parts.push( + "", + "The following shows every change this branch introduces — both committed", + "and uncommitted. Use this to scope your review. Read the actual file", + "contents for any file that warrants closer inspection.", + ); + } + + if (changeset.nameStatus.length > 0) { + parts.push( + "", + "### Changed Files", + "", + "```", + changeset.nameStatus, + "```", + ); + } + + if (changeset.diffStat.length > 0) { + parts.push( + "", + "### Diff Summary", + "", + "```", + changeset.diffStat, + "```", + ); + } + + if (changeset.uncommitted.length > 0) { + parts.push( + "", + "### Uncommitted Changes (`git status -s`)", + "", + "These changes are in the working tree but not yet committed:", + "", + "```", + changeset.uncommitted, + "```", + ); + } + + changesetSection = parts.join("\n"); + } else { + changesetSection = `## Branch Changeset (relative to \`${changeset.baseBranch}\`) + +No changes detected relative to \`${changeset.baseBranch}\`. Either nothing +was implemented, all changes were reverted, or you are already on the base +branch. Cross-check the task list to verify whether the implementation ran.`; + } + + // ── Header ───────────────────────────────────────────────────────────── const header = context.iteration - ? `# Code Review Request (Iteration ${context.iteration}${context.isConfirmationPass ? ", confirmation pass" : ""})` + ? `# Code Review Request (Iteration ${context.iteration})` : "# Code Review Request"; - const confirmationNote = context.isConfirmationPass - ? `\n\n**Note**: This is a confirmation pass. The previous review of this same iteration produced zero findings. Re-verify with fresh eyes; do not assume the prior pass was correct.` + // ── Output instructions ──────────────────────────────────────────────── + + const outputSection = context.useSubmitTool + ? `## Output + +You MUST submit your review by calling the \`submit_review\` tool exactly +once with your complete structured review. Do NOT output the review as +plain text — the tool enforces the required schema.` + : `## Output + +Your review output is captured via structured output. The schema is enforced +by the SDK — focus on providing accurate, well-reasoned data for each field.`; + + // ── Discovery context section ──────────────────────────────────────────── + + const discoverySection = context.discoveryContext + ? `## Build & Test Infrastructure Discovery + +Three sub-agents explored this repository's build, test, lint, and CI +infrastructure. Their findings are below. Use them to identify the exact +commands you must run to verify the implementation. + +${context.discoveryContext} +` : ""; - return `${header}${confirmationNote} + // ── Full prompt ──────────────────────────────────────────────────────── + + return `${header} ## Original Specification @@ -317,7 +650,15 @@ whether the implementation actually ran.`; ${spec} -${gitSection} +${changesetSection} + +${discoverySection}## Project Conventions + +Use the repository's \`AGENTS.md\` and/or \`CLAUDE.md\` files (if present) for +guidance on style, conventions, testing expectations, and architectural +patterns. Your review should respect these project-level norms — flag +deviations only when they conflict with correctness or security, not personal +preference. ## Retrieve Task List @@ -326,46 +667,62 @@ Call \`TaskList\` to fetch the current task plan and statuses. Use it to: 2. Cross-reference the plan against the specification. 3. Calculate completion metrics. +## Verification Step + +**Before writing any findings**, run the build, test, lint, and type-check +commands identified in the "Build & Test Infrastructure Discovery" section +above. Execute them via Bash from the repository root. Run ALL commands even +if earlier ones fail — the goal is a complete picture. + +- Build failures and type errors → P0 finding. +- Test failures → P1 finding. +- Lint violations → P1 finding. + +Include the exact command, exit status, and relevant error output in each +finding's body. If no discovery section is present, attempt to discover +commands yourself by reading package.json, CI configs, or CLAUDE.md. + ## Review Focus Areas (priority order) 1. **Task Completion & Specification Gap Analysis** — HIGHEST priority. Every task in PENDING / IN_PROGRESS / ERROR status MUST become a P0 finding. Every spec requirement not covered by any task is a P0 finding. Do NOT mark the patch correct if any task is incomplete. -2. **Correctness of Logic** — does the code implement the requirements? -3. **Error Handling & Edge Cases** — boundary, empty/null, error paths. -4. **Security** — injection, secret leakage, auth bypasses. -5. **Performance** — obvious resource leaks, N+1, hot loops. -6. **Test Coverage** — critical paths and edge cases tested. - -## Output Format - -Output ONLY a JSON object inside a single fenced \`\`\`json block. No prose -before or after. Use this schema exactly: - -\`\`\`json -{ - "findings": [ - { - "title": "[P0] Brief title (P0=critical, P1=important, P2=moderate, P3=minor)", - "body": "Detailed explanation, why it matters, and a suggested fix", - "confidence_score": 0.95, - "priority": 0, - "code_location": { - "absolute_file_path": "/full/path/to/file.ts", - "line_range": { "start": 42, "end": 45 } - } - } - ], - "overall_correctness": "patch is correct", - "overall_explanation": "Summary of overall quality and correctness", - "overall_confidence_score": 0.85 -} -\`\`\` - -Set \`overall_correctness\` to \`"patch is incorrect"\` whenever there is at -least one P0 or P1 finding (including incomplete tasks). Use -\`"patch is correct"\` only when findings are empty or strictly P3. +2. **Verification Failures** — Any build, test, lint, or type-check command + that failed during the verification step above is a P0 or P1 finding. + Reference the specific command and error output. +3. **Correctness of Logic** — does the code implement the requirements? +4. **Error Handling & Edge Cases** — boundary, empty/null, error paths. +5. **Security** — injection, secret leakage, auth bypasses. +6. **Performance** — obvious resource leaks, N+1, hot loops. +7. **Test Coverage** — critical paths and edge cases tested. + +## Review Guidelines + +- Be **constructive and helpful** in your feedback. Every finding should + include a clear explanation of the impact and a concrete suggested fix. +- Avoid nitpicks (P3) unless they affect readability or maintainability in + a significant way. The review loop filters out P3 findings. +- When in doubt, give the implementation the benefit of the doubt — flag + genuine issues, not stylistic preferences. + +${outputSection} + +### Field Guidance + +- **findings**: Each finding should have: + - \`title\`: Prefix with priority level, e.g. "[P0] Missing null check" + - \`body\`: What's wrong, why it matters, and how to fix it + - \`priority\`: 0 = P0 critical, 1 = P1 important, 2 = P2 moderate, 3 = P3 minor + - \`confidence_score\`: 0.0 – 1.0, how confident you are this is a real issue + - \`code_location\`: absolute file path and line range (when applicable) + +- **overall_correctness**: Set to \`"patch is incorrect"\` whenever there is at + least one P0 or P1 finding (including incomplete tasks). Use + \`"patch is correct"\` only when findings are empty or strictly P2/P3. + +- **overall_explanation**: Summary of overall quality, correctness, and any + patterns observed. Begin your review now.`; } @@ -377,8 +734,17 @@ Begin your review now.`; export interface DebuggerContext { /** 1-indexed loop iteration the debugger is investigating. */ iteration: number; - /** Output of `git status -s` from immediately before the review. */ - gitStatus: string; + /** + * Branch changeset captured immediately before the review. Provides the + * debugger with the same file-level context as the reviewer. + */ + changeset: { + baseBranch: string; + diffStat: string; + uncommitted: string; + nameStatus: string; + errors: string[]; + }; } /** @@ -420,13 +786,31 @@ ${trimmed} : `(no reviewer output captured)`; } - const gitStatus = context.gitStatus.trim(); - const gitSection = - gitStatus.length > 0 - ? `\`\`\` -${gitStatus} -\`\`\`` - : `(working tree clean)`; + const { changeset } = context; + const hasChanges = + changeset.nameStatus.length > 0 || changeset.uncommitted.length > 0; + const hasErrors = changeset.errors.length > 0; + + let changesetSection: string; + if (hasChanges || hasErrors) { + const parts: string[] = []; + if (hasErrors) { + parts.push( + "**Git errors** (changeset may be incomplete — re-run these yourself):", + ...changeset.errors.map((e) => `- ${e}`), + "", + ); + } + if (changeset.nameStatus.length > 0) { + parts.push(`Changed files (relative to \`${changeset.baseBranch}\`):`, "```", changeset.nameStatus, "```"); + } + if (changeset.uncommitted.length > 0) { + parts.push(`Uncommitted (\`git status -s\`):`, "```", changeset.uncommitted, "```"); + } + changesetSection = parts.join("\n"); + } else { + changesetSection = "(no changes detected)"; + } return `# Debugging Report Request (Iteration ${context.iteration}) @@ -442,9 +826,9 @@ read-only mode) are fine; mutations are not. ${findingsSection} -## Working Tree (\`git status -s\`) +## Branch Changeset -${gitSection} +${changesetSection} ## Investigation Steps @@ -544,7 +928,7 @@ export function parseReviewResult(content: string): ReviewResult | null { return null; } -function filterActionable(parsed: { +export function filterActionable(parsed: { findings: ReviewFinding[]; overall_correctness: string; overall_explanation?: string; diff --git a/src/sdk/workflows/builtin/ralph/opencode/index.ts b/src/sdk/workflows/builtin/ralph/opencode/index.ts index bedbff724..cbafdce78 100644 --- a/src/sdk/workflows/builtin/ralph/opencode/index.ts +++ b/src/sdk/workflows/builtin/ralph/opencode/index.ts @@ -5,7 +5,11 @@ * so users can see each iteration's progress in real time. The loop * terminates when: * - {@link MAX_LOOPS} iterations have completed, OR - * - Two consecutive reviewer passes return zero findings. + * - Two parallel reviewer passes both return zero findings. + * + * The reviewer stages use the OpenCode SDK's structured output + * (`format: { type: "json_schema" }`) to guarantee the review result + * matches the {@link ReviewResultSchema}. * * Run: atomic workflow -n ralph -a opencode "" */ @@ -15,16 +19,20 @@ import { defineWorkflow } from "../../../index.ts"; import { buildPlannerPrompt, buildOrchestratorPrompt, + buildInfraDiscoveryPrompts, buildReviewPrompt, buildDebuggerReportPrompt, - parseReviewResult, extractMarkdownBlock, + filterActionable, + mergeReviewResults, + REVIEW_RESULT_JSON_SCHEMA, + type ReviewResult, + type StructuredReviewResult, } from "../helpers/prompts.ts"; import { hasActionableFindings } from "../helpers/review.ts"; -import { safeGitStatusS } from "../helpers/git.ts"; +import { captureBranchChangeset } from "../helpers/git.ts"; const MAX_LOOPS = 10; -const CONSECUTIVE_CLEAN_THRESHOLD = 2; /** Concatenate the text-typed parts of an OpenCode response. */ function extractResponseText( @@ -36,24 +44,40 @@ function extractResponseText( .join("\n"); } +/** + * Extract a {@link StructuredReviewResult} from an OpenCode prompt response. + * Prefers the SDK's structured_output field; falls back to text extraction. + */ +function extractReview( + data: { info?: Record; parts: Array<{ type: string; [key: string]: unknown }> }, +): StructuredReviewResult { + const raw = extractResponseText(data.parts); + + // The SDK places validated structured output at data.info.structured_output + const structuredOutput = data.info?.structured_output; + if (structuredOutput && typeof structuredOutput === "object") { + return { + structured: filterActionable(structuredOutput as ReviewResult), + raw, + }; + } + + return { structured: null, raw }; +} + export default defineWorkflow<"opencode">({ name: "ralph", description: "Plan → orchestrate → review → debug loop with bounded iteration", }) .run(async (ctx) => { - // Free-form workflows receive their positional prompt under - // `inputs.prompt`; destructure once so every stage below can close - // over a bare `prompt` string without re-reaching into ctx.inputs. const prompt = ctx.inputs.prompt ?? ""; - let consecutiveClean = 0; let debuggerReport = ""; for (let iteration = 1; iteration <= MAX_LOOPS; iteration++) { // ── Plan ──────────────────────────────────────────────────────────── - const plannerName = `planner-${iteration}`; const planner = await ctx.stage( - { name: plannerName }, + { name: `planner-${iteration}` }, {}, { title: `planner-${iteration}` }, async (s) => { @@ -75,11 +99,9 @@ export default defineWorkflow<"opencode">({ }, ); - // ── Orchestrate ───────────────────────────────────────────────────── - const orchName = `orchestrator-${iteration}`; await ctx.stage( - { name: orchName }, + { name: `orchestrator-${iteration}` }, {}, { title: `orchestrator-${iteration}` }, async (s) => { @@ -99,87 +121,109 @@ export default defineWorkflow<"opencode">({ }, ); + // ── Infrastructure Discovery (three parallel sub-agent stages) ──── + const changeset = await captureBranchChangeset(); + const discoveryPrompts = buildInfraDiscoveryPrompts(); - // ── Review (first pass) ───────────────────────────────────────────── - let gitStatus = await safeGitStatusS(); - const reviewerName = `reviewer-${iteration}`; - const review = await ctx.stage( - { name: reviewerName }, - {}, - { title: `reviewer-${iteration}` }, - async (s) => { - const result = await s.client.session.prompt({ - sessionID: s.session.id, - parts: [ - { - type: "text", - text: buildReviewPrompt(prompt, { - gitStatus, - iteration, - }), - }, - ], - agent: "reviewer", - }); - s.save(result.data!); - return extractResponseText(result.data!.parts); - }, - ); - - - let reviewRaw = review.result; - let parsed = parseReviewResult(reviewRaw); - - if (!hasActionableFindings(parsed, reviewRaw)) { - consecutiveClean += 1; - if (consecutiveClean >= CONSECUTIVE_CLEAN_THRESHOLD) break; - - // Confirmation pass — re-run reviewer only - gitStatus = await safeGitStatusS(); - const confirmName = `reviewer-${iteration}-confirm`; - const confirm = await ctx.stage( - { name: confirmName }, + const [locatorResult, analyzerResult, patternResult] = await Promise.all([ + ctx.stage( + { name: `infra-locate-${iteration}` }, {}, - { title: `reviewer-${iteration}-confirm` }, + { title: `infra-locate-${iteration}` }, async (s) => { const result = await s.client.session.prompt({ sessionID: s.session.id, - parts: [ - { - type: "text", - text: buildReviewPrompt(prompt, { - gitStatus, - iteration, - isConfirmationPass: true, - }), - }, - ], - agent: "reviewer", + parts: [{ type: "text", text: discoveryPrompts.locator }], + agent: "codebase-locator", + }); + s.save(result.data!); + return extractResponseText(result.data!.parts); + }, + ), + ctx.stage( + { name: `infra-analyze-${iteration}` }, + {}, + { title: `infra-analyze-${iteration}` }, + async (s) => { + const result = await s.client.session.prompt({ + sessionID: s.session.id, + parts: [{ type: "text", text: discoveryPrompts.analyzer }], + agent: "codebase-analyzer", + }); + s.save(result.data!); + return extractResponseText(result.data!.parts); + }, + ), + ctx.stage( + { name: `infra-patterns-${iteration}` }, + {}, + { title: `infra-patterns-${iteration}` }, + async (s) => { + const result = await s.client.session.prompt({ + sessionID: s.session.id, + parts: [{ type: "text", text: discoveryPrompts.patternFinder }], + agent: "codebase-pattern-finder", }); s.save(result.data!); return extractResponseText(result.data!.parts); }, + ), + ]); + + const discoveryContext = [ + "### Infrastructure Files (codebase-locator)\n\n" + locatorResult.result, + "### Infrastructure Analysis (codebase-analyzer)\n\n" + analyzerResult.result, + "### Build & Test Patterns (codebase-pattern-finder)\n\n" + patternResult.result, + ].join("\n\n---\n\n"); + + // ── Review (two parallel passes) ──────────────────────────────────── + const reviewPrompt = buildReviewPrompt(prompt, { + changeset, + iteration, + discoveryContext, + }); + + const reviewStage = async (name: string) => + ctx.stage( + { name }, + {}, + { title: name }, + async (s) => { + const result = await s.client.session.prompt({ + sessionID: s.session.id, + parts: [{ type: "text", text: reviewPrompt }], + agent: "reviewer", + format: { + type: "json_schema" as const, + schema: REVIEW_RESULT_JSON_SCHEMA, + }, + }); + s.save(result.data!); + return extractReview( + result.data! as { + info?: Record; + parts: Array<{ type: string; [key: string]: unknown }>; + }, + ); + }, ); + const [reviewA, reviewB] = await Promise.all([ + reviewStage(`reviewer-${iteration}-a`), + reviewStage(`reviewer-${iteration}-b`), + ]); - reviewRaw = confirm.result; - parsed = parseReviewResult(reviewRaw); + const merged = mergeReviewResults(reviewA.result, reviewB.result); + const parsed = merged.structured; + const reviewRaw = merged.raw; - if (!hasActionableFindings(parsed, reviewRaw)) { - consecutiveClean += 1; - if (consecutiveClean >= CONSECUTIVE_CLEAN_THRESHOLD) break; - } else { - consecutiveClean = 0; - } - } else { - consecutiveClean = 0; - } + // Both reviewers agree the code is clean → done + if (!hasActionableFindings(parsed, reviewRaw)) break; - // ── Debug (only if findings remain AND another iteration is allowed) ─ - if (hasActionableFindings(parsed, reviewRaw) && iteration < MAX_LOOPS) { - const debuggerName = `debugger-${iteration}`; + // ── Debug (only if another iteration is allowed) ──────────────────── + if (iteration < MAX_LOOPS) { const debugger_ = await ctx.stage( - { name: debuggerName }, + { name: `debugger-${iteration}` }, {}, { title: `debugger-${iteration}` }, async (s) => { @@ -190,7 +234,7 @@ export default defineWorkflow<"opencode">({ type: "text", text: buildDebuggerReportPrompt(parsed, reviewRaw, { iteration, - gitStatus, + changeset, }), }, ],