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:
parent
1b5218e8f4
commit
2d29966f51
7 changed files with 243 additions and 16 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue