/**
* 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(" ");
}