diff --git a/src/paired-verdict.test.ts b/src/paired-verdict.test.ts index 7ba6e02..9bd3e52 100644 --- a/src/paired-verdict.test.ts +++ b/src/paired-verdict.test.ts @@ -6,7 +6,7 @@ import { } from './paired-verdict.js'; describe('paired verdict parser', () => { - it('parses visible verdicts from the first summary line only', () => { + it('parses visible verdicts from leading summary lines', () => { expect(parseVisibleVerdict('STEP_DONE\nmore to do')).toBe('step_done'); expect(parseVisibleVerdict('TASK_DONE\nall done')).toBe('task_done'); expect( @@ -18,6 +18,46 @@ describe('paired verdict parser', () => { expect(parseVisibleVerdict('random prose')).toBe('continue'); }); + it('accepts status tokens a few visible lines into the summary', () => { + expect( + parseVisibleVerdict( + [ + '판단부터 적겠습니다.', + '검증 결과는 아래와 같습니다.', + 'STEP_DONE', + '', + '나머지 설명입니다.', + ].join('\n'), + ), + ).toBe('step_done'); + }); + + it('ignores status tokens in prose or code fences', () => { + expect( + parseVisibleVerdict('검토 결과 STEP_DONE으로 보입니다.\n세부 내용'), + ).toBe('continue'); + expect( + parseVisibleVerdict( + ['```text', 'TASK_DONE', '```', '본문에는 상태가 없습니다.'].join('\n'), + ), + ).toBe('continue'); + }); + + it('does not scan beyond the leading visible line window', () => { + expect( + parseVisibleVerdict( + [ + '첫 줄', + '둘째 줄', + '셋째 줄', + '넷째 줄', + '다섯째 줄', + 'TASK_DONE', + ].join('\n'), + ), + ).toBe('continue'); + }); + it('classifies arbiter verdicts from the first visible line', () => { expect(classifyArbiterVerdict('PROCEED\ncontinue')).toBe('proceed'); expect(classifyArbiterVerdict('**VERDICT: REVISE**\nfix it')).toBe( diff --git a/src/paired-verdict.ts b/src/paired-verdict.ts index c464df9..c95eeb3 100644 --- a/src/paired-verdict.ts +++ b/src/paired-verdict.ts @@ -14,22 +14,56 @@ export type ArbiterVerdictResult = | 'escalate' | 'unknown'; +const VISIBLE_VERDICT_SCAN_LINE_LIMIT = 5; + +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 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 = summary.replace(/[\s\S]*?<\/internal>/g, '').trim(); if (!cleaned) return 'continue'; - const firstLine = cleaned.split('\n')[0].trim(); - if (/^\*{0,2}BLOCKED\*{0,2}\b/i.test(firstLine)) return 'blocked'; - if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine)) return 'needs_context'; - if (/^\*{0,2}STEP_DONE\*{0,2}\b/i.test(firstLine)) return 'step_done'; - if (/^\*{0,2}TASK_DONE\*{0,2}\b/i.test(firstLine)) return 'task_done'; - if (/^\*{0,2}DONE_WITH_CONCERNS\*{0,2}\b/i.test(firstLine)) - return 'done_with_concerns'; - if (/^\*{0,2}DONE\*{0,2}\b/i.test(firstLine)) return 'done'; - if (/^\*{0,2}Approved\.?\*{0,2}/i.test(firstLine)) return 'done'; - if (/^\*{0,2}LGTM\*{0,2}/i.test(firstLine)) return 'done'; + for (const line of leadingVisibleLines( + cleaned, + VISIBLE_VERDICT_SCAN_LINE_LIMIT, + )) { + const verdict = parseVisibleVerdictLine(line); + if (verdict) return verdict; + } return 'continue'; }