2026-03-31 00:28:15 +08:00
#!/usr/bin/env node
import fs from "node:fs" ;
import process from "node:process" ;
import path from "node:path" ;
import { spawnSync } from "node:child_process" ;
import { fileURLToPath } from "node:url" ;
2026-04-08 11:06:22 +08:00
import { getCodexAvailability } from "./lib/codex.mjs" ;
2026-03-31 00:28:15 +08:00
import { loadPromptTemplate , interpolateTemplate } from "./lib/prompts.mjs" ;
import { getConfig , listJobs } from "./lib/state.mjs" ;
import { sortJobsNewestFirst } from "./lib/job-control.mjs" ;
import { SESSION _ID _ENV } from "./lib/tracked-jobs.mjs" ;
import { resolveWorkspaceRoot } from "./lib/workspace.mjs" ;
const STOP _REVIEW _TIMEOUT _MS = 15 * 60 * 1000 ;
const SCRIPT _DIR = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
const ROOT _DIR = path . resolve ( SCRIPT _DIR , ".." ) ;
const STOP _REVIEW _TASK _MARKER = "Run a stop-gate review of the previous Claude turn." ;
function readHookInput ( ) {
const raw = fs . readFileSync ( 0 , "utf8" ) . trim ( ) ;
if ( ! raw ) {
return { } ;
}
return JSON . parse ( raw ) ;
}
function emitDecision ( payload ) {
process . stdout . write ( ` ${ JSON . stringify ( payload ) } \n ` ) ;
}
function logNote ( message ) {
if ( ! message ) {
return ;
}
process . stderr . write ( ` ${ message } \n ` ) ;
}
function filterJobsForCurrentSession ( jobs , input = { } ) {
const sessionId = input . session _id || process . env [ SESSION _ID _ENV ] || null ;
if ( ! sessionId ) {
return jobs ;
}
return jobs . filter ( ( job ) => job . sessionId === sessionId ) ;
}
function buildStopReviewPrompt ( input = { } ) {
const lastAssistantMessage = String ( input . last _assistant _message ? ? "" ) . trim ( ) ;
const template = loadPromptTemplate ( ROOT _DIR , "stop-review-gate" ) ;
const claudeResponseBlock = lastAssistantMessage
? [ "Previous Claude response:" , lastAssistantMessage ] . join ( "\n" )
: "" ;
return interpolateTemplate ( template , {
CLAUDE _RESPONSE _BLOCK : claudeResponseBlock
} ) ;
}
function buildSetupNote ( cwd ) {
2026-04-08 11:06:22 +08:00
const availability = getCodexAvailability ( cwd ) ;
if ( availability . available ) {
2026-03-31 00:28:15 +08:00
return null ;
}
2026-04-08 11:06:22 +08:00
const detail = availability . detail ? ` ${ availability . detail } . ` : "" ;
return ` Codex is not set up for the review gate. ${ detail } Run /codex:setup. ` ;
2026-03-31 00:28:15 +08:00
}
function parseStopReviewOutput ( rawOutput ) {
const text = String ( rawOutput ? ? "" ) . trim ( ) ;
if ( ! text ) {
return {
ok : false ,
reason :
"The stop-time Codex review task returned no final output. Run /codex:review --wait manually or bypass the gate."
} ;
}
const firstLine = text . split ( /\r?\n/ , 1 ) [ 0 ] . trim ( ) ;
if ( firstLine . startsWith ( "ALLOW:" ) ) {
return { ok : true , reason : null } ;
}
if ( firstLine . startsWith ( "BLOCK:" ) ) {
const reason = firstLine . slice ( "BLOCK:" . length ) . trim ( ) || text ;
return {
ok : false ,
reason : ` Codex stop-time review found issues that still need fixes before ending the session: ${ reason } `
} ;
}
return {
ok : false ,
reason :
"The stop-time Codex review task returned an unexpected answer. Run /codex:review --wait manually or bypass the gate."
} ;
}
function runStopReview ( cwd , input = { } ) {
const scriptPath = path . join ( SCRIPT _DIR , "codex-companion.mjs" ) ;
const prompt = buildStopReviewPrompt ( input ) ;
const childEnv = {
... process . env ,
... ( input . session _id ? { [ SESSION _ID _ENV ] : input . session _id } : { } )
} ;
const result = spawnSync ( process . execPath , [ scriptPath , "task" , "--json" , prompt ] , {
cwd ,
env : childEnv ,
encoding : "utf8" ,
timeout : STOP _REVIEW _TIMEOUT _MS
} ) ;
if ( result . error ? . code === "ETIMEDOUT" ) {
return {
ok : false ,
reason :
"The stop-time Codex review task timed out after 15 minutes. Run /codex:review --wait manually or bypass the gate."
} ;
}
if ( result . status !== 0 ) {
const detail = String ( result . stderr || result . stdout || "" ) . trim ( ) ;
return {
ok : false ,
reason : detail
? ` The stop-time Codex review task failed: ${ detail } `
: "The stop-time Codex review task failed. Run /codex:review --wait manually or bypass the gate."
} ;
}
try {
const payload = JSON . parse ( result . stdout ) ;
return parseStopReviewOutput ( payload ? . rawOutput ) ;
} catch {
return {
ok : false ,
reason :
"The stop-time Codex review task returned invalid JSON. Run /codex:review --wait manually or bypass the gate."
} ;
}
}
function main ( ) {
const input = readHookInput ( ) ;
const cwd = input . cwd || process . env . CLAUDE _PROJECT _DIR || process . cwd ( ) ;
const workspaceRoot = resolveWorkspaceRoot ( cwd ) ;
const config = getConfig ( workspaceRoot ) ;
const jobs = sortJobsNewestFirst ( filterJobsForCurrentSession ( listJobs ( workspaceRoot ) , input ) ) ;
const runningJob = jobs . find ( ( job ) => job . status === "queued" || job . status === "running" ) ;
const runningTaskNote = runningJob
? ` Codex task ${ runningJob . id } is still running. Check /codex:status and use /codex:cancel ${ runningJob . id } if you want to stop it before ending the session. `
: null ;
if ( ! config . stopReviewGate ) {
logNote ( runningTaskNote ) ;
return ;
}
const setupNote = buildSetupNote ( cwd ) ;
if ( setupNote ) {
logNote ( setupNote ) ;
logNote ( runningTaskNote ) ;
return ;
}
const review = runStopReview ( cwd , input ) ;
if ( ! review . ok ) {
emitDecision ( {
decision : "block" ,
reason : runningTaskNote ? ` ${ runningTaskNote } ${ review . reason } ` : review . reason
} ) ;
return ;
}
logNote ( runningTaskNote ) ;
}
2026-04-08 11:06:22 +08:00
try {
main ( ) ;
} catch ( error ) {
const message = error instanceof Error ? error . message : String ( error ) ;
process . stderr . write ( ` ${ message } \n ` ) ;
process . exitCode = 1 ;
}