@@ -22,7 +22,7 @@ import { logError, CLI_ERR } from "./error-logger.js";
2222/** An action executed before or after the agent runs */
2323export 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 ( / \/ .g i t $ / , "" ) ;
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 ( / ^ p a r e n t / 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 ( / C O M P L E T I O N _ J S O N : ( \{ [ \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