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>
244 lines
12 KiB
JavaScript
244 lines
12 KiB
JavaScript
// tests/validators/plan-gate.test.mjs
|
|
// K3 — the plan gate. `plan-gate.mjs <plan.md>`: 0 = the plan may go on,
|
|
// 1 = stop (invalid plan, plan_version < 1.8, no critic verdict, a verdict the
|
|
// plan has moved away from, or an open BLOCKER), 2 = could not run. The
|
|
// critic's verdict lives machine-readably INSIDE the plan (`## Critic Verdict`),
|
|
// because Claude Code's plan approval hands a hook the plan file's text and
|
|
// nothing next to it. `--record` writes the verdict from plan-review-dedup
|
|
// output, bound to the hash taken (`--hash`) before the reviewers read the
|
|
// plan; at most 3 rounds, then stop and ask — never silently on.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
gatePlan, recordVerdict, planHash, operativeText, extractVerdict, MAX_ROUNDS,
|
|
} from '../../lib/validators/plan-gate.mjs';
|
|
import { buildPlan, step } from '../helpers/plan-v18-builder.mjs';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const GATE = join(HERE, '..', '..', 'lib', 'validators', 'plan-gate.mjs');
|
|
|
|
const BOTH = [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: true }];
|
|
const finding = (severity, text, rule_key = 'step-quality') =>
|
|
({ id: `${severity}-${rule_key}`, file: 'plan.md', line: 10, rule_key, severity, text, raised_by: ['plan-critic'] });
|
|
const dedupOut = (...findings) => ({ findings, sources: BOTH, dedup_stats: { total_in: findings.length, total_out: findings.length } });
|
|
// One round as Phase 9 runs it: hash before the review, record after it.
|
|
const rec = (text, dedup, opts = {}) => recordVerdict(text, dedup, { reviewed: planHash(text), ...opts });
|
|
|
|
function run(args, input) {
|
|
const r = spawnSync('node', [GATE, ...args], { input, encoding: 'utf-8' });
|
|
return { code: r.status, out: r.stdout, err: r.stderr };
|
|
}
|
|
|
|
function withPlan(text, fn) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'plan-gate-'));
|
|
const p = join(dir, 'plan.md');
|
|
writeFileSync(p, text);
|
|
try { return fn(p, dir); } finally { rmSync(dir, { recursive: true, force: true }); }
|
|
}
|
|
|
|
// ---- the test that failed first ----
|
|
test('K3: a plan with an open critic BLOCKER exits 1', () => {
|
|
const r0 = rec(buildPlan(), dedupOut(finding('blocker', 'Step 2 reads a value no step writes')));
|
|
assert.equal(r0.code, 0, r0.message);
|
|
withPlan(r0.text, (p) => {
|
|
const r = run([p, '--json']);
|
|
assert.equal(r.code, 1, r.out + r.err);
|
|
const j = JSON.parse(r.out);
|
|
assert.equal(j.status, 'stop');
|
|
assert.ok(j.reasons.some(x => x.code === 'PLAN_GATE_OPEN_BLOCKER'), r.out);
|
|
});
|
|
});
|
|
|
|
// 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, 1, r.out + r.err);
|
|
const j = JSON.parse(r.out);
|
|
assert.equal(j.verdict.verdict, 'REVISE');
|
|
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);
|
|
});
|
|
});
|
|
|
|
test('K3: no critic verdict → 1 (the plan has not been through the critic)', () => {
|
|
const g = gatePlan(buildPlan());
|
|
assert.equal(g.code, 1);
|
|
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_NO_VERDICT'));
|
|
});
|
|
|
|
test('K3: an invalid plan → 1 even with a clean verdict', () => {
|
|
const bad = buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'bash tests/e2e.sh' }] });
|
|
const g = gatePlan(rec(bad, dedupOut()).text);
|
|
assert.equal(g.code, 1);
|
|
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_INVALID_PLAN'));
|
|
});
|
|
|
|
test('K3: a plan below plan_version 1.8 → 1, even with a clean verdict', () => {
|
|
const old = buildPlan({ version: '1.7', ceremony: null, design: false });
|
|
const g = gatePlan(rec(old, dedupOut()).text);
|
|
assert.equal(g.code, 1);
|
|
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERSION'), JSON.stringify(g.reasons));
|
|
});
|
|
|
|
test('K3: editing a step after the verdict makes it stale → 1', () => {
|
|
const r0 = rec(buildPlan(), dedupOut());
|
|
assert.equal(gatePlan(r0.text).code, 0);
|
|
const edited = r0.text.replace('Add `run1()`', 'Add `run1()` and `cleanup1()`');
|
|
const g = gatePlan(edited);
|
|
assert.equal(g.code, 1);
|
|
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERDICT_STALE'));
|
|
});
|
|
|
|
test('K3: a step appended after the verdict (inside a review-log section) stales it', () => {
|
|
const r0 = rec(buildPlan(), dedupOut());
|
|
const appended = `${r0.text}\n${step(3, { title: 'Sneaked in' })}\n`;
|
|
assert.notEqual(planHash(appended), planHash(r0.text));
|
|
const g = gatePlan(appended);
|
|
assert.equal(g.code, 1);
|
|
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERDICT_STALE'), JSON.stringify(g.reasons));
|
|
});
|
|
|
|
test('K3: review-log sections (quality header, score, revisions, verdict) do not stale the verdict', () => {
|
|
const r0 = rec(buildPlan(), dedupOut(finding('minor', 'nit')));
|
|
const logged = r0.text
|
|
.replace('> **Plan quality: A** (92/100) — APPROVE', '> **Plan quality: B** (80/100) — APPROVE_WITH_NOTES')
|
|
.replace('## Verification', '## Revisions\n\n| # | Finding | Severity | Resolution |\n|---|---|---|---|\n| 1 | nit | minor | left |\n\n## Verification');
|
|
assert.equal(planHash(logged), planHash(r0.text));
|
|
assert.equal(gatePlan(logged).code, 0);
|
|
});
|
|
|
|
test('K3: only exact review-log titles are excluded ("Revisions to the API" is plan)', () => {
|
|
const plan = buildPlan().replace('## Verification', '## Revisions to the API\n\nThe endpoint moves.\n\n## Verification');
|
|
const changed = plan.replace('The endpoint moves.', 'The endpoint is deleted.');
|
|
assert.notEqual(planHash(changed), planHash(plan));
|
|
});
|
|
|
|
test('K3: whitespace at line ends and CRLF do not stale the verdict', () => {
|
|
const r0 = rec(buildPlan(), dedupOut());
|
|
assert.equal(gatePlan(r0.text.replace(/\n/g, ' \r\n')).code, 0);
|
|
});
|
|
|
|
test('K3: a malformed verdict block → 1 (bad JSON, a null finding, max_rounds raised); a missing file → 2', () => {
|
|
const r0 = rec(buildPlan(), dedupOut(finding('minor', 'n')));
|
|
for (const broken of [
|
|
r0.text.replace('"schema"', '"schema" oops'),
|
|
r0.text.replace(/"findings": \[\n\s*\{/, '"findings": [\n null,\n {'),
|
|
r0.text.replace('"max_rounds": 3', '"max_rounds": 99'),
|
|
]) {
|
|
const g = gatePlan(broken);
|
|
assert.equal(g.code, 1);
|
|
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_VERDICT_MALFORMED'), JSON.stringify(g.reasons));
|
|
}
|
|
assert.equal(run(['/nonexistent/plan.md', '--json']).code, 2);
|
|
});
|
|
|
|
// ---- what --record refuses ----
|
|
test('--record refuses a dedup input where a reviewer is absent (an absent review is not a clean one)', () => {
|
|
const plan = buildPlan();
|
|
const oneMissing = { findings: [], sources: [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: false }] };
|
|
const r1 = rec(plan, oneMissing);
|
|
assert.equal(r1.code, 2);
|
|
assert.match(r1.message, /scope-guardian/);
|
|
assert.equal(rec(plan, { findings: [] }).code, 2, 'no sources at all');
|
|
});
|
|
|
|
test('--record binds the verdict to the text the reviewers read: no hash or a changed plan → 2', () => {
|
|
const plan = buildPlan();
|
|
assert.equal(recordVerdict(plan, dedupOut()).code, 2, '--reviewed is required');
|
|
const before = planHash(plan);
|
|
const revised = plan.replace('Add `run1()`', 'Add `run1()` after review');
|
|
const r = recordVerdict(revised, dedupOut(), { reviewed: before });
|
|
assert.equal(r.code, 2);
|
|
assert.match(r.message, /changed after the reviewers read it/);
|
|
});
|
|
|
|
test(`--record counts rounds; round ${MAX_ROUNDS + 1} needs the operator's extra round, which is only for then`, () => {
|
|
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');
|
|
for (let i = 1; i <= 3; i++) {
|
|
const r = rec(text, dedupOut(finding('blocker', `still broken ${i}`)));
|
|
assert.equal(r.code, 0, r.message);
|
|
assert.equal(r.verdict.round, i);
|
|
text = r.text;
|
|
}
|
|
const fourth = rec(text, dedupOut());
|
|
assert.equal(fourth.code, 1);
|
|
assert.match(fourth.message, /ask the operator/i);
|
|
const g = gatePlan(text);
|
|
assert.equal(g.code, 1);
|
|
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_ROUNDS_EXHAUSTED'), JSON.stringify(g.reasons));
|
|
const extra = rec(text, dedupOut(), { extraRound: true });
|
|
assert.equal(extra.code, 0);
|
|
assert.equal(extra.verdict.round, 4);
|
|
assert.equal(extra.verdict.extra_rounds, 1);
|
|
assert.equal(gatePlan(extra.text).code, 0);
|
|
assert.equal(rec(extra.text, dedupOut()).code, 1, 'one yes buys one round');
|
|
});
|
|
|
|
test('--record clamps a raised round cap to MAX_ROUNDS', () => {
|
|
const r = rec(buildPlan(), dedupOut(), { maxRounds: 99 });
|
|
assert.equal(r.verdict.max_rounds, MAX_ROUNDS);
|
|
});
|
|
|
|
test('--record replaces the verdict section instead of stacking a second one, and replaces a copied placeholder', () => {
|
|
const once = rec(buildPlan(), dedupOut()).text;
|
|
const twice = rec(once, dedupOut()).text;
|
|
assert.equal((twice.match(/^## Critic Verdict$/gm) || []).length, 1);
|
|
assert.equal(extractVerdict(twice).verdict.round, 2);
|
|
const withPlaceholder = `${buildPlan()}\n## Critic Verdict\n\n*Written by plan-gate --record.*\n`;
|
|
const r = rec(withPlaceholder, dedupOut());
|
|
assert.equal(r.code, 0, r.message);
|
|
assert.equal(r.verdict.round, 1);
|
|
assert.doesNotMatch(r.text, /\*Written by plan-gate --record\.\*/);
|
|
});
|
|
|
|
test('--record: a finding without a valid severity counts as a blocker', () => {
|
|
const r = rec(buildPlan(), dedupOut({ file: 'plan.md', line: 1, rule_key: 'x', text: 'unrated' }));
|
|
assert.equal(r.verdict.verdict, 'BLOCK');
|
|
assert.equal(r.verdict.findings[0].severity, 'blocker');
|
|
assert.equal(r.verdict.findings[0].severity_defaulted, true);
|
|
});
|
|
|
|
// ---- the CLI as Phase 9 calls it ----
|
|
test('CLI: --hash, then --record --reviewed writes in place; malformed stdin or unknown flags → 2, nothing written', () => {
|
|
withPlan(buildPlan(), (p, dir) => {
|
|
const before = readFileSync(p, 'utf-8');
|
|
const hash = run(['--hash', p]).out.trim();
|
|
assert.match(hash, /^[0-9a-f]{64}$/);
|
|
assert.equal(run(['--record', p, '--reviewed', hash], 'not json').code, 2);
|
|
assert.equal(run(['--record', p, '--reviewed', hash, '--max-rounds', '9'], JSON.stringify(dedupOut())).code, 2);
|
|
assert.equal(run(['--record', p], JSON.stringify(dedupOut())).code, 2, '--reviewed is required');
|
|
assert.equal(readFileSync(p, 'utf-8'), before);
|
|
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, 1, 'the recorded open MAJOR stops the gate (operator decision 2026-10-08)');
|
|
assert.ok(!existsSync(join(dir, 'plan.md.plan-gate.tmp')));
|
|
});
|
|
});
|
|
|
|
test('operativeText drops only the review-log sections', () => {
|
|
const r0 = rec(buildPlan(), dedupOut());
|
|
const op = operativeText(r0.text);
|
|
assert.ok(!op.includes('## Critic Verdict'));
|
|
assert.ok(!op.includes('Plan quality:'));
|
|
assert.ok(op.includes('### Step 2:'));
|
|
assert.ok(op.includes('## Design'));
|
|
});
|