/** * The judge engine: shells out to the locally installed Claude Code CLI * (`claude -p`) with read-only tool access, run from a scratch directory * outside every project (`judgeCwd` in claude.ts) so the target repo's * instructions never leak into judging. Shot paths are absolute, so standing * nowhere near them costs the judge nothing. * * The subprocess reads the screenshot files itself (vision via the Read * tool); lookout passes paths plus a manifest and demands a strict JSON * reply, retrying once with a harder instruction when parsing fails. */ import { type Severity, type ShotRecord } from "../types.js"; import type { FindingConsensus, OracleId } from "../backlog/consensus.js"; import type { Region } from "../backlog/region.js"; import type { Category } from "./rubric.js"; import { type Pieces } from "./manifest.js"; import { type ContractLapse, type PanelLane } from "./reply.js"; export interface AiFinding { shotId: string; category: Category; /** * The panel that owns the finding's category, stamped at ingestion so a * ticket can say which specialist filed it and an amendment can target that * specialist's skill. Optional because verdicts cached before the stamp * existed come back without it; readers fall back to deriving it from the * category. */ judge?: string; /** * Which AI produced this finding, when lookout judges with more than one. * * Absent on verdicts from before two AIs judged, including ones read back * from a ledger written then, and absence reads as unknown rather than as * whichever AI happens to be configured now. */ oracle?: OracleId; /** * What the second judge said about this finding, when two judged. * * On the judge's own shape rather than only in the backlog because the * ledger stores these findings whole: without it, a cached verdict would * come back looking unanimous when it had in fact been contested. */ consensus?: FindingConsensus; attribute: string; /** * The shot this finding was copied from, when the judge listed this one in * that finding's `alsoShotIds`. * * A defect visible on four shots of a view is one defect and four sightings. * The judge files it once and names the rest; ingestion expands each named * shot into its own finding so the shot is accounted for, the refuter can * kill an over-listed sibling on its own evidence, and the fingerprint (which * carries form factor and scheme) stays the identity it has always been. * Present only on the copies, never on the shot the judge chose. */ siblingOf?: string; /** * Which part of the frame the defect lives in. Never trusted blindly: an * unknown or missing value degrades to "content", the route-scoped default, * because a mangled region must cost precision, not the finding. */ region: Region; severity: Severity; title: string; problem: string; expected: string; observed: string; confidence: "high" | "medium" | "low"; /** What would prove this defect gone, in the judge's own words. */ acceptance: string[]; } export interface JudgeBatchResult { findings: AiFinding[]; cleanShotIds: string[]; /** * Shots the reply accounted for in neither `findings` nor `cleanShotIds`. * * The output contract requires every shot to appear in one of them, precisely * so a judge that quietly skipped one can be detected. Nothing read this, so a * skipped shot was indistinguishable from a clean one and was cached as clean, * durably. These are the shots lookout has no verdict for, and saying so is * the difference between "clean" and "not looked at". */ unaccounted: string[]; /** * Shots the reply called clean without the model ever opening their file * (or every piece of it). Already folded into `unaccounted`, so they are * left uncached and judged again; listed apart so the run can say why. */ unread: string[]; rejected: { reason: string; raw: unknown; }[]; /** Findings filed although their problem is written for one reader. */ degraded: ContractLapse[]; raw: string; costUsd?: number; durationMs: number; } export interface PriorFinding { /** * The shot the defect is open on, or "*" for one that is open everywhere: * a shell finding belongs to the application's frame rather than to any * view, so its name has to travel to every batch or each route's call would * re-mint it under a fresh attribute and split the issue. */ shotId: string; category: string; attribute: string; title: string; region?: Region; /** * A name a person already settled: ruled intentional, blocked, or fixed. * * It travels for its NAME, not as a defect to look for. Reusing it is what * lets a ruling reach a re-sighting: merge suppresses an exact fingerprint * and cannot suppress a synonym, so a by-design defect re-filed under a * fresh attribute mints a second issue the ruling never touches. */ settled?: true; } /** One shot's accessibility tree, as the prompt builder takes it. */ export interface AriaEvidence { yaml: string; hash: string; } export interface JudgeContext { /** * Which AI will read this prompt, or unset for lookout's primary. * * Only the wording of "open this screenshot" depends on it: the skills say * what to judge and never who is judging, and this is the one sentence that * has to be spelled in the reader's own vocabulary. */ ai?: string; /** Hand-off instructions, carried only when a shot has a `design:` reference. */ handoff?: string; /** What lookout already has open on these views, so a re-file keeps its name. */ prior?: PriorFinding[]; /** * The lane this call judges in. Absent, the full vocabulary applies, which * is what the transitional monolith and the regression replay want. */ panel?: PanelLane; /** * Each shot's accessibility tree, by shot id, for the lanes given it. Loaded * by judgeBatch when the lane asks and the caller has not supplied it. */ aria?: ReadonlyMap; /** * The pieces tall shots are read in, from `preparePieces`. Absent, every * shot is read whole, which is right for a prompt built for its text alone. */ pieces?: Pieces; /** * The project's directory, for the incident log. Distinct from `project`, * which is the display name the prompt is written with: what decides where * a failure is recorded is a path, and a name is not one. */ projectDir?: string; } export declare function buildJudgePrompt(skillText: string, project: string, shots: ShotRecord[], evidenceDir: string, ctx?: JudgeContext): string; export declare const RETRY_SUFFIX = "\n\nYour previous reply could not be parsed. Reply with NOTHING but the fenced ```json block."; export declare function judgeBatch(skillText: string, project: string, shots: ShotRecord[], evidenceDir: string, model: string, ctx?: JudgeContext): Promise; export { claudeBin, DEFAULT_JUDGE_MODEL, extractJson, invokeClaude, type JudgeInvocation } from "./claude.js"; export { ADAPTERS, adapterFor, invokeAi, isJudge, JUDGES, PRIMARY_AI } from "./adapters.js"; export { type JudgeSay } from "./stream.js"; export { batchShots, groupShots, viewGroupId } from "./grouping.js"; export { ingestJudgeReply, type ContractLapse, type PanelLane } from "./reply.js";