fix(gate): part 1 verifier findings — stop-rule wording, guard tests, unreadable round

- plan-gate: the bare gate's stale-verdict reason no longer says "run the
  next round" once the stop rule fired; it says there is no next round, as
  acceptOpen already does.
- trekplan Phase 9: "REVISE after the last round" (Fix now with
  --extra-round) is qualified "and the stop rule did not fire", pinned in
  review-loop-docs.test.mjs next to the [STP] pins.
- plan-gate-stop-rule tests: a planted stop-rule acceptance claiming an open
  security blocker stays STOP; a planted acceptance without under:
  "stop-rule" stays STOP; the "a blocker -> refused" test now asserts the
  blocker refusal; --accept-open under the stop rule covers an open MAJOR
  and the gate passes with a WARN.
- review-rounds prepare: when the stop rule is judged (P >= 2) and either
  round's JSON block cannot be read, exit 2 (not measured) with "stop rule:
  a round has no readable JSON block" instead of skipping the rule. The
  [RND] differing-archive and [CAP] fixtures now use readable archives; one
  new [STP] test covers an unreadable archive and an unreadable review.md.

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

View file

@ -30,8 +30,8 @@
// `carried_from`; class = `classOf`. Fired -> exit 1 with
// `review-stop-rule: <describeStop line>` and `stop_rule`; nothing is
// archived. A round whose trailing JSON block cannot be read gives no
// summary, and the rule is then not judged (as a plan verdict without
// history). The cap stays.
// summary: exit 2 (not measured), `stop rule: a round has no readable
// JSON block`, nothing archived. The cap stays.
// - The round base is the previous `scope_sha_end`: a full sha that is an
// ancestor of HEAD, else exit 2 (not measured).
// - review.md is copied to `review-round-<run>-<P>.md`; an existing file
@ -245,10 +245,9 @@ export function prepare({ projectDir, since, repo, dryRun = false } = {}) {
const at = repo ?? projectDir;
const a = prevBytes === null ? null : reviewRoundSummary(prevBytes.toString('utf8'), at);
const b = reviewRoundSummary(text, at);
if (a && b) {
const d = decideStop([{ round: P - 1, ...a }, { round: P, ...b }]);
if (d.fired) return { exit: 1, reason: `review-stop-rule: ${describeStop(d)}`, stop_rule: d };
}
if (!a || !b) return notMeasured('stop rule: a round has no readable JSON block');
const d = decideStop([{ round: P - 1, ...a }, { round: P, ...b }]);
if (d.fired) return { exit: 1, reason: `review-stop-rule: ${describeStop(d)}`, stop_rule: d };
}
const base = ownEnd;
if (!base || !isAncestorOfHead(repo ?? projectDir, base)) {

View file

@ -489,7 +489,12 @@ export function gatePlan(rawText) {
reasons.push({ code: 'PLAN_GATE_VERDICT_MALFORMED', message: ex.error });
} else {
if (v.plan_sha256 !== planHash(text)) {
reasons.push({ code: 'PLAN_GATE_VERDICT_STALE', message: `The plan changed after critic round ${v.round}; run the next round.` });
reasons.push({
code: 'PLAN_GATE_VERDICT_STALE',
message: v.stop_rule?.fired === true
? `The plan changed after critic round ${v.round}, and the stop rule fired, so there is no next round; restore the reviewed text, or stop and take the direction to the operator.`
: `The plan changed after critic round ${v.round}; run the next round.`,
});
}
const sev = f => normalizeSeverity(f.severity);
const blockers = v.findings.filter(f => sev(f) !== 'major' && sev(f) !== 'minor');