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

@ -29,12 +29,19 @@ const dedup = (...f) => ({ findings: f, sources: BOTH });
const rec = (text, d, opts = {}) => recordVerdict(text, d, { reviewed: planHash(text), ...opts });
const codes = (list) => list.map(x => x.code);
// The earlier rounds hold more open majors than the last (a falling count), so
// the stop rule (lib/review/stop-rule.mjs, "not fewer") does not fire and the
// last round is judged by the open-major rules alone.
function lastRound(...f) {
let text = buildPlan();
for (let i = 1; i < MAX_ROUNDS; i++) text = rec(text, dedup()).text;
for (let i = 1; i < MAX_ROUNDS; i++) {
const earlier = Array.from({ length: MAX_ROUNDS - i + 5 }, (_, k) => finding('major', `earlier round ${i} open point ${'x'.repeat(k)}`));
text = rec(text, dedup(...earlier)).text;
}
const r = rec(text, dedup(...f));
assert.equal(r.code, 0, r.message);
assert.equal(r.verdict.round, MAX_ROUNDS);
assert.equal(r.verdict.stop_rule?.fired, false, 'the stop rule does not fire on a falling count');
return r.text;
}
@ -76,7 +83,8 @@ test('--accept-open never accepts a BLOCKER, and refuses when no MAJOR is open',
});
test('an acceptance does not carry over to a later round', () => {
const accepted = acceptOpen(lastRound(finding('major', WEAK))).text;
// Two majors accepted, one in the extra round: fewer, so the stop rule stays quiet.
const accepted = acceptOpen(lastRound(finding('major', WEAK), finding('major', 'Step 3 verify line never names the failing assertion'))).text;
const revised = accepted.replace('Add `run1()`', 'Add `run1()` and its error path');
const next = rec(revised, dedup(finding('major', 'Step 1 error path untested')), { extraRound: true });
assert.equal(next.code, 0, next.message);