voyage/lib/validators/plan-gate.mjs
Kjell Tore Guttormsen bb3884d0f4
refactor: simplify pass (trekexecute)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:44:43 +02:00

641 lines
31 KiB
JavaScript

// lib/validators/plan-gate.mjs
// K3 — the plan gate: may this plan go on to execution?
//
// node plan-gate.mjs <plan.md> [--json]
// 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), 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>
// Prints the sha256 of the plan's operative text. Phase 9 takes it BEFORE
// the reviewers read the plan and hands it to --record.
//
// node plan-gate.mjs --record <plan.md> --reviewed <sha256> [--extra-round]
// stdin = plan-review-dedup.mjs output. Writes this round's verdict into
// the plan. 0 = written; 1 = refused: the round cap is spent (stop and ask
// the operator; `--extra-round` only after their yes, and only then);
// 2 = could not run: bad stdin, a reviewer missing from the dedup input,
// 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.
// From round 2 on it counts against the snapshot named by the previous
// verdict's plan_sha256 (rule A, below); a missing, oversized, misnamed or
// tampered snapshot means full counting, said in one `diff_note` line. On
// 0 it keeps this round's operative text as a snapshot (tmp + rename)
// before the plan is written. Output: one JSON line, which adds
// `diff_base`, `diff_note`, `lowered` (a number) and `snapshot` ("kept: ..."
// or "not kept: <reason>"; a failed snapshot write does not fail the round).
//
// node plan-gate.mjs --diff <plan.md>
// Prints what the next critic round reviews: `diff-base: <sha256> (round N)`
// and each hunk as `@@ lines A-B @@` in the plan's full-file line numbers,
// `-` old lines and `+NNN ` new lines; or `diff-base: none — <reason>;
// review the whole plan`. 0 = printed; 2 = unreadable plan, bad arguments.
//
// Snapshots: `<git dir>/voyage/plan-snapshots/<sha256>.md`, the operative text
// the round reviewed, named by its own sha256. The git dir is found from the
// plan file's own directory only (no cwd fallback); a plan in no repository
// keeps none. git never tracks its own directory, so a snapshot is never
// committed, whatever the repo's .gitignore or artifact_store says.
//
// 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
// (rounds left, 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
// it, so a verdict in a sibling file would be invisible there.
//
// What the verdict is bound to: sha256 of the plan's operative text — the plan
// minus its review log, with CRLF and line-end whitespace normalized. The
// review log is the `> **Plan quality:` header line and the level-2 sections
// titled exactly Critic Verdict, Revisions, Plan Quality Score and
// "Adversarial Pass 2 (v5.1.1 high-effort)" — each only up to its first
// other heading, so a step or section placed under a log heading is hashed.
// Edit a step, the design or the verification after the critic ran, and the
// verdict is stale until the next round.
//
// The loop is bounded (Kiro's bounded review loop): MAX_ROUNDS critic rounds,
// then stop and ask; never silently on. Unrated findings count as blockers.
// The verdict is a trace of a process, not a signature: anything that can
// write the plan can write the block.
//
// Rule A (decision 43): from critic round 2 on, `recordVerdict` counts a round
// against the diff between the operative text the previous round reviewed (the
// base, `opts.base`) and the plan now, through `countRound` in
// lib/review/critic-diff.mjs. A new MAJOR that cites a plan line outside that
// diff counts as minor; a blocker, a security finding and a finding with no
// plan line count at full severity; a previous finding stays counted (carried,
// with its id) until every reviewer that raised it marks it resolved and nobody
// re-reports it. Rule B (a major a named test, `Regression:` command or gate
// will fail on is minor) is recorded from round 1 and never lowers a blocker or
// a security finding. Without a usable base (none, not the previous round's
// text, or the plan unchanged since it) the round is counted as before, and
// `diff_note` says why in one line. Every new verdict field is optional
// (DIFF_FIELDS); its absence means round-1 behaviour, so an older block gives
// the same exits. Trust limit: anything that can write the plan can also
// rewrite the previous verdict's plan_sha256 and plant a matching base; rule A
// raises what such a forgery buys, and an unchanged base lowers nothing.
import {
readFileSync, existsSync, writeFileSync, renameSync, rmSync, statSync, mkdirSync, realpathSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { validatePlanContent } from './plan-validator.mjs';
import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from '../parsers/plan-schema.mjs';
import { normalizeSeverity } from '../review/plan-review-dedup.mjs';
import { countRound, lineDiff, oneLine } from '../review/critic-diff.mjs';
import { resolveRepo } from '../verification/repo-tools.mjs';
import { isMain } from '../util/is-main.mjs';
export const MAX_ROUNDS = 3;
export const VERDICT_SCHEMA = 'voyage.plan-verdict/1';
export const VERDICT_HEADING = '## Critic Verdict';
export const REQUIRED_REVIEWERS = Object.freeze(['plan-critic', 'scope-guardian']);
// Level-2 sections that are review log, not plan: excluded from the hash.
export const REVIEW_LOG_SECTIONS = Object.freeze([
'Critic Verdict', 'Revisions', 'Plan Quality Score', 'Adversarial Pass 2 (v5.1.1 high-effort)',
]);
// The quality line alone; a line that also carries `plan_version`/`ceremony` stays hashed.
const QUALITY_LINE = /^> \*\*Plan quality:[^`]*$/;
const isLogTitle = t => t !== null && REVIEW_LOG_SECTIONS.includes(t);
const norm = t => String(t).replace(/\r\n?/g, '\n');
/** A log section counts as log only up to its first other heading (outside code). */
function logPart(sectionText) {
const lines = sectionText.split('\n');
const fence = fenceTracker();
for (let i = 1; i < lines.length; i++) {
if (fence.feed(lines[i])) continue;
if (/^#{1,6} /.test(lines[i])) return { log: lines.slice(0, i).join('\n'), rest: lines.slice(i).join('\n') };
}
return { log: sectionText, rest: '' };
}
/** The plan minus its review log, normalized (LF, no line-end whitespace, one trailing newline). */
export function operativeText(text) {
const kept = splitSections(norm(text), 2)
.map(s => (isLogTitle(s.title) ? logPart(s.text).rest : s.text))
.join('\n');
return kept
.split('\n')
.filter(l => !QUALITY_LINE.test(l))
.map(l => l.replace(/[ \t]+$/, ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim() + '\n';
}
/**
* The operative text with its line map, built from the same transforms as
* `operativeText` (whose output it returns unchanged).
* @returns {{text: string, fullToOp: Array<number|null>, opToFull: Array<number|null>}}
* 1-based both ways (index 0 is null). fullToOp[i] is null for a review-log
* line, the quality line or a collapsed blank; opToFull[j] is null for an
* operative line no file line carries (a blank left where a log section stood).
*/
export function operativeLines(text) {
const src = norm(text);
const full = src.split('\n');
const excluded = new Set();
for (const s of splitSections(src, 2)) {
if (!isLogTitle(s.title)) continue;
const n = logPart(s.text).log.split('\n').length;
for (let i = s.start; i < s.start + n; i++) excluded.add(i);
}
full.forEach((l, i) => { if (QUALITY_LINE.test(l)) excluded.add(i); });
const op = operativeText(text);
const opL = op.split('\n');
const opCount = opL.length - 1; // the text ends with '\n'; the last split element is not a line
const fullToOp = new Array(full.length + 1).fill(null);
const opToFull = new Array(opCount + 1).fill(null);
let j = 0;
for (let i = 0; i < full.length && j < opCount; i++) {
if (excluded.has(i)) continue;
let l = full[i].replace(/[ \t]+$/, '');
if (j === 0) l = l.replace(/^\s+/, '');
// Only blanks lose their partner, so a non-blank kept line facing an operative
// blank means that blank has no file line: step past it.
while (l !== '' && opL[j] === '' && j < opCount - 1) j += 1;
const want = opL[j];
if (l === want || (j === opCount - 1 && l.trimEnd() === want)) {
fullToOp[i + 1] = j + 1;
opToFull[j + 1] = i + 1;
j += 1;
}
}
return { text: op, fullToOp, opToFull };
}
export function planHash(text) {
return sha256(operativeText(text));
}
const sha256 = s => createHash('sha256').update(s).digest('hex');
/**
* @returns {{found: boolean, placeholder: boolean, verdict: object|null, error: string|null}}
* placeholder = a `## Critic Verdict` heading with no fenced block at all
* (copied prose, not a verdict): --record replaces it, the gate stops on it.
*/
export function extractVerdict(text) {
const secs = splitSections(norm(text), 2).filter(s => s.title === 'Critic Verdict');
const none = { found: false, placeholder: false, verdict: null };
if (secs.length === 0) return { ...none, error: null };
if (secs.length > 1) return { ...none, found: true, error: `${secs.length} "## Critic Verdict" sections; expected one` };
const log = logPart(secs[0].text).log;
const m = log.match(/```json[ \t]*\n([\s\S]*?)\n[ \t]*```/);
if (!m) {
const placeholder = !/^\s{0,3}(```|~~~)/m.test(log);
return { ...none, found: true, placeholder, error: 'no ```json block in ## Critic Verdict' };
}
let v;
try { v = JSON.parse(m[1]); } catch (e) { return { ...none, found: true, error: `verdict JSON does not parse: ${e.message}` }; }
const okShape = v && typeof v === 'object' && v.schema === VERDICT_SCHEMA && Number.isInteger(v.round) && v.round >= 1 &&
Number.isInteger(v.max_rounds) && v.max_rounds >= 1 && v.max_rounds <= MAX_ROUNDS &&
Number.isInteger(v.extra_rounds ?? 0) && (v.extra_rounds ?? 0) >= 0 &&
typeof v.plan_sha256 === 'string' && Array.isArray(v.findings) &&
v.findings.every(f => f && typeof f === 'object' && typeof f.severity === 'string');
if (!okShape) {
return { ...none, found: true, error: `verdict block is not ${VERDICT_SCHEMA} (schema, round, max_rounds <= ${MAX_ROUNDS}, extra_rounds, plan_sha256, findings with severity)` };
}
return { found: true, placeholder: false, verdict: v, error: null };
}
function verdictFor(counts) {
if (counts.blocker > 0) return 'BLOCK';
if (counts.major > 0) return 'REVISE';
return 'PASS';
}
function renderSection(v) {
return [
VERDICT_HEADING,
'',
`<!-- Written by lib/validators/plan-gate.mjs --record (round ${v.round} of ${v.max_rounds}` +
`${v.extra_rounds ? ` + ${v.extra_rounds} extra` : ''}). Do not edit by hand: the gate re-hashes the plan and reads this block. -->`,
'',
'```json',
JSON.stringify(v, null, 2),
'```',
'',
].join('\n');
}
/** Replace (or append) the `## Critic Verdict` section; anything after its log part is kept. */
function placeSection(text, section) {
const secs = splitSections(norm(text), 2);
const idx = secs.findIndex(s => s.title === 'Critic Verdict');
if (idx === -1) return norm(text).replace(/\s*$/, '\n\n') + section;
const parts = secs.map(s => s.text);
const { rest } = logPart(parts[idx]);
parts[idx] = section.replace(/\n$/, '') + (rest ? `\n\n${rest}` : '');
return parts.join('\n').replace(/\s*$/, '\n');
}
/** Both reviewers must have been present in the dedup input; an absent one is not a clean review. */
function missingReviewers(dedup) {
const present = new Set(
(Array.isArray(dedup.sources) ? dedup.sources : []).filter(s => s && s.present === true).map(s => s.agent),
);
return REQUIRED_REVIEWERS.filter(r => !present.has(r));
}
/**
* Record one critic round into the plan text.
* @param {string} text - the plan
* @param {{findings: Array<object>, sources: Array<{agent: string, present: boolean}>}} dedup
* plan-review-dedup.mjs output
* @param {{reviewed?: string, extraRound?: boolean, maxRounds?: number, now?: string,
* base?: {sha256: string, text: string}|null, baseNote?: string, planPaths?: string[]}} [opts]
* reviewed = planHash of the text the reviewers read (from --hash);
* base = the operative text the previous round reviewed, with its sha256;
* baseNote = why there is no base (one line, written as diff_note);
* planPaths = the absolute forms of the plan path (a finding whose
* resolve(file) is one of them cites the plan). Without base, baseNote and
* planPaths the round is recorded as before, plus the rule-B record.
* @returns {{code: 0|1|2, text?: string, verdict?: object, lowered?: number, message: string}}
*/
export function recordVerdict(text, dedup, opts = {}) {
if (!dedup || typeof dedup !== 'object' || !Array.isArray(dedup.findings)) {
return { code: 2, message: 'record input must be plan-review-dedup output: {"findings": [...], "sources": [...]}' };
}
if (dedup.findings.some(f => !f || typeof f !== 'object')) {
return { code: 2, message: 'record input has a finding that is not an object' };
}
const missing = missingReviewers(dedup);
if (missing.length > 0) {
return { code: 2, message: `reviewer(s) missing from the dedup input: ${missing.join(', ')} — an absent review is not a clean one; rerun the round` };
}
const lines = operativeLines(text);
const current = sha256(lines.text);
if (typeof opts.reviewed !== 'string' || !/^[0-9a-f]{64}$/.test(opts.reviewed)) {
return { code: 2, message: '--reviewed <sha256> is required: take it with --hash before the reviewers read the plan' };
}
if (opts.reviewed !== current) {
return { code: 2, message: 'the plan changed after the reviewers read it (--reviewed differs from the plan now); rerun the round on the current plan' };
}
const prev = extractVerdict(text);
if (prev.error && !prev.placeholder) return { code: 2, message: `existing verdict unreadable: ${prev.error}` };
const maxRounds = Math.min(prev.verdict?.max_rounds ?? opts.maxRounds ?? MAX_ROUNDS, MAX_ROUNDS);
const prevExtra = prev.verdict?.extra_rounds ?? 0;
const round = (prev.verdict?.round ?? 0) + 1;
const capped = round > maxRounds + prevExtra;
if (opts.extraRound && !capped) {
return { code: 2, message: `--extra-round refused: round ${round} is within the cap; it is only for a round after the cap, after the operator's yes` };
}
if (capped && !opts.extraRound) {
return {
code: 1,
message: `round ${round} refused: ${maxRounds} critic rounds${prevExtra ? ` (+${prevExtra} extra)` : ''} are spent. ` +
'Stop and ask the operator: one more round (rerun with --extra-round after their yes), or stop here.',
};
}
const extra = prevExtra + (capped ? 1 : 0);
const diffMode = opts.base !== undefined || opts.baseNote !== undefined || opts.planPaths !== undefined;
const c = countRound({
round,
previous: prev.verdict ?? null,
base: opts.base ?? null,
baseNote: opts.baseNote ?? null,
current: { text: lines.text, sha256: current, fullToOp: lines.fullToOp, opToFull: lines.opToFull },
planPaths: Array.isArray(opts.planPaths) ? opts.planPaths : [],
findings: dedup.findings,
marks: Array.isArray(dedup.marks) ? dedup.marks : [],
});
const findings = c.findings;
const counts = { blocker: 0, major: 0, minor: 0 };
for (const f of findings) counts[f.severity] += 1;
const verdict = {
schema: VERDICT_SCHEMA,
round,
max_rounds: maxRounds,
extra_rounds: extra,
recorded_at: opts.now ?? new Date().toISOString(),
plan_sha256: current,
};
if (c.diffBase !== null) verdict.diff_base = c.diffBase;
if (diffMode && c.diffNote !== null) verdict.diff_note = c.diffNote;
verdict.verdict = verdictFor(counts);
verdict.counts = counts;
verdict.findings = findings;
if (c.resolved !== null) verdict.resolved = c.resolved;
return {
code: 0, text: placeSection(text, renderSection(verdict)), verdict, lowered: c.lowered,
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' };
// Only the last round: with rounds left the answer is to revise, not accept
// (found by the independent review: acceptance at round 1 passed the gate).
const left = v.max_rounds + (v.extra_rounds ?? 0) - v.round;
if (left > 0) return { code: 1, message: `${left} critic round(s) left: revise the plan and run the next round; acceptance is only for after the last round` };
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}}
*/
export function gatePlan(rawText) {
const text = norm(rawText);
const reasons = [];
const warnings = [];
const validation = validatePlanContent(text, { strict: true });
if (!validation.valid) {
reasons.push({ code: 'PLAN_GATE_INVALID_PLAN', message: `plan-validator --strict: ${validation.errors.map(e => e.code).join(', ')}`, errors: validation.errors });
}
const version = extractPlanVersion(text);
if (!versionAtLeast(version, '1.8')) {
reasons.push({ code: 'PLAN_GATE_VERSION', message: `plan_version ${version ?? '(none)'}: the gate passes only plans written to plan_version 1.8 or later` });
}
const ex = extractVerdict(text);
const v = ex.verdict;
if (!ex.found) {
reasons.push({ code: 'PLAN_GATE_NO_VERDICT', message: 'No "## Critic Verdict": the plan has not been through the critic (run Phase 9).' });
} else if (ex.error) {
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.` });
}
const sev = f => normalizeSeverity(f.severity);
const blockers = v.findings.filter(f => sev(f) !== 'major' && sev(f) !== 'minor');
const majors = v.findings.filter(f => sev(f) === 'major');
if (blockers.length > 0) {
reasons.push({ code: 'PLAN_GATE_OPEN_BLOCKER', message: `${blockers.length} open BLOCKER(s) in critic round ${v.round}: ${blockers.map(b => b.text).join(' | ')}` });
if (v.round >= v.max_rounds + (v.extra_rounds ?? 0)) {
reasons.push({ code: 'PLAN_GATE_ROUNDS_EXHAUSTED', message: `All ${v.round} critic rounds are spent with a BLOCKER open: stop and ask the operator.` });
}
}
if (majors.length > 0) {
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 };
}
// ---- CLI --------------------------------------------------------------------
function out(obj) {
process.stdout.write(JSON.stringify(obj) + '\n');
}
const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
/** `<git dir>/voyage/plan-snapshots` of the repo holding the plan file, or null (no cwd fallback). */
function snapshotDir(planPath) {
const repo = resolveRepo(dirname(resolve(planPath)));
return repo ? join(repo.gitDir, 'voyage', 'plan-snapshots') : null;
}
/** The absolute forms of the plan path: as given, and its realpath. */
function planPathsOf(planPath) {
const paths = [resolve(planPath)];
try {
const real = realpathSync(planPath);
if (!paths.includes(real)) paths.push(real);
} catch { /* the given form only */ }
return paths;
}
/**
* The snapshot named `sha` in `dir`, or a one-line reason it cannot be used.
* The name is checked before any path is built, so a `../` name is never read.
* @returns {{base: {sha256: string, text: string}|null, reason: string|null}}
*/
function readBase(dir, sha) {
if (typeof sha !== 'string' || !/^[0-9a-f]{64}$/.test(sha)) {
return { base: null, reason: 'the previous verdict\'s plan_sha256 is not a sha256, so no snapshot is read' };
}
const short = sha.slice(0, 12);
const file = join(dir, `${sha}.md`);
let st;
try { st = statSync(file); } catch (e) {
return { base: null, reason: e.code === 'ENOENT' ? `no snapshot of ${short} in the git directory` : `the snapshot of ${short} is unreadable: ${oneLine(e.message)}` };
}
if (!st.isFile()) return { base: null, reason: `the snapshot of ${short} is not a file` };
if (st.size > SNAPSHOT_MAX_BYTES) return { base: null, reason: `the snapshot of ${short} is over 4 MB` };
let buf;
try { buf = readFileSync(file); } catch (e) {
return { base: null, reason: `the snapshot of ${short} is unreadable: ${oneLine(e.message)}` };
}
if (buf.length > SNAPSHOT_MAX_BYTES) return { base: null, reason: `the snapshot of ${short} is over 4 MB` };
if (createHash('sha256').update(buf).digest('hex') !== sha) {
return { base: null, reason: `the snapshot of ${short} does not hash to its name (tampered or damaged)` };
}
return { base: { sha256: sha, text: buf.toString('utf-8') }, reason: null };
}
/**
* What the next round counts against: the snapshot of the text the current
* verdict's round reviewed. verdict null = no round recorded yet.
* @returns {{verdict: object|null, base: {sha256: string, text: string}|null, reason: string|null}}
*/
function loadBase(text, dir) {
const ex = extractVerdict(text);
if (!ex.verdict) {
return { verdict: null, base: null, reason: ex.error && !ex.placeholder ? `the current verdict is unreadable: ${oneLine(ex.error)}` : 'no critic round has been recorded yet' };
}
if (dir === null) return { verdict: ex.verdict, base: null, reason: 'the plan is in no git repository, so no snapshot was kept' };
const r = readBase(dir, ex.verdict.plan_sha256);
return { verdict: ex.verdict, base: r.base, reason: r.reason };
}
/** Keep this round's operative text as `<dir>/<sha>.md` (tmp + rename); returns the `snapshot` field. */
function keepSnapshot(dir, text, sha) {
if (dir === null) return 'not kept: no git repository';
const file = join(dir, `${sha}.md`);
const tmp = `${file}.${process.pid}.tmp`;
try {
mkdirSync(dir, { recursive: true });
writeFileSync(tmp, operativeText(text));
renameSync(tmp, file);
return `kept: ${file}`;
} catch (e) {
try { rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
return `not kept: ${oneLine(e.message)}`;
}
}
/** The `--diff` text: the base round and each hunk in the plan's full-file line numbers. */
function diffReport(text, dir) {
const none = why => `diff-base: none — ${why}; review the whole plan\n`;
const lb = loadBase(text, dir);
if (!lb.base) return none(lb.reason);
const cur = operativeLines(text);
if (lb.base.sha256 === sha256(cur.text)) return none(`the plan has not changed since round ${lb.verdict.round}`);
const oldL = lb.base.text.split('\n');
const newL = cur.text.split('\n');
const newCount = newL.length - 1; // the operative text ends with '\n'
const d = lineDiff(lb.base.text, cur.text);
const lines = [`diff-base: ${lb.base.sha256} (round ${lb.verdict.round})`];
for (const h of d.hunks) {
const changed = h.newEnd >= h.newStart;
const lo = changed ? h.newStart : Math.max(1, h.newStart - 1);
const hi = changed ? h.newEnd : Math.min(newCount, h.newStart);
const mapped = [];
for (let n = lo; n <= hi; n++) if (cur.opToFull[n] != null) mapped.push(cur.opToFull[n]);
lines.push(mapped.length ? `@@ lines ${mapped[0]}-${mapped[mapped.length - 1]} @@` : '@@ lines (no file line) @@');
for (let o = h.oldStart; o <= h.oldEnd; o++) lines.push(`-${oldL[o - 1]}`);
for (let n = h.newStart; n <= h.newEnd; n++) lines.push(`+${cur.opToFull[n] ?? '?'} ${newL[n - 1]}`);
}
return `${lines.join('\n')}\n`;
}
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --diff <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json | --accept-open <plan.md>';
const known = new Set(['--json', '--hash', '--diff', '--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));
const unknown = args.filter(a => a.startsWith('--') && !known.has(a));
const planPath = positional[0];
if (!planPath || positional.length > 1 || unknown.length > 0 || (rIdx >= 0 && (!reviewed || reviewed.startsWith('--')))) {
process.stderr.write(`${usage}\n`);
out({ code: 2, status: 'error', message: unknown.length ? `unknown argument: ${unknown.join(' ')}` : 'bad arguments' });
process.exit(2);
}
let text;
try {
if (!existsSync(planPath)) throw new Error('file not found');
text = readFileSync(planPath, 'utf-8');
} catch (e) {
out({ code: 2, status: 'error', plan: planPath, message: `cannot read plan: ${e.message}` });
process.exit(2);
}
if (args.includes('--hash')) {
process.stdout.write(`${planHash(text)}\n`);
process.exit(0);
}
if (args.includes('--diff')) {
process.stdout.write(diffReport(text, snapshotDir(planPath)));
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) {
out({ code: 2, recorded: false, message: `stdin is not JSON: ${e.message}` });
process.exit(2);
}
const dir = snapshotDir(planPath);
const opts = { extraRound: args.includes('--extra-round'), reviewed, planPaths: planPathsOf(planPath) };
const lb = loadBase(text, dir);
if (lb.verdict) {
opts.base = lb.base;
if (lb.reason) opts.baseNote = `${lb.reason}; full counting`;
}
const r = recordVerdict(text, dedup, opts);
let snapshot = null;
if (r.code === 0) {
// The snapshot first, then the plan: a verdict never names a text that was not kept on purpose.
snapshot = keepSnapshot(dir, text, r.verdict.plan_sha256);
const tmp = `${planPath}.plan-gate.tmp`;
try {
writeFileSync(tmp, r.text);
renameSync(tmp, planPath);
} catch (e) {
rmSync(tmp, { force: true });
out({ code: 2, recorded: false, message: `cannot write the plan: ${e.message}` });
process.exit(2);
}
}
out({
code: r.code, recorded: r.code === 0, message: r.message,
round: r.verdict?.round ?? null, max_rounds: r.verdict?.max_rounds ?? null,
verdict: r.verdict?.verdict ?? null, counts: r.verdict?.counts ?? null,
diff_base: r.verdict?.diff_base ?? null, diff_note: r.verdict?.diff_note ?? null,
lowered: r.code === 0 ? r.lowered : null, snapshot,
});
process.exit(r.code);
}
const g = gatePlan(text);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ code: g.code, status: g.status, plan: planPath, reasons: g.reasons, warnings: g.warnings, verdict: g.verdict }, null, 2) + '\n');
} else {
process.stdout.write(
`plan-gate: ${g.status.toUpperCase()} ${planPath}\n` +
g.reasons.map(r => ` STOP [${r.code}] ${r.message}\n`).join('') +
g.warnings.map(w => ` WARN [${w.code}] ${w.message}\n`).join(''),
);
}
process.exit(g.code);
}