/** * pr-review-subagent * * Adds configurable, tiered review subagents to the /pr-review workflow. * * - Config surface: `pr-review.json` (user: ~/.pi/agent, project: /.pi) maps * the labels `light` / `medium` / `heavy` to whatever models you choose, * plus optional per-tier fallback chains for quota/capacity failures. * No model names are hardcoded here — you configure them. * - Tool: `review_subagent` spawns one isolated `pi` subprocess on the model * bound to the requested tier and returns its review report. * - Tool: `review_subagents` accepts multiple pass assignments with shared PR * context and runs those isolated subprocesses concurrently with bounded * parallelism, returning deterministic per-pass results. * - Tool: `pr_review_verify` discovers and runs trusted user-level named * baselines in a detached worktree with bounded process-group lifecycle. * - Command: `/pr-review-config` shows or edits the tier→model mapping. * * The orchestrating /pr-review prompt dispatches passes by tier label: * light -> overview / strengths / high-level risk scan * medium -> convention compliance + readability / maintainability * heavy -> bug + security/logic review */ import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { Message } from "@earendil-works/pi-ai"; import { StringEnum } from "@earendil-works/pi-ai"; import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext, getAgentDir, getSelectListTheme, getSettingsListTheme, } from "@earendil-works/pi-coding-agent"; import { Container, fuzzyFilter, getKeybindings, Input, type SelectItem, SelectList, type SettingItem, SettingsList, Text, } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { classifyReviewLane, finalAssistantText, type ReviewLaneArtifact, type ReviewLaneLifecycle, } from "../lib/pr-review-artifacts.ts"; import { runWithConcurrency } from "../lib/pr-review-concurrency.ts"; import { attemptDeadline, fallbackBudget, type ReviewBudget } from "../lib/pr-review-deadlines.ts"; import { buildExtractionSystemPrompt, buildExtractionTask, MAX_EXTRACTION_OUTPUT_BYTES } from "../lib/pr-review-extract.ts"; import { loadReviewContext, shardUnifiedDiff } from "../lib/pr-review-context.ts"; import { combineAbortSignals, ReviewLoopCoordinator, reviewDeadlineKindOf, reviewLoopDeniedResult, type ReviewArtifactPublisher, type ReviewDeadlineKind, type ReviewFocusPublisher, } from "../lib/pr-review-loop.ts"; import { runSelfReviewRpcSubprocess } from "../lib/pr-self-review-rpc.ts"; import { buildSelfReviewDelta, parseSelfReviewOutput, SelfReviewPermitCoordinator, selfReviewDeniedResult, } from "../lib/pr-self-review.ts"; import { normalizeReviewFocusJsonEvent, ReviewJsonLineDecoder, } from "../lib/pr-review-focus.ts"; import { appendToolPolicyArgs, buildReviewBaseArgs, normalizeToolPolicy, resolveToolPolicy, type ToolPolicy, } from "../lib/pr-review-policy.ts"; import { resolveAllowStaleApprovalsSetting, resolveAllowStalePublishSetting, resolveAutoPostSetting, resolveApproveMaxPriorityLevelSetting, type ApproveMaxPriorityLevel, } from "../lib/pr-review-publish.ts"; import { monotonicNow } from "../lib/pr-review-telemetry.ts"; import { discoverVerificationBaselines, resolveUserVerificationBaselines, verificationLifecycleFailed, verifyPullRequestHead, type VerificationBaselines, } from "../lib/pr-review-verify.ts"; import { appendTierThinkingArgs, modelSpecThinkingLevel, normalizeThinkingLevel, normalizeTierThinkingLevels, sharedThinkingInheritanceWarning, THINKING_LEVELS, thinkingShadowingWarning, type ThinkingLevel, type ThinkingShadow, } from "../lib/pr-review-thinking.ts"; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- type Tier = "light" | "medium" | "heavy"; const TIERS: Tier[] = ["light", "medium", "heavy"]; const UNSET = "(unset — pi default)"; const INHERIT_THINKING = "(inherit — pi default)"; const INHERIT_TOOL_POLICY = "(inherit — configured tools)"; const TOOL_POLICIES: ToolPolicy[] = ["configured", "none"]; const TOOLS_PRESETS = ["read,bash,grep,find,ls", "read,grep,find,ls", "read"]; const DEFAULT_BATCH_PARALLEL = 4; const MAX_BATCH_PARALLEL = 18; const TIER_PURPOSE: Record = { light: "overview / strengths / high-level risk scan", medium: "convention compliance + readability / maintainability", heavy: "bug + security/logic review", }; interface PrReviewConfig { /** Tier label -> model spec (e.g. "anthropic/model", "openai/model:high"). */ tiers: Partial>; /** Tier label -> ordered fallback model specs used for quota/capacity failures. */ fallbacks: Partial>; /** Optional child Pi thinking level. Model-spec :thinking takes precedence. */ thinkingLevels: Partial>; /** Optional tier-level policy used when a tool call does not override it. */ toolPolicies: Partial>; /** Automatically publish the completed review as a host-gated GitHub COMMENT or APPROVE review. Disabled by default. */ autoPostReviews: boolean; /** Permit stale publication as body-only with reviewed/current SHAs disclosed. Enabled by default. */ allowStalePublish: boolean; /** Permit otherwise-qualified stale fully parsed reviews to record APPROVE. Disabled by default. */ allowStaleApprovals: boolean; /** Maximum severity that permits APPROVE for any fully parsed, complete review. */ approveMaxPriorityLevel: ApproveMaxPriorityLevel; /** Trusted user-level named verification profiles. Project config never overlays these. */ verificationBaselines: VerificationBaselines; /** Tools granted to review subagents whose effective policy is configured. */ tools: string[]; /** Preserved host deadline object; resolved by the shared bounded lifecycle module. */ deadlines?: unknown; } const DEFAULT_TOOLS = ["read", "bash", "grep", "find", "ls"]; const CONFIG_FILENAME = "pr-review.json"; function userConfigPath(): string { return path.join(getAgentDir(), CONFIG_FILENAME); } function projectConfigPath(cwd: string): string { return path.join(cwd, CONFIG_DIR_NAME, CONFIG_FILENAME); } function readConfigFile(filePath: string): Partial { try { if (!fs.existsSync(filePath)) return {}; const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")); return typeof parsed === "object" && parsed ? (parsed as Partial) : {}; } catch { return {}; } } function normalizeFallbacks( raw: Partial> | undefined, preserveEmpty = false, ): Partial> { const out: Partial> = {}; if (!raw || typeof raw !== "object") return out; for (const tier of TIERS) { const value = raw[tier]; if (value === undefined) continue; const list = Array.isArray(value) ? value.map((v) => String(v).trim()).filter(Boolean) : typeof value === "string" ? value.split(",").map((v) => v.trim()).filter(Boolean) : []; if (list.length > 0 || preserveEmpty) out[tier] = [...new Set(list)]; } return out; } function normalizeThinkingLevels( raw: Partial> | undefined, source: string, ): Partial> { return normalizeTierThinkingLevels(raw, source); } function normalizeToolPolicies( raw: Partial> | undefined, ): Partial> { const out: Partial> = {}; if (!raw || typeof raw !== "object") return out; for (const tier of TIERS) { const policy = normalizeToolPolicy(raw[tier]); if (policy) out[tier] = policy; } return out; } function resolveAutoPostForContext(ctx: Pick) { const user = readConfigFile(userConfigPath()); let project: Partial | undefined; try { if (ctx.isProjectTrusted()) project = readConfigFile(projectConfigPath(ctx.cwd)); } catch { /* user config only */ } return resolveAutoPostSetting(user, project); } function resolveAllowStaleForContext(ctx: Pick) { const user = readConfigFile(userConfigPath()); let project: Partial | undefined; try { if (ctx.isProjectTrusted()) project = readConfigFile(projectConfigPath(ctx.cwd)); } catch { /* user config only */ } return resolveAllowStalePublishSetting(user, project); } function resolveAllowStaleApprovalsForContext(ctx: Pick) { const user = readConfigFile(userConfigPath()); let project: Partial | undefined; try { if (ctx.isProjectTrusted()) project = readConfigFile(projectConfigPath(ctx.cwd)); } catch { /* user config only */ } return resolveAllowStaleApprovalsSetting(user, project); } function resolveApproveMaxPriorityForContext(ctx: Pick) { const user = readConfigFile(userConfigPath()); let project: Partial | undefined; try { if (ctx.isProjectTrusted()) project = readConfigFile(projectConfigPath(ctx.cwd)); } catch { /* user config only */ } return resolveApproveMaxPriorityLevelSetting(user, project); } /** User config overlaid by a trusted project, except user-only verification profiles. */ function loadConfig(ctx: Pick): PrReviewConfig { const user = readConfigFile(userConfigPath()); let project: Partial = {}; try { if (ctx.isProjectTrusted()) project = readConfigFile(projectConfigPath(ctx.cwd)); } catch { /* trust check unavailable -> user config only */ } const autoPost = resolveAutoPostSetting(user, project); const allowStale = resolveAllowStalePublishSetting(user, project); const allowStaleApprovals = resolveAllowStaleApprovalsSetting(user, project); const approveMaxPriority = resolveApproveMaxPriorityLevelSetting(user, project); return { tiers: { ...(user.tiers ?? {}), ...(project.tiers ?? {}) }, fallbacks: { ...normalizeFallbacks(user.fallbacks, true), ...normalizeFallbacks(project.fallbacks, true) }, thinkingLevels: { ...normalizeThinkingLevels(user.thinkingLevels, "User pr-review config"), ...normalizeThinkingLevels(project.thinkingLevels, "Project pr-review config"), }, toolPolicies: { ...normalizeToolPolicies(user.toolPolicies), ...normalizeToolPolicies(project.toolPolicies), }, autoPostReviews: autoPost.valid ? autoPost.value : false, allowStalePublish: allowStale.valid ? allowStale.value : false, allowStaleApprovals: allowStaleApprovals.valid ? allowStaleApprovals.value : false, approveMaxPriorityLevel: approveMaxPriority.valid ? approveMaxPriority.value : "off", // Verification is intentionally user-owned. Never accept a profile from // the repository-controlled project overlay, even for trusted projects. verificationBaselines: resolveUserVerificationBaselines(user, project), tools: project.tools ?? user.tools ?? DEFAULT_TOOLS, deadlines: project.deadlines ?? user.deadlines, }; } /** User-level config only (the scope the config command edits), with defaults. */ function readUserConfig(): PrReviewConfig { const raw = readConfigFile(userConfigPath()); const autoPost = resolveAutoPostSetting(raw); const allowStale = resolveAllowStalePublishSetting(raw); const allowStaleApprovals = resolveAllowStaleApprovalsSetting(raw); const approveMaxPriority = resolveApproveMaxPriorityLevelSetting(raw); return { tiers: { ...(raw.tiers ?? {}) }, fallbacks: normalizeFallbacks(raw.fallbacks), thinkingLevels: normalizeThinkingLevels(raw.thinkingLevels, "User pr-review config"), toolPolicies: normalizeToolPolicies(raw.toolPolicies), autoPostReviews: autoPost.valid ? autoPost.value : false, allowStalePublish: allowStale.valid ? allowStale.value : false, allowStaleApprovals: allowStaleApprovals.valid ? allowStaleApprovals.value : false, approveMaxPriorityLevel: approveMaxPriority.valid ? approveMaxPriority.value : "off", verificationBaselines: resolveUserVerificationBaselines(raw), tools: raw.tools ?? [...DEFAULT_TOOLS], deadlines: raw.deadlines, }; } function writeUserConfig(next: PrReviewConfig): string { const filePath = userConfigPath(); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 }); return filePath; } interface ModelAttempt { spec?: string; usedTier?: Tier; kind: "primary" | "fallback" | "nearest" | "default"; fallbackIndex?: number; } const NEAREST_TIER_ORDER: Record = { light: ["light", "medium", "heavy"], medium: ["medium", "heavy", "light"], heavy: ["heavy", "medium", "light"], }; /** Resolve the ordered model attempts for a tier, preserving nearest-tier/default behavior when the tier is unset. */ function resolveModelAttempts(config: PrReviewConfig, tier: Tier): ModelAttempt[] { const attempts: ModelAttempt[] = []; const seen = new Set(); const add = (attempt: ModelAttempt) => { const key = attempt.spec ?? "__pi_default__"; if (seen.has(key)) return; seen.add(key); attempts.push(attempt); }; const primary = config.tiers[tier]; if (primary) { add({ spec: primary, usedTier: tier, kind: "primary" }); } else { let foundNearest = false; for (const candidate of NEAREST_TIER_ORDER[tier]) { const spec = config.tiers[candidate]; if (!spec) continue; add({ spec, usedTier: candidate, kind: "nearest" }); foundNearest = true; break; } if (!foundNearest) add({ kind: "default" }); } for (const [index, spec] of (config.fallbacks[tier] ?? []).entries()) { add({ spec, usedTier: tier, kind: "fallback", fallbackIndex: index }); } return attempts; } function thinkingWarnings(config: PrReviewConfig, tiers: readonly Tier[] = TIERS): string[] { const warnings: string[] = []; const inheritedTiers = tiers.filter( (tier) => !config.thinkingLevels[tier] && resolveModelAttempts(config, tier).some((attempt) => !modelSpecThinkingLevel(attempt.spec)), ); const inheritance = sharedThinkingInheritanceWarning(inheritedTiers); if (inheritance) warnings.push(inheritance); const shadows: ThinkingShadow[] = []; const seen = new Set(); for (const tier of tiers) { const tierLevel = config.thinkingLevels[tier]; if (!tierLevel) continue; for (const attempt of resolveModelAttempts(config, tier)) { const modelLevel = modelSpecThinkingLevel(attempt.spec); if (!attempt.spec || !modelLevel) continue; const key = `${tier}\0${attempt.spec}\0${tierLevel}`; if (seen.has(key)) continue; seen.add(key); shadows.push({ tier, modelSpec: attempt.spec, tierLevel, modelLevel }); } } const shadowing = thinkingShadowingWarning(shadows); if (shadowing) warnings.push(shadowing); return warnings; } // --------------------------------------------------------------------------- // Subprocess plumbing (mirrors the official subagent example) // --------------------------------------------------------------------------- /** Test seam: overrides subprocess discovery so tests can supply a fake pi. */ let piInvocationOverrideCommand: string | undefined; /** Replace subprocess discovery (tests only); returns the previous command. */ export function overridePiInvocation(nextCommand: string | undefined) { const previous = piInvocationOverrideCommand; piInvocationOverrideCommand = nextCommand; return previous; } function getPiInvocation(args: string[]): { command: string; args: string[] } { // An override fixes only the command; flags always pass through so the // child still receives the exact production argv. if (piInvocationOverrideCommand) return { command: piInvocationOverrideCommand, args }; const currentScript = process.argv[1]; const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/"); if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) { return { command: process.execPath, args: [currentScript, ...args] }; } const execName = path.basename(process.execPath).toLowerCase(); const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName); if (!isGenericRuntime) return { command: process.execPath, args }; return { command: "pi", args }; } const TIER_GUIDANCE: Record = { light: "You are a fast overview reviewer. Produce a concise overview of what the change does and how, list genuine strengths, and note high-level risk areas worth closer specialist review. Do not deep-dive into defects.", medium: "You are a balanced convention, readability, and maintainability reviewer. Follow the assigned objective exactly: apply only in-scope repository convention files and capture clear maintainability/readability issues without duplicating deep correctness or security analysis.", heavy: "You are a rigorous specialist reviewer for correctness, security, performance, and logic. Follow the assigned objective exactly, validate each candidate before reporting, and drop anything that is actually correct or that you cannot substantiate.", }; function buildSubagentSystemPrompt(tier: Tier, majorOnly = false, minorHygiene = false, expectedOutput?: "review_lane" | "nonempty"): string { const lines = [ "You are an isolated code-review subagent invoked by the /pr-review orchestrator.", TIER_GUIDANCE[tier], "", minorHygiene ? "This is a bounded minor-hygiene scan. Report at most three direct, substantiated P3/nit observations from the supplied diff; do not use tools, deep-audit the repository, report P0-P2 defects, or inflate severity. The dedicated heavy passes cover P0-P2." : majorOnly ? "This is major-only mode. Within the assigned objective, report only substantiated P0, P1, or P2 defects. Do not spend review time on P3/nit style, naming, documentation, or low-impact observations, and never inflate a minor issue's severity to include it." : "Stay inside the assigned objective. Within that objective, surface EVERY issue the author would want to know about — from trivial nits up to blocking defects. Do not discard minor issues; classify them by severity instead. Only leave out non-issues: things that are actually correct, unsubstantiated speculation, or subjective preferences with no concrete benefit.", "Stay strictly in PR scope: only report issues caused by or directly relevant to this PR's diff (the changed lines and the code they provably affect). Do NOT flag pre-existing issues in untouched code or audit the wider codebase; if a problem existed before this change, leave it out. Reading surrounding files/callers is for context and confirmation only.", ]; if (tier === "light") { lines.push( minorHygiene ? "Return concise Markdown with three sections:" : "Return concise Markdown with two sections:", "- 'Overview:' 1-3 short paragraphs on what the PR does and author intent.", "- 'Strengths:' a bullet list of genuine strengths (or 'none').", ); if (minorHygiene) { lines.push( "- 'Minor candidates:' at most three direct-diff observations. For each include title prefixed [P3] or [nit], severity, why, location, side, in_diff, pr_related, and confidence. Use 'none' when no such observation is clear from the diff.", ); } } else { lines.push( "Start from the supplied complete diff. Use repository tools only to substantiate a concrete candidate caused by that diff; never browse for unrelated issues or run broad repository audits/tests.", "Before your first tool call, identify the evidence needed for all current candidates. Issue independent reads/searches/checks together when the interface supports concurrent calls, grouped to avoid rereading the same file. Use at most one follow-up tool turn, only for a dependency revealed by the first evidence wave. This schedules validation efficiently but never permits skipping evidence needed to substantiate a finding.", "Return your findings as a concise Markdown list. For each finding include, on its own lines:", majorOnly ? "- title: an imperative summary prefixed with [P0], [P1], or [P2]" : "- title: an imperative summary prefixed with a severity tag [P0]|[P1]|[P2]|[P3]|[nit]", majorOnly ? "- severity: one of P0, P1, P2 (P0/P1 are blocking)" : "- severity: one of P0, P1, P2, P3, nit (P0/P1 are blocking)", "- why: the impact and the exact input/environment needed for it to bite", "- location: : (or 'repo-wide')", "- side: RIGHT for added/context lines, LEFT for removed lines", "- in_diff: yes if those lines are inside the PR diff (so an inline comment can be posted), otherwise no", "- pr_related: yes only if this PR introduces or provably affects the issue (drop pre-existing/unrelated issues)", "- confidence: a float 0.0-1.0", // A nonempty completion contract lets the pass carry framing prose // (overview, strengths, risk) around its findings; the exact-match // sentinel would forbid that and strand the lane as partial. expectedOutput === "nonempty" ? majorOnly ? "If there are no substantiated P0-P2 findings, state that plainly in prose after your overview; do not emit the candidate fields." : "If there are genuinely no findings at any severity, state that plainly in prose after your overview; do not emit the candidate fields." : majorOnly ? "If there are no substantiated P0-P2 findings, reply exactly with: NO FINDINGS." : "If there are genuinely no findings at any severity, reply exactly with: NO FINDINGS.", ); } lines.push("Do not attempt to post GitHub comments or modify files. Reviewing only."); return lines.join("\n"); } async function writeTempPrompt(tier: Tier, body: string): Promise<{ dir: string; filePath: string }> { const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-pr-review-")); const filePath = path.join(dir, `system-${tier}.md`); await fs.promises.writeFile(filePath, body, { encoding: "utf-8", mode: 0o600 }); return { dir, filePath }; } interface RunResult { text: string; exitCode: number; processSignal?: string; stderr: string; stopReason?: string; errorMessage?: string; model?: string; timedOut?: boolean; /** Which host invocation deadline (not the lane's own attempt deadline) ended this run. */ deadlineExpired?: ReviewDeadlineKind; terminationGraceMs?: number; forcedTermination?: boolean; firstEventMs?: number; firstAssistantMs?: number; toolElapsedMs: number; toolCallCount: number; } export function runReviewSubprocess( command: string, args: string[], cwd: string, input: string, signal: AbortSignal | undefined, onText: (text: string) => void, onEvent?: (event: unknown) => void, deadline?: { deadlineMs: number; terminationGraceMs: number; cleanupReserveMs: number; now?: () => number }, ): Promise { return new Promise((resolve) => { const messages: Message[] = []; const result: RunResult = { text: "", exitCode: 0, stderr: "", toolElapsedMs: 0, toolCallCount: 0 }; const now = deadline?.now ?? monotonicNow; const startedAt = now(); let termination: "abort" | "timeout" | undefined; let terminationStartedAt: number | undefined; let closed = false; let settled = false; let groupCleanupStarted = false; let killTimer: ReturnType | undefined; let deadlineTimer: ReturnType | undefined; let drainTimer: ReturnType | undefined; let groupKillDeadline: number | undefined; let pendingClose: { code: number | null; processSignal?: NodeJS.Signals | null; error?: Error } | undefined; let activeTools = 0; let activeToolsStartedAt = 0; let updateAssistantText = ""; let assistantUpdatePending = false; // Pi's print/json modes combine piped stdin into the initial user message. // Keep the complete review task off argv: macOS rejects a single argument // near 1 MiB, while context_file intentionally supports up to 16 MiB. // A detached POSIX leader gives the host one process group to supervise, // including reviewer tools which outlive or redirect stdio away from Pi. const detached = process.platform !== "win32"; const proc = spawn(command, args, { cwd, shell: false, detached, stdio: ["pipe", "pipe", "pipe"] }); const processGroupId = detached ? proc.pid : undefined; const groupExists = () => { if (processGroupId === undefined) return false; try { process.kill(-processGroupId, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; } }; const signalProcess = (processSignal: NodeJS.Signals) => { try { if (processGroupId !== undefined) process.kill(-processGroupId, processSignal); else if (!closed) proc.kill(processSignal); } catch { // Concurrent exits are observed by close and the bounded group probe. } }; const onAbort = () => { // A host total/synthesis deadline is a timeout, not user cancellation. // The kind is recorded only when it actually wins termination, so a // coincidental host abort during an attempt-timeout drain cannot // mislabel the lane. const kind = reviewDeadlineKindOf(signal?.reason); terminate(kind ? "timeout" : "abort", kind); }; const cleanupAbort = () => { if (killTimer) clearTimeout(killTimer); if (deadlineTimer) clearTimeout(deadlineTimer); if (drainTimer) clearTimeout(drainTimer); signal?.removeEventListener("abort", onAbort); }; const processEvent = (raw: unknown) => { if (settled || !raw || typeof raw !== "object") return; const event = raw as { type?: string; message?: Message; assistantMessageEvent?: { type?: string; delta?: string }; }; try { onEvent?.(event); } catch { // Focus observers are non-authoritative and must never change review results. } const now = monotonicNow(); result.firstEventMs ??= now - startedAt; if (event.type === "tool_execution_start") { if (activeTools === 0) activeToolsStartedAt = now; activeTools++; result.toolCallCount++; } if (event.type === "tool_execution_end" && activeTools > 0) { activeTools--; if (activeTools === 0) result.toolElapsedMs += now - activeToolsStartedAt; } if (event.type === "message_start" && event.message) { assistantUpdatePending = event.message.role === "assistant"; if (assistantUpdatePending) { result.firstAssistantMs ??= now - startedAt; updateAssistantText = ""; result.stopReason = undefined; result.errorMessage = undefined; result.model = event.message.model || undefined; } } if (event.type === "message_update") { // Pi 0.84.1 omits message from canonical update events. Bind their // deltas to the assistant turn opened by message_start so a later turn // cannot inherit evidence or model metadata from an earlier one. let snapshotApplied = false; if (event.message?.role === "assistant") { result.firstAssistantMs ??= now - startedAt; if (!assistantUpdatePending) { assistantUpdatePending = true; updateAssistantText = ""; result.stopReason = undefined; result.errorMessage = undefined; } if (event.message.model) result.model = event.message.model; const snapshot = finalAssistantText([event.message]); if (snapshot) { updateAssistantText = snapshot; snapshotApplied = true; } } if (assistantUpdatePending && !snapshotApplied && event.assistantMessageEvent?.type === "text_delta" && typeof event.assistantMessageEvent.delta === "string") { updateAssistantText += event.assistantMessageEvent.delta; } } if (event.type === "message_end" && event.message) { messages.push(event.message); if (event.message.role === "assistant") { assistantUpdatePending = false; result.firstAssistantMs ??= now - startedAt; if (event.message.model) result.model = event.message.model; if (event.message.stopReason) result.stopReason = event.message.stopReason; if (event.message.errorMessage) result.errorMessage = event.message.errorMessage; const t = finalAssistantText(messages); if (t) onText(t); } } }; const decoder = new ReviewJsonLineDecoder(processEvent); proc.stdout.on("data", (data) => { if (!settled) decoder.push(data.toString()); }); proc.stderr.on("data", (data) => { if (!settled) result.stderr += data.toString(); }); // A fast child failure can close stdin before the buffered task is flushed. // Observe EPIPE so it cannot become an unhandled stream error; close/exit // remains the authoritative subprocess outcome. proc.stdin.on("error", () => {}); proc.stdin.end(input, "utf8"); const finish = (code: number | null, processSignal: NodeJS.Signals | null | undefined, error?: Error) => { if (settled) return; cleanupAbort(); // Flush a final unterminated JSON record while processEvent is still // authoritative. Marking settled first would silently discard it. decoder.end(); settled = true; if (activeTools > 0) { result.toolElapsedMs += now() - activeToolsStartedAt; activeTools = 0; } result.text = assistantUpdatePending ? updateAssistantText : finalAssistantText(messages); result.processSignal = processSignal ?? undefined; result.exitCode = code ?? (processSignal ? 1 : 0); if (terminationStartedAt !== undefined) result.terminationGraceMs = Math.max(0, now() - terminationStartedAt); if (termination === "timeout") { result.timedOut = true; result.stopReason = "timeout"; result.errorMessage ??= result.deadlineExpired ? `Review ${result.deadlineExpired} deadline expired while this lane was still running.` : "Review attempt exceeded its host deadline."; } else if (termination === "abort") { result.stopReason = "aborted"; } else if (error) result.errorMessage = error.message; resolve(result); }; const finishPending = () => { const pending = pendingClose ?? { code: 1, processSignal: undefined }; finish(pending.code, pending.processSignal, pending.error); }; const forceKillAndDrain = () => { result.forcedTermination = true; signalProcess("SIGKILL"); const cleanupDeadline = now() + (deadline?.cleanupReserveMs ?? 1_000); const drain = () => { if (closed && !groupExists()) { finishPending(); return; } const remaining = cleanupDeadline - now(); if (remaining <= 0) { signalProcess("SIGKILL"); finishPending(); return; } drainTimer = setTimeout(drain, Math.min(10, Math.max(1, remaining))); }; drain(); }; const checkGroupCleanupGrace = () => { if (!groupCleanupStarted || settled || groupKillDeadline === undefined) return; if (killTimer) clearTimeout(killTimer); if (closed && !groupExists()) { finishPending(); return; } const remaining = groupKillDeadline - now(); if (remaining <= 0) { forceKillAndDrain(); return; } killTimer = setTimeout(checkGroupCleanupGrace, closed ? Math.min(10, remaining) : remaining); }; const beginGroupCleanup = () => { if (groupCleanupStarted || settled || processGroupId === undefined) return; groupCleanupStarted = true; signalProcess("SIGTERM"); groupKillDeadline = now() + (deadline?.terminationGraceMs ?? 5_000); checkGroupCleanupGrace(); }; const terminate = (reason: "abort" | "timeout", deadlineKind?: ReviewDeadlineKind) => { if (settled || termination) return; termination = reason; if (deadlineKind) result.deadlineExpired = deadlineKind; terminationStartedAt = now(); pendingClose ??= { code: 1, processSignal: undefined }; if (processGroupId !== undefined) { beginGroupCleanup(); return; } // Preserve direct-child behavior on platforms without POSIX groups. signalProcess("SIGTERM"); const graceMs = deadline?.terminationGraceMs ?? 5_000; killTimer = setTimeout(() => { if (!closed) result.forcedTermination = true; if (!closed) signalProcess("SIGKILL"); }, graceMs); drainTimer = setTimeout(finishPending, graceMs + (deadline?.cleanupReserveMs ?? 1_000)); }; proc.on("close", (code, processSignal) => { closed = true; pendingClose = { code, processSignal }; if (processGroupId !== undefined && (termination || groupExists())) { beginGroupCleanup(); checkGroupCleanupGrace(); return; } finishPending(); }); proc.on("error", (error) => { closed = true; pendingClose = { code: 1, processSignal: undefined, error }; finishPending(); }); if (deadline) { const remaining = deadline.deadlineMs - now(); if (remaining <= 0) queueMicrotask(() => terminate("timeout")); else deadlineTimer = setTimeout(() => terminate("timeout"), remaining); } if (signal) { if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); } }); } interface SubagentPassRequest { id?: string; tier: Tier; objective: string; context?: string; toolPolicy?: ToolPolicy; majorOnly?: boolean; minorHygiene?: boolean; expectedOutput?: "review_lane" | "nonempty"; systemPrompt?: string; focusPublisher?: ReviewFocusPublisher; artifactPublisher?: ReviewArtifactPublisher; generation?: number; artifactKey?: string; requestedPassOrdinal?: number; } interface ModelAttemptReport { kind: ModelAttempt["kind"]; spec?: string; usedTier?: Tier; model?: string; exitCode: number; status: ReviewLaneLifecycle; rawText: string; retryable: boolean; elapsedMs: number; firstEventMs?: number; firstAssistantMs?: number; toolElapsedMs: number; toolCallCount: number; processSignal?: string; stopReason?: string; errorMessage?: string; timedOut?: boolean; terminationGraceMs?: number; forcedTermination?: boolean; deadlineMs?: number; configuredDeadlineMs?: number; } interface SubagentPassResult { id: string; tier: Tier; usedTier?: Tier; model?: string; exitCode: number; status: ReviewLaneLifecycle; notice: string; text: string; stderr?: string; processSignal?: string; stopReason?: string; errorMessage?: string; attempts: ModelAttemptReport[]; fallbackUsed: boolean; retryableFailure: boolean; toolPolicy: ToolPolicy; elapsedMs: number; startOffsetMs?: number; endOffsetMs?: number; fallbackBudgetRejected?: boolean; deadlineSource?: ReviewBudget["source"]; batchDeadlineMs?: number; totalDeadlineMs?: number; } function noticeForAttempt(tier: Tier, attempt: ModelAttempt): string { if (!attempt.spec) { return `tier=${tier} (no tier configured; using pi default model — run /pr-review-config to set tiers)`; } if (attempt.kind === "fallback") { return `tier=${tier} fallback #${(attempt.fallbackIndex ?? 0) + 1} model=${attempt.spec}`; } if (attempt.usedTier && attempt.usedTier !== tier) { return `tier=${tier} (not configured; using ${attempt.usedTier} model=${attempt.spec})`; } return `tier=${tier} model=${attempt.spec}`; } function noticeForResult(tier: Tier, attempts: ModelAttemptReport[], finalNotice: string): string { const incompleteBeforeSuccess = attempts.filter((a) => a.status !== "complete").length; if (incompleteBeforeSuccess > 0 && attempts.some((a) => a.status === "complete")) { return `${finalNotice} (after ${incompleteBeforeSuccess} retry${incompleteBeforeSuccess === 1 ? "" : "ies"})`; } return finalNotice; } function buildPassTask(objective: string, context: string | undefined): string { return context ? `Objective: ${objective}\n\n--- PR context / diff ---\n${context}` : `Objective: ${objective}`; } function isRetryableModelFailure(result: RunResult): boolean { if (result.stopReason === "aborted") return false; const haystack = [result.errorMessage, result.stderr, result.text, result.stopReason] .filter(Boolean) .join("\n") .toLowerCase(); if (!haystack) return false; return /(?:\b429\b|rate[\s_-]*limit(?:ed)?|too many requests|quota|usage[\s_-]*limit|insufficient[\s_-]*quota|resource[\s_-]*exhausted|out of credits|insufficient credits|credit limit|billing quota|billing hard limit|at capacity|overloaded|temporarily unavailable|model (?:is )?(?:temporarily|currently) unavailable|service unavailable|try again later)/i.test( haystack, ); } async function runSubagentAttempt( config: PrReviewConfig, ctx: Pick, pass: SubagentPassRequest, attempt: ModelAttempt, attemptOrdinal: number, toolPolicy: ToolPolicy, signal: AbortSignal | undefined, onText?: (text: string) => void, beforeSpawn?: () => boolean, budget?: ReviewBudget, ): Promise<{ result: RunResult; notice: string; elapsedMs: number; deadlineMs?: number }> { let tmp: { dir: string; filePath: string } | undefined; const startedAt = monotonicNow(); const deadlineAtMs = budget ? attemptDeadline(budget, pass.tier, attempt.kind === "fallback", () => startedAt) : undefined; const deadlineMs = deadlineAtMs === undefined ? undefined : Math.max(0, deadlineAtMs - startedAt); try { if (deadlineAtMs !== undefined && deadlineAtMs <= startedAt) { throw new Error("Review attempt timed out before it could start within the batch deadline."); } const args = buildReviewBaseArgs(); if (attempt.spec) args.push("--model", attempt.spec); appendTierThinkingArgs(args, attempt.spec, config.thinkingLevels[pass.tier]); appendToolPolicyArgs(args, toolPolicy, config.tools); tmp = await writeTempPrompt( pass.tier, pass.systemPrompt ?? buildSubagentSystemPrompt( pass.tier, pass.majorOnly === true, pass.minorHygiene && pass.tier === "light", pass.expectedOutput, ), ); args.push("--append-system-prompt", tmp.filePath); const task = buildPassTask(pass.objective, pass.context); if (beforeSpawn && !beforeSpawn()) { throw new Error("The active /pr-review loop ended before the reviewer could start."); } pass.focusPublisher?.publish({ type: "attempt_started", attempt: attemptOrdinal, model: attempt.spec }); const invocation = getPiInvocation(args); const result = await runReviewSubprocess( invocation.command, invocation.args, ctx.cwd, task, signal, (text) => onText?.(text), (event) => { for (const normalized of normalizeReviewFocusJsonEvent(event)) { pass.focusPublisher?.publish(normalized); } }, budget ? { deadlineMs: deadlineAtMs!, terminationGraceMs: budget.config.terminationGraceMs, cleanupReserveMs: budget.config.cleanupReserveMs, } : undefined, ); return { result, notice: noticeForAttempt(pass.tier, attempt), elapsedMs: monotonicNow() - startedAt, deadlineMs }; } catch (e) { return { result: { text: "", exitCode: 1, stderr: "", errorMessage: errMessage(e), toolElapsedMs: 0, toolCallCount: 0 }, notice: noticeForAttempt(pass.tier, attempt), elapsedMs: monotonicNow() - startedAt, deadlineMs, }; } finally { if (tmp) { try { fs.rmSync(tmp.dir, { recursive: true, force: true }); } catch { /* ignore */ } } } } const SELF_REVIEW_SYSTEM_PROMPT = [ "You are an isolated self-review subagent invoked once by the host for a completed top-level coding task.", "Review only the complete host-derived Git working-tree delta supplied below. Do not infer or request caller context.", "Find only substantiated defects introduced by this delta in correctness, security, performance, state/lifecycle, concurrency, or integration behavior.", "Report only P0, P1, or P2 findings. Do not report P3/nits, style, naming, documentation, tests-only hygiene, or subjective maintainability preferences, and never inflate severity.", "You have no tools. Do not attempt to inspect paths, modify files, run verification, publish comments, or delegate work.", "Return only strict JSON with this exact shape and no Markdown fence: {\"findings\":[{\"title\":\"[P2] Imperative summary\",\"severity\":\"P2\",\"blocking\":false,\"impact\":\"concrete user/system impact\",\"trigger\":\"exact input or environment\",\"evidence\":\"specific causal evidence visible in the delta\",\"path\":\"repo/relative/path.ts\",\"startLine\":1,\"endLine\":1,\"side\":\"RIGHT\",\"inDiff\":true,\"prRelated\":true,\"confidence\":0.9}]}", "Every listed field is required and no additional fields are allowed. blocking is true only for P0/P1. Use exact changed-line coordinates and LEFT only for removed lines. Emit {\"findings\":[]} when no substantiated P0-P2 finding survives validation.", ].join("\n"); async function runSelfReviewAttempt( config: PrReviewConfig, worktree: string, delta: string, signal: AbortSignal | undefined, onText?: (text: string) => void, ): Promise<{ result: RunResult; modelSpec?: string; elapsedMs: number }> { let tmp: { dir: string; filePath: string } | undefined; const startedAt = monotonicNow(); const modelSpec = config.tiers.heavy; try { const args = buildReviewBaseArgs(); args[args.indexOf("json")] = "rpc"; args.splice(args.indexOf("-p"), 1); if (modelSpec) args.push("--model", modelSpec); appendTierThinkingArgs(args, modelSpec, config.thinkingLevels.heavy); args.push("--no-tools", "--no-approve"); tmp = await writeTempPrompt("heavy", SELF_REVIEW_SYSTEM_PROMPT); args.push("--append-system-prompt", tmp.filePath); const task = [ "Objective: Perform the host-defined one-shot major-only self-review of this complete task delta.", "", "--- Complete host-derived task delta ---", delta, ].join("\n"); const invocation = getPiInvocation(args); const result = await runSelfReviewRpcSubprocess( invocation.command, invocation.args, worktree, task, signal, getAgentDir(), ); if (result.text) onText?.(result.text); return { result, modelSpec, elapsedMs: monotonicNow() - startedAt }; } catch (error) { return { result: { text: "", exitCode: 1, stderr: "", errorMessage: errMessage(error), toolElapsedMs: 0, toolCallCount: 0 }, modelSpec, elapsedMs: monotonicNow() - startedAt, }; } finally { if (tmp) { try { fs.rmSync(tmp.dir, { recursive: true, force: true }); } catch { /* ignore */ } } } } const OUTPUT_REPAIR_SYSTEM_PROMPT = [ "You are an isolated output-repair subagent for a completed PR review.", "You have no tools. Do not review the PR, add findings, remove findings, or change the review's substance.", "Convert the supplied completed-review text into exactly one JSON object that satisfies the supplied output contract.", "Return only that JSON object: no Markdown, headings, prose, or code fences. If the source cannot be safely converted, return it unchanged so host validation rejects it.", ].join("\\n"); /** Run the configured light-tier model as a no-tools, one-shot JSON-format repairer. */ export async function repairReviewOutput( text: string, outputContract: string, ctx: Pick, signal?: AbortSignal, ): Promise { const result = await runSubagentPass(loadConfig(ctx), ctx, { id: "output-repair", tier: "light", objective: `Reformat this completed review without changing its substance.\n\n--- required output contract ---\n${outputContract}\n\n--- completed review ---\n${text}`, toolPolicy: "none", expectedOutput: "nonempty", systemPrompt: OUTPUT_REPAIR_SYSTEM_PROMPT, }, signal); return result.status === "complete" && result.text.trim() ? result.text : undefined; } function retainPassArtifact(pass: SubagentPassRequest, result: SubagentPassResult): void { if (!pass.artifactPublisher || pass.generation === undefined || !pass.artifactKey) return; const finalAttempt = result.attempts.at(-1); const artifact: ReviewLaneArtifact = { generation: pass.generation, key: pass.artifactKey, passId: result.id, requestedPassOrdinal: pass.requestedPassOrdinal, tier: result.tier, minorHygiene: pass.minorHygiene === true, requestedModel: result.attempts[0]?.spec, observedModel: result.model, rawText: result.text, exitCode: result.exitCode, processSignal: result.processSignal, stopReason: result.stopReason, errorMessage: result.errorMessage, lifecycle: result.status, deadlineExpired: result.deadlineExpired, attempts: result.attempts.map((attempt, index) => ({ ordinal: index + 1, kind: attempt.kind, requestedModel: attempt.spec, observedModel: attempt.model, usedTier: attempt.usedTier, rawText: attempt.rawText, exitCode: attempt.exitCode, processSignal: attempt.processSignal, stopReason: attempt.stopReason, errorMessage: attempt.errorMessage, lifecycle: attempt.status, deadlineExpired: attempt.deadlineExpired, retryable: attempt.retryable, elapsedMs: attempt.elapsedMs, firstEventMs: attempt.firstEventMs, firstAssistantMs: attempt.firstAssistantMs, toolElapsedMs: attempt.toolElapsedMs, toolCallCount: attempt.toolCallCount, timedOut: attempt.timedOut, terminationGraceMs: attempt.terminationGraceMs, forcedTermination: attempt.forcedTermination, deadlineMs: attempt.deadlineMs, configuredDeadlineMs: attempt.configuredDeadlineMs, })), fallbackUsed: result.fallbackUsed, elapsedMs: result.elapsedMs, firstEventMs: finalAttempt?.firstEventMs, firstAssistantMs: finalAttempt?.firstAssistantMs, toolElapsedMs: finalAttempt?.toolElapsedMs ?? 0, toolCallCount: finalAttempt?.toolCallCount ?? 0, startOffsetMs: result.startOffsetMs, endOffsetMs: result.endOffsetMs, fallbackBudgetRejected: result.fallbackBudgetRejected, deadlineSource: result.deadlineSource, batchDeadlineMs: result.batchDeadlineMs, totalDeadlineMs: result.totalDeadlineMs, }; pass.artifactPublisher.retain(artifact); } async function runSubagentPass( config: PrReviewConfig, ctx: Pick, pass: SubagentPassRequest, signal: AbortSignal | undefined, onText?: (text: string) => void, beforeSpawn?: () => boolean, budget?: ReviewBudget, ): Promise { const startedAt = monotonicNow(); const tier = pass.tier; const toolPolicy = resolveToolPolicy(pass.toolPolicy, config.toolPolicies[tier]); const attempts = resolveModelAttempts(config, tier); const reports: ModelAttemptReport[] = []; let lastResult: RunResult | undefined; let lastNotice = noticeForAttempt(tier, attempts[0]!); let fallbackBudgetRejected = false; // One primary plus at most one configured fallback preserves user model quality // while bounding retry amplification. const boundedAttempts = attempts.slice(0, attempts[0]?.kind === "fallback" ? 1 : 2); for (let attemptIndex = 0; attemptIndex < boundedAttempts.length; attemptIndex++) { const attempt = boundedAttempts[attemptIndex]!; if (attempt.kind === "fallback" && budget && !fallbackBudget(budget).allowed) { fallbackBudgetRejected = true; break; } const { result, notice, elapsedMs, deadlineMs } = await runSubagentAttempt( config, ctx, pass, attempt, attemptIndex + 1, toolPolicy, signal, onText, beforeSpawn, budget, ); const lifecycle = classifyReviewLane({ tier, rawText: result.text, exitCode: result.exitCode, stopReason: result.stopReason, errorMessage: result.errorMessage, minorHygiene: pass.minorHygiene, expectedOutput: pass.expectedOutput, }); const processFailed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; const retryable = lifecycle === "timed_out" || (processFailed && isRetryableModelFailure(result)); lastResult = result; lastNotice = notice; reports.push({ kind: attempt.kind, spec: attempt.spec, usedTier: attempt.usedTier, model: result.model ?? attempt.spec, exitCode: result.exitCode, status: lifecycle, rawText: result.text, retryable, elapsedMs, firstEventMs: result.firstEventMs, firstAssistantMs: result.firstAssistantMs, toolElapsedMs: result.toolElapsedMs, toolCallCount: result.toolCallCount, processSignal: result.processSignal, stopReason: result.stopReason, errorMessage: result.errorMessage, timedOut: result.timedOut, deadlineExpired: result.deadlineExpired, terminationGraceMs: result.terminationGraceMs, forcedTermination: result.forcedTermination, deadlineMs, configuredDeadlineMs: budget ? (attempt.kind === "fallback" ? budget.config.fallbackAttemptMs : budget.config.attemptMs[tier]) : undefined, }); if (lifecycle === "complete") { pass.focusPublisher?.publish({ type: "completed" }); const completed: SubagentPassResult = { id: pass.id ?? tier, tier, usedTier: attempt.usedTier, model: result.model ?? attempt.spec, exitCode: result.exitCode, status: "complete", notice: noticeForResult(tier, reports, notice), text: result.text, stderr: result.stderr || undefined, processSignal: result.processSignal, stopReason: result.stopReason, errorMessage: result.errorMessage, attempts: reports, fallbackUsed: attempt.kind === "fallback" || reports.length > 1, retryableFailure: false, toolPolicy, elapsedMs: monotonicNow() - startedAt, fallbackBudgetRejected, deadlineExpired: result.deadlineExpired, deadlineSource: budget?.source, batchDeadlineMs: budget ? budget.batchDeadlineMs - budget.startedAtMs : undefined, totalDeadlineMs: budget ? budget.totalDeadlineMs - budget.startedAtMs : undefined, }; retainPassArtifact(pass, completed); return completed; } if (!retryable || signal?.aborted) break; pass.focusPublisher?.publish({ type: "retrying" }); } const final = lastResult ?? { text: "", exitCode: 1, stderr: "", errorMessage: "No model attempts were available.", toolElapsedMs: 0, toolCallCount: 0 }; const lifecycle = reports.at(-1)?.status ?? "failed"; pass.focusPublisher?.publish({ type: lifecycle === "partial" ? "partial" : lifecycle === "timed_out" ? "timed_out" : signal?.aborted || final.stopReason === "aborted" ? "aborted" : "failed", }); const incomplete: SubagentPassResult = { id: pass.id ?? tier, tier, usedTier: reports.at(-1)?.usedTier, model: reports.at(-1)?.model, exitCode: final.exitCode, status: lifecycle, notice: noticeForResult(tier, reports, lastNotice), text: final.text, stderr: final.stderr || undefined, processSignal: final.processSignal, stopReason: final.stopReason, errorMessage: final.errorMessage, attempts: reports, fallbackUsed: reports.length > 1, retryableFailure: reports.at(-1)?.retryable ?? false, toolPolicy, elapsedMs: monotonicNow() - startedAt, fallbackBudgetRejected, deadlineExpired: final.deadlineExpired, deadlineSource: budget?.source, batchDeadlineMs: budget ? budget.batchDeadlineMs - budget.startedAtMs : undefined, totalDeadlineMs: budget ? budget.totalDeadlineMs - budget.startedAtMs : undefined, }; retainPassArtifact(pass, incomplete); return incomplete; } function normalizeMaxParallel(raw: unknown, count: number): number { if (count <= 0) return 0; const requested = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : DEFAULT_BATCH_PARALLEL; return Math.max(1, Math.min(count, MAX_BATCH_PARALLEL, requested > 0 ? requested : DEFAULT_BATCH_PARALLEL)); } function formatAttemptSummary(result: SubagentPassResult): string { if (result.attempts.length <= 1) return ""; return `attempts: ${result.attempts .map((a) => `${a.status === "complete" ? "✓" : a.retryable ? "↻" : "✗"} ${a.spec ?? "pi default"} (${a.status})`) .join(" → ")}`; } function deadlineExpiryCounts(results: readonly SubagentPassResult[]): Record { return Object.fromEntries( (["total", "synthesis"] as const).map((kind) => [kind, results.filter((r) => r.deadlineExpired === kind).length]), ) as Record; } function formatBatchResults( results: SubagentPassResult[], maxParallel: number, warnings: readonly string[] = [], ): string { const lifecycleCounts = Object.fromEntries( (["complete", "partial", "timed_out", "failed"] as const).map((state) => [state, results.filter((r) => r.status === state).length]), ) as Record; const incomplete = results.length - lifecycleCounts.complete; const externalDeadlines = deadlineExpiryCounts(results); const externalTotal = externalDeadlines.total + externalDeadlines.synthesis; const lines = [ `Review subagents completed: ${lifecycleCounts.complete}/${results.length} semantically complete (max_parallel=${maxParallel}; partial=${lifecycleCounts.partial}; timed_out=${lifecycleCounts.timed_out}; failed=${lifecycleCounts.failed}).`, ...warnings, ]; if (externalTotal > 0) { const kinds = (["total", "synthesis"] as const).filter((kind) => externalDeadlines[kind] > 0).join("/"); lines.push( `WARNING: the host ${kinds} deadline expired while ${externalTotal} lane(s) were still running; those lane attempt budgets were not exceeded.`, ); } if (incomplete) { lines.push( `WARNING: ${incomplete} pass(es) are incomplete. Preserve their raw evidence, then rerun or cover those passes inline before finalizing.`, ); } for (const result of results) { lines.push( "", `## Pass: ${result.id}`, `status: ${result.status}`, `tool_policy: ${result.toolPolicy}`, `elapsed_ms: ${result.elapsedMs}`, ...(result.deadlineExpired ? [`deadline_expired: ${result.deadlineExpired}`] : []), result.notice, ); const attemptSummary = formatAttemptSummary(result); if (attemptSummary) lines.push(attemptSummary); if (result.text.length > 0) lines.push(result.text); else { const detail = result.errorMessage || result.stderr || "(no output)"; lines.push(`error: ${detail}`); } } return lines.join("\n"); } function combineContexts(shared: string | undefined, specific: string | undefined): string | undefined { const parts: string[] = []; if (shared?.trim()) parts.push(shared.trim()); if (specific?.trim()) parts.push(`--- Pass-specific context ---\n${specific.trim()}`); return parts.length ? parts.join("\n\n") : undefined; } // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- const SelfReviewSubagentParams = Type.Object({}, { additionalProperties: false }); // Keep the model-facing schema flat. Some tool consumers do not expose fields // that exist only under a root anyOf, causing otherwise well-prompted calls to // arrive as {} and fail validation before the extension can provide feedback. const PrReviewVerifyParams = Type.Object( { action: StringEnum(["list", "run"] as const, { description: "Use list to discover applicable trusted baselines; use run to execute one discovered name.", }), pr_number: Type.Optional(Type.Integer({ minimum: 1, description: "Required for action=run. GitHub pull request number whose pull ref must be fetched.", })), head_sha: Type.Optional(Type.String({ pattern: "^[0-9a-f]{40}$", description: "Required for action=run. Exact full lowercase headRefOid captured from current PR metadata.", })), baseline_name: Type.Optional(Type.String({ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", description: "Required for action=run. Exact name returned by action=list; command and timeout come only from user config.", })), }, // Explicitly reject legacy command/timeout override fields and all other extras. { additionalProperties: false }, ); const ReviewSubagentParams = Type.Object({ tier: StringEnum(["light", "medium", "heavy"] as const, { description: "Model tier / subagent label. light = overview & risk scan; medium = conventions/readability; heavy = correctness/security/performance.", }), objective: Type.String({ description: "Precise instruction for this subagent — what to review and what to return.", }), context: Type.Optional( Type.String({ description: "Shared context for the subagent, typically PR metadata and either the unified diff or a context_file reference.", }), ), context_file: Type.Optional( Type.String({ description: "Path to a complete unified diff already captured by the orchestrator. Its contents are appended to context inside the extension, avoiding duplicate large tool arguments.", }), ), tool_policy: Type.Optional( StringEnum(["none", "configured"] as const, { description: "Tool access for this pass. none emits --no-tools; configured uses the configured allowlist. Request override wins over tier config; omission preserves configured legacy behavior unless tier policy is set.", }), ), major_only: Type.Optional( Type.Boolean({ description: "Report only substantiated P0-P2 findings; do not spend review time on P3/nit observations.", }), ), minor_hygiene: Type.Optional( Type.Boolean({ description: "For a light overview pass, add at most three direct-diff P3/nit candidates without tools or a repository audit.", }), ), }); const ReviewSubagentsParams = Type.Object({ // Keep the required structured value first and the free-form context last. // Some model tool encoders follow schema order; putting a long scalar first can // cause malformed markup to absorb every later argument into that string. passes: Type.Array( Type.Object({ expected_output: Type.Optional( StringEnum(["review_lane", "nonempty"] as const, { description: "Completion contract for this pass. review_lane (default): candidate-evidence fields or exactly NO FINDINGS. nonempty: any nonempty terminal output completes; use for an integrated pass that carries framing prose around its findings.", }), ), id: Type.Optional( Type.String({ description: "Stable pass label used in the ordered batch result, e.g. overview, conventions, correctness.", }), ), tier: StringEnum(["light", "medium", "heavy"] as const, { description: "Model tier / subagent label. light = overview & risk scan; medium = conventions/readability; heavy = correctness/security/performance.", }), objective: Type.String({ description: "Precise instruction for this pass — what to review and what to return.", }), context: Type.Optional( Type.String({ description: "Optional pass-specific context appended after the shared context.", }), ), context_file: Type.Optional( Type.String({ description: "Optional pass-specific unified-diff shard. When set, this pass receives the shard instead of the top-level context_file.", }), ), tool_policy: Type.Optional( StringEnum(["none", "configured"] as const, { description: "Tool access for this pass. none emits --no-tools; configured uses the configured allowlist. The resolved policy remains fixed across fallback model attempts.", }), ), }), { description: "Required JSON array of independent review-pass objects to run concurrently. Pass this as an array value, never as serialized JSON, XML-like markup, or text inside context. Results are returned in this same order.", }, ), context_file: Type.Optional( Type.String({ description: "Path to a complete unified diff already captured by the orchestrator. Its contents are appended to shared context inside the extension.", }), ), shard_count: Type.Optional( Type.Integer({ minimum: 1, maximum: 3, description: "For large diffs, split whole changed-file blocks into two or three balanced shards and run every requested lens once per shard.", }), ), max_parallel: Type.Optional( Type.Number({ description: `Maximum pass subprocesses to run concurrently. Defaults to ${DEFAULT_BATCH_PARALLEL}; capped at ${MAX_BATCH_PARALLEL}.`, }), ), major_only: Type.Optional( Type.Boolean({ description: "Apply major-only P0-P2 candidate reporting to every requested pass without changing model, thinking, tools, or diff scope.", }), ), minor_hygiene: Type.Optional( Type.Boolean({ description: "Add a bounded direct-diff P3/nit scan to the light overview pass; heavy passes remain P0-P2 only.", }), ), context: Type.Optional( Type.String({ description: "Shared PR metadata and cross-cutting requirements for every pass. Supply a large complete diff through context_file to avoid duplicating it in tool arguments. Do not embed other tool parameters in this string.", }), ), }); export default function registerPrReviewSubagents( pi: ExtensionAPI, loopCoordinator = new ReviewLoopCoordinator(pi), selfReviewCoordinator = new SelfReviewPermitCoordinator(pi, () => !!loopCoordinator.peek()), ) { // Resolve security-sensitive executables only from the PATH trusted when this // extension starts, never from a later mutable process environment. const trustedStartupPath = process.env.PATH ?? ""; pi.registerTool({ name: "self_review_subagent", label: "Self Review Subagent", description: [ "Perform the host-defined, one-shot P0-P2 self-review for the current eligible top-level user task.", "The host supplies the complete bounded Git-visible task delta, heavy-tier model, objective, isolation, and no-tools policy; this tool accepts no arguments.", ].join(" "), promptSnippet: "Run the one-shot host-bounded major-only self-review near the end of an eligible coding task", promptGuidelines: [ "Call at most once, near the end of a long-running top-level coding task after implementation and focused tests.", "The permit is consumed before delta capture. A denied or failed call cannot be replayed, and the tool is unavailable during /pr-review.", ], parameters: SelfReviewSubagentParams, async execute(toolCallId, _params, signal, onUpdate, ctx) { const permit = await selfReviewCoordinator.consume(toolCallId, ctx); if (!permit) return selfReviewDeniedResult(); const executionSignal = combineAbortSignals(signal, permit.signal); try { if (executionSignal?.aborted) throw new Error("Self-review was aborted before delta capture."); const captured = await buildSelfReviewDelta({ ...permit, signal: executionSignal ?? permit.signal }); if (executionSignal?.aborted) throw new Error("Self-review was aborted before the reviewer could start."); const config = loadConfig(ctx); const attempt = await runSelfReviewAttempt( config, permit.worktree, captured.delta, executionSignal, ); const failed = attempt.result.exitCode !== 0 || attempt.result.stopReason === "error" || attempt.result.stopReason === "aborted"; const details = { authorized: true, generation: permit.generation, tier: "heavy", model: attempt.result.model ?? attempt.modelSpec, exitCode: attempt.result.exitCode, status: failed ? "failed" : "completed", attempts: 1, fallbackUsed: false, toolPolicy: "none", majorOnly: true, minorHygiene: false, deltaBytes: captured.bytes, fileCount: captured.fileCount, elapsedMs: attempt.elapsedMs, }; if (failed) { const error = attempt.result.errorMessage || attempt.result.stderr || attempt.result.text || "(no output)"; return { content: [{ type: "text", text: `Self-review subagent failed: ${error}` }], isError: true, details, }; } let report; try { report = parseSelfReviewOutput(attempt.result.text, captured.anchors); } catch (error) { return { content: [{ type: "text", text: `Self-review rejected malformed or out-of-policy output: ${errMessage(error)}` }], isError: true, details: { ...details, status: "failed", outputRejected: true }, }; } const text = JSON.stringify(report, null, 2); onUpdate?.({ content: [{ type: "text", text }] }); return { content: [{ type: "text", text }], details: { ...details, findingCount: report.findings.length }, }; } catch (error) { return { content: [{ type: "text", text: `Self-review failed closed: ${errMessage(error)}` }], isError: true, details: { authorized: true, generation: permit.generation, attempts: 0 }, }; } finally { selfReviewCoordinator.finish(permit); } }, }); selfReviewCoordinator.hideTool(); pi.registerTool({ name: "pr_review_verify", label: "PR Review Verify", description: [ "Discover or run one trusted user-level named PR verification baseline.", "Project-local profile definitions are ignored. Run accepts only the PR number, exact head SHA, and a name returned by list; command argv and total timeout are fixed by user config.", "Execution is unsandboxed PR code. Lifecycle supervision signals only the original POSIX process group; a deliberate setsid/session escape can survive. Use an external sandbox or container wrapper for untrusted PRs.", ].join(" "), promptSnippet: "List trusted user-level verification baselines, then optionally run one by name against an exact PR head", promptGuidelines: [ "Call action=list after resolving PR metadata. If it returns an applicable name, select at most one without inventing argv or timeout overrides.", "Emit action=run concurrently with review_subagents using only pr_number, exact full headRefOid, and baseline_name.", "If list returns no applicable profile, skip verification; never substitute a prompt-owned bash worktree lifecycle.", "Disclose that baseline execution is unsandboxed, supervises only its original POSIX process group, and cannot stop a deliberate setsid/session escape; recommend an external sandbox/container for untrusted PRs.", ], parameters: PrReviewVerifyParams, async execute(_toolCallId, params, signal, _onUpdate, ctx) { const lease = loopCoordinator.acquire(ctx); if (!lease) return reviewLoopDeniedResult("pr_review_verify"); const executionSignal = combineAbortSignals(signal, lease.signal); const config = loadConfig(ctx); if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("pr_review_verify"); if (params.action === "list") { if (params.pr_number !== undefined || params.head_sha !== undefined || params.baseline_name !== undefined) { return { content: [{ type: "text", text: "pr_review_verify action=list accepts only the action field." }], isError: true, details: { authorized: true, reason: "invalid_list_arguments" }, }; } const discovery = await discoverVerificationBaselines(ctx.cwd, config.verificationBaselines, executionSignal, { startupPath: trustedStartupPath }); return { content: [{ type: "text", text: JSON.stringify(discovery, null, 2) }], details: discovery, }; } if (params.pr_number === undefined || params.head_sha === undefined || params.baseline_name === undefined) { return { content: [{ type: "text", text: "pr_review_verify action=run requires pr_number, head_sha, and baseline_name." }], isError: true, details: { authorized: true, reason: "missing_run_arguments" }, }; } if (params.pr_number !== loopCoordinator.peek()?.prNumber) { return { content: [{ type: "text", text: "pr_review_verify PR number does not match the active /pr-review invocation." }], isError: true, details: { authorized: false, reason: "pr_mismatch" }, }; } if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("pr_review_verify"); const result = await verifyPullRequestHead( ctx.cwd, { prNumber: params.pr_number, headSha: params.head_sha, baselineName: params.baseline_name, }, config.verificationBaselines, executionSignal, { startupPath: trustedStartupPath }, ); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], ...(verificationLifecycleFailed(result) ? { isError: true } : {}), details: result, }; }, }); pi.registerTool({ name: "review_subagent", label: "Review Subagent", description: [ "Delegate one PR-review pass to an isolated subagent running on a configured model tier.", "Pass tier (light|medium|heavy) plus an objective and the diff as context.", "Model per tier is configured via /pr-review-config (stored in pr-review.json).", "Returns the subagent's candidate findings; the orchestrator validates, filters, and emits the final Markdown review.", ].join(" "), promptSnippet: "Run a tiered PR-review pass (light/medium/heavy) in an isolated subagent on the configured model", promptGuidelines: [ "Use review_subagent for a single /pr-review pass when review_subagents is unavailable or when rerunning one failed batch pass.", "When rerunning a failed pass, reuse the captured complete diff with `context_file` plus compact PR metadata in `context`; embedding the diff in context remains supported for compatibility.", ], parameters: ReviewSubagentParams, async execute(toolCallId, params, signal, onUpdate, ctx) { const lease = loopCoordinator.acquire(ctx); if (!lease) return reviewLoopDeniedResult("review_subagent"); const executionSignal = combineAbortSignals(signal, lease.signal); const tier = params.tier as Tier; let loadedContext; try { loadedContext = await loadReviewContext(ctx.cwd, params.context, params.context_file); } catch (error) { return { content: [{ type: "text", text: `Review context failed: ${errMessage(error)}` }], isError: true, details: { tier, contextFileBytes: 0 }, }; } if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("review_subagent"); const artifactKey = `${toolCallId}:single`; if (!loopCoordinator.registerExpectedArtifacts(lease, [{ key: artifactKey, tier, minorHygiene: params.minor_hygiene === true, }], ctx)) { return reviewLoopDeniedResult("review_subagent"); } const focusPublisher = loopCoordinator.createFocusPublisher(lease, ctx, { key: artifactKey, label: `${tier} review`, tier, }); const artifactPublisher = loopCoordinator.createArtifactPublisher(lease, ctx); const config = loadConfig(ctx); const result = await runSubagentPass( config, ctx, { tier, objective: params.objective, context: loadedContext.context, toolPolicy: normalizeToolPolicy(params.tool_policy), majorOnly: params.major_only === true, minorHygiene: params.minor_hygiene === true, focusPublisher, artifactPublisher, generation: lease.generation, artifactKey, }, executionSignal, (text) => onUpdate?.({ content: [{ type: "text", text }] }), () => loopCoordinator.isLeaseActive(lease, ctx), lease.budget, ); const warnings = [...thinkingWarnings(config, [tier]), ...(lease.budget?.warnings ?? [])]; const detail = result.text || result.errorMessage || result.stderr || "(no output)"; return { content: [{ type: "text", text: result.status === "complete" ? [`[${result.notice}]`, ...warnings, "", result.text].join("\n") : [`Review subagent ${result.status} [${result.notice}]. Raw output follows:`, ...warnings, "", detail].join("\n"), }], ...(result.status !== "complete" ? { isError: true } : {}), details: { tier: result.tier, usedTier: result.usedTier, model: result.model, exitCode: result.exitCode, status: result.status, rawText: result.text, processSignal: result.processSignal, stopReason: result.stopReason, errorMessage: result.errorMessage, fallbackUsed: result.fallbackUsed, retryableFailure: result.retryableFailure, toolPolicy: result.toolPolicy, elapsedMs: result.elapsedMs, attempts: result.attempts, contextFileBytes: loadedContext.contextFileBytes, deadlineSource: result.deadlineSource, deadlineWarnings: lease.budget?.warnings, batchDeadlineMs: result.batchDeadlineMs, totalDeadlineMs: result.totalDeadlineMs, fallbackBudgetRejected: result.fallbackBudgetRejected, }, }; }, }); pi.registerTool({ name: "review_subagents", label: "Review Subagents Batch", description: [ "Delegate multiple independent PR-review passes to isolated subagents and run them concurrently.", "Pass shared PR context once plus ordered pass assignments with tier, objective, and optional pass-specific context.", "Each pass uses the configured light/medium/heavy model tier from /pr-review-config.", "Returns deterministic ordered per-pass outputs; partial failures are explicit so the orchestrator can rerun or cover them inline.", ].join(" "), promptSnippet: "Run independent light/medium/heavy PR-review passes concurrently in isolated subagents with shared PR context", promptGuidelines: [ "Prefer review_subagents over separate review_subagent calls for independent /pr-review passes; it guarantees bounded parallel execution instead of relying on the orchestrator to emit concurrent tool calls.", "Always provide `passes` as the tool call's top-level JSON array of pass objects. Never serialize it or append any parameter markup to `context`.", "Fetch PR metadata and the unified diff once. Prefer the captured diff path in `context_file` plus compact metadata in `context`; for large multi-file diffs shard_count=2 or 3 runs every requested lens over every balanced whole-file shard.", "If any pass reports status=failed, treat the review evidence as incomplete: rerun that pass or perform it inline before finalizing the JSON.", ], parameters: ReviewSubagentsParams, async execute(toolCallId, params, signal, onUpdate, ctx) { const lease = loopCoordinator.acquire(ctx); if (!lease) return reviewLoopDeniedResult("review_subagents"); const executionSignal = combineAbortSignals(signal, lease.signal); const rawPasses = Array.isArray(params.passes) ? params.passes : []; if (rawPasses.length === 0) { return { content: [{ type: "text", text: "review_subagents requires at least one pass assignment." }], isError: true, details: { passCount: 0 }, }; } if (params.context_file && rawPasses.some((pass) => pass.context_file)) { return { content: [{ type: "text", text: "Review batch context failed: use either top-level context_file or pass-specific context_file shards, not both." }], isError: true, details: { passCount: rawPasses.length, contextFileBytes: 0 }, }; } let loadedContext; let loadedPassContexts; try { loadedContext = await loadReviewContext(ctx.cwd, params.context, params.context_file); loadedPassContexts = await Promise.all( rawPasses.map((pass) => loadReviewContext( ctx.cwd, typeof pass.context === "string" ? pass.context : undefined, typeof pass.context_file === "string" ? pass.context_file : undefined, ), ), ); } catch (error) { return { content: [{ type: "text", text: `Review batch context failed: ${errMessage(error)}` }], isError: true, details: { passCount: rawPasses.length, contextFileBytes: 0 }, }; } const requestedShardCount = params.shard_count === 2 || params.shard_count === 3 ? params.shard_count : 1; if (requestedShardCount > 1 && !loadedContext.contextFileText) { return { content: [{ type: "text", text: "Review batch context failed: shard_count>1 requires a top-level context_file." }], isError: true, details: { passCount: rawPasses.length, contextFileBytes: 0, shardCount: 0 }, }; } const requestedShards = requestedShardCount > 1 ? shardUnifiedDiff(loadedContext.contextFileText!, requestedShardCount) : []; const sharded = requestedShards.length > 1; const sharedContext = sharded ? (typeof params.context === "string" ? params.context.trim() : undefined) : loadedContext.context; const majorOnly = params.major_only === true; const minorHygiene = params.minor_hygiene === true; const passesWithoutFocus: SubagentPassRequest[] = rawPasses.flatMap((pass, index) => { const tier = pass.tier as Tier; const baseId = typeof pass.id === "string" && pass.id.trim() ? pass.id.trim() : `${index + 1}-${tier}`; const baseContext = combineContexts(sharedContext, loadedPassContexts[index]!.context); const makePass = (shard: string | undefined, shardIndex: number): SubagentPassRequest => ({ // Every actual shard receives an explicit identity, including shard 1. // The unsharded compatibility path retains the caller's exact base ID. id: sharded ? `${baseId}-shard-${shardIndex + 1}` : baseId, expectedOutput: pass.expected_output, tier, objective: shard ? `${pass.objective}\nReview every changed line in diff shard ${shardIndex + 1}/${requestedShards.length} under this objective. Other concurrent shard passes cover the remaining changed files.` : pass.objective, context: shard ? combineContexts( baseContext, `--- Complete diff shard ${shardIndex + 1}/${requestedShards.length} ---\n${shard}`, ) : baseContext, toolPolicy: normalizeToolPolicy(pass.tool_policy), majorOnly, minorHygiene: minorHygiene && tier === "light" && baseId === "overview", }); return sharded ? requestedShards.map((shard, shardIndex) => makePass(shard, shardIndex)) : [makePass(undefined, 0)]; }); if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("review_subagents"); const artifactPublisher = loopCoordinator.createArtifactPublisher(lease, ctx); const passes = passesWithoutFocus.map((pass, index) => { const artifactKey = `${toolCallId}:${index}`; return { ...pass, focusPublisher: loopCoordinator.createFocusPublisher(lease, ctx, { key: artifactKey, label: pass.id ?? `${pass.tier} review`, tier: pass.tier, }), artifactPublisher, generation: lease.generation, artifactKey, requestedPassOrdinal: index, }; }); if (!loopCoordinator.registerExpectedArtifacts(lease, passes.map((pass) => ({ key: pass.artifactKey, tier: pass.tier, minorHygiene: pass.minorHygiene === true, ...(pass.expectedOutput ? { expectedOutput: pass.expectedOutput } : {}), })), ctx)) { return reviewLoopDeniedResult("review_subagents"); } const maxParallel = normalizeMaxParallel(params.max_parallel, passes.length); const config = loadConfig(ctx); const batchStartedAt = monotonicNow(); const tierPriority: Record = { heavy: 0, medium: 1, light: 2 }; const dispatchPasses = passes .map((pass, originalIndex) => ({ pass, originalIndex })) .sort((a, b) => tierPriority[a.pass.tier] - tierPriority[b.pass.tier] || a.originalIndex - b.originalIndex); const dispatchResults = await runWithConcurrency(dispatchPasses, maxParallel, async ({ pass, originalIndex }) => { const startOffsetMs = monotonicNow() - batchStartedAt; const result = await runSubagentPass( config, ctx, pass, executionSignal, undefined, () => loopCoordinator.isLeaseActive(lease, ctx), lease.budget, ); const endOffsetMs = monotonicNow() - batchStartedAt; result.startOffsetMs = startOffsetMs; result.endOffsetMs = endOffsetMs; retainPassArtifact(pass, result); onUpdate?.({ content: [ { type: "text", text: `review_subagents: pass ${result.id} ${result.status} (${result.notice})`, }, ], }); return { result, originalIndex, startOffsetMs, endOffsetMs }; }); const scheduledResults = dispatchResults.sort((a, b) => a.originalIndex - b.originalIndex); const elapsedMs = monotonicNow() - batchStartedAt; const results = scheduledResults.map((scheduled) => scheduled.result); const incomplete = results.filter((r) => r.status !== "complete"); const lifecycleCounts = Object.fromEntries( (["complete", "partial", "timed_out", "failed"] as const).map((state) => [state, results.filter((r) => r.status === state).length]), ) as Record; const usedTiers = [...new Set(passes.map((pass) => pass.tier))]; return { content: [{ type: "text", text: formatBatchResults(results, maxParallel, [...thinkingWarnings(config, usedTiers), ...(lease.budget?.warnings ?? [])]) }], ...(incomplete.length > 0 ? { isError: true } : {}), details: { maxParallel, majorOnly, minorHygiene, passCount: results.length, shardCount: sharded ? requestedShards.length : 1, contextFileBytes: loadedContext.contextFileBytes + loadedPassContexts.reduce((total, loaded) => total + loaded.contextFileBytes, 0), failedCount: lifecycleCounts.failed, incompleteCount: incomplete.length, fallbackStarts: results.filter((result) => result.attempts.length > 1).length, fallbackBudgetRejections: results.filter((result) => result.fallbackBudgetRejected).length, deadlineExpiries: deadlineExpiryCounts(results), deadlineSource: lease.budget?.source, deadlineWarnings: lease.budget?.warnings, batchDeadlineMs: lease.budget ? lease.budget.batchDeadlineMs - lease.budget.startedAtMs : undefined, totalDeadlineMs: lease.budget ? lease.budget.totalDeadlineMs - lease.budget.startedAtMs : undefined, lifecycleCounts, lifecycleElapsedMs: Object.fromEntries( (["complete", "partial", "timed_out", "failed"] as const).map((state) => [ state, results.filter((result) => result.status === state).reduce((total, result) => total + result.elapsedMs, 0), ]), ), elapsedMs, scheduling: { clock: "monotonic", label: "observable review_subagents scheduling", intervals: scheduledResults.map((scheduled) => ({ id: scheduled.result.id, startOffsetMs: scheduled.startOffsetMs, endOffsetMs: scheduled.endOffsetMs, })), }, results: scheduledResults.map(({ result: r, startOffsetMs, endOffsetMs }) => ({ id: r.id, tier: r.tier, usedTier: r.usedTier, model: r.model, exitCode: r.exitCode, processSignal: r.processSignal, status: r.status, stopReason: r.stopReason, errorMessage: r.errorMessage, deadlineExpired: r.deadlineExpired, fallbackUsed: r.fallbackUsed, retryableFailure: r.retryableFailure, retryableFailure: r.retryableFailure, toolPolicy: r.toolPolicy, elapsedMs: r.elapsedMs, firstEventMs: r.attempts.at(-1)?.firstEventMs, firstAssistantMs: r.attempts.at(-1)?.firstAssistantMs, toolElapsedMs: r.attempts.at(-1)?.toolElapsedMs ?? 0, toolCallCount: r.attempts.at(-1)?.toolCallCount ?? 0, startOffsetMs, endOffsetMs, attempts: r.attempts, rawText: r.text, outputChars: r.text.length, })), }, }; }, }); pi.registerCommand("pr-review-config", { description: "Open review-tier settings, or show/set models, thinking, and fallbacks for /pr-review", handler: async (args, ctx) => { // Extension commands execute before input events, so revoke explicitly. loopCoordinator.clear(); selfReviewCoordinator.clear(); const raw = (args ?? "").trim(); const parsed = parseConfigArgs(raw); if (parsed.errors.length) { ctx.ui.notify(`Invalid pr-review config: ${parsed.errors.join("; ")}`, "error"); return; } try { // Direct set: `/pr-review-config light=... heavy=... heavy_fallbacks=...` if (parsed.hasChanges) { const next = applyConfigPatch(readUserConfig(), parsed.patch); writeUserConfig(next); post(ctx, pi, summarizeConfig(next, loadConfig(ctx), ctx, true), { config: next }); return; } // Interactive menu: `/pr-review-config` in the TUI. if (shouldOpenConfigMenu(raw, ctx)) { let available: string[] = []; try { available = (await ctx.modelRegistry.getAvailable()).map((m) => `${m.provider}/${m.id}`).sort(); } catch { /* fall back to text if models can't be listed */ } if (available.length > 0) { const result = await showConfigMenu(pi, ctx, available); if (result === "closed") return; } } // Text summary: `/pr-review-config show`, non-TUI, or menu fallback. post(ctx, pi, summarizeConfig(readUserConfig(), loadConfig(ctx), ctx, false), {}); } catch (e) { ctx.ui.notify(`pr-review-config failed: ${errMessage(e)}`, "error"); } }, getArgumentCompletions(prefix: string) { const normalized = prefix.toLowerCase(); const filtered = CONFIG_COMPLETIONS.filter((item) => item.value.toLowerCase().startsWith(normalized)); return filtered.length ? filtered : null; }, }); } /* ----------------------- config command helpers ------------------------- */ function errMessage(e: unknown): string { return e instanceof Error ? e.message : String(e); } function post( ctx: ExtensionContext, pi: ExtensionAPI, markdown: string, details: Record, ): void { if (ctx.hasUI) { pi.sendMessage({ customType: "pr-review", content: markdown, display: true, details }, { triggerTurn: false }); } else { ctx.ui.notify(markdown, "info"); } } function shouldOpenConfigMenu(args: string, ctx: ExtensionContext): boolean { return args.length === 0 && ctx.mode === "tui" && typeof ctx.ui.custom === "function"; } const CONFIG_COMPLETIONS: Array<{ value: string; label: string }> = [ ...TIERS.map((t) => ({ value: `${t}=`, label: `${t}= — ${TIER_PURPOSE[t]}` })), ...TIERS.map((t) => ({ value: `${t}_fallbacks=`, label: `${t}_fallbacks= — retry chain for quota/rate-limit failures`, })), ...TIERS.map((t) => ({ value: `${t}_thinking=`, label: `${t}_thinking=<${THINKING_LEVELS.join("|")}|unset> — child Pi thinking when model spec has no :thinking`, })), ...TIERS.map((t) => ({ value: `${t}_tool_policy=`, label: `${t}_tool_policy= — default tool access when a pass does not override it`, })), { value: "auto_post_reviews=", label: "auto_post_reviews= — automatically post host-gated reviews (default false)" }, { value: "allow_stale_publish=", label: "allow_stale_publish= — permit disclosed body-only stale publication (default true)" }, { value: "tools=", label: "tools=read,bash,grep,find,ls — allowlist used by configured policy" }, { value: "show", label: "show — print the current review config" }, ]; /** Split respecting single/double quotes so `evidence`-style values with spaces survive. */ function splitConfigArgs(input: string): string[] { const tokens: string[] = []; let token = ""; let quote: '"' | "'" | undefined; for (let i = 0; i < input.length; i++) { const ch = input[i]!; if (quote) { if (ch === quote) quote = undefined; else token += ch; continue; } if (ch === '"' || ch === "'") { quote = ch; continue; } if (/\s/.test(ch)) { if (token) { tokens.push(token); token = ""; } continue; } token += ch; } if (token) tokens.push(token); return tokens; } function splitCommaList(value: string): string[] { return value.split(",").map((s) => s.trim()).filter(Boolean); } function isFallbackKey(key: string): boolean { for (const tier of TIERS) { if (key === `${tier}_fallbacks` || key === `${tier}-fallbacks` || key === `${tier}.fallbacks`) { return true; } } return false; } function isThinkingKey(key: string): boolean { for (const tier of TIERS) { if (key === `${tier}_thinking` || key === `${tier}-thinking` || key === `${tier}.thinking`) { return true; } } return false; } function isToolPolicyKey(key: string): boolean { for (const tier of TIERS) { if ( key === `${tier}_tool_policy` || key === `${tier}-tool-policy` || key === `${tier}.toolpolicy` ) { return true; } } return false; } function tierFromCompoundKey(key: string): Tier { return key.split(/[_\-.]/)[0] as Tier; } interface ConfigPatch { tiers: Partial>; fallbacks: Partial>; thinkingLevels: Partial>; toolPolicies: Partial>; autoPostReviews?: boolean; allowStalePublish?: boolean; allowStaleApprovals?: boolean; approveMaxPriorityLevel?: ApproveMaxPriorityLevel; tools?: string[]; } function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolean; errors: string[] } { const patch: ConfigPatch = { tiers: {}, fallbacks: {}, thinkingLevels: {}, toolPolicies: {} }; const errors: string[] = []; const tokens = splitConfigArgs(args).filter((t) => !["show", "get", "current"].includes(t.toLowerCase())); for (const token of tokens) { const eq = token.indexOf("="); if (eq < 0) { errors.push(`unrecognized argument "${token}" (use key=value, e.g. heavy=provider/model)`); continue; } const key = token.slice(0, eq).replace(/^--?/, "").toLowerCase(); const value = token.slice(eq + 1); if ((TIERS as string[]).includes(key)) { patch.tiers[key as Tier] = value === "" || value === "unset" ? null : value; } else if (isFallbackKey(key)) { const tier = tierFromCompoundKey(key); patch.fallbacks[tier] = value === "" || value === "unset" || value === "none" ? null : splitCommaList(value); } else if (isThinkingKey(key)) { const tier = tierFromCompoundKey(key); if (value === "" || value === "unset" || value === "inherit") patch.thinkingLevels[tier] = null; else { const level = normalizeThinkingLevel(value); if (level) patch.thinkingLevels[tier] = level; else errors.push(`invalid ${key} "${value}" (expected ${THINKING_LEVELS.join("|")}|unset)`); } } else if (isToolPolicyKey(key)) { const tier = tierFromCompoundKey(key); if (value === "" || value === "unset" || value === "inherit") patch.toolPolicies[tier] = null; else { const policy = normalizeToolPolicy(value); if (policy) patch.toolPolicies[tier] = policy; else errors.push(`invalid ${key} "${value}" (expected none|configured|unset)`); } } else if (key === "auto_post_reviews" || key === "autopostreviews" || key === "auto-post-reviews") { if (value === "true") patch.autoPostReviews = true; else if (value === "false") patch.autoPostReviews = false; else errors.push(`invalid ${key} "${value}" (expected true|false)`); } else if (key === "allow_stale_publish" || key === "allowstalepublish" || key === "allow-stale-publish") { if (value === "true") patch.allowStalePublish = true; else if (value === "false") patch.allowStalePublish = false; else errors.push(`invalid ${key} "${value}" (expected true|false)`); } else if (key === "allow_stale_approvals" || key === "allowstaleapprovals" || key === "allow-stale-approvals") { if (value === "true") patch.allowStaleApprovals = true; else if (value === "false") patch.allowStaleApprovals = false; else errors.push(`invalid ${key} "${value}" (expected true|false)`); } else if ( key === "approve_max_priority_level" || key === "approvemaxprioritylevel" || key === "approve-max-priority-level" ) { const normalized = value.toLowerCase(); if (normalized === "off") { patch.approveMaxPriorityLevel = "off"; } else if (["p2", "p3"].includes(normalized)) { patch.approveMaxPriorityLevel = normalized.toUpperCase() as ApproveMaxPriorityLevel; } else if (normalized === "nit") { patch.approveMaxPriorityLevel = "nit"; } else { errors.push(`invalid ${key} "${value}" (expected off|P2|P3|nit)`); } } else if (key === "tools") { patch.tools = splitCommaList(value); } else { errors.push( `unknown key "${key}" (expected light|medium|heavy|_fallbacks|_thinking|_tool_policy|auto_post_reviews|allow_stale_publish|allow_stale_approvals|approve_max_priority_level|tools)`, ); } } const hasChanges = Object.keys(patch.tiers).length > 0 || Object.keys(patch.fallbacks).length > 0 || Object.keys(patch.thinkingLevels).length > 0 || Object.keys(patch.toolPolicies).length > 0 || patch.autoPostReviews !== undefined || patch.allowStalePublish !== undefined || patch.allowStaleApprovals !== undefined || patch.approveMaxPriorityLevel !== undefined || patch.tools !== undefined; return { patch, hasChanges, errors }; } function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewConfig { const next: PrReviewConfig = { tiers: { ...base.tiers }, fallbacks: normalizeFallbacks(base.fallbacks), thinkingLevels: normalizeThinkingLevels(base.thinkingLevels, "PR review config"), toolPolicies: normalizeToolPolicies(base.toolPolicies), autoPostReviews: base.autoPostReviews, allowStalePublish: base.allowStalePublish, allowStaleApprovals: base.allowStaleApprovals, approveMaxPriorityLevel: base.approveMaxPriorityLevel, verificationBaselines: { ...base.verificationBaselines }, tools: [...base.tools], deadlines: base.deadlines, }; for (const tier of TIERS) { if (tier in patch.tiers) { const value = patch.tiers[tier]; if (value === null || value === undefined) delete next.tiers[tier]; else next.tiers[tier] = value; } if (tier in patch.fallbacks) { const value = patch.fallbacks[tier]; if (value === null || value === undefined || value.length === 0) delete next.fallbacks[tier]; else next.fallbacks[tier] = [...new Set(value)]; } if (tier in patch.thinkingLevels) { const value = patch.thinkingLevels[tier]; if (value === null || value === undefined) delete next.thinkingLevels[tier]; else next.thinkingLevels[tier] = value; } if (tier in patch.toolPolicies) { const value = patch.toolPolicies[tier]; if (value === null || value === undefined) delete next.toolPolicies[tier]; else next.toolPolicies[tier] = value; } } if (patch.autoPostReviews !== undefined) next.autoPostReviews = patch.autoPostReviews; if (patch.allowStalePublish !== undefined) next.allowStalePublish = patch.allowStalePublish; if (patch.allowStaleApprovals !== undefined) next.allowStaleApprovals = patch.allowStaleApprovals; if (patch.approveMaxPriorityLevel !== undefined) next.approveMaxPriorityLevel = patch.approveMaxPriorityLevel; if (patch.tools) next.tools = patch.tools; return next; } function formatModelList(models: string[] | undefined): string { return models && models.length > 0 ? `\`${models.join(",")}\`` : "_none_"; } function summarizeConfig( user: PrReviewConfig, effective: PrReviewConfig, ctx: ExtensionContext, changed: boolean, ): string { let projectPath: string | undefined; try { if (ctx.isProjectTrusted()) { const p = projectConfigPath(ctx.cwd); if (fs.existsSync(p)) projectPath = p; } } catch { /* ignore */ } const autoPost = resolveAutoPostForContext(ctx); const allowStale = resolveAllowStaleForContext(ctx); const allowStaleApprovals = resolveAllowStaleApprovalsForContext(ctx); const approveMaxPriority = resolveApproveMaxPriorityForContext(ctx); const lines = [ `# PR review config${changed ? " updated" : ""}`, "", "| Tier | Your setting | Effective | Used for |", "|---|---|---|---|", ...TIERS.map( (t) => `| \`${t}\` | ${user.tiers[t] ? `\`${user.tiers[t]}\`` : "_unset_"} | ${effective.tiers[t] ? `\`${effective.tiers[t]}\`` : "_pi default_"} | ${TIER_PURPOSE[t]} |`, ), `| \`autoPostReviews\` | \`${user.autoPostReviews}\` | \`${effective.autoPostReviews}\` (${autoPost.source}) | automatically post one GitHub review; default \`false\` |`, `| \`allowStalePublish\` | \`${user.allowStalePublish}\` | \`${effective.allowStalePublish}\` (${allowStale.source}) | permit body-only stale publication with reviewed/current SHAs; default \`true\` |`, `| \`allowStaleApprovals\` | \`${user.allowStaleApprovals}\` | \`${effective.allowStaleApprovals}\` (${allowStaleApprovals.source}) | permit qualified stale fully parsed reviews to record APPROVE; default \`false\` |`, `| \`approveMaxPriorityLevel\` | \`${user.approveMaxPriorityLevel}\` | \`${effective.approveMaxPriorityLevel}\` (${approveMaxPriority.source}) | fully parsed review max severity for APPROVE; degraded output is COMMENT; default \`off\` |`, `| \`verificationBaselines\` | \`${Object.keys(user.verificationBaselines).length} configured\` | user scope only | strict named argv profiles; project overlays ignored |`, `| \`tools\` | \`${user.tools.join(",")}\` | \`${effective.tools.join(",")}\` | allowlist used when policy is \`configured\` |`, "", "| Tier | Your fallbacks | Effective fallbacks | Thinking | Tool policy |", "|---|---|---|---|---|", ...TIERS.map( (t) => `| \`${t}\` | ${formatModelList(user.fallbacks[t])} | ${formatModelList(effective.fallbacks[t])} | ${user.thinkingLevels[t] ? `\`${user.thinkingLevels[t]}\`` : "_inherit pi default_"} → ${effective.thinkingLevels[t] ? `\`${effective.thinkingLevels[t]}\`` : "_inherit pi default_"} | ${user.toolPolicies[t] ? `\`${user.toolPolicies[t]}\`` : "_inherit configured_"} → \`${effective.toolPolicies[t] ?? "configured"}\` |`, ), "", `User config: \`${userConfigPath()}\``, ]; if (projectPath) lines.push(`Project overlay (trusted): \`${projectPath}\``); const warnings = thinkingWarnings(effective); if (warnings.length) lines.push("", ...warnings); if (!autoPost.valid) lines.push(`Automatic posting config error: ${autoPost.error}`); else if (autoPost.source === "project") { lines.push("Automatic posting is controlled by the trusted project overlay; this command edits user config only."); } if (!allowStale.valid) lines.push(`Stale publication config error: ${allowStale.error}`); else if (allowStale.source === "project") { lines.push("Stale publication is controlled by the trusted project overlay; this command edits user config only."); } if (!allowStaleApprovals.valid) lines.push(`Stale approval config error: ${allowStaleApprovals.error}`); else if (allowStaleApprovals.source === "project") { lines.push("Stale approval is controlled by the trusted project overlay; this command edits user config only."); } if (!approveMaxPriority.valid) lines.push(`Auto-approve priority config error: ${approveMaxPriority.error}`); else if (approveMaxPriority.source === "project") { lines.push("Auto-approve priority is controlled by the trusted project overlay; this command edits user config only."); } lines.push( "", "## Usage", "- Open the settings menu: `/pr-review-config`", "- Print this summary: `/pr-review-config show`", "- Set directly: `/pr-review-config light=provider/model heavy=provider/model:high`", "- Set fallback chain: `/pr-review-config heavy_fallbacks=provider/backup:high,provider/backup2`", "- Set tier thinking: `/pr-review-config light_thinking=low medium_thinking=medium heavy_thinking=high`", "- Enable automatic GitHub review posting: `/pr-review-config auto_post_reviews=true`", "- Disable automatic GitHub review posting: `/pr-review-config auto_post_reviews=false`", "- Disable stale publication: `/pr-review-config allow_stale_publish=false`", "- Enable stale publication (default): `/pr-review-config allow_stale_publish=true`", "- Permit qualified stale fully parsed reviews to record APPROVE: `/pr-review-config allow_stale_approvals=true`", "- Keep stale reviews as COMMENT (default): `/pr-review-config allow_stale_approvals=false`", "- Enable approval for qualified fully parsed reviews: `/pr-review-config approve_max_priority_level=P2`", "- Disable auto-approve (default): `/pr-review-config approve_max_priority_level=off`", "- Set tier tool policy: `/pr-review-config light_tool_policy=none`", "- Clear a tier: `/pr-review-config medium=unset`", "- Clear fallback chain: `/pr-review-config heavy_fallbacks=unset`", "- Restore inherited Pi thinking: `/pr-review-config light_thinking=unset`", "- Restore legacy tier tool behavior: `/pr-review-config light_tool_policy=unset`", `- Thinking levels: \`${THINKING_LEVELS.join("\`, \`")}\`. A model spec's \`:thinking\` suffix takes precedence over tier thinking.`, "- A `` is any pi model pattern (`provider/model` or `provider/model:thinking`).", ); return lines.join("\n"); } /* --------------------------- interactive menu --------------------------- */ const MODEL_LIST_ROWS = 10; /** * Model picker submenu with type-to-filter search. * * A raw SelectList only handles arrows/enter/esc, so we wrap it: an Input captures * keystrokes and we fuzzy-filter the options (matching the built-in SettingsList * search UX and fuzzyFilter's slash-token matching, so typing a model name substring * finds `provider/model`). The SelectList is rebuilt as the query changes. */ function buildModelSubmenu( available: string[], currentSpec: string | undefined, unsetDescription = "Fall back to the nearest configured tier, then the pi default.", ) { return (_currentValue: string, done: (selectedValue?: string) => void) => { const allItems: SelectItem[] = [ { value: "__unset__", label: UNSET, description: unsetDescription }, ...available.map((spec) => ({ value: spec, label: spec })), ]; const theme = getSelectListTheme(); const search = new Input(); const makeList = (items: SelectItem[], selectValue?: string): SelectList => { const list = new SelectList(items, MODEL_LIST_ROWS, theme); if (selectValue) { const idx = items.findIndex((i) => i.value === selectValue); if (idx >= 0) list.setSelectedIndex(idx); } list.onSelect = (item) => done(item.value); list.onCancel = () => done(); return list; }; let list = makeList(allItems, currentSpec ?? "__unset__"); const applyQuery = (query: string) => { const filtered = query.trim() ? fuzzyFilter(allItems, query, (i) => i.label) : allItems; list = makeList(filtered); }; const navKeys = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"]; return { render(width: number): string[] { return [...search.render(width), ...list.render(width)]; }, invalidate(): void { search.invalidate?.(); list.invalidate(); }, handleInput(data: string): void { const kb = getKeybindings(); if (navKeys.some((k) => kb.matches(data, k))) { list.handleInput(data); return; } // Ignore other escape sequences (arrows/fn/etc.) so they never pollute the search box. if (data.startsWith("\x1b")) return; // Everything else is search typing (Input handles printable chars + backspace + editing keys). const sanitized = data.replace(/ /g, ""); if (!sanitized) return; search.handleInput(sanitized); applyQuery(search.getValue()); }, }; }; } function configMenuItems(cfg: PrReviewConfig, available: string[]): SettingItem[] { const tierItems: SettingItem[] = TIERS.map((tier) => ({ id: tier, label: `${tier} model`, description: `Used for: ${TIER_PURPOSE[tier]}. Press Enter to pick a model.`, currentValue: cfg.tiers[tier] ?? UNSET, submenu: buildModelSubmenu(available, cfg.tiers[tier]), })); const fallbackItems: SettingItem[] = TIERS.map((tier) => ({ id: `${tier}_fallbacks`, label: `${tier} fallback model`, description: "Retry model for quota/rate-limit/capacity failures. Press Enter to set one fallback; use key=value for chains.", currentValue: cfg.fallbacks[tier]?.join(",") || "(none)", submenu: buildModelSubmenu( available, cfg.fallbacks[tier]?.[0], "Clear this tier's fallback chain. Use key=value form to set multiple fallbacks.", ), })); const thinkingItems: SettingItem[] = TIERS.map((tier) => ({ id: `${tier}_thinking`, label: `${tier} thinking`, description: "Child Pi thinking when the selected model spec has no :thinking suffix. Enter/Space cycles values.", currentValue: cfg.thinkingLevels[tier] ?? INHERIT_THINKING, values: [INHERIT_THINKING, ...THINKING_LEVELS], })); const policyItems: SettingItem[] = TIERS.map((tier) => ({ id: `${tier}_tool_policy`, label: `${tier} tool policy`, description: "Default when a pass does not explicitly set tool_policy. Enter/Space cycles values.", currentValue: cfg.toolPolicies[tier] ?? INHERIT_TOOL_POLICY, values: [INHERIT_TOOL_POLICY, ...TOOL_POLICIES], })); const current = cfg.tools.join(","); const toolValues = [current, ...TOOLS_PRESETS.filter((p) => p !== current)]; return [ ...tierItems, ...fallbackItems, ...thinkingItems, ...policyItems, { id: "auto_post_reviews", label: "user automatic posting setting", description: "Post one host-gated GitHub review after terminal synthesis. Disabled by default.", currentValue: String(cfg.autoPostReviews), values: ["false", "true"], }, { id: "allow_stale_publish", label: "user stale publication setting", description: "Permit body-only stale reviews with reviewed/current commit disclosure. Enabled by default.", currentValue: String(cfg.allowStalePublish), values: ["true", "false"], }, { id: "allow_stale_approvals", label: "stale approval setting", description: "Permit otherwise-qualified stale fully parsed reviews to record APPROVE. Disabled by default.", currentValue: String(cfg.allowStaleApprovals), values: ["false", "true"], }, { id: "approve_max_priority_level", label: "auto-approve priority gate", description: "Fully parsed review maximum severity that permits APPROVE; degraded output is COMMENT. Enter/Space cycles values.", currentValue: String(cfg.approveMaxPriorityLevel), values: ["off", "P2", "P3", "nit"], }, { id: "tools", label: "configured tool allowlist", description: "Tools available when effective policy is configured. Enter/Space cycles presets.", currentValue: current, values: toolValues, }, ]; } async function showConfigMenu( pi: ExtensionAPI, ctx: ExtensionContext, available: string[], ): Promise<"closed" | "fallback"> { const draft = readUserConfig(); try { await ctx.ui.custom((tui, theme, _keybindings, done) => { const container = new Container(); container.addChild(new Text(theme.fg("accent", theme.bold("PR Review Config")), 1, 1)); container.addChild( new Text(theme.fg("dim", "Select a value to apply it immediately. Esc closes the menu."), 1, 0), ); let settingsList: SettingsList; const refresh = () => { for (const tier of TIERS) { settingsList.updateValue(tier, draft.tiers[tier] ?? UNSET); settingsList.updateValue(`${tier}_fallbacks`, draft.fallbacks[tier]?.join(",") || "(none)"); settingsList.updateValue(`${tier}_thinking`, draft.thinkingLevels[tier] ?? INHERIT_THINKING); settingsList.updateValue(`${tier}_tool_policy`, draft.toolPolicies[tier] ?? INHERIT_TOOL_POLICY); } settingsList.updateValue("auto_post_reviews", String(draft.autoPostReviews)); settingsList.updateValue("allow_stale_publish", String(draft.allowStalePublish)); settingsList.updateValue("allow_stale_approvals", String(draft.allowStaleApprovals)); settingsList.updateValue("tools", draft.tools.join(",")); }; const persist = (id: string, newValue: string) => { if ((TIERS as string[]).includes(id)) { if (newValue === "__unset__") delete draft.tiers[id as Tier]; else draft.tiers[id as Tier] = newValue; } else if (isFallbackKey(id)) { const tier = tierFromCompoundKey(id); if (newValue === "__unset__") delete draft.fallbacks[tier]; else draft.fallbacks[tier] = [newValue]; } else if (isThinkingKey(id)) { const tier = tierFromCompoundKey(id); if (newValue === INHERIT_THINKING) delete draft.thinkingLevels[tier]; else { const level = normalizeThinkingLevel(newValue); if (!level) { ctx.ui.notify(`Invalid thinking level: ${newValue}`, "error"); return; } draft.thinkingLevels[tier] = level; } } else if (isToolPolicyKey(id)) { const tier = tierFromCompoundKey(id); if (newValue === INHERIT_TOOL_POLICY) delete draft.toolPolicies[tier]; else { const policy = normalizeToolPolicy(newValue); if (policy) draft.toolPolicies[tier] = policy; } } else if (id === "auto_post_reviews") { draft.autoPostReviews = newValue === "true"; } else if (id === "allow_stale_publish") { draft.allowStalePublish = newValue === "true"; } else if (id === "allow_stale_approvals") { draft.allowStaleApprovals = newValue === "true"; } else if (id === "approve_max_priority_level") { const normalized = newValue.toLowerCase(); if (normalized === "off") { draft.approveMaxPriorityLevel = "off"; } else if (["p2", "p3"].includes(normalized)) { draft.approveMaxPriorityLevel = normalized.toUpperCase() as ApproveMaxPriorityLevel; } else if (normalized === "nit") { draft.approveMaxPriorityLevel = "nit"; } } else if (id === "tools") { draft.tools = splitCommaList(newValue); } else { return; } try { writeUserConfig(draft); refresh(); const shown = id === "tools" ? draft.tools.join(",") : id === "auto_post_reviews" ? String(draft.autoPostReviews) : id === "allow_stale_publish" ? String(draft.allowStalePublish) : id === "allow_stale_approvals" ? String(draft.allowStaleApprovals) : id === "approve_max_priority_level" ? String(draft.approveMaxPriorityLevel) : isFallbackKey(id) ? (draft.fallbacks[tierFromCompoundKey(id)]?.join(",") ?? "(none)") : isThinkingKey(id) ? (draft.thinkingLevels[tierFromCompoundKey(id)] ?? INHERIT_THINKING) : isToolPolicyKey(id) ? (draft.toolPolicies[tierFromCompoundKey(id)] ?? INHERIT_TOOL_POLICY) : (draft.tiers[id as Tier] ?? UNSET); if (id === "auto_post_reviews") { const effective = resolveAutoPostForContext(ctx); if (effective.source === "project") { ctx.ui.notify( `User autoPostReviews saved as ${shown}, but trusted project config remains effective at ${effective.value}. Edit ${projectConfigPath(ctx.cwd)} or use --no-comment.`, "warning", ); } else { ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info"); } } else if (id === "allow_stale_publish") { const effective = resolveAllowStaleForContext(ctx); if (effective.source === "project") { ctx.ui.notify( `User allowStalePublish saved as ${shown}, but trusted project config remains effective at ${effective.value}. Edit ${projectConfigPath(ctx.cwd)}.`, "warning", ); } else { ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info"); } } else if (id === "allow_stale_approvals") { const effective = resolveAllowStaleApprovalsForContext(ctx); if (effective.source === "project") { ctx.ui.notify( `User allowStaleApprovals saved as ${shown}, but trusted project config remains effective at ${effective.value}. Edit ${projectConfigPath(ctx.cwd)}.`, "warning", ); } else { ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info"); } } else if (id === "approve_max_priority_level") { const effective = resolveApproveMaxPriorityForContext(ctx); if (effective.source === "project") { ctx.ui.notify( `User approveMaxPriorityLevel saved as ${shown}, but trusted project config remains effective at ${effective.value}. Edit ${projectConfigPath(ctx.cwd)}.`, "warning", ); } else { ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info"); } } else { ctx.ui.notify(`PR review config: ${id} = ${shown}`, "info"); } } catch (e) { ctx.ui.notify(`pr-review-config failed: ${errMessage(e)}`, "error"); } tui.requestRender(); }; settingsList = new SettingsList( configMenuItems(draft, available), 13, getSettingsListTheme(), (id, newValue) => persist(id, newValue), () => done(undefined), { enableSearch: true }, ); container.addChild(settingsList); return { render: (width: number) => container.render(width), invalidate: () => container.invalidate(), handleInput: (data: string) => { settingsList.handleInput(data); tui.requestRender(); }, }; }); } catch (e) { ctx.ui.notify(`PR review config menu unavailable; showing text config instead. ${errMessage(e)}`, "warning"); return "fallback"; } return "closed"; } /** * Run the Phase 1 finding-extraction child: one bounded light-tier no-tools * subprocess bound to the review lease. The lease abort signal terminates the * child on replacement, cancellation, session switch, or deadline expiry, and * the typed deadline bounds the await inside the re-armed synthesis window. */ export async function runFindingExtraction( ctx: Pick, lease: { generation: number; signal: AbortSignal; budget?: ReviewBudget }, input: string, ): Promise<{ text: string; exitCode: number; errorMessage?: string; timedOut?: boolean; effectiveModel?: string }> { const config = loadConfig(ctx); const modelSpec = config.tiers.light; // With no light tier configured, the child runs on the ambient default // model. The effective model is reported so the egress disclosure and // Phase 2 metrics can name the provider that actually received the payload. const effectiveModel = modelSpec ?? "pi default model"; let tmp: { dir: string; filePath: string } | undefined; try { const args = buildReviewBaseArgs(); if (modelSpec) args.push("--model", modelSpec); appendTierThinkingArgs(args, modelSpec, config.thinkingLevels.light); args.push("--no-tools", "--no-approve"); tmp = await writeTempPrompt("light", buildExtractionSystemPrompt()); args.push("--append-system-prompt", tmp.filePath); const startedAt = monotonicNow(); const budget = lease.budget; const reserve = budget ? budget.config.terminationGraceMs + budget.config.cleanupReserveMs : 0; const windowMs = budget ? Math.min(120_000, budget.config.synthesisMs, Math.max(1, budget.totalDeadlineMs - reserve - startedAt)) : 120_000; const invocation = getPiInvocation(args); const result = await runReviewSubprocess( invocation.command, invocation.args, ctx.cwd, buildExtractionTask(input), lease.signal, undefined, undefined, { deadlineMs: startedAt + Math.max(1, windowMs), terminationGraceMs: budget?.config.terminationGraceMs ?? 5_000, cleanupReserveMs: budget?.config.cleanupReserveMs ?? 5_000, }, ); // Output blast-radius bound: reject oversized documents before parsing. // Measured in UTF-8 bytes to match the parser's own budget. if (Buffer.byteLength(result.text, "utf8") > MAX_EXTRACTION_OUTPUT_BYTES) { return { text: "", exitCode: result.exitCode || 1, errorMessage: "extraction output exceeded the size cap" }; } return { text: result.text, exitCode: result.exitCode, ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}), ...(result.timedOut ? { timedOut: true } : {}), effectiveModel, }; } catch (error) { return { text: "", exitCode: 1, errorMessage: errMessage(error), effectiveModel }; } finally { if (tmp) { try { fs.rmSync(tmp.dir, { recursive: true, force: true }); } catch { /* ignore */ } } } }