// verdict.ts — Verdict parsing (the queue_review reviewer contract: the FIRST // non-empty line is a standalone verdict line; anything else → null → the // manual path, never a silent wrong flip). // // THE VERDICT-LINE GRAMMAR (KEY: AUTOPILOT-99) — ONE spelling, shared by the // parser (matchVerdictLine) and the reviewer prompt (canonical.md). A line is // a verdict line iff it reads: // // VERDICT VALUE // // optional, in this order: leading whitespace; a markdown // heading (`#{1,6}` + whitespace); a list marker (`-`/`*`/`+` // or `N.`/`N)`) + whitespace; then a run of emphasis/backtick // wrappers (`*`, `_`, `` ` ``). // VERDICT the word `verdict`, case-insensitive. // optional whitespace, `:`, optional whitespace. // optional emphasis/backtick wrappers and whitespace around // the value (allows `**Verdict: PASS**` and `**Verdict:** **FAIL**`). // VALUE `PASS`, `FAIL` or `NO-CHANGE`, case-insensitive, followed // by a word boundary. // ignored: trailing wrappers, punctuation, prose and EOL. // // NO-CHANGE (KEY: AUTOPILOT-108) is the NON-DELIVERABLE outcome: the correct // thing to do was to make NO change — a refusal/withdrawal with no artifact. // PASS/FAIL cannot express it (PASS implies an artifact was produced and // accepted; FAIL re-dispatches), which is how a CORRECT refusal got re-run // into a destructive change. It is one spelling on purpose: the hyphenated // `NO-CHANGE`, never `NO CHANGE` (a word between VERDICT and VALUE would break // the adjacency the whole grammar rests on). // // The decoration is anchored at the START of the line, and VERDICT and VALUE // are ADJACENT (only /, never a word, between them), so a // verdict QUOTED inside prose ("I could not determine a Verdict: PASS here") // is never read. The FIRST line matching the grammar wins; a preamble is // tolerated (the P3 reviewer's verdict sat at line 47). import type { CompletionEvent } from "./types.ts"; // --------------------------------------------------------------------------- // Verdict-line grammar + parsing // --------------------------------------------------------------------------- /** The three possible verdicts. PASS and FAIL are the deliverable outcomes * (unchanged); NO-CHANGE is the non-deliverable outcome (KEY: AUTOPILOT-108). */ export type Verdict = "PASS" | "FAIL" | "NO-CHANGE"; const VERDICT_LINE_RE = /^\s*(?:#{1,6}\s+)?(?:(?:[-*+]|\d{1,3}[.)])\s+)?[*_`]*\s*verdict\s*:\s*(?:[*_`]*\s*)*(pass|fail|no-change)\b/i; /** Match ONE line against the verdict-line grammar (see the header comment). * Returns the value, or null when the line is not a standalone verdict. */ export function matchVerdictLine(line: string): Verdict | null { const m = line.match(VERDICT_LINE_RE); if (!m) return null; const value = m[1].toUpperCase(); return value === "PASS" ? "PASS" : value === "FAIL" ? "FAIL" : "NO-CHANGE"; } /** The candidate texts a result may live in, in PRECEDENCE order: the named * priority agents' children first (a fan-out may include other results), * then every result, then the event's own summary (backends that carry the * child's text there instead of results[].output). Non-string shapes are * dropped — one normalisation for every shape, shared by verdict parsing * (priority = reviewer agents), spec-pass output parsing (priority = worker * agents, see parseSpecPassScope below) and acceptance-evidence parsing * (priority = worker agents, see acceptance-evidence.ts) so all read a * completion event the SAME way. */ export function candidateTexts(ev: CompletionEvent, priorityAgents: string[]): string[] { const results = Array.isArray(ev.results) ? ev.results : []; const prioritySet = new Set(priorityAgents); const priorityFirst = results.filter((r) => { const agent = (r as { agent?: unknown }).agent; return typeof agent === "string" && prioritySet.has(agent); }); return [...priorityFirst, ...results] .map((r) => (r as { output?: unknown }).output) .concat([ev.summary]) .filter((out): out is string => typeof out === "string"); } function verdictCandidates(ev: CompletionEvent, reviewerAgents: string[]): string[] { return candidateTexts(ev, reviewerAgents); } /** The FIRST line matching the verdict-line grammar in a text — the contract * says first line, but reviewers occasionally preamble (the P3 reviewer's * verdict sat at line 47), so every line is scanned. Strict about the LINE * (a match must start it, per the grammar), tolerant of the decoration * around it (KEY: AUTOPILOT-99). */ function pickVerdict(out: string): Verdict | null { for (const line of out.split(/\r?\n/)) { const v = matchVerdictLine(line); if (v) return v; } return null; } /** * Verdict parse: the queue_review reviewer contract mandates the FIRST * non-empty line is a verdict line. Formatting around that line is tolerated * (see the header grammar, KEY: AUTOPILOT-99); anything that is not a * standalone verdict line anywhere in the text → null → the manual path (no * silent wrong flips, and no verdict read out of prose). */ export function parseVerdict(ev: CompletionEvent, reviewerAgents: string[] = []): Verdict | null { for (const out of verdictCandidates(ev, reviewerAgents)) { const v = pickVerdict(out); if (v) return v; } return null; } /** The reviewer TEXT the verdict came from — the body parseVerdict reads and * then discards. Same candidate order, so the notes captured alongside a * verdict are the notes of the run that produced it. Returns the first * candidate carrying a verdict line, else the first non-empty candidate (a * body with no parseable verdict still has words worth keeping); null when * the event carries no text at all. Read-only: this changes nothing about * verdict parsing. */ export function verdictBody(ev: CompletionEvent, reviewerAgents: string[] = []): string | null { const candidates = verdictCandidates(ev, reviewerAgents); return candidates.find((out) => pickVerdict(out) !== null) ?? candidates.find((out) => out.trim().length > 0) ?? null; } /** How much of an UNPARSEABLE reviewer body is surfaced for adjudication * (KEY: AUTOPILOT-99). A verdict that did not parse is a routing failure, * never a silent stall: the tail carries the reviewer's own words so an * operator can rule on the item without opening the run's artifacts. 800 * characters is the same order as REVIEW_NOTE_FIELD_CHARS — a bounded block, * not the whole transcript. */ export const VERDICT_TAIL_CHARS = 800; /** The bounded TAIL of the reviewer text an unparseable verdict came from — * the adjudication material for the manual path (outcome 3 of AUTOPILOT-99). * Same candidate precedence as parseVerdict, so the tail is the text the * parser actually read. null when the event carries no text; never throws. */ export function verdictTail(ev: CompletionEvent, reviewerAgents: string[] = [], maxChars = VERDICT_TAIL_CHARS): string | null { const body = verdictBody(ev, reviewerAgents); if (!body) return null; const text = body.replace(/\s+$/, ""); if (!text) return null; return text.length <= maxChars ? text : "…" + text.slice(text.length - (maxChars - 1)); } // --------------------------------------------------------------------------- // SPEC-PASS output contract (KEY: AUTOPILOT-36) — a spec-pass worker (a // bounded, READ-ONLY reconnaissance run dispatched against ONE under-specified // proposal, see dispatchPendingSpecPass in framework/auto-dispatch.ts) writes // its completed scope between two EXACT marker lines in its own output. Strict // like parseVerdict: no markers, or markers with nothing between them, parses // to null — a malformed run can never silently overwrite a scope with // garbage, and the completion handler (core.ts) records that plainly instead // of guessing. // --------------------------------------------------------------------------- export const SPEC_PASS_SCOPE_START = "=== SPEC PASS SCOPE START ==="; export const SPEC_PASS_SCOPE_END = "=== SPEC PASS SCOPE END ==="; /** The text strictly between the START/END markers in one candidate, or null * when the markers are missing/out of order/empty between them. */ function pickSpecPassScope(out: string): string | null { const startIdx = out.indexOf(SPEC_PASS_SCOPE_START); if (startIdx === -1) return null; const afterStart = startIdx + SPEC_PASS_SCOPE_START.length; const endIdx = out.indexOf(SPEC_PASS_SCOPE_END, afterStart); if (endIdx === -1) return null; const body = out.slice(afterStart, endIdx).trim(); return body ? body : null; } /** Strict spec-pass scope parse: the FIRST candidate (worker's own output * first, same precedence discipline as verdictCandidates) that carries a * correctly-marked block. Returns null when no candidate does — the caller * (core.ts) must then leave the item's scope untouched and say so, never * guess at a completed scope from prose. */ export function parseSpecPassScope(ev: CompletionEvent, workerAgents: string[] = []): string | null { for (const out of candidateTexts(ev, workerAgents)) { const scope = pickSpecPassScope(out); if (scope) return scope; } return null; }