import type { ArbiterVerdict } from './types.js'; export type VisibleVerdict = | 'step_done' | 'task_done' | 'done' | 'done_with_concerns' | 'blocked' | 'needs_context' | 'continue'; export type ArbiterVerdictResult = ArbiterVerdict | 'unknown'; const VISIBLE_VERDICT_SCAN_LINE_LIMIT = 12; const ARBITER_VERDICT_SCAN_LINE_LIMIT = 12; function leadingVisibleLines(text: string, limit: number): string[] { const lines: string[] = []; let inFence = false; for (const rawLine of text.split('\n')) { const line = rawLine.trim(); if (/^(```|~~~)/.test(line)) { inFence = !inFence; continue; } if (inFence || line.length === 0) { continue; } lines.push(line); if (lines.length >= limit) { break; } } return lines; } function stripInternalBlocks(text: string): string { return text.replace(/[\s\S]*?(?:<\/internal>|$)/g, ''); } function parseVisibleVerdictLine(line: string): VisibleVerdict | null { if (/^\*{0,2}BLOCKED(?:\*{0,2})?\b/i.test(line)) return 'blocked'; if (/^\*{0,2}NEEDS_CONTEXT(?:\*{0,2})?\b/i.test(line)) return 'needs_context'; if (/^\*{0,2}STEP_DONE(?:\*{0,2})?\b/i.test(line)) return 'step_done'; if (/^\*{0,2}TASK_DONE(?:\*{0,2})?\b/i.test(line)) return 'task_done'; if (/^\*{0,2}DONE_WITH_CONCERNS(?:\*{0,2})?\b/i.test(line)) return 'done_with_concerns'; if (/^\*{0,2}DONE(?:\*{0,2})?\b/i.test(line)) return 'done'; if (/^\*{0,2}Approved\.?(?:\*{0,2})?/i.test(line)) return 'done'; if (/^\*{0,2}LGTM(?:\*{0,2})?/i.test(line)) return 'done'; return null; } export function parseVisibleVerdict( summary: string | null | undefined, ): VisibleVerdict { if (!summary) return 'continue'; const cleaned = stripInternalBlocks(summary).trim(); if (!cleaned) return 'continue'; for (const line of leadingVisibleLines( cleaned, VISIBLE_VERDICT_SCAN_LINE_LIMIT, )) { const verdict = parseVisibleVerdictLine(line); if (verdict) return verdict; } return 'continue'; } /** * Remove a leading status/verdict marker line (e.g. "TASK_DONE", "STEP_DONE") * from owner output so a held answer can be shown to the user directly without * the internal status token. Only strips when the very first non-empty line is * a recognised marker; otherwise returns the text untouched. */ export function stripLeadingStatusLine(text: string): string { const lines = text.split('\n'); let firstNonEmpty = 0; while (firstNonEmpty < lines.length && lines[firstNonEmpty].trim() === '') { firstNonEmpty += 1; } if ( firstNonEmpty >= lines.length || !parseVisibleVerdictLine(lines[firstNonEmpty].trim()) ) { return text; } const remainder = lines.slice(firstNonEmpty + 1); while (remainder.length > 0 && remainder[0].trim() === '') { remainder.shift(); } return remainder.join('\n').trim(); } const REVIEWER_APPROVAL_LINE = /^\*{0,2}(?:VERDICT\s*[:—-]\s*)?PROCEED(?:\*{0,2})?\b/i; /** * Reviewers — especially the Codex reviewer — approve a turn with an * arbiter-style "PROCEED" line instead of the documented TASK_DONE. Plain * parseVisibleVerdict() does not know "PROCEED" and maps it to 'continue', * which resolveReviewerCompletionSignal() treats as a change request. So an * *approving* reviewer silently re-opens the owner turn and the two roles * ping-pong (owner TASK_DONE ↔ reviewer PROCEED) until the deadlock cap fires — * the user sees a burst of near-identical messages that then just stops. * * Treat a leading PROCEED as an approval (equivalent to DONE) so the state * machine routes to owner finalize and the task actually completes after one * round instead of looping. Only PROCEED is upgraded; REVISE/RESET/plain text * stay 'continue' (a genuine change request). */ export function parseReviewerVerdict( summary: string | null | undefined, ): VisibleVerdict { const verdict = parseVisibleVerdict(summary); if (verdict !== 'continue' || !summary) return verdict; const cleaned = stripInternalBlocks(summary).trim(); if (!cleaned) return verdict; for (const line of leadingVisibleLines( cleaned, VISIBLE_VERDICT_SCAN_LINE_LIMIT, )) { if (REVIEWER_APPROVAL_LINE.test(line)) return 'done'; } return verdict; } export function classifyArbiterVerdict( summary: string | null | undefined, ): ArbiterVerdictResult { if (!summary) return 'unknown'; const cleaned = stripInternalBlocks(summary).trim(); if (!cleaned) return 'unknown'; for (const line of leadingVisibleLines( cleaned, ARBITER_VERDICT_SCAN_LINE_LIMIT, )) { const verdictMatch = line.match( /\*{0,2}(?:VERDICT\s*[:—-]\s*)?(PROCEED|REVISE|RESET|ESCALATE|CONTINUE)\*{0,2}/i, ); if (verdictMatch) { const normalized = verdictMatch[1].toLowerCase(); return normalized === 'continue' ? 'proceed' : (normalized as ArbiterVerdict); } } return 'unknown'; } export function resolveStoredVisibleVerdict(args: { verdict?: VisibleVerdict | null; outputText?: string | null; }): VisibleVerdict | null { if (args.verdict) { return args.verdict; } if (!args.outputText) { return null; } return parseVisibleVerdict(args.outputText); }