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

@ -172,8 +172,12 @@ test(`--record counts rounds; round ${MAX_ROUNDS + 1} needs the operator's extra
assert.equal(MAX_ROUNDS, 3);
let text = buildPlan();
assert.equal(rec(text, dedupOut(), { extraRound: true }).code, 2, '--extra-round within the cap is refused, not banked');
// A falling count of blockers (3, 2, 1), so the stop rule (lib/review/stop-rule.mjs,
// "not fewer") never fires and the cap is what refuses round 4.
for (let i = 1; i <= 3; i++) {
const r = rec(text, dedupOut(finding('blocker', `still broken ${i}`)));
const blockers = Array.from({ length: 4 - i }, (_, k) => finding('blocker', `still broken ${i}.${k}`));
const r = rec(text, dedupOut(...blockers));
assert.equal(r.verdict?.stop_rule?.fired, false, `round ${i}: the stop rule does not fire on a falling count`);
assert.equal(r.code, 0, r.message);
assert.equal(r.verdict.round, i);
text = r.text;