/** * pi-reviewer — Post-task critique extension * * At the end of every task (agent_settled), prompts the user to run a * multi-area code review. The /review command triggers the same flow * manually. Each of the 7 areas is evaluated with its own specialized * system prompt. * * Areas: * general – Did the implementation satisfy the original task? * security – Did it introduce security vulnerabilities? * code_quality – Is it maintainable and consistent with the codebase? * ui_ux – Is the user-facing implementation complete? * testing – Is it adequately tested? * performance – Could it cause performance problems? * scope – Did the agent change more than it should have? */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { complete } from "@earendil-works/pi-ai/compat"; import type { ExtensionAPI, ExtensionContext, SessionEntry, } from "@earendil-works/pi-coding-agent"; import { BorderedLoader, CONFIG_DIR_NAME, convertToLlm, serializeConversation, } from "@earendil-works/pi-coding-agent"; import { Box, Key, Text, matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui"; // --------------------------------------------------------------------------- // Review area definitions // --------------------------------------------------------------------------- interface ReviewArea { id: string; label: string; description: string; systemPrompt: string; } const REVIEW_AREAS: ReviewArea[] = [ { id: "general", label: "General Task Review", description: "Verify the implementation satisfied the original task", systemPrompt: `You are a senior code reviewer conducting a GENERAL TASK REVIEW. QUESTION: Did the implementation actually satisfy the original task? Check every aspect below and give concrete examples from the conversation: 1. Requirements completed — Were ALL stated requirements addressed? 2. Missing functionality — Is anything the user asked for still missing? 3. Incorrect assumptions — Did the agent misunderstand any requirement? 4. Regressions — Did any previously working feature break? 5. Overall quality — Is the implementation sensible and well-thought-out? Structure your response as: ## General Task Review ### ✅ What was done well ### ❌ Issues found ### ⚠️ Risks / concerns ### 📋 Recommendation`, }, { id: "security", label: "Security Review", description: "Check for vulnerabilities introduced by the change", systemPrompt: `You are a security auditor conducting a SECURITY REVIEW. QUESTION: Did the implementation introduce security vulnerabilities? Check every aspect below and give concrete examples from the conversation or code: 1. Authentication & authorization — Are access controls correct? 2. Input validation — Is all user input validated and sanitized? 3. SQL injection — Are queries using parameterized statements / ORM? 4. XSS — Is all user-controlled output properly escaped? 5. CSRF — Are state-changing requests protected? 6. Sensitive data exposure — Are secrets, tokens, or PII logged or leaked? 7. Insecure file handling — Are file operations path-traversal safe? 8. Secrets accidentally exposed — Are API keys, passwords in code? 9. API endpoint security — Are new routes properly protected? Structure your response as: ## Security Review ### ✅ Secure practices observed ### ❌ Vulnerabilities found ### ⚠️ Potential concerns ### 📋 Recommendation`, }, { id: "code_quality", label: "Code Quality Review", description: "Assess maintainability and consistency with codebase", systemPrompt: `You are a software architect conducting a CODE QUALITY REVIEW. QUESTION: Is the implementation maintainable and consistent with the existing codebase? Check every aspect below and give concrete examples: 1. Architecture — Are classes/modules well-structured? 2. Duplication — Is there copy-pasted or near-duplicate code? 3. Naming — Are variables, methods, classes named clearly and consistently? 4. Complexity — Are there overly complex methods or deep nesting? 5. Error handling — Are errors caught and handled appropriately? 6. Framework conventions — Does the code follow the project's established patterns? 7. Unnecessary code — Are there dead comments, unused imports, or leftover debug code? 8. Technical debt — Does this add shortcuts that will cost later? Structure your response as: ## Code Quality Review ### ✅ What's well-structured ### ❌ Issues found ### ⚠️ Technical debt introduced ### 📋 Recommendation`, }, { id: "ui_ux", label: "UI/UX Completion Review", description: "Verify frontend completeness and user experience", systemPrompt: `You are a UX specialist conducting a UI/UX COMPLETION REVIEW. QUESTION: Is the user-facing implementation actually complete? Check every aspect below and give concrete examples: 1. All requested UI elements exist — Are buttons, inputs, cards, modals present? 2. Loading states — Are async operations shown with spinners / skeletons? 3. Empty states — What does the user see when there is no data? 4. Error states — Are errors surfaced clearly and helpfully? 5. Validation feedback — Are form validation messages timely and clear? 6. Responsive behavior — Does it work on mobile/tablet breakpoints? 7. Visual consistency — Does it match the design system (colors, spacing, typography)? 8. User flow completeness — Can the user complete the entire task without gaps? If the change has no frontend impact, state that and skip. Structure your response as: ## UI/UX Review ### ✅ Complete & well-done ### ❌ Missing or broken ### ⚠️ Edge cases to address ### 📋 Recommendation`, }, { id: "testing", label: "Testing Review", description: "Check test coverage and regression risks", systemPrompt: `You are a QA engineer conducting a TESTING REVIEW. QUESTION: Is the implementation adequately tested? Check every aspect below and give concrete examples: 1. Existing tests still pass — Is there evidence tests ran successfully? 2. New functionality has appropriate tests — Are there tests for the new behavior? 3. Edge cases — Are boundary conditions, nulls, and error paths tested? 4. Regression risks — Could this change silently break other features? 5. Missing test coverage — Which code paths lack tests? If no tests exist in the project, note that and suggest what should be tested. Structure your response as: ## Testing Review ### ✅ Well-tested areas ### ❌ Missing or insufficient tests ### ⚠️ Regression risks ### 📋 Recommendation`, }, { id: "performance", label: "Performance Review", description: "Identify performance issues and optimization opportunities", systemPrompt: `You are a performance engineer conducting a PERFORMANCE REVIEW. QUESTION: Could this implementation cause performance problems? Check every aspect below and give concrete examples: 1. N+1 queries — Are there loops that trigger repeated database queries? 2. Unnecessary database queries — Could queries be combined or eliminated? 3. Expensive loops — Is there O(n²) or worse complexity where linear would work? 4. Memory usage — Are large datasets loaded into memory unnecessarily? 5. Missing indexes — Do new queries need database indexes? 6. API calls — Are there redundant or unbatched external API calls? 7. Caching opportunities — Could results be cached to reduce repeated work? 8. Frontend performance — Large bundles, unoptimized images, blocking scripts? If the change is trivial with no performance impact, state that. Structure your response as: ## Performance Review ### ✅ Efficient patterns used ### ❌ Performance issues found ### ⚠️ Potential bottlenecks at scale ### 📋 Recommendation`, }, { id: "scope", label: "Requirements / Scope Review", description: "Check for unnecessary or out-of-scope changes", systemPrompt: `You are a project manager conducting a SCOPE REVIEW. QUESTION: Did the agent change more than it should have? IMPORTANT DISTINCTION — Not every change outside the request is a problem. Some changes are FUNCTIONAL DEPENDENCIES: modifications to supporting code (imports, utility functions, config files, type definitions, helper modules, related components) that are REQUIRED for the requested feature to work correctly. These are legitimate and should NOT be flagged as out-of-scope. What SHOULD be flagged: - Truly unrelated files changed with no connection to the task - Cosmetic / formatting-only changes in unrelated files - Refactoring of working code that had nothing to do with the task - Adding features or behavior that weren't asked for - Changing established patterns or APIs without necessity Check every aspect below and give concrete examples from the conversation: 1. Unrequested changes — Were files modified that had nothing to do with the task? (Exclude files that needed updating as functional dependencies of the task.) 2. Unnecessary refactoring — Was working code rewritten without reason? (Be specific: is it truly unnecessary, or was it required to integrate the change?) 3. Breaking changes — Did any API, signature, or behavior change unexpectedly? 4. Files modified outside the scope — List all modified files and flag ONLY the out-of-scope ones. For each file, state whether it is a legitimate functional dependency or truly unrelated. 5. Features implemented differently from the requested behavior — Does the solution match what was asked? Structure your response as: ## Scope Review ### ✅ Changes aligned with the request (including necessary dependencies) ### ❌ Out-of-scope changes (unrelated / unnecessary) ### ⚠️ Unnecessary modifications ### 📋 Recommendation In your recommendation, clearly separate: - Changes that should be reverted (truly out of scope) - Changes that look out of scope but are functional dependencies (keep them)`, }, ]; // --------------------------------------------------------------------------- // Review config (project-level and session-level disable) // --------------------------------------------------------------------------- interface ReviewConfig { autoReview: boolean; reviewModel?: string; } function readProjectReviewConfig(ctx: ExtensionContext): ReviewConfig | null { try { const configPath = join(ctx.cwd, CONFIG_DIR_NAME, "review-config.json"); if (!existsSync(configPath)) return null; const raw = readFileSync(configPath, "utf-8"); return JSON.parse(raw) as ReviewConfig; } catch { return null; } } function isReviewDisabledByEnv(): boolean { const val = process.env["REVIEWER_DISABLE_SESSION"]; return val === "true" || val === "1"; } // --------------------------------------------------------------------------- // Persistent state // --------------------------------------------------------------------------- interface ReviewerState { autoOffer: boolean; reviewModel?: string; } const STATE_CUSTOM_TYPE = "pi-reviewer-config"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function entryToMessage(entry: SessionEntry): AgentMessage | undefined { if (entry.type === "message") return entry.message; if (entry.type === "compaction") { return { role: "compactionSummary", summary: entry.summary, tokensBefore: entry.tokensBefore, timestamp: new Date(entry.timestamp).getTime(), }; } return undefined; } function collectConversation(branch: SessionEntry[]): AgentMessage[] { let compactionIndex = -1; for (let i = branch.length - 1; i >= 0; i--) { if (branch[i].type === "compaction") { compactionIndex = i; break; } } if (compactionIndex < 0) { return branch .map(entryToMessage) .filter((m): m is AgentMessage => m !== undefined); } const compaction = branch[compactionIndex]; const firstKeptIndex = compaction.type === "compaction" ? branch.findIndex((e) => e.id === compaction.firstKeptEntryId) : -1; const compacted = [ compaction, ...(firstKeptIndex >= 0 ? branch.slice(firstKeptIndex, compactionIndex) : []), ...branch.slice(compactionIndex + 1), ]; return compacted .map(entryToMessage) .filter((m): m is AgentMessage => m !== undefined); } /** * Collect only the last user-assistant exchange (last prompt to end of branch). * Default scope for reviews — avoids reviewing the entire session. */ function collectLastTurn(branch: SessionEntry[]): AgentMessage[] { let lastUserIndex = -1; for (let i = branch.length - 1; i >= 0; i--) { const entry = branch[i]; if (entry.type === "message" && entry.message.role === "user") { lastUserIndex = i; break; } } if (lastUserIndex < 0) { return []; } return branch .slice(lastUserIndex) .map(entryToMessage) .filter((m): m is AgentMessage => m !== undefined); } // --------------------------------------------------------------------------- // Multi‑select checkbox component // --------------------------------------------------------------------------- interface CheckboxItem { id: string; label: string; description: string; } class CheckboxList { private cursor = 0; public onConfirm?: (selected: Set) => void; public onCancel?: () => void; private cachedWidth?: number; private cachedLines?: string[]; constructor( private items: CheckboxItem[], private checked: Set, ) {} handleInput(data: string): void { if (matchesKey(data, Key.up)) { this.cursor = Math.max(0, this.cursor - 1); this.invalidate(); } else if (matchesKey(data, Key.down)) { this.cursor = Math.min(this.items.length - 1, this.cursor + 1); this.invalidate(); } else if (matchesKey(data, Key.space)) { const id = this.items[this.cursor]!.id; if (this.checked.has(id)) this.checked.delete(id); else this.checked.add(id); this.invalidate(); } else if (data === "a" || data === "A") { if (this.checked.size === this.items.length) { this.checked.clear(); } else { for (const item of this.items) this.checked.add(item.id); } this.invalidate(); } else if (matchesKey(data, Key.enter)) { this.onConfirm?.(new Set(this.checked)); } else if (matchesKey(data, Key.escape)) { this.onCancel?.(); } } render(width: number, theme: { fg: (color: string, text: string) => string; bg: (color: string, text: string) => string; }): string[] { if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; const lines: string[] = []; const rw = Math.max(1, width); function addWrapped(text: string) { lines.push(...wrapTextWithAnsi(text, rw)); } function addWrappedWithPrefix(prefix: string, text: string) { const linesWrapped = wrapTextWithAnsi(text, rw - [...prefix].length); for (const l of linesWrapped) addWrapped(prefix + l); } addWrapped(theme.fg("accent", "─".repeat(rw))); addWrapped( theme.fg("accent", theme.bold("Code Review — select areas to evaluate")), ); addWrapped(""); addWrapped( theme.fg( "dim", "Space: toggle • A: toggle all • Enter: run • Esc: cancel", ), ); addWrapped(""); for (let i = 0; i < this.items.length; i++) { const item = this.items[i]!; const isFocused = i === this.cursor; const isChecked = this.checked.has(item.id); const prefix = isFocused ? theme.fg("accent", ">") : " "; const check = isChecked ? "☒" : "☐"; const body = `${check} ${item.label}`; const fullLine = `${prefix} ${ isFocused ? theme.bg("selectedBg", theme.fg("text", body)) : theme.fg(isChecked ? "success" : "text", body) }`; addWrapped(fullLine); if (item.description) { addWrappedWithPrefix( " ", theme.fg("muted", item.description), ); } } addWrapped(""); const selectedCount = this.checked.size; addWrapped( theme.fg( selectedCount > 0 ? "success" : "dim", `${selectedCount} of ${this.items.length} areas selected`, ), ); addWrapped(theme.fg("accent", "─".repeat(rw))); this.cachedWidth = width; this.cachedLines = lines; return lines; } invalidate(): void { this.cachedWidth = undefined; this.cachedLines = undefined; } } // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- export default function piReviewer(pi: ExtensionAPI) { // Whether we auto-offered after agent_settled (avoid double‑prompt) let offeredThisTask = false; // Persistent configuration let autoOffer = true; let globalReviewModel: string | undefined; let perReviewModelOverride: string | undefined; // ------------------------------------------------------------------- // Entry renderers — display review results in the chat transcript // without polluting LLM context (pi.appendEntry entries are NOT sent // to the LLM, unlike pi.sendMessage). // ------------------------------------------------------------------- pi.registerEntryRenderer<{ content: string; timestamp: number }>( "pi-reviewer-report", (entry, _opts, theme) => { const data = entry.data ?? { content: "", timestamp: Date.now() }; const header = theme.fg("accent", theme.bold("📋 Code Review Report")); const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); box.addChild(new Text(header, 0, 0)); box.addChild(new Text(data.content, 0, 1)); return box; }, ); pi.registerEntryRenderer<{ content: string; timestamp: number }>( "pi-reviewer-fixes", (entry, _opts, theme) => { const data = entry.data ?? { content: "", timestamp: Date.now() }; const header = theme.fg("success", theme.bold("🔧 Fixes Applied")); const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); box.addChild(new Text(header, 0, 0)); box.addChild(new Text(data.content, 0, 1)); return box; }, ); function persistState() { pi.appendEntry(STATE_CUSTOM_TYPE, { autoOffer, reviewModel: globalReviewModel }); } function restoreFromBranch(ctx: ExtensionContext) { const entries = ctx.sessionManager.getBranch(); for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if ( entry.type === "custom" && entry.customType === STATE_CUSTOM_TYPE ) { const data = entry.data as ReviewerState | undefined; if (typeof data?.autoOffer === "boolean") { autoOffer = data.autoOffer; } if (data?.reviewModel !== undefined) { globalReviewModel = data.reviewModel; } break; } } } // ----------------------------------------------------------------------- // Model resolution — picks the effective review model by priority: // 1. Per-review override (set during pickAndReview / auto-offer flow) // 2. Project-level config (.pi/review-config.json) // 3. Global session state (set via /review-set-model) // 4. Current session model (fallback) // ----------------------------------------------------------------------- function resolveReviewModel(ctx: ExtensionContext) { const findModel = (modelId: string) => { const available = ctx.modelRegistry.getAvailable?.() ?? []; const slashIdx = modelId.indexOf("/"); if (slashIdx < 0) return undefined; const provider = modelId.slice(0, slashIdx); const id = modelId.slice(slashIdx + 1); return available.find((m) => m.provider === provider && m.id === id); }; // 1. Per-review override (highest priority, cleared after review) if (perReviewModelOverride) { const model = findModel(perReviewModelOverride); if (model) return model; } // 2. Project-level config const projectCfg = readProjectReviewConfig(ctx); if (projectCfg?.reviewModel) { const model = findModel(projectCfg.reviewModel); if (model) return model; } // 3. Global (session) state if (globalReviewModel) { const model = findModel(globalReviewModel); if (model) return model; } // 4. Fall back to current session model return ctx.model; } /** * Show a picker to choose a model for reviews. * @param showClear If true, adds a "None — use current session model" option that returns "". * @returns The model ID string ("provider/id"), "" to clear, or undefined if cancelled. */ async function chooseReviewModel( ctx: ExtensionContext, showClear: boolean, ): Promise { const available = ctx.modelRegistry.getAvailable?.() ?? []; if (available.length === 0) { ctx.ui.notify("No models available", "error"); return undefined; } const choices: string[] = []; if (showClear) { choices.push("None — use current session model (clear review model)"); } for (const m of available) { choices.push(`${m.name ?? m.id} (${m.provider}/${m.id})`); } const choice = await ctx.ui.select("Choose review model:", choices); if (!choice) return undefined; if (showClear && choice.startsWith("None")) { return ""; // Signal to clear } // Extract provider/id from the choice format "name (provider/id)" const match = choice.match(/\(([^)]+)\)$/); if (match) { return match[1]!; } return undefined; } function isAutoReviewActive(ctx: ExtensionContext): boolean { // Session-level env var takes highest priority if (isReviewDisabledByEnv()) return false; // Project-level config const projectCfg = readProjectReviewConfig(ctx); if (projectCfg && projectCfg.autoReview === false) return false; return true; } function updateStatus(ctx: ExtensionContext) { if (!isAutoReviewActive(ctx)) { ctx.ui.setStatus("pi-reviewer", undefined); } else if (autoOffer) { ctx.ui.setStatus( "pi-reviewer", ctx.ui.theme.fg("accent", "🔍 auto-review"), ); } else { ctx.ui.setStatus("pi-reviewer", undefined); } } // ----------------------------------------------------------------------- // Issue detection and apply-findings logic // ----------------------------------------------------------------------- /** * Check whether the combined review output contains actionable issues. * Looks for "### ❌ Issues found" / "### ❌" / "### ⚠️" sections with bullet points. */ function reviewHasIssues(combinedOutput: string): boolean { // Match ❌ sections const issueSections = combinedOutput.match(/### ❌[^\n]*\n\n([\s\S]*?)(?=### |$)/g); if (issueSections) { for (const section of issueSections) { const body = section.replace(/### ❌[^\n]*\n\n/, "").trim(); if ( body.length > 0 && !/^(none|no issues|n\/a)\s*$/i.test(body) && /-\s/.test(body) ) { return true; } } } // Match ⚠️ sections (warnings) const warningSections = combinedOutput.match(/### ⚠️[^\n]*\n\n([\s\S]*?)(?=### |$)/g); if (warningSections) { for (const section of warningSections) { const body = section.replace(/### ⚠️[^\n]*\n\n/, "").trim(); if ( body.length > 0 && !/^(none|no (issues|concerns)|n\/a)\s*$/i.test(body) && /-\s/.test(body) ) { return true; } } } return false; } /** * Run the LLM to apply review findings to the codebase. * @param userInstructions Optional instructions from the user about how to * apply the findings (e.g. skip certain items, handle others differently). */ async function applyFindings( reviewText: string, ctx: ExtensionContext, userInstructions?: string, ): Promise { const reviewModel = resolveReviewModel(ctx); if (!reviewModel) { ctx.ui.notify("No model selected — cannot apply findings", "error"); return null; } const messages = collectConversation(ctx.sessionManager.getBranch()); const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); const instructionsBlock = userInstructions ? `\n\n## User instructions for applying the review\n\n${userInstructions}\n\nFollow these instructions carefully: if the user asks to skip a finding, do not apply it. If they ask to handle a finding differently, follow their guidance.` : ""; const systemPrompt = `You are applying code review findings to fix issues discovered during a review. Below is the review report and the conversation that led to it. Your job is to: 1. Read every ❌ Issues found and ⚠️ section in the review. 2. For each concrete, actionable item, edit the relevant files to fix it. 3. Skip items marked as "None", "N/A", or that are purely advisory. 4. Run tests if available to verify your fixes don't break anything. 5. Use the project's existing conventions and code style. 6. IMPORTANT: If the user provided specific instructions about which findings to skip or how to handle them differently, follow those instructions EXACTLY. After applying fixes, output: ## Fixes Applied - \`file.ts:42\` — what was changed and why ## Issues Deferred (if any) - \`file.ts:100\` — why it wasn't changed (include user-requested skips here) ## Verification - Test results or confirmation that changes are safe.`; return await ctx.ui.custom((tui, theme, _kb, done) => { const loader = new BorderedLoader( tui, theme, "Applying review findings...", ); loader.onAbort = () => done(null); const doApply = async () => { try { const auth = await ctx.modelRegistry.getApiKeyAndHeaders( reviewModel, ); if (!auth.ok || !auth.apiKey) { done(null); return; } const response = await complete( reviewModel, { systemPrompt, messages: [ { role: "user", content: [ { type: "text", text: `## Original conversation\n\n${conversationText}\n\n## Review findings to apply\n\n${reviewText}${instructionsBlock}\n\nPlease apply the actionable fixes from the review above.`, }, ], timestamp: Date.now(), }, ], }, { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal: loader.signal, }, ); if (response.stopReason === "aborted") { done(null); return; } const text = response.content .filter( (c): c is { type: "text"; text: string } => c.type === "text", ) .map((c) => c.text) .join("\n"); done(text); } catch { done(null); } }; doApply(); return loader; }); } // ----------------------------------------------------------------------- // Save review report to .pi/reviews/ // ----------------------------------------------------------------------- async function saveReviewToFile( content: string, ctx: ExtensionContext, ): Promise { // Put the review content in the editor so the user can modify it before saving ctx.ui.setEditorText(content); ctx.ui.notify( "Review content placed in editor — edit as needed, then enter a filename to save", "info", ); // Generate a sensible default filename const now = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; const defaultName = `review-${ts}`; const filename = await ctx.ui.input( "Save review as (filename, no extension):", defaultName, ); if (!filename) { // Leave content in editor so the user can edit it and re-save with /save-review ctx.ui.notify( "Review left in editor — edit it, then run /save-review to save", "info", ); return; } // Read back the (potentially modified) content from the editor const modifiedContent = ctx.ui.getEditorText(); ctx.ui.setEditorText(""); const { writeFileSync, mkdirSync } = await import("node:fs"); const reviewsDir = join(ctx.cwd, CONFIG_DIR_NAME, "reviews"); try { if (!existsSync(reviewsDir)) { mkdirSync(reviewsDir, { recursive: true }); } // Sanitize filename: remove path separators and limit length const safeName = filename.replace(/[/\\:*?"<>|]/g, "-").slice(0, 120); const filePath = join(reviewsDir, `${safeName}.md`); const header = [ `# Code Review Report`, "", `**Generated:** ${now.toISOString()}`, `**Project:** ${ctx.cwd}`, "", "---", "", ].join("\n"); writeFileSync(filePath, header + modifiedContent, "utf-8"); ctx.ui.notify(`Review saved to .pi/reviews/${safeName}.md`, "success"); } catch (err: any) { ctx.ui.notify(`Failed to save review: ${err.message}`, "error"); } } // ----------------------------------------------------------------------- // Core review function // ----------------------------------------------------------------------- async function runReview( areaIds: Set, ctx: ExtensionContext, scope: "last" | "full" = "last", ) { const areas = REVIEW_AREAS.filter((a) => areaIds.has(a.id)); if (areas.length === 0) { ctx.ui.notify("No areas selected", "error"); return; } const reviewModel = resolveReviewModel(ctx); if (!reviewModel) { ctx.ui.notify("No model selected", "error"); return; } // Collect conversation context (based on scope) const branch = ctx.sessionManager.getBranch(); const messages = scope === "last" ? collectLastTurn(branch) : collectConversation(branch); if (messages.length === 0) { ctx.ui.notify("No conversation to review", "error"); return; } const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); // Run each area sequentially with a loader UI per area const results: { area: ReviewArea; text: string }[] = []; for (const area of areas) { const areaResult = await ctx.ui.custom( (tui, theme, _kb, done) => { const loader = new BorderedLoader( tui, theme, `Reviewing: ${area.label}...`, ); loader.onAbort = () => done(null); const doReview = async () => { try { const auth = await ctx.modelRegistry.getApiKeyAndHeaders( reviewModel, ); if (!auth.ok || !auth.apiKey) { done(null); return; } const response = await complete( reviewModel, { systemPrompt: area.systemPrompt, messages: [ { role: "user", content: [ { type: "text", text: `## Conversation to review\n\n${conversationText}\n\nPlease provide your ${area.label} based on the conversation above.`, }, ], timestamp: Date.now(), }, ], }, { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal: loader.signal, }, ); if (response.stopReason === "aborted") { done(null); return; } const text = response.content .filter( (c): c is { type: "text"; text: string } => c.type === "text", ) .map((c) => c.text) .join("\n"); done(text); } catch { done(null); } }; doReview(); return loader; }, ); if (areaResult === null) { ctx.ui.notify(`Review cancelled during "${area.label}"`, "info"); return; } results.push({ area, text: areaResult }); } if (results.length === 0) return; // Build combined output const combined = results .map((r) => r.text) .join("\n\n---\n\n"); // Detect whether actionable issues were found const hasIssues = reviewHasIssues(combined); // Show review results as a durable chat entry (visible to user, // but NOT sent to the LLM — pi.appendEntry entries don't // participate in LLM context). pi.appendEntry<{ content: string; timestamp: number }>( "pi-reviewer-report", { content: combined, timestamp: Date.now() }, ); // Always offer three options: Apply fixes (if issues found), Save, or Dismiss const choices = hasIssues ? ["Apply fixes now", "Save report to file", "Dismiss"] : ["Save report to file", "Dismiss"]; const action = await ctx.ui.select("Review complete — what would you like to do?", choices); if (action === "Apply fixes now") { // Ask the user for optional instructions before applying const instructions = await ctx.ui.input( "Optional: add instructions for applying the review (e.g. skip finding X, handle Y differently). Press Enter to apply all findings as-is:", "", ); // undefined means the user cancelled the prompt if (instructions === undefined) { ctx.ui.notify("Apply cancelled", "info"); return; } const userInstructions = instructions.trim() || undefined; const fixResult = await applyFindings(combined, ctx, userInstructions); if (fixResult) { pi.appendEntry<{ content: string; timestamp: number }>( "pi-reviewer-fixes", { content: fixResult, timestamp: Date.now() }, ); ctx.ui.notify( `✅ Review complete — ${results.length} area(s), fixes applied.`, "info", ); return; } } else if (action === "Save report to file") { await saveReviewToFile(combined, ctx); return; } // Dismiss (or undefined / cancelled) ctx.ui.notify( `✅ Review complete — ${results.length} area(s).`, "info", ); } // ----------------------------------------------------------------------- // Review area picker UI (shared by command and agent_settled) // ----------------------------------------------------------------------- async function pickAndReview( ctx: ExtensionContext, ): Promise { if (ctx.mode !== "tui") { ctx.ui.notify("/review requires interactive mode", "error"); return; } // Ask about review scope — default to last prompt only const scopeChoice = await ctx.ui.select("Review scope?", [ "Last prompt only", "Entire session", ]); if (scopeChoice === undefined) { ctx.ui.notify("Review cancelled", "info"); return; } const scope: "last" | "full" = scopeChoice === "Entire session" ? "full" : "last"; // Ask about review model — per-review override const effectiveModel = resolveReviewModel(ctx); const currentModelLabel = effectiveModel ? `${effectiveModel.name ?? effectiveModel.id} (${effectiveModel.provider}/${effectiveModel.id})` : "none"; const modelChoice = await ctx.ui.select( `Review model? [current: ${currentModelLabel}]`, [ "Use current/default model", "Choose a different model for this review", ], ); if (modelChoice === undefined) { ctx.ui.notify("Review cancelled", "info"); return; } if (modelChoice.startsWith("Choose")) { const chosen = await chooseReviewModel(ctx, false); if (chosen) perReviewModelOverride = chosen; } const selected = await ctx.ui.custom | null>( (tui, theme, _kb, done) => { const list = new CheckboxList( REVIEW_AREAS.map((a) => ({ id: a.id, label: a.label, description: a.description, })), new Set(REVIEW_AREAS.map((a) => a.id)), // all checked by default ); list.onConfirm = (s) => done(s); list.onCancel = () => done(null); return { render: (w: number) => list.render(w, theme), invalidate: () => list.invalidate(), handleInput: (data: string) => { list.handleInput(data); tui.requestRender(); }, }; }, ); if (selected === null || selected.size === 0) { ctx.ui.notify("Review cancelled", "info"); return; } await runReview(selected, ctx, scope); perReviewModelOverride = undefined; } // ----------------------------------------------------------------------- // /review command // ----------------------------------------------------------------------- pi.registerCommand("review", { description: "Run a multi-area code review on the just-completed task", handler: async (_args, ctx) => { await pickAndReview(ctx); }, }); // ----------------------------------------------------------------------- // /save-review command — save editor content to .pi/reviews/ // ----------------------------------------------------------------------- pi.registerCommand("save-review", { description: "Save the review currently in the editor to .pi/reviews/.md", handler: async (_args, ctx) => { const content = ctx.ui.getEditorText(); if (!content || content.trim().length === 0) { ctx.ui.notify("No review content in editor to save", "error"); return; } // If an argument was passed, use it as the filename let filename = _args.trim(); if (!filename) { // Generate default filename const now = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; filename = await ctx.ui.input( "Save review as (filename, no extension):", `review-${ts}`, ) ?? ""; } if (!filename) { ctx.ui.notify("Save cancelled", "info"); return; } const { writeFileSync, mkdirSync } = await import("node:fs"); const reviewsDir = join(ctx.cwd, CONFIG_DIR_NAME, "reviews"); try { if (!existsSync(reviewsDir)) { mkdirSync(reviewsDir, { recursive: true }); } const safeName = filename.replace(/[/\\:*?"<>|]/g, "-").slice(0, 120); const filePath = join(reviewsDir, `${safeName}.md`); const now = new Date(); const header = [ `# Code Review Report`, "", `**Generated:** ${now.toISOString()}`, `**Project:** ${ctx.cwd}`, "", "---", "", ].join("\n"); writeFileSync(filePath, header + content, "utf-8"); ctx.ui.setEditorText(""); ctx.ui.notify(`Review saved to .pi/reviews/${safeName}.md`, "success"); } catch (err: any) { ctx.ui.notify(`Failed to save review: ${err.message}`, "error"); } }, }); // ----------------------------------------------------------------------- // /review-toggle command — enable/disable auto‑offer on task completion // ----------------------------------------------------------------------- pi.registerCommand("review-toggle", { description: "Toggle auto-review prompt after each task", handler: async (_args, ctx) => { autoOffer = !autoOffer; persistState(); updateStatus(ctx); ctx.ui.notify( autoOffer ? "Auto-review ON — you will be prompted after each task" : "Auto-review OFF — use /review to run manually", "info", ); }, }); // ----------------------------------------------------------------------- // /review-disable-project — disable auto-review for the current project // ----------------------------------------------------------------------- pi.registerCommand("review-disable-project", { description: "Disable auto-review for this project (writes .pi/review-config.json)", handler: async (_args, ctx) => { const { writeFileSync } = await import("node:fs"); const { mkdirSync } = await import("node:fs"); const configDir = join(ctx.cwd, CONFIG_DIR_NAME); try { if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); const existing = readProjectReviewConfig(ctx); const config: ReviewConfig = { autoReview: false, reviewModel: existing?.reviewModel, }; writeFileSync( join(configDir, "review-config.json"), JSON.stringify(config, null, 2) + "\n", "utf-8", ); updateStatus(ctx); ctx.ui.notify( "Auto-review DISABLED for this project. Use /review-enable-project to re-enable or set REVIEWER_DISABLE_SESSION=1 for session-only.", "info", ); } catch (err: any) { ctx.ui.notify(`Failed: ${err.message}`, "error"); } }, }); // ----------------------------------------------------------------------- // /review-enable-project — re-enable auto-review for the current project // ----------------------------------------------------------------------- pi.registerCommand("review-enable-project", { description: "Re-enable auto-review for this project (updates .pi/review-config.json)", handler: async (_args, ctx) => { const { writeFileSync } = await import("node:fs"); const { mkdirSync } = await import("node:fs"); const configDir = join(ctx.cwd, CONFIG_DIR_NAME); try { if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); const existing = readProjectReviewConfig(ctx); const config: ReviewConfig = { autoReview: true, reviewModel: existing?.reviewModel, }; writeFileSync( join(configDir, "review-config.json"), JSON.stringify(config, null, 2) + "\n", "utf-8", ); updateStatus(ctx); ctx.ui.notify( "Auto-review ENABLED for this project.", "info", ); } catch (err: any) { ctx.ui.notify(`Failed: ${err.message}`, "error"); } }, }); // ----------------------------------------------------------------------- // /review-set-model — set the global (session-persistent) review model // ----------------------------------------------------------------------- pi.registerCommand("review-set-model", { description: "Set the model used for code reviews (global, persists across sessions)", handler: async (_args, ctx) => { const chosen = await chooseReviewModel(ctx, true); if (chosen === undefined) { ctx.ui.notify("Model selection cancelled", "info"); return; } if (chosen === "") { globalReviewModel = undefined; persistState(); updateStatus(ctx); ctx.ui.notify( "Review model cleared — will use the current session model for reviews.", "info", ); return; } globalReviewModel = chosen; persistState(); updateStatus(ctx); ctx.ui.notify( `Review model set globally to ${chosen}`, "success", ); }, }); // ----------------------------------------------------------------------- // /review-model-project — set the project-level review model // ----------------------------------------------------------------------- pi.registerCommand("review-model-project", { description: "Set the model used for code reviews in this project (writes .pi/review-config.json)", handler: async (_args, ctx) => { const chosen = await chooseReviewModel(ctx, true); if (chosen === undefined) { ctx.ui.notify("Model selection cancelled", "info"); return; } const { writeFileSync } = await import("node:fs"); const { mkdirSync } = await import("node:fs"); const configDir = join(ctx.cwd, CONFIG_DIR_NAME); try { if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); // Preserve existing config values (autoReview, etc.) const existing = readProjectReviewConfig(ctx); const config: ReviewConfig = { autoReview: existing?.autoReview ?? true, reviewModel: chosen !== "" ? chosen : undefined, }; writeFileSync( join(configDir, "review-config.json"), JSON.stringify(config, null, 2) + "\n", "utf-8", ); updateStatus(ctx); if (chosen === "") { ctx.ui.notify( "Project review model cleared — will fall back to global or current session model.", "info", ); } else { ctx.ui.notify( `Project review model set to ${chosen}`, "success", ); } } catch (err: any) { ctx.ui.notify(`Failed: ${err.message}`, "error"); } }, }); // ----------------------------------------------------------------------- // Auto‑offer after agent settles // ----------------------------------------------------------------------- pi.on("agent_settled", async (_event, ctx) => { if (!ctx.hasUI) return; if (offeredThisTask) return; offeredThisTask = true; if (!autoOffer) return; // Check project-level and session-level disable if (!isAutoReviewActive(ctx)) return; const choice = await ctx.ui.select("Task complete — run a code review?", [ "Review (all 7 areas)", "Pick which areas to review", "Disable auto-offer (use /review manually)", "Skip", ]); if (choice === "Skip" || choice === undefined) return; if (choice?.startsWith("Disable")) { autoOffer = false; persistState(); updateStatus(ctx); ctx.ui.notify( "Auto-review disabled. Use /review to run manually.", "info", ); return; } if (choice === "Review (all 7 areas)") { const scopeChoice = await ctx.ui.select("Review scope?", [ "Last prompt only", "Entire session", ]); if (scopeChoice === undefined) return; const scope: "last" | "full" = scopeChoice === "Entire session" ? "full" : "last"; // Model selection const effectiveModel = resolveReviewModel(ctx); const currentModelLabel = effectiveModel ? `${effectiveModel.name ?? effectiveModel.id} (${effectiveModel.provider}/${effectiveModel.id})` : "none"; const modelChoice = await ctx.ui.select( `Review model? [current: ${currentModelLabel}]`, [ "Use current/default model", "Choose a different model for this review", ], ); if (modelChoice === undefined) return; if (modelChoice.startsWith("Choose")) { const chosen = await chooseReviewModel(ctx, false); if (chosen) perReviewModelOverride = chosen; } const allIds = new Set(REVIEW_AREAS.map((a) => a.id)); await runReview(allIds, ctx, scope); perReviewModelOverride = undefined; } else { await pickAndReview(ctx); } }); // Reset the offer flag when a new agent run starts pi.on("agent_start", () => { offeredThisTask = false; }); // ----------------------------------------------------------------------- // Session lifecycle — restore state and show status // ----------------------------------------------------------------------- pi.on("session_start", async (_event, ctx) => { restoreFromBranch(ctx); updateStatus(ctx); }); pi.on("session_tree", async (_event, ctx) => { restoreFromBranch(ctx); updateStatus(ctx); }); }