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

@ -5,7 +5,8 @@
// 0 = the plan may go on
// 1 = stop: invalid plan (plan-validator --strict, plan_version >= 1.8),
// no critic verdict, a malformed verdict, a verdict the plan has
// moved away from (stale), or an open BLOCKER
// moved away from (stale), an open BLOCKER, or an open MAJOR the
// operator has not accepted
// 2 = could not run (missing/unreadable plan, bad arguments)
//
// node plan-gate.mjs --hash <plan.md>
@ -20,6 +21,20 @@
// 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.
//
// node plan-gate.mjs --accept-open <plan.md>
// After the operator's explicit yes to "build as is": records the open
// 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
// (a BLOCKER is open, no MAJOR is open, or the verdict is stale); 2 = could
// not run.
//
// 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
// fix one — a weakness the critics had named was built and came back from the
// review as MAJOR. Now an open MAJOR stops: with rounds left, revise and run
// the next round; after the last one, ask the operator — fix now (one extra
// round) or build as is (`--accept-open`).
//
// Where the verdict lives: INSIDE the plan, as the `## Critic Verdict` section
// with one fenced json block. Claude Code's plan approval hands a hook the
// plan file's text (`tool_input.plan`, `planFilePath`) and nothing next to
@ -231,6 +246,43 @@ export function recordVerdict(text, dedup, opts = {}) {
return { code: 0, text: placeSection(text, renderSection(verdict)), verdict, message: `round ${round} recorded: ${verdict.verdict}` };
}
const sevOf = f => normalizeSeverity(f.severity);
const majorKey = f => `${f.id ?? ''}\u0000${f.text ?? ''}`;
/** The verdict's acceptance names this round, this plan text and every open MAJOR. */
function acceptanceCovers(v, majors) {
const a = v.accepted_open;
if (!a || typeof a !== 'object' || a.round !== v.round || a.plan_sha256 !== v.plan_sha256 || !Array.isArray(a.findings)) return false;
const accepted = new Set(a.findings.map(majorKey));
return majors.every(m => accepted.has(majorKey(m)));
}
/**
* Record the operator's "build as is" for the current round's open MAJORs.
* Only after their explicit yes; never for a BLOCKER.
* @returns {{code: 0|1|2, text?: string, message: string}}
*/
export function acceptOpen(rawText, opts = {}) {
const text = norm(rawText);
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.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' };
const accepted = {
...v,
accepted_open: {
round: v.round,
plan_sha256: v.plan_sha256,
accepted_at: opts.now ?? new Date().toISOString(),
findings: majors.map(m => ({ id: m.id ?? null, text: m.text ?? '' })),
},
};
return { code: 0, text: placeSection(text, renderSection(accepted)), message: `${majors.length} open MAJOR(s) of round ${v.round} accepted` };
}
/**
* K3 — decide whether the plan may go on.
* @returns {{code: 0|1, status: 'pass'|'stop', reasons: object[], warnings: object[], verdict: object|null, validation: object}}
@ -267,7 +319,19 @@ export function gatePlan(rawText) {
}
}
if (majors.length > 0) {
warnings.push({ code: 'PLAN_GATE_OPEN_MAJOR', message: `${majors.length} MAJOR finding(s) left open in round ${v.round}; show them to the operator.` });
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)) {
warnings.push({ code: 'PLAN_GATE_OPEN_MAJOR', message: `${majors.length} MAJOR finding(s) open in round ${v.round}, accepted by the operator (${v.accepted_open.accepted_at}): ${list}` });
} else if (exhausted) {
reasons.push({
code: 'PLAN_GATE_OPEN_MAJOR_FINAL',
message: `${majors.length} MAJOR finding(s) open after the last critic round (${v.round}): ${list}. ` +
'Stop and ask the operator: fix now (revise, then --record --extra-round after their yes) or build as is (--accept-open after their yes).',
});
} else {
reasons.push({ code: 'PLAN_GATE_OPEN_MAJOR', message: `${majors.length} MAJOR finding(s) open in round ${v.round}: ${list}. Revise the plan and run the next round.` });
}
}
}
return { code: reasons.length === 0 ? 0 : 1, status: reasons.length === 0 ? 'pass' : 'stop', reasons, warnings, verdict: v ?? null, validation };
@ -289,8 +353,8 @@ function out(obj) {
if (isMain()) {
const args = process.argv.slice(2);
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json';
const known = new Set(['--json', '--hash', '--record', '--reviewed', '--extra-round']);
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json | --accept-open <plan.md>';
const known = new Set(['--json', '--hash', '--record', '--reviewed', '--extra-round', '--accept-open']);
const rIdx = args.indexOf('--reviewed');
const reviewed = rIdx >= 0 ? args[rIdx + 1] : undefined;
const positional = args.filter((a, i) => !a.startsWith('--') && !(rIdx >= 0 && i === rIdx + 1));
@ -313,6 +377,22 @@ if (isMain()) {
process.stdout.write(`${planHash(text)}\n`);
process.exit(0);
}
if (args.includes('--accept-open')) {
const r = acceptOpen(text);
if (r.code === 0) {
const tmp = `${planPath}.plan-gate.tmp`;
try {
writeFileSync(tmp, r.text);
renameSync(tmp, planPath);
} catch (e) {
rmSync(tmp, { force: true });
out({ code: 2, accepted: false, message: `cannot write the plan: ${e.message}` });
process.exit(2);
}
}
out({ code: r.code, accepted: r.code === 0, message: r.message });
process.exit(r.code);
}
if (args.includes('--record')) {
let dedup;
try { dedup = JSON.parse(readFileSync(0, 'utf-8')); } catch (e) {