/** * Build the review directive injected into the main agent (hidden, via * `sendMessage` with `display:false` + `triggerTurn:true` — see index.ts). * * v0.5.2: shared diff + file list + change-kind; exactly one fan-out + one gate; * lean pi-review.* agents; turnBudget default 20; reviewer thinking inherits. */ import { join } from "node:path"; import { FALSE_POSITIVE_GUIDANCE, LEAN_GATE_AGENT, leanAgentName, resolveLeanBudgets, toolBudgetForReviewer, withThinkingSuffix, type LeanBudgetSpec, } from "./lean-agents.js"; import { buildObtainDiffScript, DIFF_META_REL } from "./obtain-diff.js"; import type { ReviewerSpec, ReviewTarget } from "./types.js"; export interface ReviewDirectiveInput { target: ReviewTarget; reviewers: ReviewerSpec[]; /** Resolved gate model id (from config.gate.model or --gate-model). */ gateModel: string; /** Optional gate thinking from config (appended as model:thinking). */ gateThinking?: string; threshold: number; lite: boolean; cwd: string; /** Optional turnBudget override from config.budgets. */ budgets?: LeanBudgetSpec; } export const DIFF_REL_PATH = join(".pi", "pi-review", "change.diff"); export const FILES_REL_PATH = join(".pi", "pi-review", "changed-files.txt"); export const KIND_REL_PATH = join(".pi", "pi-review", "change-kind.txt"); export { DIFF_META_REL }; export function diffFilePath(cwd: string): string { return join(cwd, DIFF_REL_PATH); } export function filesListPath(cwd: string): string { return join(cwd, FILES_REL_PATH); } export function kindFilePath(cwd: string): string { return join(cwd, KIND_REL_PATH); } export function metaFilePath(cwd: string): string { return join(cwd, DIFF_META_REL); } export function buildReviewDirective(input: ReviewDirectiveInput): string { const { target, reviewers, gateModel, gateThinking, threshold, lite, cwd } = input; const budgets = input.budgets ?? resolveLeanBudgets(); const diffPath = diffFilePath(cwd); const filesPath = filesListPath(cwd); const kindPath = kindFilePath(cwd); const concurrency = Math.min(reviewers.length, 4); const gateModelWithThinking = withThinkingSuffix(gateModel, gateThinking); const blocks: string[] = []; blocks.push("# Code review (token-lean)"); blocks.push(""); if (target.userContext?.trim()) { blocks.push(`**User request:** ${target.userContext.trim()}`); blocks.push(""); } blocks.push( `Review the change (${target.label}). Obtain the diff once, fan out ${reviewers.length} lean reviewer${reviewers.length === 1 ? "" : "s"}${lite ? " (lite)" : ""}${lite ? "" : ", run one gate pass"}, then write the report.`, ); blocks.push(""); blocks.push("## Hard rules (do not violate)"); blocks.push(""); blocks.push( `- Call \`subagent\` **at most ${lite ? "1" : "2"} time(s)** in this whole review: **exactly one** Step 2 fan-out${lite ? "" : " and **exactly one** Step 3 gate"}.`, ); blocks.push( "- Step 2 must be a **single** `subagent({ tasks: [...] })` with **all** reviewers — never one call per reviewer, never serial waves.", ); blocks.push( "- **Do not retry** or re-spawn if a reviewer times out, hits turnBudget, returns partial output, or fails — mark it failed in the report and continue.", ); blocks.push( "- **Do not** call `subagent` for obtaining the diff, verification, re-review, or rewriting the report.", ); blocks.push( "- Use the exact `pi-review.*` agents below — do not substitute builtin `reviewer`. Keep `turnBudget` / `toolBudget` / `reads: false` / `outputMode: \"file-only\"` / `acceptance: false`.", ); blocks.push("- Reviewer models **inherit** the parent session (omit per-task `model` unless the reviewer config sets an explicit model)."); blocks.push(""); blocks.push(`**Skip these false positives:** ${FALSE_POSITIVE_GUIDANCE}.`); blocks.push(""); blocks.push( "First, post the workflow as a markdown checklist into chat, then work through it — flip each `- [ ]` to `- [x]` as you finish.", ); blocks.push(""); const todoSteps = lite ? [ `Obtain diff + file list → ${DIFF_REL_PATH} (write only)`, "Fan out a single lite-reviewer (one subagent call)", "Write the report from output file(s)", ] : [ `Obtain diff + file list → ${DIFF_REL_PATH} (write only)`, `Fan out ${reviewers.length} lean reviewers (one subagent call)`, "Run the gate pass (one subagent call)", "Write the report from output files", ]; for (const s of todoSteps) blocks.push(`- [ ] ${s}`); blocks.push(""); // Step 1 blocks.push("## Step 1 — Obtain the change (you, the main agent)"); blocks.push(""); blocks.push( `Create \`${join(cwd, ".pi", "pi-review")}\`, write the diff (+ file list + change-kind + diff-meta). **Do not read, cat, or summarize the diff body.**`, ); blocks.push(""); blocks.push( "**Accuracy:** for a clean tree, **fetch the remote default branch first** and compare against `origin/` (not a stale local `main`/`master`). For PRs, prefer `gh pr diff`; if that fails, fetch `pull//head` + base and three-dot. Write `diff-meta.txt` so the base/head SHAs are auditable.", ); blocks.push(""); blocks.push("```bash"); blocks.push( buildObtainDiffScript({ cwd, diffPath, filesPath, kindPath, metaPath: metaFilePath(cwd), prRef: target.kind === "pr" && target.prRef ? target.prRef : undefined, }), ); blocks.push("```"); blocks.push(""); // Step 2 blocks.push("## Step 2 — Fan out lean reviewers (exactly one subagent call)"); blocks.push(""); blocks.push("```js"); blocks.push("subagent({"); blocks.push(" tasks: ["); for (const r of reviewers) { const agent = leanAgentName(r.id); const tb = toolBudgetForReviewer(r.id); const task = buildReviewerTask(r.id, diffPath, filesPath, kindPath, target.userContext); const modelLine = r.model && r.model !== "inherit" ? `\n model: ${JSON.stringify(r.model)},` : ""; blocks.push(" {"); blocks.push(` agent: ${JSON.stringify(agent)},`); blocks.push(` task: ${JSON.stringify(task)},`); blocks.push(` output: ${JSON.stringify(r.id)},`); blocks.push(` outputMode: "file-only",`); blocks.push(` reads: false,`); blocks.push(` acceptance: false,`); blocks.push(` toolBudget: { soft: ${tb.soft}, hard: ${tb.hard} },${modelLine}`); blocks.push(" },"); } blocks.push(" ],"); blocks.push(` concurrency: ${concurrency},`); blocks.push( ` turnBudget: { maxTurns: ${budgets.turnBudget.maxTurns}, graceTurns: ${budgets.turnBudget.graceTurns} },`, ); blocks.push(` timeoutMs: ${budgets.timeoutMs},`); blocks.push(` context: "fresh",`); blocks.push("})"); blocks.push("```"); blocks.push(""); if (!lite) { blocks.push("## Step 3 — Gate (exactly one subagent call)"); blocks.push(""); blocks.push( "Pass **file paths** from the previous file-only results only. Do not paste full transcripts. Do not retry Step 2.", ); blocks.push(""); const gateTask = [ `Synthesize reviewer findings for change ${target.label}.`, `Threshold: ${threshold} (drop issues with confidence < ${threshold}).`, `Read each reviewer output file path from the previous subagent call (file-only).`, `Dedupe by (file, line, category), re-score 1-10, return surviving issues + verdict.`, `Skip false positives: ${FALSE_POSITIVE_GUIDANCE}.`, ].join(" "); blocks.push("```js"); blocks.push("subagent({"); blocks.push(` agent: ${JSON.stringify(LEAN_GATE_AGENT)},`); blocks.push(` task: ${JSON.stringify(gateTask)},`); blocks.push(` model: ${JSON.stringify(gateModelWithThinking)},`); blocks.push(` output: "gate",`); blocks.push(` outputMode: "file-only",`); blocks.push(` reads: false,`); blocks.push(` acceptance: false,`); blocks.push( ` toolBudget: { soft: ${budgets.gateToolBudget.soft}, hard: ${budgets.gateToolBudget.hard} },`, ); blocks.push( ` turnBudget: { maxTurns: ${budgets.gateTurnBudget.maxTurns}, graceTurns: ${budgets.gateTurnBudget.graceTurns} },`, ); blocks.push(` timeoutMs: ${Math.min(budgets.timeoutMs, 300_000)},`); blocks.push(` context: "fresh",`); blocks.push("})"); blocks.push("```"); blocks.push(""); } const reportStep = lite ? 3 : 4; blocks.push(`## Step ${reportStep} — Report`); blocks.push(""); blocks.push( "Read only the file-only output path(s). **Do not re-read the full diff.** Write markdown into chat:", ); blocks.push(""); blocks.push("- **Verdict**: `request_changes` if any blocker OR ≥3 major; `approve` if no blocker and no major; otherwise `comment`."); blocks.push("- Group findings by reviewer; format `[SEVERITY · category · conf N] file:line — evidence`."); blocks.push( lite ? "- Lite mode skips the gate — apply the verdict rule directly." : "- Short gate summary: verdict, reason, surviving issue count.", ); blocks.push("- Cite `file:line`. Skip pre-existing issues, nitpicks, and CI/linter noise."); blocks.push(""); return blocks.join("\n"); } function buildReviewerTask( id: string, diffPath: string, filesPath: string, kindPath: string, userContext?: string, ): string { const parts = [ `Read ${diffPath} as the change (only diff source — do not re-fetch via gh/git for the patch itself).`, `Also read ${filesPath} (changed paths) and ${kindPath} (docs|code).`, "Follow your system instructions. Stay within budgets; write JSON to your assigned output path and stop.", "Do not read plan.md, progress.md, .pi-subagents transcripts, or node_modules.", "Prefer Read/Grep. If you use bash, only simple allowlisted commands (no &&/||/; compounds).", ]; if (id === "bugbot" || id === "security-review") { parts.push( "If change-kind is docs: return empty issues after skimming the diff — no per-file reads. Otherwise prefer diff-only; at most 3 extra file reads; optional git show/log/blame only when a symbol needs clarification.", ); } if (id === "history-context") { parts.push( "Take ≤5 paths from the file list. Run ONE bash: git log -n 5 --oneline -- ... (multiple paths, one command). Optional git blame -L on one suspicious hunk. No per-file separate bash turns.", ); } if (id === "claude-md-compliance") { parts.push( "Only audit written project rules (AGENTS.md / CLAUDE.md / .pi rules). If none exist, empty issues.", ); } if (userContext?.trim()) { parts.push(`User request: ${userContext.trim()}`); } return parts.join(" "); }