computeVerdict counted only the findings handed to it (reasoned.kept), so a
finding removed by Pass 2 or Pass 3, and a reviewer whose payload was thrown
away or never arrived, were arithmetically identical to a finding that never
existed. All three pushed the verdict toward ALLOW.
Measured before the fix (probes, 2026-09-01):
- a BLOCKER with a 101-character title -> ALLOW (Pass 2 succinctness)
- a payload with one ad-hoc rule_key is skipped WHOLE at ingest, taking a
valid BLOCKER sibling with it -> ALLOW
- a reviewer that never reported -> ALLOW
Pass 3's own no-citation / unknown-rule_key branches turned out unreachable
through runContract (validateFindings rejects those payloads first), so the
reachable exposure was Pass 2 plus the skipped/absent reviewer.
THE OPEN DESIGN DECISION, and why it went against the order's default.
The order proposed: indeterminate file-existence YES, plain succinctness NO
("a too-long finding is not an uncertain finding"). I kept the first and
overrode the second, on one principle:
A removal is `dropped` only when the test REFUTED the finding as a claim
about this codebase. Every other removal is `unverified`.
Succinctness and actionability read a `.length`. They never examine the claim,
so they cannot establish the finding is unreal - and dropping a BLOCKER for a
101-character title is precisely the fail-open shape being fixed. Three things
settled it:
1. Under the order's default the fix would have been almost inert. Pass 3's
drop branches are unreachable via runContract, so leaving Pass 2 out would
have left the only reachable finding-level exposure open.
2. Cost asymmetry, priced rather than asserted: the verdict is not a gate.
Handover 6 feeds `findings` filtered to BLOCKER+MAJOR into /trekplan
(commands/trekplan.md:218); `verdict` is optional metadata
(docs/HANDOVER-CONTRACTS.md:353). Nothing loops or re-plans on WARN. So a
false `unverified` costs WARN plus a printed reason; a false drop costs a
silent ALLOW over a live BLOCKER.
3. unknown-rule_key joins them for the same reason: an ad-hoc key is a real
defect wearing the wrong label, and v5.1.1 high-effort mode already KEEPS
those, normalised to PLAN_EXECUTE_DRIFT. Refuting them at normal effort
while keeping them at high effort would be incoherent.
no-citation stays a drop: a finding whose file is empty or whose line is
negative names no location, so it makes no checkable claim at all - the one
deterministic refutation, and what the Pass 3 prose already said it was.
Iron Law: tests/lib/coordinator-contract.test.mjs first, red (missing export +
the three measured ALLOWs), then production code. Two existing assertions were
updated AFTER implementation as contract changes, not to make the red pass.
A known-positive control pins that ALLOW is still reachable - without it,
"no ALLOW" is not a fail-closed contract, only a broken one.
lib/review/coordinator-contract.mjs
+ classifySuppression / REFUTING_REASONS / UNVERIFIED_REASONS - one
vocabulary owned by the lib, including the tokens only the LLM
coordinator emits (accuracy:refuted, file-existence:refuted/indeterminate),
so prose and lib cannot drift. Unclassified reasons default to unverified:
the default fails closed.
~ judgeFilter / reasonablenessFilter return {kept, dropped, unverified}
~ computeVerdict(findings, {unverified, missingReviewers}) -> + allow_blocked_by.
Never raises a verdict, only withholds ALLOW. Unverified findings are NOT
counted into a severity tier: their severity was never substantiated, and
counting it would be invention.
~ runContract(payloads, {expectedReviewers}) -> + unverified,
missing_reviewers, allow_blocked_by. `suppressed` stays the union of
dropped + unverified, so existing consumers (gold-eval) keep their meaning.
agents/review-coordinator.md - Pass 2/3 tables gain a fate column, new
"Suppression is two-valued" section, Pass 4 threshold table gains the two
fail-closed rows, Executive Summary must state a withheld ALLOW, Suppressed
Findings tags each line [dropped]/[unverified]. Pass 3's unknown-rule_key
bullet explicitly says high-effort does not reach that branch, so the same
input never has two documented fates.
commands/trekreview.md - Phase 5 "Reviewer accounting": the expected set is
written down before the spawn, a silent reviewer gets one re-ask and then
STOP. That extends the pattern already in the file (schema failure -> 2
bounded re-asks -> "do not feed unvalidated findings to the coordinator") to
the other two ways a reviewer goes missing, rather than softening it to WARN.
The lib's missing_reviewers stays as belt-and-braces for direct callers.
docs/agent-return-channel-defect.md - the "inferred, not observed" caveat on
the unnamed arm above 66 lines is struck: akashic-intelligence S27
(f168630) measured 2/2 unnamed agents returning against a 4370-line plan,
30449 B and 10989 B, both valid JSON. Recorded with akashic's own two
caveats intact - the measurer owns the finding, and byte-identity between
the returned string and the file on disk was not proven. The separate S25
named-arm figures are left standing; these are two measurements, not a
correction of one by the other.
No release, no version bump, no tag, no catalogue ref, no Workflow port.
Suite 1025 (1023/0/2) -> 1034 (1032/0/2), 0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
371 lines
15 KiB
JavaScript
371 lines
15 KiB
JavaScript
// lib/review/coordinator-contract.mjs
|
|
// SKAL-1·4a — deterministic reference implementation of the review-coordinator
|
|
// 4-pass contract (agents/review-coordinator.md §"Your 4-pass process").
|
|
//
|
|
// This is a DETERMINISTIC SUBSET, not a full mirror of the LLM coordinator.
|
|
// It implements the pure, hermetic passes and DELIBERATELY EXCLUDES the parts
|
|
// that need a live filesystem or LLM judgement (which belong to the 4c
|
|
// LLM-in-the-loop eval, not this all-agree foundation tier):
|
|
// - Pass 2 "Accuracy" file-existence / line-plausibility glob (fs I/O).
|
|
// - Pass 2 "Actionability" imperative-verb heuristic — the real coordinator
|
|
// uses LLM judgement here; a verb-list approximation would DIVERGE from the
|
|
// contract being mirrored, so only the deterministic "recommended_action is
|
|
// present-and-non-empty when supplied" half is kept.
|
|
// - The doc's 4-tuple `(file,line,rule_key,title)` id recompute — the shipped
|
|
// `computeFindingId` is 3-arg `(file,line,rule_key)`; this module follows
|
|
// the shipped code and flags the doc divergence (the 4-tuple id is not
|
|
// producible by the current helper).
|
|
//
|
|
// What IS implemented, purely: Pass 1 (triplet dedup → highest-severity-wins
|
|
// survivor + conformance tiebreak + detail concat + raised_by provenance),
|
|
// Pass 2 succinctness + actionability-presence, Pass 3 reasonableness
|
|
// (citation / unknown-rule_key suppression, severity-mismatch correction),
|
|
// Pass 4 verdict thresholds — fail-closed: a suppression that did not REFUTE
|
|
// the finding, and a reviewer that never reported, forbid ALLOW (see
|
|
// classifySuppression). No LLM, no network, no time, no randomness.
|
|
//
|
|
// Reuses: SEVERITY_VALUES / RULE_KEYS / getRule (rule-catalogue.mjs),
|
|
// computeFindingId (finding-id.mjs, triplet), validateFindings
|
|
// (findings-schema.mjs). Triplet key format mirrors
|
|
// scripts/bakeoff-armA-merge.mjs:33; raised_by provenance mirrors
|
|
// lib/review/plan-review-dedup.mjs.
|
|
|
|
import { SEVERITY_VALUES, RULE_KEYS, getRule } from './rule-catalogue.mjs';
|
|
import { computeFindingId } from '../parsers/finding-id.mjs';
|
|
import { validateFindings } from './findings-schema.mjs';
|
|
|
|
export const JUDGE_TITLE_MAX = 100;
|
|
export const JUDGE_DETAIL_MAX = 800;
|
|
|
|
// ---- Suppression classification (fail-closed) --------------------------------
|
|
//
|
|
// A removal is `dropped` ONLY when the test refuted the finding as a claim
|
|
// about this codebase. Every other removal is `unverified`: the coordinator
|
|
// took the finding out of the count without ever establishing it was unreal,
|
|
// so it may not be spent as evidence of a clean review.
|
|
|
|
/**
|
|
* Reasons that REFUTE. `no-citation` is the only one this deterministic subset
|
|
* can emit: a finding whose `file` is empty or whose `line` is negative names
|
|
* no location, so it makes no checkable claim at all
|
|
* (agents/review-coordinator.md Pass 3 — "Speculative 'code might break
|
|
* somewhere' findings have no anchor").
|
|
*
|
|
* `accuracy:refuted` (Pass 2 Accuracy — a citation escaping the repo root) and
|
|
* `file-existence:refuted` (Pass 3 — absent from both working tree and diff)
|
|
* are emitted by the LLM coordinator, whose fs/judgement branches this module
|
|
* excludes. They are declared here anyway: the vocabulary is owned in one
|
|
* place so prose and lib cannot drift.
|
|
*/
|
|
export const REFUTING_REASONS = Object.freeze(new Set([
|
|
'no-citation',
|
|
'accuracy:refuted',
|
|
'file-existence:refuted',
|
|
]));
|
|
|
|
/**
|
|
* The reason vocabulary on the unverified side. `file-existence:indeterminate`
|
|
* is emitted by the LLM coordinator's Pass 3 (which runs the fs Glob this
|
|
* module deliberately excludes); the vocabulary is owned here so prose and lib
|
|
* cannot drift.
|
|
*/
|
|
export const UNVERIFIED_REASONS = Object.freeze([
|
|
'succinctness:title',
|
|
'succinctness:detail',
|
|
'actionability:empty',
|
|
'unknown-rule_key',
|
|
'file-existence:indeterminate',
|
|
]);
|
|
|
|
/**
|
|
* Classify a suppression reason. Anything not declared refuting is
|
|
* `unverified` — the default is fail-CLOSED, so a reason introduced later
|
|
* without a decision cannot silently move the verdict toward ALLOW.
|
|
* @param {string} reason
|
|
* @returns {'refuted'|'unverified'}
|
|
*/
|
|
export function classifySuppression(reason) {
|
|
return REFUTING_REASONS.has(reason) ? 'refuted' : 'unverified';
|
|
}
|
|
|
|
/**
|
|
* Tag a finding with its suppression reason and route it to the refuted
|
|
* (`dropped`) or the `unverified` bucket.
|
|
* @param {object} finding
|
|
* @param {string} reason
|
|
* @param {object[]} dropped
|
|
* @param {object[]} unverified
|
|
*/
|
|
function suppress(finding, reason, dropped, unverified) {
|
|
const tagged = { ...finding, suppressed_reason: reason };
|
|
if (classifySuppression(reason) === 'refuted') dropped.push(tagged);
|
|
else unverified.push(tagged);
|
|
}
|
|
|
|
/**
|
|
* Catalogue-tier rank of a severity: lower number = higher severity.
|
|
* BLOCKER=0 … SUGGESTION=3; an unknown severity ranks last.
|
|
* @param {string} severity
|
|
* @returns {number}
|
|
*/
|
|
export function severityRank(severity) {
|
|
const i = SEVERITY_VALUES.indexOf(severity);
|
|
return i === -1 ? SEVERITY_VALUES.length : i;
|
|
}
|
|
|
|
function isConformance(reviewer) {
|
|
return typeof reviewer === 'string' && reviewer.toLowerCase().includes('conformance');
|
|
}
|
|
|
|
function tripletKey(f) {
|
|
return `${f.file} ${f.line} ${f.rule_key}`;
|
|
}
|
|
|
|
/**
|
|
* Validate each reviewer payload and collect findings from the VALID ones,
|
|
* tagging each finding with its source reviewer (mirrors mergeArmA — invalid
|
|
* payloads are skipped, not crashed-on).
|
|
* @param {Array<{reviewer?: string, findings: object[]}>} reviewerPayloads
|
|
* @returns {{ findings: object[], skipped: Array<{reviewer: string|null, error_codes: string[]}> }}
|
|
*/
|
|
export function ingest(reviewerPayloads) {
|
|
const findings = [];
|
|
const skipped = [];
|
|
for (const payload of reviewerPayloads) {
|
|
const r = validateFindings(payload);
|
|
if (!r.valid) {
|
|
skipped.push({ reviewer: payload?.reviewer ?? null, error_codes: r.errors.map((e) => e.code) });
|
|
continue;
|
|
}
|
|
for (const f of payload.findings) {
|
|
findings.push({ ...f, reviewer: f.reviewer ?? payload.reviewer ?? f.owner_reviewer ?? null });
|
|
}
|
|
}
|
|
return { findings, skipped };
|
|
}
|
|
|
|
/**
|
|
* Pass 1 — dedup by (file, line, rule_key) triplet. Survivor = highest
|
|
* catalogue severity; severity tie → prefer the conformance reviewer; carries
|
|
* raised_by provenance, concatenates other reviewers' attribution into detail,
|
|
* and recomputes the id over the triplet.
|
|
* @param {object[]} findings
|
|
* @returns {object[]}
|
|
*/
|
|
export function dedupByTriplet(findings) {
|
|
const groups = new Map();
|
|
for (const f of findings) {
|
|
const key = tripletKey(f);
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key).push(f);
|
|
}
|
|
const out = [];
|
|
for (const group of groups.values()) {
|
|
let survivor = group[0];
|
|
for (const f of group.slice(1)) {
|
|
const higher = severityRank(f.severity) < severityRank(survivor.severity);
|
|
const tieToConformance =
|
|
severityRank(f.severity) === severityRank(survivor.severity) &&
|
|
isConformance(f.reviewer) && !isConformance(survivor.reviewer);
|
|
if (higher || tieToConformance) survivor = f;
|
|
}
|
|
const raised_by = [...new Set(group.map((f) => f.reviewer).filter(Boolean))];
|
|
const others = group.filter((f) => f !== survivor);
|
|
let detail = survivor.detail;
|
|
if (others.length > 0) {
|
|
detail = survivor.detail ?? '';
|
|
for (const o of others) {
|
|
detail += `\nAlso flagged by ${o.reviewer ?? 'unknown'}: ${o.title ?? o.rule_key}.`;
|
|
}
|
|
}
|
|
const id = computeFindingId(survivor.file, survivor.line, survivor.rule_key);
|
|
out.push({ ...survivor, id, ...(detail !== undefined ? { detail } : {}), raised_by });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Pass 2 — HubSpot Judge (deterministic subset): drop on succinctness
|
|
* (title > 100 or detail > 800 chars) and actionability (recommended_action,
|
|
* when present, must be a non-empty string). The imperative-verb test is
|
|
* excluded (LLM judgement).
|
|
*
|
|
* Both tests read a `.length`; neither examines the claim, so neither can
|
|
* establish the finding is unreal. Both therefore route to `unverified`.
|
|
* `dropped` stays in the signature for the refuting Pass-2 filter this subset
|
|
* excludes (Accuracy: a path-traversal escape IS a refutation).
|
|
* @param {object[]} findings
|
|
* @returns {{ kept: object[], dropped: object[], unverified: object[] }}
|
|
*/
|
|
export function judgeFilter(findings) {
|
|
const kept = [];
|
|
const dropped = [];
|
|
const unverified = [];
|
|
for (const f of findings) {
|
|
const titleLen = (f.title ?? '').length;
|
|
const detailLen = (f.detail ?? '').length;
|
|
let reason = null;
|
|
if (titleLen > JUDGE_TITLE_MAX) reason = 'succinctness:title';
|
|
else if (detailLen > JUDGE_DETAIL_MAX) reason = 'succinctness:detail';
|
|
else if ('recommended_action' in f &&
|
|
(typeof f.recommended_action !== 'string' || f.recommended_action.trim().length === 0)) {
|
|
reason = 'actionability:empty';
|
|
}
|
|
if (reason) suppress(f, reason, dropped, unverified);
|
|
else kept.push(f);
|
|
}
|
|
return { kept, dropped, unverified };
|
|
}
|
|
|
|
/**
|
|
* Pass 3 — Cloudflare reasonableness (deterministic subset): drop findings
|
|
* with no citation (empty file / line < 0) or an unknown rule_key; CORRECT a
|
|
* severity that does not match the catalogue tier (a correction, not a drop).
|
|
* The fs file-existence glob is excluded (I/O) — its indeterminate branch is
|
|
* prose-side, tokenised as `file-existence:indeterminate`.
|
|
*
|
|
* `no-citation` REFUTES (the finding names no location, so it makes no
|
|
* checkable claim) and is dropped. `unknown-rule_key` does not: an ad-hoc key
|
|
* is a real defect wearing the wrong label — v5.1.1 high-effort mode already
|
|
* KEEPS these, normalised to PLAN_EXECUTE_DRIFT — so it routes to `unverified`.
|
|
* @param {object[]} findings
|
|
* @returns {{ kept: object[], dropped: object[], unverified: object[] }}
|
|
*/
|
|
export function reasonablenessFilter(findings) {
|
|
const kept = [];
|
|
const dropped = [];
|
|
const unverified = [];
|
|
for (const f of findings) {
|
|
if (typeof f.file !== 'string' || f.file.length === 0 ||
|
|
(typeof f.line === 'number' && f.line < 0)) {
|
|
suppress(f, 'no-citation', dropped, unverified);
|
|
continue;
|
|
}
|
|
if (!RULE_KEYS.has(f.rule_key)) {
|
|
suppress(f, 'unknown-rule_key', dropped, unverified);
|
|
continue;
|
|
}
|
|
const rule = getRule(f.rule_key);
|
|
if (rule && f.severity !== rule.severity) {
|
|
kept.push({ ...f, severity: rule.severity, original_severity: f.severity });
|
|
} else {
|
|
kept.push(f);
|
|
}
|
|
}
|
|
return { kept, dropped, unverified };
|
|
}
|
|
|
|
/**
|
|
* Pass 4 — compute the verdict from severity counts (after dedup + filtering).
|
|
* BLOCKER ≥ 1 → BLOCK; else MAJOR ≥ 1 → WARN; else ALLOW.
|
|
*
|
|
* FAIL-CLOSED: ALLOW additionally requires that nothing is `unverified` and
|
|
* that every expected reviewer reported. Neither ever RAISES a verdict — the
|
|
* severity thresholds are untouched — they only forbid the clean one, so the
|
|
* worst case of a false unverified is WARN plus a stated reason, never a
|
|
* silent pass. Unverified findings are NOT counted into a severity tier: their
|
|
* severity is reviewer-asserted and was never substantiated.
|
|
*
|
|
* @param {object[]} findings
|
|
* @param {{ unverified?: object[], missingReviewers?: string[] }} [options]
|
|
* @returns {{ verdict: 'BLOCK'|'WARN'|'ALLOW', counts: Record<string, number>, allow_blocked_by: string[] }}
|
|
*/
|
|
export function computeVerdict(findings, options = {}) {
|
|
const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
|
|
for (const f of findings) {
|
|
if (counts[f.severity] !== undefined) counts[f.severity] += 1;
|
|
}
|
|
|
|
const unverified = options.unverified ?? [];
|
|
const missingReviewers = options.missingReviewers ?? [];
|
|
const allow_blocked_by = [];
|
|
const byReason = new Map();
|
|
for (const f of unverified) {
|
|
const reason = f?.suppressed_reason ?? 'unspecified';
|
|
byReason.set(reason, (byReason.get(reason) ?? 0) + 1);
|
|
}
|
|
for (const [reason, n] of byReason) allow_blocked_by.push(`unverified:${reason} (${n})`);
|
|
for (const r of missingReviewers) allow_blocked_by.push(`missing-reviewer:${r}`);
|
|
|
|
let verdict;
|
|
if (counts.BLOCKER >= 1) verdict = 'BLOCK';
|
|
else if (counts.MAJOR >= 1) verdict = 'WARN';
|
|
else if (allow_blocked_by.length > 0) verdict = 'WARN';
|
|
else verdict = 'ALLOW';
|
|
return { verdict, counts, allow_blocked_by };
|
|
}
|
|
|
|
/**
|
|
* Run the full deterministic contract: ingest → Pass 1 → Pass 2 → Pass 3 → Pass 4.
|
|
*
|
|
* `options.expectedReviewers` names the reviewers this review was supposed to
|
|
* hear from. A reviewer that is absent from the payloads, or whose payload
|
|
* failed schema validation and was thrown away at ingest, lands in
|
|
* `missing_reviewers` and forbids ALLOW: an unread reviewer is an absent one,
|
|
* and zero findings from a silent reviewer must not read like zero findings
|
|
* from a clean diff.
|
|
*
|
|
* `suppressed` stays the UNION of `dropped` (refuted) and `unverified` so
|
|
* existing consumers keep their meaning; `unverified` is the subset that
|
|
* forbids ALLOW. Do not iterate both and count twice.
|
|
*
|
|
* @param {Array<{reviewer?: string, findings: object[]}>} reviewerPayloads
|
|
* @param {{ expectedReviewers?: string[] }} [options]
|
|
* @returns {{ verdict: string, counts: Record<string, number>, findings: object[], suppressed: object[], unverified: object[], skipped: object[], missing_reviewers: string[], allow_blocked_by: string[] }}
|
|
*/
|
|
export function runContract(reviewerPayloads, options = {}) {
|
|
const { findings: ingested, skipped } = ingest(reviewerPayloads);
|
|
const deduped = dedupByTriplet(ingested);
|
|
const judged = judgeFilter(deduped);
|
|
const reasoned = reasonablenessFilter(judged.kept);
|
|
const unverified = [...judged.unverified, ...reasoned.unverified];
|
|
|
|
const reported = new Set();
|
|
for (const payload of reviewerPayloads) {
|
|
if (typeof payload?.reviewer === 'string' && payload.reviewer.length > 0) reported.add(payload.reviewer);
|
|
}
|
|
for (const s of skipped) reported.delete(s.reviewer);
|
|
const missing_reviewers = [];
|
|
for (const s of skipped) {
|
|
const name = s.reviewer ?? 'unnamed reviewer';
|
|
if (!missing_reviewers.includes(name)) missing_reviewers.push(name);
|
|
}
|
|
for (const r of options.expectedReviewers ?? []) {
|
|
if (!reported.has(r) && !missing_reviewers.includes(r)) missing_reviewers.push(r);
|
|
}
|
|
|
|
const { verdict, counts, allow_blocked_by } = computeVerdict(reasoned.kept, {
|
|
unverified,
|
|
missingReviewers: missing_reviewers,
|
|
});
|
|
return {
|
|
verdict,
|
|
counts,
|
|
findings: reasoned.kept,
|
|
suppressed: [...judged.dropped, ...judged.unverified, ...reasoned.dropped, ...reasoned.unverified],
|
|
unverified,
|
|
skipped,
|
|
missing_reviewers,
|
|
allow_blocked_by,
|
|
};
|
|
}
|
|
|
|
// ---- CLI shim ----------------------------------------------------------------
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
const args = process.argv.slice(2);
|
|
const filePath = args.find((a) => !a.startsWith('--'));
|
|
if (!filePath) {
|
|
process.stderr.write('Usage: coordinator-contract.mjs [--json] <reviewer-payloads.json>\n');
|
|
process.exit(2);
|
|
}
|
|
const { readFileSync } = await import('node:fs');
|
|
const payloads = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
const result = runContract(Array.isArray(payloads) ? payloads : [payloads]);
|
|
if (args.includes('--json')) {
|
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
} else {
|
|
process.stdout.write(`coordinator-contract: ${result.verdict} (${result.findings.length} findings, ${result.suppressed.length} suppressed)\n`);
|
|
}
|
|
process.exit(0);
|
|
}
|