Skip to content

Commit cbe9036

Browse files
recuu-pfegclaude
andcommitted
feat: Reviewer agent — spawn independent process for code review
Replace 1-turn claude --print with full agent process: - 5 turns, 5 minute timeout - Can read files, run tests, analyze diff - Uses reviewer template (read-only + Bash) - Playbook rules injected via customReviewRules - COMPLETION_JSON with verdict parsed and applied - Falls back to review_changes if spawn_reviewer fails Implementation and content templates now use spawn_reviewer. review_changes kept as fallback for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 08ed4fd commit cbe9036

1 file changed

Lines changed: 165 additions & 3 deletions

File tree

src/agent-templates.ts

Lines changed: 165 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { logError, CLI_ERR } from "./error-logger.js";
2222
/** An action executed before or after the agent runs */
2323
export interface TemplateAction {
2424
/** Action type */
25-
type: "update_task" | "update_agent" | "git_merge" | "git_push" | "git_auth_check" | "review_changes" | "submit_retro" | "notify_user" | "shell" | "inject_memory" | "collect_memory";
25+
type: "update_task" | "update_agent" | "git_merge" | "git_push" | "git_auth_check" | "review_changes" | "spawn_reviewer" | "submit_retro" | "notify_user" | "shell" | "inject_memory" | "collect_memory";
2626
/** Parameters passed to the action */
2727
params?: Record<string, unknown>;
2828
/** Human-readable description */
@@ -78,7 +78,7 @@ const DEFAULT_TEMPLATES: AgentTemplate[] = [
7878
{ type: "collect_memory", when: "success", label: "Collect agent memory" },
7979
{ type: "git_merge", when: "success", label: "Merge branch to base" },
8080
{ type: "git_push", when: "success", label: "Push main to remote" },
81-
{ type: "review_changes", when: "success", label: "Auto-review code changes" },
81+
{ type: "spawn_reviewer", when: "success", label: "Spawn Reviewer agent for code review" },
8282
{ type: "update_task", params: { status: "review" }, when: "success", label: "Move task to review" },
8383
{ type: "submit_retro", when: "success", label: "Submit retrospective" },
8484
{ type: "update_agent", params: { status: "idle", activity: "Task completed" }, when: "success", label: "Report agent idle" },
@@ -157,7 +157,7 @@ DO NOT: git add, git commit, git push, or modify any files. Only read and analyz
157157
{ type: "collect_memory", when: "success", label: "Collect agent memory" },
158158
{ type: "git_merge", when: "success", label: "Merge branch to base" },
159159
{ type: "git_push", when: "success", label: "Push main to remote" },
160-
{ type: "review_changes", when: "success", label: "Auto-review content changes" },
160+
{ type: "spawn_reviewer", when: "success", label: "Spawn Reviewer agent for content review" },
161161
{ type: "update_task", params: { status: "review" }, when: "success", label: "Move task to review" },
162162
{ type: "submit_retro", when: "success", label: "Submit retrospective" },
163163
{ type: "update_agent", params: { status: "idle", activity: "Task completed" }, when: "success", label: "Report agent idle" },
@@ -216,6 +216,42 @@ COMPLETION_JSON:{"review_comment":"<your strategic analysis and recommendations>
216216
],
217217
},
218218
},
219+
{
220+
id: "reviewer",
221+
name: "Code Reviewer",
222+
match: {
223+
roles: ["reviewer"],
224+
},
225+
tools: ["Read", "Grep", "Glob", "Bash", "Agent"],
226+
pre_actions: [],
227+
post_actions: [],
228+
prompt: {
229+
mode_header: "## REVIEW MODE — Analyze code changes, run tests, report verdict. Do NOT modify any files.",
230+
completion: `You are reviewing code changes for a task. Your job:
231+
232+
1. Run: git diff {{diffRef}} to see the changes
233+
2. Read relevant source files for context
234+
3. Run: npm test (check if tests pass)
235+
4. Evaluate against the criteria below
236+
237+
## Review Criteria
238+
{{reviewCriteria}}
239+
240+
## Project Review Rules
241+
{{customReviewRules}}
242+
243+
IMPORTANT: Do NOT modify any files. Do NOT commit. Do NOT push. Only analyze and report.
244+
245+
When done, output your verdict on a new line in this exact format:
246+
COMPLETION_JSON:{"verdict":"APPROVE or NEEDS_CHANGES","requirement_match":"met/partial/not — explain","files_changed":"file: summary","code_quality":"issues or clean","test_coverage":"tested or not","risks":"risks or none"}`,
247+
rules: [
248+
"You MUST NOT create, edit, write, or delete any files.",
249+
"You MUST NOT run git add, git commit, git push.",
250+
"Run tests and read code to inform your review.",
251+
"Be strict: if changes don't match the task, verdict = NEEDS_CHANGES.",
252+
],
253+
},
254+
},
219255
];
220256

221257
// ---------------------------------------------------------------------------
@@ -513,6 +549,132 @@ export async function executeActions(
513549
}
514550
break;
515551
}
552+
case "spawn_reviewer": {
553+
ctx.onReviewUpdate?.(ctx.task.id, "started");
554+
const { execSync: revExec2 } = await import("node:child_process");
555+
const { existsSync: revExists2 } = await import("node:fs");
556+
const { spawn: reviewSpawn2 } = await import("node:child_process");
557+
558+
// Resolve repo root
559+
const reviewRepoDir = (() => {
560+
if (revExists2(ctx.config.workingDir)) {
561+
try {
562+
return revExec2("git rev-parse --path-format=absolute --git-common-dir", { cwd: ctx.config.workingDir, stdio: "pipe" })
563+
.toString().trim().replace(/\/.git$/, "");
564+
} catch { /* fall through */ }
565+
}
566+
return ctx.config.workingDir;
567+
})();
568+
569+
// Get diff ref for the reviewer prompt
570+
const diffRef = (() => {
571+
try {
572+
const parents = revExec2("git cat-file -p HEAD", { cwd: reviewRepoDir, stdio: "pipe" }).toString();
573+
const parentCount = (parents.match(/^parent /gm) || []).length;
574+
return parentCount === 0 ? "--root HEAD" : "HEAD~1..HEAD";
575+
} catch { return "HEAD~1..HEAD"; }
576+
})();
577+
578+
// Get diff stat for context
579+
let filesChanged: string[] = [];
580+
try {
581+
const diffStat = revExec2(`git diff ${diffRef} --stat`, { cwd: reviewRepoDir, stdio: "pipe", timeout: 10_000 }).toString().trim();
582+
filesChanged = diffStat.split("\n").slice(0, -1).map(l => l.trim().split(/\s+/)[0]).filter(Boolean);
583+
} catch { /* empty */ }
584+
585+
// Build reviewer prompt
586+
const taskType = (ctx.task as Record<string, unknown>).type as string || "implementation";
587+
const { PROMPT_TEMPLATES } = await import("./prompts/templates.js");
588+
const typeHints = JSON.parse(PROMPT_TEMPLATES["reviewer-type-hints"] || "{}") as Record<string, string>;
589+
590+
let customRules = "";
591+
try { customRules = await ctx.api.fetchPlaybookPrompt("reviewer") || ""; } catch { /* non-fatal */ }
592+
593+
const reviewerTemplate = DEFAULT_TEMPLATES.find((t) => t.id === "reviewer")!;
594+
const reviewCriteria = [
595+
"1. REQUIREMENT MATCH: Do changes address the task description? Unrelated = NEEDS_CHANGES",
596+
"2. SCOPE: Limited to what the task asks? Out-of-scope = NEEDS_CHANGES",
597+
"3. MEANINGFUL CHANGES: Real code/content? Metadata-only = NEEDS_CHANGES",
598+
"4. CODE QUALITY: Readability, security, error handling",
599+
`5. ${typeHints[taskType] || typeHints.implementation || ""}`,
600+
"",
601+
"If tests fail, verdict MUST be NEEDS_CHANGES.",
602+
"If changes don't match the task, verdict MUST be NEEDS_CHANGES.",
603+
].join("\n");
604+
605+
const reviewPrompt = interpolate(reviewerTemplate.prompt.completion, {
606+
diffRef,
607+
taskTitle: ctx.task.title,
608+
taskDescription: ctx.task.description || "(no description)",
609+
taskType,
610+
reviewCriteria,
611+
customReviewRules: customRules ? `\n${customRules}` : "",
612+
});
613+
614+
const fullPrompt = `${reviewerTemplate.prompt.mode_header}\n\nTask: ${ctx.task.title}\nType: ${taskType}\nFiles changed: ${filesChanged.join(", ") || "unknown"}\n\n${reviewPrompt}`;
615+
616+
// Spawn reviewer as agent process
617+
ctx.onReviewUpdate?.(ctx.task.id, "analyzing");
618+
ui.info(`[${phase}] ${label}: spawning Reviewer agent (${filesChanged.length} files)`);
619+
620+
const REVIEWER_TIMEOUT = 300_000; // 5 minutes
621+
const reviewResult = await new Promise<string>((resolve) => {
622+
const env = { ...process.env };
623+
delete env.CLAUDECODE;
624+
const child = reviewSpawn2("claude", [
625+
"--print", "--model", "claude-sonnet-4-20250514", "--max-turns", "5", fullPrompt,
626+
], {
627+
env, cwd: reviewRepoDir, stdio: ["ignore", "pipe", "pipe"], timeout: REVIEWER_TIMEOUT,
628+
});
629+
let out = "";
630+
let resolved = false;
631+
child.stdout?.on("data", (chunk: Buffer) => { out += chunk.toString(); });
632+
child.stderr?.on("data", () => {}); // consume stderr
633+
child.on("close", () => { if (!resolved) { resolved = true; resolve(out); } });
634+
child.on("error", () => { if (!resolved) { resolved = true; resolve(out || ""); } });
635+
setTimeout(() => { if (!resolved) { resolved = true; try { child.kill(); } catch {} resolve(out || ""); } }, REVIEWER_TIMEOUT);
636+
});
637+
638+
// Parse COMPLETION_JSON from reviewer output
639+
let verdict: "APPROVE" | "NEEDS_CHANGES" = "NEEDS_CHANGES";
640+
let reviewComment = "";
641+
const completionMatch = reviewResult.match(/COMPLETION_JSON:(\{[\s\S]*?\})\s*$/m);
642+
if (completionMatch) {
643+
try {
644+
const report = JSON.parse(completionMatch[1]) as Record<string, unknown>;
645+
// Normalize verdict
646+
const v = String(report.verdict || "").toUpperCase();
647+
verdict = (v.includes("APPROVE") && !v.includes("NEEDS")) ? "APPROVE" : "NEEDS_CHANGES";
648+
report.verdict = verdict;
649+
reviewComment = JSON.stringify(report);
650+
651+
// Save structured review
652+
try {
653+
await fetch(`${ctx.config.apiUrl}/api/v1/tasks/${ctx.task.id}/review-report`, {
654+
method: "POST",
655+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${ctx.config.apiKey}` },
656+
body: JSON.stringify(report),
657+
});
658+
} catch { /* fallback below */ }
659+
} catch {
660+
reviewComment = reviewResult.slice(-2000);
661+
}
662+
} else {
663+
// No COMPLETION_JSON — use raw output as review
664+
reviewComment = reviewResult.slice(-2000) || "Reviewer agent produced no output";
665+
}
666+
667+
// Save review comment if not saved via review-report
668+
if (!completionMatch) {
669+
await ctx.api.updateTask(ctx.task.id, { review_comment: reviewComment } as Partial<Task>);
670+
}
671+
672+
ctx.reviewVerdict = verdict;
673+
ctx.onDataUpdate?.("task", ctx.task.id, { review_comment: reviewComment });
674+
ctx.onReviewUpdate?.(ctx.task.id, "completed", reviewComment);
675+
ui.info(`[${phase}] ${label}: verdict = ${verdict}`);
676+
break;
677+
}
516678
case "review_changes": {
517679
ctx.onReviewUpdate?.(ctx.task.id, "started");
518680
const { execSync: revExec } = await import("node:child_process");

0 commit comments

Comments
 (0)