fix(plan): an open MAJOR stops the plan; after the last round the operator decides

voyage-lekerepo trial (2026-10-06) point 6, operator decision 2026-10-08
(«samme skala, og du spør»): the critics rated a non-discriminating test
minor in round 3, there was no way to fix it after the last round, it was
built, and /trekreview called the same weakness MAJOR (MISSING_TEST).

- plan-critic, scope-guardian: rate on the review catalogue's tiers; a test
  that cannot fail on a wrong implementation is major, never minor.
- plan-gate: an open MAJOR stops (PLAN_GATE_OPEN_MAJOR: revise and run the
  next round; PLAN_GATE_OPEN_MAJOR_FINAL after the last round: ask the
  operator). --accept-open records "build as is" after their yes, bound to
  the round and the plan hash; never for a blocker.
- trekplan Phase 9: REVISE after the last round asks fix now (one extra
  round) or build as is, instead of carrying the majors silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-08 07:07:00 +02:00
commit 2d29966f51
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
7 changed files with 243 additions and 16 deletions

View file

@ -0,0 +1,109 @@
// tests/validators/plan-gate-open-major.test.mjs
// Operator decision 2026-10-08 (voyage-lekerepo trial point 6, «samme skala,
// og du spør»). In the trial the critics marked a non-discriminating test as
// minor in round 3; with no round left there was no way to fix it, it was
// built, and the review called the same weakness MAJOR (MISSING_TEST). Now:
// - plan-critic and scope-guardian rate on the review's scale (a test that
// cannot fail on a wrong implementation is MAJOR, as MISSING_TEST is);
// - an open MAJOR stops the gate: with rounds left the loop revises; after
// the last round the operator is asked — fix now (one extra round) or
// build as is, recorded by `--accept-open` after their yes;
// - an acceptance is bound to the round and the plan text it was given for.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { gatePlan, recordVerdict, planHash, acceptOpen, MAX_ROUNDS } from '../../lib/validators/plan-gate.mjs';
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const GATE = join(ROOT, 'lib', 'validators', 'plan-gate.mjs');
const BOTH = [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: true }];
const finding = (severity, text) => ({ id: `${severity}-${text.length}`, file: 'plan.md', line: 3, rule_key: 'test-quality', severity, text, raised_by: ['plan-critic'] });
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);
function lastRound(...f) {
let text = buildPlan();
for (let i = 1; i < MAX_ROUNDS; i++) text = rec(text, dedup()).text;
const r = rec(text, dedup(...f));
assert.equal(r.code, 0, r.message);
assert.equal(r.verdict.round, MAX_ROUNDS);
return r.text;
}
const WEAK = 'Step 2 test passes for any truncation at a hyphen: it cannot fail on the wrong cut';
test('an open MAJOR with rounds left stops and says: revise and run the next round', () => {
const g = gatePlan(rec(buildPlan(), dedup(finding('major', WEAK))).text);
assert.equal(g.code, 1);
const r = g.reasons.find(x => x.code === 'PLAN_GATE_OPEN_MAJOR');
assert.ok(r, codes(g.reasons).join(','));
assert.match(r.message, /next round/);
});
test('an open MAJOR after the last round stops and says: ask the operator, fix now or build as is', () => {
const g = gatePlan(lastRound(finding('major', WEAK)));
assert.equal(g.code, 1);
const r = g.reasons.find(x => x.code === 'PLAN_GATE_OPEN_MAJOR_FINAL');
assert.ok(r, codes(g.reasons).join(','));
assert.match(r.message, /ask the operator/);
assert.match(r.message, /--extra-round/);
assert.match(r.message, /--accept-open/);
});
test('--accept-open after the operator\'s yes: the gate passes, the accepted majors stay listed as a warning', () => {
const text = lastRound(finding('major', WEAK), finding('minor', 'typo'));
const a = acceptOpen(text, { now: '2026-10-08T07:00:00.000Z' });
assert.equal(a.code, 0, a.message);
assert.equal(planHash(a.text), planHash(text), 'the acceptance is review log: it does not stale the verdict');
const g = gatePlan(a.text);
assert.equal(g.code, 0, JSON.stringify(g.reasons));
const w = g.warnings.find(x => x.code === 'PLAN_GATE_OPEN_MAJOR');
assert.ok(w);
assert.match(w.message, /accepted by the operator/);
});
test('--accept-open never accepts a BLOCKER, and refuses when no MAJOR is open', () => {
assert.equal(acceptOpen(lastRound(finding('blocker', 'Step 2 reads nothing'), finding('major', WEAK))).code, 1);
assert.equal(acceptOpen(lastRound(finding('minor', 'typo'))).code, 1);
});
test('an acceptance does not carry over to a later round', () => {
const accepted = acceptOpen(lastRound(finding('major', WEAK))).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);
const g = gatePlan(next.text);
assert.ok(codes(g.reasons).includes('PLAN_GATE_OPEN_MAJOR_FINAL'), codes(g.reasons).join(','));
});
test('CLI: --accept-open writes the acceptance into the plan and exits 0', () => {
const dir = mkdtempSync(join(tmpdir(), 'plan-accept-'));
try {
const p = join(dir, 'plan.md');
writeFileSync(p, lastRound(finding('major', WEAK)));
assert.equal(spawnSync(process.execPath, [GATE, p]).status, 1);
const a = spawnSync(process.execPath, [GATE, '--accept-open', p], { encoding: 'utf8' });
assert.equal(a.status, 0, a.stdout + a.stderr);
assert.match(readFileSync(p, 'utf8'), /"accepted_open"/);
assert.equal(spawnSync(process.execPath, [GATE, p]).status, 0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('prose: critics rate a test weakness on the review\'s scale; Phase 9 asks after the last round', () => {
for (const a of ['agents/plan-critic.md', 'agents/scope-guardian.md']) {
assert.match(readFileSync(join(ROOT, a), 'utf8'), /cannot fail on a wrong\s+implementation[\s\S]{0,200}\*\*major\*\*/, a);
}
const plan = readFileSync(join(ROOT, 'commands', 'trekplan.md'), 'utf8');
assert.match(plan, /plan-gate\.mjs --accept-open/);
assert.doesNotMatch(plan, /carry the open majors into the Phase 10 summary as known/);
});

View file

@ -55,14 +55,24 @@ test('K3: a plan with an open critic BLOCKER exits 1', () => {
});
});
test('K3: a valid plan whose latest verdict has no blocker exits 0 (majors are warnings)', () => {
// Operator decision 2026-10-08 (voyage-lekerepo trial point 6): an open MAJOR
// no longer passes as a warning. It stops until it is fixed or the operator
// accepts it (tests/validators/plan-gate-open-major.test.mjs).
test('K3: a valid plan whose latest verdict has an open MAJOR stops (fix it, or the operator accepts it)', () => {
const r0 = rec(buildPlan(), dedupOut(finding('major', 'Step 1 lacks an error path'), finding('minor', 'typo')));
withPlan(r0.text, (p) => {
const r = run([p, '--json']);
assert.equal(r.code, 0, r.out + r.err);
assert.equal(r.code, 1, r.out + r.err);
const j = JSON.parse(r.out);
assert.equal(j.verdict.verdict, 'REVISE');
assert.ok(j.warnings.some(w => w.code === 'PLAN_GATE_OPEN_MAJOR'));
assert.ok(j.reasons.some(w => w.code === 'PLAN_GATE_OPEN_MAJOR'));
});
});
test('K3: a valid plan whose latest verdict has only minors exits 0', () => {
const r0 = rec(buildPlan(), dedupOut(finding('minor', 'typo')));
withPlan(r0.text, (p) => {
assert.equal(run([p, '--json']).code, 0);
});
});
@ -219,7 +229,7 @@ test('CLI: --hash, then --record --reviewed writes in place; malformed stdin or
const ok = run(['--record', p, '--reviewed', hash], JSON.stringify(dedupOut(finding('major', 'm'))));
assert.equal(ok.code, 0, ok.out + ok.err);
assert.equal(JSON.parse(ok.out).verdict, 'REVISE');
assert.equal(run([p]).code, 0);
assert.equal(run([p]).code, 1, 'the recorded open MAJOR stops the gate (operator decision 2026-10-08)');
assert.ok(!existsSync(join(dir, 'plan.md.plan-gate.tmp')));
});
});