// review-notes.ts — the REVIEWER'S CAVEATS, carried to the human decision. // // The verdict line routes the item (verdict.ts, strict PASS/FAIL/NO-CHANGE). Everything // ELSE the reviewer wrote used to be dropped on the floor: the body arrived in // the completion event, parseVerdict took its first `Verdict:` line, and the // human approved from a tick that knew only "PASSED". Observed live, a PASS // body said "the new test does NOT guard the fixed line — reverting still // passes" and "no shell, 330/8/1/0 unverified" — exactly the material an // approval decision needs, and none of it survived. // // This module extracts a BOUNDED handover record from that body. Rules: // - DUMB + DETERMINISTIC: plain regex/line scanning, no model call. A // reviewer can predict the output by reading the function. // - ADDITIVE: it never influences the verdict. Capture runs AFTER the // verdict is parsed and a miss degrades to null, never to a lost flip. // - TOTAL: extractReviewNotes never throws (any surprise → null). // - BOUNDED AT WRITE TIME: the record is stored on the queue item, and // queue.json is re-read by EVERY tick and panel render — an unbounded // verdict transcript would bloat the hot path. See the caps below. import type { CompletionEvent } from "./types.ts"; import { matchVerdictLine, verdictBody, type Verdict } from "./verdict.ts"; /** What the reviewer said, beyond the verdict — bounded, captured at verdict * parse, stored on the queue item and shown on the approval screen. */ export interface ReviewNotes { /** ISO timestamp of the capture (the verdict moment). */ at: string; /** The verdict this body accompanied — PASS caveats and FAIL findings are * the same kind of material and both matter (a FAIL's findings feed the * redo, a PASS's caveats feed the approval; a NO-CHANGE's rationale is the * entire record of a non-deliverable outcome). */ verdict: Verdict; /** What the reviewer could NOT verify (its own "unverified"/"not tested" * sections and lines). null when nothing was detectable. */ unverified: string | null; /** Caveats the reviewer flagged: residual risks, non-blocking suggestions, * findings/blockers — from its own headed sections. null when absent. */ caveats: string | null; /** Bounded head of the body when NO structure was detectable — better than * storing nothing (the operator still sees the reviewer's own words). */ bodyHead: string | null; /** true when any captured field was cut at its cap. */ truncated: boolean; } /** PER-FIELD CAP, justified: a caveat that cannot be read in one panel screen * is not a caveat, it is a transcript. 600 characters is ~8 wrapped terminal * lines — enough for a reviewer's "could not verify" list with `file:line` * citations, and the same order as the `scope` field queue.json already * carries per item. */ export const REVIEW_NOTE_FIELD_CHARS = 600; /** WHOLE-RECORD CAP, justified: queue.json is parsed by every tick, every * sweep and every panel render, so the record must stay a constant-order * addition per item. 1200 characters = two full fields; at a realistic * human-review depth (≤20 items) that is ≤24KB added to a file that already * carries scope + notes per item. Fields are filled in priority order * (unverified → caveats → bodyHead) against this shared budget. */ export const REVIEW_NOTES_TOTAL_CHARS = 1200; /** Reviewer wording for "I could not check this". Deliberately literal — it * matches the reviewer's OWN vocabulary (canonical.md: "If you cannot verify * something, say so plainly"), not a paraphrase of it. */ const UNVERIFIED_RE = /\b(unverified|untested|unverifiable|not verified|never verified|could not verify|couldn'?t verify|cannot verify|can'?t verify|did not verify|didn'?t verify|unable to verify|not exercised|never exercised|not tested|not run|did not run|no shell|out of scope for this review|limitations?)\b/i; /** Headings whose CONTENT is a caveat for the approving human. */ const CAVEAT_RE = /\b(caveats?|residual risks?|risks?|non-?blocking|suggestions?|nits?|follow[- ]ups?|concerns?|findings?|blockers?|issues?|reservations?)\b/i; interface Heading { text: string; /** Content on the SAME line (`Could not verify: the shell path`). */ inline: string; } /** Heading shapes a reviewer actually writes: markdown headings, bold lines, * and `Label:` lines. Anything else is body text. */ function headingOf(rawLine: string): Heading | null { const line = rawLine.trim(); if (!line) return null; const md = line.match(/^#{1,6}\s+(.{1,80})$/); if (md) return { text: md[1].replace(/[*_`:]+$/, "").trim(), inline: "" }; const bold = line.match(/^\*\*(.{1,80}?)\*\*:?\s*(.*)$/); if (bold) return { text: bold[1].trim(), inline: bold[2].trim() }; const labeled = line.match(/^([A-Za-z][A-Za-z0-9 ()/&'’,-]{2,60}):\s*(.*)$/); if (labeled) return { text: labeled[1].trim(), inline: labeled[2].trim() }; return null; } type Bucket = "unverified" | "caveats"; function classify(heading: string): Bucket | null { if (UNVERIFIED_RE.test(heading)) return "unverified"; if (CAVEAT_RE.test(heading)) return "caveats"; return null; } /** Cap `text` to `limit` characters, marking a cut with a trailing ellipsis * (the stored length never exceeds `limit`). */ function capTo(text: string, limit: number): { text: string | null; truncated: boolean } { const clean = text.trim(); if (!clean) return { text: null, truncated: false }; if (limit <= 0) return { text: null, truncated: true }; if (clean.length <= limit) return { text: clean, truncated: false }; return { text: clean.slice(0, Math.max(1, limit - 1)) + "…", truncated: true }; } /** * Extract the bounded handover record from a reviewer completion. NEVER * throws: any unexpected shape degrades to null, so a capture miss can never * cost the verdict flip. */ export function extractReviewNotes( ev: CompletionEvent, reviewerAgents: string[] | undefined, verdict: Verdict, at: string, ): ReviewNotes | null { try { const body = verdictBody(ev, reviewerAgents ?? []); if (!body) return null; const lines = body.split(/\r?\n/); const buckets: Record = { unverified: [], caveats: [] }; const loose: string[] = []; let current: Bucket | null = null; for (const raw of lines) { const line = raw.trim(); if (!line) continue; if (matchVerdictLine(line) !== null) { current = null; continue; } const heading = headingOf(raw); if (heading) { const bucket = classify(heading.text); // A heading with content on its OWN line (`Residual risks: `) // is a ONE-LINE section: what follows is body again, not more of it. // A bare heading (`## Could not verify`) opens a section that runs to // the next heading. if (bucket && heading.inline) buckets[bucket].push(heading.inline); current = heading.inline ? null : bucket; continue; } if (current) buckets[current].push(line); else loose.push(line); } // Loose lines are harvested for the UNVERIFIED bucket only: "I could not // check X" is stated inline as often as it is given a heading (and it is // the material the approval decision turns on), whereas a loose-line hunt // for "risk"/"issue" words would drag in prose like "no blocking issues". for (const line of loose) { if (UNVERIFIED_RE.test(line)) buckets.unverified.push(line); } let budget = REVIEW_NOTES_TOTAL_CHARS; let truncated = false; const take = (parts: string[]): string | null => { if (!parts.length) return null; const capped = capTo(parts.join("\n"), Math.min(REVIEW_NOTE_FIELD_CHARS, budget)); if (capped.truncated) truncated = true; if (capped.text) budget -= capped.text.length; return capped.text; }; const unverified = take(buckets.unverified); const caveats = take(buckets.caveats); // No structure detected anywhere — keep a bounded head of the reviewer's // own words rather than storing nothing. const bodyHead = !unverified && !caveats ? take(loose) : null; if (!unverified && !caveats && !bodyHead) return null; return { at, verdict, unverified, caveats, bodyHead, truncated }; } catch { return null; // a parse miss degrades to null — never to a lost verdict } } /** The human-facing lines for a captured record — ONE spelling of this * material, shared by the panel doc, both renderers and the flag's * residual_risks. Empty array when there is nothing to show. */ export function reviewNotesLines(notes: ReviewNotes | null | undefined): string[] { if (!notes) return []; const out: string[] = []; if (notes.unverified) out.push(`Reviewer could NOT verify: ${notes.unverified}`); if (notes.caveats) out.push(`Reviewer caveats: ${notes.caveats}`); if (notes.bodyHead) out.push(`Reviewer notes: ${notes.bodyHead}`); return out; } /** One-line projection for a list row (the detail view shows the full lines). */ export function reviewNotesHeadline(notes: ReviewNotes | null | undefined, cap = 160): string | null { const lines = reviewNotesLines(notes); if (!lines.length) return null; const one = lines.join(" · ").replace(/\s+/g, " ").trim(); return one.length > cap ? one.slice(0, Math.max(1, cap - 1)) + "…" : one; } /** The flag's `residual_risks` text — the SAME material the panel shows, so * the notification and the approval screen cannot disagree. */ export function reviewNotesResidualRisks(notes: ReviewNotes | null | undefined): string | null { const lines = reviewNotesLines(notes); return lines.length ? lines.join(" | ") : null; } /** `path.ext:line` or `path.ext:line-line` — the reviewer's OWN citation * shape (canonical.md: "code findings cite `file:line`"), reused (not * reinvented) from the identical evidence pattern in spec-completeness.ts. */ const FILE_LINE_ANCHOR_RE = /\b([\w./-]+\.[A-Za-z]{1,6}):(\d+)(?:-(\d+))?\b/g; /** FILE:LINE ANCHORS THE REVIEWER CITED AS EVIDENCE (AUTOPILOT-57) — pulled * from the SAME captured unverified/caveats/bodyHead text `reviewNotesLines` * renders, so a review target the human sees traces back to something the * reviewer actually wrote (never invented from the diff itself). Deduped and * capped: a review citing the same file five times is one target, not five. */ export function reviewNotesCitedAnchors(notes: ReviewNotes | null | undefined, cap = 4): string[] { if (!notes) return []; const text = [notes.unverified, notes.caveats, notes.bodyHead].filter(Boolean).join("\n"); if (!text) return []; const seen = new Set(); const out: string[] = []; for (const m of text.matchAll(FILE_LINE_ANCHOR_RE)) { const anchor = m[3] ? `${m[1]}:${m[2]}-${m[3]}` : `${m[1]}:${m[2]}`; if (seen.has(anchor)) continue; seen.add(anchor); out.push(anchor); if (out.length >= cap) break; } return out; }