feat(gate): stop rule in the plan gate (decision 217, 220)

recordVerdict appends each round's summary to a new optional verdict
field `rounds` (carried from the previous verdict; newByClass null for a
round counted in full) and stores `stop_rule`; extractVerdict shape-checks
both. After a fired stop rule --record exits 1 with PLAN_GATE_STOP_RULE
and writes nothing (--extra-round included). The bare gate stops with
PLAN_GATE_STOP_RULE and the operator line in place of the open-blocker and
open-major reasons; --json and --record output carry the decision.
--accept-open after a fired stop rule is allowed with rounds left, covers
open blockers and majors (`under: "stop-rule"`), refuses a counting
security finding (PLAN_GATE_SECURITY_OPEN); the gate then warns.

Known issue fixed: REVIEW_LOG_SECTIONS drops "Adversarial Pass 2 (v5.1.1
high-effort)"; the CHANGELOG known-issue line closes and Handover 4 names
the heading list, the new fields and codes.

Existing tests updated for the planned rule: fixtures that ran a later
round after one with no fewer blocking findings now use a falling count
(plan-gate.test round cap, plan-gate-open-major lastRound, diff-mode
5/18/27 resolve two earlier majors in round 2); the diff-mode CLI pipe
case now expects PLAN_GATE_STOP_RULE (round 3: 2 blocking) after its
clean round 2.

Part 1, step 1.2 of the E plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-08 07:04:48 +01:00
commit a576391e28
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
7 changed files with 448 additions and 32 deletions

View file

@ -15,8 +15,10 @@
//
// node plan-gate.mjs --record <plan.md> --reviewed <sha256> [--extra-round]
// stdin = plan-review-dedup.mjs output. Writes this round's verdict into
// the plan. 0 = written; 1 = refused: the round cap is spent (stop and ask
// the operator; `--extra-round` only after their yes, and only then);
// the plan. 0 = written; 1 = refused: the previous verdict's stop rule
// fired (`PLAN_GATE_STOP_RULE`, below; `--extra-round` cannot open it) or
// the round cap is spent (stop and ask the operator; `--extra-round` only
// after their yes, and only then);
// 2 = could not run: bad stdin, a reviewer missing from the dedup input,
// the plan changed since --hash (the verdict would bless unreviewed text),
// an unreadable plan or a failed write. Nothing is written on 1 or 2.
@ -26,7 +28,9 @@
// 0 it keeps this round's operative text as a snapshot (tmp + rename)
// before the plan is written. Output: one JSON line, which adds
// `diff_base`, `diff_note`, `lowered` (a number) and `snapshot` ("kept: ..."
// or "not kept: <reason>"; a failed snapshot write does not fail the round).
// or "not kept: <reason>"; a failed snapshot write does not fail the round),
// `stop_rule` (this round's decision, or on a refusal the one that fired)
// and `reason` (`PLAN_GATE_STOP_RULE` on that refusal, else null).
//
// node plan-gate.mjs --diff <plan.md>
// Prints what the next critic round reviews: `diff-base: <sha256> (round N)`
@ -45,7 +49,10 @@
// MAJORs of the current round as accepted (`accepted_open` in the verdict
// block, bound to the round and the plan hash). 0 = written; 1 = refused
// (rounds left, a BLOCKER is open, no MAJOR is open, or the verdict is
// stale); 2 = could not run.
// stale); 2 = could not run. After the stop rule fired (decision 220) it
// is allowed with rounds left and covers the open BLOCKERs and MAJORs as
// known issues (`under: "stop-rule"`), except a counting security finding
// (refused, `PLAN_GATE_SECURITY_OPEN`); the gate then warns on each.
//
// Open MAJORs (operator decision 2026-10-08, voyage-lekerepo trial point 6):
// they used to pass as warnings, and after the last round there was no way to
@ -62,9 +69,10 @@
// What the verdict is bound to: sha256 of the plan's operative text — the plan
// minus its review log, with CRLF and line-end whitespace normalized. The
// review log is the `> **Plan quality:` header line and the level-2 sections
// titled exactly Critic Verdict, Revisions, Plan Quality Score and
// "Adversarial Pass 2 (v5.1.1 high-effort)" — each only up to its first
// other heading, so a step or section placed under a log heading is hashed.
// titled exactly Critic Verdict, Revisions and Plan Quality Score — each only
// up to its first other heading, so a step or section placed under a log
// heading is hashed. (The old "Adversarial Pass 2 (v5.1.1 high-effort)"
// section is plan text now: no command writes it.)
// Edit a step, the design or the verification after the critic ran, and the
// verdict is stale until the next round.
//
@ -89,6 +97,20 @@
// the same exits. Trust limit: anything that can write the plan can also
// rewrite the previous verdict's plan_sha256 and plant a matching base; rule A
// raises what such a forgery buys, and an unchanged base lowers nothing.
//
// The stop rule (decisions 217, 220; lib/review/stop-rule.mjs): each round
// appends its summary (blocking findings, and the new blocking findings per
// class unless the round was counted in full) to the optional verdict field
// `rounds`, carried from the previous verdict, and stores the decision as
// `stop_rule`. Once it fired — one class with new blocking findings two
// rounds in a row, or blocking findings that did not get fewer — there is no
// new round: --record refuses (exit 1, PLAN_GATE_STOP_RULE, `--extra-round`
// included), and the bare gate stops with PLAN_GATE_STOP_RULE and the
// operator line (the class, the counts per round, "known issue" or "new
// mechanism") in place of the open-blocker and open-major reasons, until the
// operator accepts the open findings (--accept-open) or the direction
// changes. A verdict without `rounds` starts its history at the next round,
// so an older block never fires on its first new round.
import {
readFileSync, existsSync, writeFileSync, renameSync, rmSync, statSync, mkdirSync, realpathSync,
@ -97,8 +119,9 @@ import { createHash } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { validatePlanContent } from './plan-validator.mjs';
import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from '../parsers/plan-schema.mjs';
import { normalizeSeverity } from '../review/plan-review-dedup.mjs';
import { normalizeSeverity, isSecurity } from '../review/plan-review-dedup.mjs';
import { countRound, lineDiff, oneLine } from '../review/critic-diff.mjs';
import { roundSummary, decideStop, describeStop, STOP_REASONS } from '../review/stop-rule.mjs';
import { resolveRepo } from '../verification/repo-tools.mjs';
import { isMain } from '../util/is-main.mjs';
@ -107,9 +130,7 @@ export const VERDICT_SCHEMA = 'voyage.plan-verdict/1';
export const VERDICT_HEADING = '## Critic Verdict';
export const REQUIRED_REVIEWERS = Object.freeze(['plan-critic', 'scope-guardian']);
// Level-2 sections that are review log, not plan: excluded from the hash.
export const REVIEW_LOG_SECTIONS = Object.freeze([
'Critic Verdict', 'Revisions', 'Plan Quality Score', 'Adversarial Pass 2 (v5.1.1 high-effort)',
]);
export const REVIEW_LOG_SECTIONS = Object.freeze(['Critic Verdict', 'Revisions', 'Plan Quality Score']);
// The quality line alone; a line that also carries `plan_version`/`ceremony` stays hashed.
const QUALITY_LINE = /^> \*\*Plan quality:[^`]*$/;
@ -210,13 +231,32 @@ export function extractVerdict(text) {
Number.isInteger(v.max_rounds) && v.max_rounds >= 1 && v.max_rounds <= MAX_ROUNDS &&
Number.isInteger(v.extra_rounds ?? 0) && (v.extra_rounds ?? 0) >= 0 &&
typeof v.plan_sha256 === 'string' && Array.isArray(v.findings) &&
v.findings.every(f => f && typeof f === 'object' && typeof f.severity === 'string');
v.findings.every(f => f && typeof f === 'object' && typeof f.severity === 'string') &&
okRounds(v.rounds) && okStopRule(v.stop_rule);
if (!okShape) {
return { ...none, found: true, error: `verdict block is not ${VERDICT_SCHEMA} (schema, round, max_rounds <= ${MAX_ROUNDS}, extra_rounds, plan_sha256, findings with severity)` };
return { ...none, found: true, error: `verdict block is not ${VERDICT_SCHEMA} (schema, round, max_rounds <= ${MAX_ROUNDS}, extra_rounds, plan_sha256, findings with severity, optional rounds and stop_rule)` };
}
return { found: true, placeholder: false, verdict: v, error: null };
}
const isCount = n => Number.isInteger(n) && n >= 0;
const isPlainObject = o => o !== null && typeof o === 'object' && !Array.isArray(o);
/** Optional `rounds`: a list of {round >= 1, blocking >= 0, newByClass: null | {class: count}}. */
function okRounds(r) {
if (r === undefined) return true;
return Array.isArray(r) && r.every(x => isPlainObject(x) && Number.isInteger(x.round) && x.round >= 1 &&
isCount(x.blocking) && (x.newByClass === null || (isPlainObject(x.newByClass) && Object.values(x.newByClass).every(isCount))));
}
/** Optional `stop_rule`: {fired: boolean, reason: one of STOP_REASONS when fired else null, classes: string[], perRound: list}. */
function okStopRule(s) {
if (s === undefined) return true;
return isPlainObject(s) && typeof s.fired === 'boolean' &&
(s.fired ? STOP_REASONS.includes(s.reason) : s.reason === null) &&
Array.isArray(s.classes) && s.classes.every(c => typeof c === 'string') && okRounds(s.perRound);
}
function verdictFor(counts) {
if (counts.blocker > 0) return 'BLOCK';
if (counts.major > 0) return 'REVISE';
@ -292,6 +332,13 @@ export function recordVerdict(text, dedup, opts = {}) {
}
const prev = extractVerdict(text);
if (prev.error && !prev.placeholder) return { code: 2, message: `existing verdict unreadable: ${prev.error}` };
// The stop rule (decision 217): once it fired there is no new round, whatever the cap or --extra-round says.
if (prev.verdict?.stop_rule?.fired === true) {
return {
code: 1, reason: 'PLAN_GATE_STOP_RULE', stop_rule: prev.verdict.stop_rule,
message: `PLAN_GATE_STOP_RULE: no round ${prev.verdict.round + 1}. ${describeStop(prev.verdict.stop_rule)}`,
};
}
const maxRounds = Math.min(prev.verdict?.max_rounds ?? opts.maxRounds ?? MAX_ROUNDS, MAX_ROUNDS);
const prevExtra = prev.verdict?.extra_rounds ?? 0;
const round = (prev.verdict?.round ?? 0) + 1;
@ -335,9 +382,16 @@ export function recordVerdict(text, dedup, opts = {}) {
verdict.counts = counts;
verdict.findings = findings;
if (c.resolved !== null) verdict.resolved = c.resolved;
// The stop rule's history: this round's summary after the carried ones. A
// round with a diff_note (or, outside diff mode, any round from 2 on) was
// counted in full, so "new" is not known: newByClass null.
const history = Array.isArray(prev.verdict?.rounds) ? prev.verdict.rounds : [];
verdict.rounds = [...history, { round, ...roundSummary(findings, { fullCount: c.diffNote !== null }) }];
verdict.stop_rule = decideStop(verdict.rounds);
return {
code: 0, text: placeSection(text, renderSection(verdict)), verdict, lowered: c.lowered,
message: `round ${round} recorded: ${verdict.verdict}`,
message: `round ${round} recorded: ${verdict.verdict}` +
(verdict.stop_rule.fired ? `; PLAN_GATE_STOP_RULE: ${describeStop(verdict.stop_rule)}` : ''),
};
}
@ -354,7 +408,10 @@ function acceptanceCovers(v, majors) {
/**
* Record the operator's "build as is" for the current round's open MAJORs.
* Only after their explicit yes; never for a BLOCKER.
* Only after their explicit yes; never for a BLOCKER — except after the stop
* rule fired (decision 220): then the open BLOCKERs and MAJORs are accepted as
* known issues, with rounds left too, unless one is a counting security
* finding (never accepted).
* @returns {{code: 0|1|2, text?: string, message: string}}
*/
export function acceptOpen(rawText, opts = {}) {
@ -362,7 +419,33 @@ export function acceptOpen(rawText, opts = {}) {
const ex = extractVerdict(text);
if (!ex.found || ex.error) return { code: 2, message: `no readable verdict: ${ex.error ?? 'no ## Critic Verdict'}` };
const v = ex.verdict;
if (v.plan_sha256 !== planHash(text)) return { code: 1, message: 'the verdict is stale: the plan changed after the last round; run the next round instead' };
if (v.plan_sha256 !== planHash(text)) {
return {
code: 1,
message: v.stop_rule?.fired === true
? 'the verdict is stale: the plan changed after the last round, and the stop rule fired, so there is no next round; restore the reviewed text to accept, or stop and take the direction to the operator'
: 'the verdict is stale: the plan changed after the last round; run the next round instead',
};
}
if (v.stop_rule?.fired === true) {
const open = v.findings.filter(f => sevOf(f) !== 'minor');
if (open.length === 0) return { code: 1, message: 'no BLOCKER or MAJOR is open; nothing to accept' };
const security = open.filter(isSecurity);
if (security.length > 0) {
return { code: 1, message: `PLAN_GATE_SECURITY_OPEN: ${security.length} open security finding(s) count and are never accepted: ${security.map(s => s.text).join(' | ')}` };
}
const accepted = {
...v,
accepted_open: {
round: v.round,
plan_sha256: v.plan_sha256,
accepted_at: opts.now ?? new Date().toISOString(),
under: 'stop-rule',
findings: open.map(m => ({ id: m.id ?? null, text: m.text ?? '' })),
},
};
return { code: 0, text: placeSection(text, renderSection(accepted)), message: `${open.length} open finding(s) of round ${v.round} accepted as known issues under the stop rule` };
}
if (v.findings.some(f => sevOf(f) !== 'major' && sevOf(f) !== 'minor')) return { code: 1, message: 'a BLOCKER is open; a blocker is never accepted' };
const majors = v.findings.filter(f => sevOf(f) === 'major');
if (majors.length === 0) return { code: 1, message: 'no MAJOR is open; nothing to accept' };
@ -384,7 +467,7 @@ export function acceptOpen(rawText, opts = {}) {
/**
* K3 — decide whether the plan may go on.
* @returns {{code: 0|1, status: 'pass'|'stop', reasons: object[], warnings: object[], verdict: object|null, validation: object}}
* @returns {{code: 0|1, status: 'pass'|'stop', reasons: object[], warnings: object[], verdict: object|null, stop_rule: object|null, validation: object}}
*/
export function gatePlan(rawText) {
const text = norm(rawText);
@ -411,13 +494,32 @@ export function gatePlan(rawText) {
const sev = f => normalizeSeverity(f.severity);
const blockers = v.findings.filter(f => sev(f) !== 'major' && sev(f) !== 'minor');
const majors = v.findings.filter(f => sev(f) === 'major');
if (blockers.length > 0) {
const stop = v.stop_rule?.fired === true ? v.stop_rule : null;
if (stop) {
// The stop rule replaces the open-blocker and open-major reasons: no new
// round, the operator decides (decision 220). Covered = accepted under
// the stop rule for this round and text, with no security finding open.
const open = [...blockers, ...majors];
const a = v.accepted_open;
const covered = open.length > 0 && a?.under === 'stop-rule' && acceptanceCovers(v, open) &&
v.plan_sha256 === planHash(text) && !open.some(isSecurity);
if (open.length > 0 && !covered) {
reasons.push({ code: 'PLAN_GATE_STOP_RULE', message: describeStop(stop) });
} else if (covered) {
if (blockers.length > 0) {
warnings.push({ code: 'PLAN_GATE_OPEN_BLOCKER', message: `${blockers.length} BLOCKER(s) open in round ${v.round}, accepted by the operator as known issues after the stop rule (${a.accepted_at}): ${blockers.map(b => b.text).join(' | ')}` });
}
if (majors.length > 0) {
warnings.push({ code: 'PLAN_GATE_OPEN_MAJOR', message: `${majors.length} MAJOR finding(s) open in round ${v.round}, accepted by the operator as known issues after the stop rule (${a.accepted_at}): ${majors.map(m => m.text).join(' | ')}` });
}
}
} else if (blockers.length > 0) {
reasons.push({ code: 'PLAN_GATE_OPEN_BLOCKER', message: `${blockers.length} open BLOCKER(s) in critic round ${v.round}: ${blockers.map(b => b.text).join(' | ')}` });
if (v.round >= v.max_rounds + (v.extra_rounds ?? 0)) {
reasons.push({ code: 'PLAN_GATE_ROUNDS_EXHAUSTED', message: `All ${v.round} critic rounds are spent with a BLOCKER open: stop and ask the operator.` });
}
}
if (majors.length > 0) {
if (!stop && majors.length > 0) {
const exhausted = v.round >= v.max_rounds + (v.extra_rounds ?? 0);
const list = majors.map(m => m.text).join(' | ');
if (acceptanceCovers(v, majors) && v.plan_sha256 === planHash(text)) {
@ -433,7 +535,10 @@ export function gatePlan(rawText) {
}
}
}
return { code: reasons.length === 0 ? 0 : 1, status: reasons.length === 0 ? 'pass' : 'stop', reasons, warnings, verdict: v ?? null, validation };
return {
code: reasons.length === 0 ? 0 : 1, status: reasons.length === 0 ? 'pass' : 'stop', reasons, warnings,
verdict: v ?? null, stop_rule: v?.stop_rule ?? null, validation,
};
}
// ---- CLI --------------------------------------------------------------------
@ -624,12 +729,13 @@ if (isMain(import.meta.url)) {
verdict: r.verdict?.verdict ?? null, counts: r.verdict?.counts ?? null,
diff_base: r.verdict?.diff_base ?? null, diff_note: r.verdict?.diff_note ?? null,
lowered: r.code === 0 ? r.lowered : null, snapshot,
stop_rule: r.verdict?.stop_rule ?? r.stop_rule ?? null, reason: r.reason ?? null,
});
process.exit(r.code);
}
const g = gatePlan(text);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ code: g.code, status: g.status, plan: planPath, reasons: g.reasons, warnings: g.warnings, verdict: g.verdict }, null, 2) + '\n');
process.stdout.write(JSON.stringify({ code: g.code, status: g.status, plan: planPath, reasons: g.reasons, warnings: g.warnings, stop_rule: g.stop_rule, verdict: g.verdict }, null, 2) + '\n');
} else {
process.stdout.write(
`plan-gate: ${g.status.toUpperCase()} ${planPath}\n` +