/**
* Build the review directive injected into the main agent (hidden, via
* `sendMessage` with `display:false` + `triggerTurn:true` — see index.ts).
*
* v0.6.0: pi-subagents ≥0.41 removed top-level `subagent({ tasks: [...] })`;
* fan-out now runs through one `subagent({ workflowScript, async:false })`.
* The script fans out lean reviewers via `runs.all([...])` (each child carries
* its own toolBudget/turnBudget), then feeds inlined reviewer JSON to the gate
* via `runs.run("gate", ...)`. Reviewers return JSON as their final reply.
*/
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 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, run one workflowScript (fan out ${reviewers.length} lean reviewer${reviewers.length === 1 ? "" : "s"}${lite ? " (lite)" : ""}${lite ? "" : " + inline gate"}), then write the report.`,
);
blocks.push("");
blocks.push("## Hard rules (do not violate)");
blocks.push("");
blocks.push(
"- Call `subagent` **exactly one** time in this whole review: the Step 2 workflowScript call.",
);
blocks.push(
lite
? "- Step 2 must be a **single** `subagent({ workflowScript, async:false, ... })` that fans out the lite-reviewer via `runs.all([...])` — never more than one call."
: "- Step 2 must be a **single** `subagent({ workflowScript, async:false, ... })` that fans out **all** reviewers via `runs.all([...])` and runs the inline gate via `runs.run(\"gate\", ...)` — never one call per reviewer, never serial waves.",
);
blocks.push(
"- **Do not retry** or re-spawn if a reviewer times out, hits its turnBudget, returns partial output, or fails — `runs.all` collects failures as `{ ok:false }`; the script continues and you mark failures in the report.",
);
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 per-child `toolBudget` / `turnBudget` and the top-level `async:false` / `context:\"fresh\"` / `timeoutMs`.",
);
blocks.push("- Reviewer models **inherit** the parent session (omit per-child `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 = [
`Obtain diff + file list → ${DIFF_REL_PATH} (write only)`,
lite
? "Run one workflowScript: the lite-reviewer (one subagent call)"
: `Run one workflowScript: ${reviewers.length} parallel reviewers + inline gate (one subagent call)`,
"Write the report from the workflow return value",
];
for (const s of todoSteps) blocks.push(`- [ ] ${s}`);
blocks.push("");
// Step 1 — unchanged: obtain the diff.
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 — single workflowScript call (fan-out + inline gate).
const script = buildWorkflowScript({
reviewers,
diffPath,
filesPath,
kindPath,
userContext: target.userContext,
target,
threshold,
gateModelWithThinking,
budgets,
lite,
});
blocks.push("## Step 2 — Run the review (exactly one subagent workflowScript call)");
blocks.push("");
blocks.push(
lite
? "The script fans out the single lite-reviewer. Reviewers return JSON as their final reply; the script captures it."
: "The script fans out the lean reviewers in parallel, then feeds their inlined JSON findings to the gate. Reviewers return JSON as their final reply; the script captures each `result.output`.",
);
blocks.push("");
blocks.push("```js");
blocks.push("subagent({");
blocks.push(` workflowScript: ${JSON.stringify(script)},`);
blocks.push(` async: false,`);
blocks.push(` context: "fresh",`);
blocks.push(` timeoutMs: ${budgets.timeoutMs},`);
blocks.push(` chatProgress: "milestones",`);
blocks.push("})");
blocks.push("```");
blocks.push("");
blocks.push(
"The return value is a JSON object: `{ reviewers: [{ key, ok, output }], gate: { ok, output } | null }`. Each `output` is the child's final reply text (JSON) — parse it to write the report. Mark any `ok:false` reviewer as failed.",
);
blocks.push("");
// Step 3 — Report.
blocks.push("## Step 3 — Report");
blocks.push("");
blocks.push(
"Read the **workflow return value** from Step 2 (the `Return:` object). **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("- For any `ok:false` child, list it as failed with the error from `output`.");
blocks.push("");
return blocks.join("\n");
}
/**
* Build the inline workflowScript string. Runs all reviewers in parallel via
* `runs.all([...])` (each child carries its own toolBudget/turnBudget), then —
* unless lite — feeds inlined reviewer JSON to the gate via `runs.run("gate")`.
*
* Task strings are injected as JSON-stringified JS string literals at the head
* of the script (`const TASK_ = "...";`). JSON string literals are valid
* JS string literals, so quoting/escaping is always correct regardless of the
* task text content.
*/
export function buildWorkflowScript(input: {
reviewers: ReviewerSpec[];
diffPath: string;
filesPath: string;
kindPath: string;
userContext?: string;
target: ReviewTarget;
threshold: number;
gateModelWithThinking: string;
budgets: LeanBudgetSpec;
lite: boolean;
}): string {
const { reviewers, diffPath, filesPath, kindPath, userContext, target, threshold, gateModelWithThinking, budgets, lite } = input;
// Stable identifier for a reviewer id (e.g. "history-context" → "history_context").
const ident = (id: string): string => id.replace(/[^A-Za-z0-9_]/g, "_");
const lines: string[] = [];
// Pre-declare each reviewer task as a JSON-stringified JS string literal.
for (const r of reviewers) {
const task = buildReviewerTask(r.id, diffPath, filesPath, kindPath, userContext);
lines.push(`const TASK_${ident(r.id)} = ${JSON.stringify(task)};`);
}
// Parallel reviewers — each child carries its own toolBudget/turnBudget.
lines.push("const reviews = await runs.all([");
for (const r of reviewers) {
const tb = toolBudgetForReviewer(r.id);
const modelLine =
r.model && r.model !== "inherit" ? `\n model: ${JSON.stringify(r.model)},` : "";
lines.push(" {");
lines.push(` key: ${JSON.stringify(r.id)},`);
lines.push(` agent: ${JSON.stringify(leanAgentName(r.id))},`);
lines.push(` task: TASK_${ident(r.id)},`);
lines.push(` toolBudget: { soft: ${tb.soft}, hard: ${tb.hard} },`);
lines.push(` turnBudget: { maxTurns: ${budgets.turnBudget.maxTurns}, graceTurns: ${budgets.turnBudget.graceTurns} },${modelLine}`);
lines.push(" },");
}
lines.push("]);");
lines.push("");
// Inline gate (skipped in lite mode).
if (!lite) {
const gateTask = buildGateTask(target.label, threshold);
lines.push(`const TASK_gate = ${JSON.stringify(gateTask)};`);
// Build the inlined reviewer-findings block from each child result.
// NOTE: this line is a plain single-quoted string so the inner `${...}`
// reaches the workflow script as the script's own template-literal syntax
// (a directive.ts template literal here would interpolate it prematurely).
lines.push(
'const gateInput = reviews.map(r => `## ${r.key} (${r.ok ? "ok" : "FAILED"})\\n${r.ok ? r.output : (r.error ?? r.output)}`).join("\\n\\n");',
);
lines.push('const gate = await runs.run("gate", {');
lines.push(` agent: ${JSON.stringify(LEAN_GATE_AGENT)},`);
lines.push(' task: TASK_gate + "\\n\\n## Reviewer findings (inline)\\n" + gateInput,');
lines.push(` model: ${JSON.stringify(gateModelWithThinking)},`);
lines.push(` toolBudget: { soft: ${budgets.gateToolBudget.soft}, hard: ${budgets.gateToolBudget.hard} },`);
lines.push(` turnBudget: { maxTurns: ${budgets.gateTurnBudget.maxTurns}, graceTurns: ${budgets.gateTurnBudget.graceTurns} },`);
lines.push("});");
lines.push("");
lines.push("return {");
lines.push(" reviewers: reviews.map(r => ({ key: r.key, ok: r.ok, output: r.output })),");
lines.push(" gate: { ok: gate.ok, output: gate.output },");
lines.push("};");
} else {
lines.push("return {");
lines.push(" reviewers: reviews.map(r => ({ key: r.key, ok: r.ok, output: r.output })),");
lines.push(" gate: null,");
lines.push("};");
}
return lines.join("\n");
}
/** Build the static gate task briefing (reviewer findings are appended inline by the script). */
function buildGateTask(changeLabel: string, threshold: number): string {
return [
`Synthesize reviewer findings for change ${changeLabel}.`,
`Threshold: ${threshold} (drop issues with confidence < ${threshold}).`,
`Reviewer findings are inlined below as JSON text (one block per reviewer). Parse each block's JSON.`,
`If a block fails to parse or the reviewer is FAILED, skip it and note it.`,
`Dedupe by (file, line, category), re-score 1-10, return surviving issues + verdict.`,
`Skip false positives: ${FALSE_POSITIVE_GUIDANCE}.`,
`Output JSON: {"verdict":"approve|request_changes|comment","issues":[...],"reason":"..."}`,
].join(" ");
}
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; return your findings as JSON in your final reply 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(" ");
}