// lib/validators/plan-gate.mjs // K3 — the plan gate: may this plan go on to execution? // // node plan-gate.mjs [--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 // 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 --reviewed [--extra-round] [--brief ] // stdin = plan-review-dedup.mjs output. Writes this round's verdict into // the plan. 0 = written; 1 = refused: the previous verdict's stop rule // fired (`PLAN_GATE_STOP_RULE`, below; `--extra-round` cannot open it) or // 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: "; a failed snapshot write does not fail the round), // `stop_rule` (this round's decision, or on a refusal the one that fired), // `reason` (`PLAN_GATE_STOP_RULE` on that refusal, else null), // `known_outside` (how many findings the threat model lists as known // issues) and `threat_model` ({applied, why}: whether the brief's threat // model was applied, and why not; below). // // node plan-gate.mjs --diff // Prints what the next critic round reviews: `diff-base: (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 — ; // review the whole plan`. 0 = printed; 2 = unreadable plan, bad arguments. // // Snapshots: `/voyage/plan-snapshots/.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 // 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. After the stop rule fired (decision 220) it // is allowed with rounds left and covers the open BLOCKERs and MAJORs as // known issues (`under: "stop-rule"`), except a counting security finding // (refused, `PLAN_GATE_SECURITY_OPEN`); the gate then warns on each. // // 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 and Plan Quality Score — each only // up to its first other heading, so a step or section placed under a log // heading is hashed. (The old "Adversarial Pass 2 (v5.1.1 high-effort)" // section is plan text now: no command writes it.) // 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. // // The stop rule (decisions 217, 220; lib/review/stop-rule.mjs): each round // appends its summary (blocking findings, and the new blocking findings per // class unless the round was counted in full) to the optional verdict field // `rounds`, carried from the previous verdict, and stores the decision as // `stop_rule`. Once it fired — one class with new blocking findings two // rounds in a row, or blocking findings that did not get fewer — there is no // new round: --record refuses (exit 1, PLAN_GATE_STOP_RULE, `--extra-round` // included), and the bare gate stops with PLAN_GATE_STOP_RULE and the // operator line (the class, the counts per round, "known issue" or "new // mechanism") in place of the open-blocker and open-major reasons, until the // operator accepts the open findings (--accept-open) or the direction // changes. A verdict without `rounds` starts its history at the next round, // so an older block never fires on its first new round. // // The brief's threat model (E 2.4, decisions 164, 218, 220; // lib/review/threat-model.mjs): `--record --brief ` applies it to // the deduped findings before countRound, when it is usable (present, well // formed, and the intent approval current by the marker and the approval // log). A backed finding — Voyage's own security finding (no engine) whose // `threat_model_ref` equals an `Outside:` line's text exactly — leaves the // counted set and the stop rule and goes to the optional verdict field // `known_outside` ({id, rule_key, rule_keys, text, line}), carried to later // rounds until a reviewer marks it resolved (or it counts again under its id). // The threat model applies to this round's new findings only (decision 221): a // finding counted in an earlier round stays counted, through rule A's carry, // until a reviewer marks it resolved; the gate never counts less. The verdict // stores `brief_path`, relative to the repo's top level // (carried when a later round has no --brief), and the gate reads it from // there whatever its working directory. A threat // model that is not usable backs nothing: every security finding counts, and // `threat_model.why` says why. The bare gate re-reads the brief at // `brief_path` on every run: an entry still backed is a warning // (PLAN_GATE_KNOWN_OUTSIDE); one no longer backed (its line gone, the // approval stale, the brief unreadable, no brief_path) stops the plan // (PLAN_GATE_OUTSIDE_NOT_BACKED): it counts in full, and a security finding // is never accepted. 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, isSecurity } from '../review/plan-review-dedup.mjs'; import { countRound, lineDiff, oneLine } from '../review/critic-diff.mjs'; import { roundSummary, decideStop, describeStop, STOP_REASONS } from '../review/stop-rule.mjs'; import { usableInRepo, repoRelativeBrief, splitBacked } from '../review/threat-model.mjs'; import { resolveRepo } from '../verification/repo-tools.mjs'; import { resolveRepo as resolveTopLevel } from '../util/approval-log.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']); // 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, opToFull: Array}} * 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') && okRounds(v.rounds) && okStopRule(v.stop_rule) && okKnownOutside(v.known_outside) && okBriefPath(v.brief_path); 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, optional rounds, stop_rule, known_outside and brief_path)` }; } return { found: true, placeholder: false, verdict: v, error: null }; } const isCount = n => Number.isInteger(n) && n >= 0; const isPlainObject = o => o !== null && typeof o === 'object' && !Array.isArray(o); /** Optional `rounds`: a list of {round >= 1, blocking >= 0, newByClass: null | {class: count}}. */ function okRounds(r) { if (r === undefined) return true; return Array.isArray(r) && r.every(x => isPlainObject(x) && Number.isInteger(x.round) && x.round >= 1 && isCount(x.blocking) && (x.newByClass === null || (isPlainObject(x.newByClass) && Object.values(x.newByClass).every(isCount)))); } /** Optional `stop_rule`: {fired: boolean, reason: one of STOP_REASONS when fired else null, classes: string[], perRound: list}. */ function okStopRule(s) { if (s === undefined) return true; return isPlainObject(s) && typeof s.fired === 'boolean' && (s.fired ? STOP_REASONS.includes(s.reason) : s.reason === null) && Array.isArray(s.classes) && s.classes.every(c => typeof c === 'string') && okRounds(s.perRound); } const isStrOrNull = x => x === null || typeof x === 'string'; /** Optional `known_outside`: a list of {id: string|null, rule_key: string|null, rule_keys?: string[], text: string, line: non-empty string}. */ function okKnownOutside(k) { if (k === undefined) return true; return Array.isArray(k) && k.every(e => isPlainObject(e) && isStrOrNull(e.id) && isStrOrNull(e.rule_key) && (e.rule_keys === undefined || (Array.isArray(e.rule_keys) && e.rule_keys.every(x => typeof x === 'string'))) && typeof e.text === 'string' && typeof e.line === 'string' && e.line !== ''); } /** Optional `brief_path`: a non-empty string. */ const okBriefPath = p => p === undefined || (typeof p === 'string' && p !== ''); /** A previous known_outside entry leaves on a reviewer's resolved: true, and nobody's false. */ function knownResolved(e, marks) { const ms = marks.filter(m => m && typeof m.id === 'string' && m.id === e.id); return ms.some(m => m.resolved === true) && !ms.some(m => m.resolved === false); } function verdictFor(counts) { if (counts.blocker > 0) return 'BLOCK'; if (counts.major > 0) return 'REVISE'; return 'PASS'; } function renderSection(v) { return [ VERDICT_HEADING, '', ``, '', '```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, 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[], * threatModel?: {usable: boolean, why: string|null, threatModel: object}, briefPath?: 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. * threatModel = lib/review/threat-model.mjs `usable(brief)` for --brief; * briefPath = the --brief path, stored as `brief_path`. * @returns {{code: 0|1|2, text?: string, verdict?: object, lowered?: number, * threat_model?: {applied: boolean, why: string|null}, 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 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}` }; // The stop rule (decision 217): once it fired there is no new round, whatever the cap or --extra-round says. if (prev.verdict?.stop_rule?.fired === true) { return { code: 1, reason: 'PLAN_GATE_STOP_RULE', stop_rule: prev.verdict.stop_rule, message: `PLAN_GATE_STOP_RULE: no round ${prev.verdict.round + 1}. ${describeStop(prev.verdict.stop_rule)}`, }; } 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; // The threat model (E 2.4): a backed finding new in this round leaves the // counted set (and so the stop rule) before countRound and is listed in // known_outside; a copy rule A carries from an earlier round stays counted // until a reviewer marks it resolved (decision 221). const use = opts.threatModel; const split = splitBacked(dedup.findings, use); const counted = split.unbacked; // rule_keys kept, so the bare gate re-checks the entry with the same keys // (keysOf); line = the `Outside:` text, which a backed threat_model_ref equals. const backed = split.backed.map(f => ({ id: f.id ?? null, rule_key: f.rule_key ?? null, ...(Array.isArray(f.rule_keys) ? { rule_keys: f.rule_keys.filter(k => typeof k === 'string') } : {}), text: typeof f.text === 'string' ? f.text : '', line: f.threat_model_ref, })); const backedIds = new Set(backed.map(e => e.id).filter(id => id !== null)); 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: counted, 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; // known_outside: this round's backed findings, after the previous round's // entries that no reviewer marked resolved and that are not reported again // under their id. The threat model applies to this round's new findings only // (decision 221): a finding counted in an earlier round stays counted, through // rule A's carry, until a reviewer marks it resolved. const marks = Array.isArray(dedup.marks) ? dedup.marks : []; const countedIds = new Set(findings.map(x => x.id).filter(id => typeof id === 'string')); const kept = (Array.isArray(prev.verdict?.known_outside) ? prev.verdict.known_outside : []) .filter(e => !knownResolved(e, marks) && !backedIds.has(e.id) && !countedIds.has(e.id)); const knownOutside = [...kept, ...backed]; if (knownOutside.length > 0) verdict.known_outside = knownOutside; const briefPath = typeof opts.briefPath === 'string' && opts.briefPath !== '' ? opts.briefPath : prev.verdict?.brief_path; if (briefPath !== undefined) verdict.brief_path = briefPath; // The stop rule's history: this round's summary after the carried ones. A // round with a diff_note (or, outside diff mode, any round from 2 on) was // counted in full, so "new" is not known: newByClass null. const history = Array.isArray(prev.verdict?.rounds) ? prev.verdict.rounds : []; verdict.rounds = [...history, { round, ...roundSummary(findings, { fullCount: c.diffNote !== null }) }]; verdict.stop_rule = decideStop(verdict.rounds); const threat = use === undefined ? { applied: false, why: 'no --brief given' } : { applied: use.usable === true, why: use.usable === true ? null : (use.why ?? 'the threat model is not usable') }; return { code: 0, text: placeSection(text, renderSection(verdict)), verdict, lowered: c.lowered, threat_model: threat, message: `round ${round} recorded: ${verdict.verdict}` + (knownOutside.length > 0 ? `; ${knownOutside.length} finding(s) outside the threat model listed as known issues` : '') + (use !== undefined && !threat.applied ? `; threat model not applied, every security finding counts: ${threat.why}` : '') + (verdict.stop_rule.fired ? `; PLAN_GATE_STOP_RULE: ${describeStop(verdict.stop_rule)}` : ''), }; } 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 — except after the stop * rule fired (decision 220): then the open BLOCKERs and MAJORs are accepted as * known issues, with rounds left too, unless one is a counting security * finding (never accepted). * @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; const fired = v.stop_rule?.fired === true; if (v.plan_sha256 !== planHash(text)) { return { code: 1, message: fired ? 'the verdict is stale: the plan changed after the last round, and the stop rule fired, so there is no next round; restore the reviewed text to accept, or stop and take the direction to the operator' : 'the verdict is stale: the plan changed after the last round; run the next round instead', }; } // The verdict with `accepted_open` naming `list`, bound to this round and plan text. const accept = (list, under) => ({ ...v, accepted_open: { round: v.round, plan_sha256: v.plan_sha256, accepted_at: opts.now ?? new Date().toISOString(), ...(under ? { under } : {}), findings: list.map(m => ({ id: m.id ?? null, text: m.text ?? '' })), }, }); if (fired) { const open = v.findings.filter(f => sevOf(f) !== 'minor'); if (open.length === 0) return { code: 1, message: 'no BLOCKER or MAJOR is open; nothing to accept' }; const security = open.filter(isSecurity); if (security.length > 0) { return { code: 1, message: `PLAN_GATE_SECURITY_OPEN: ${security.length} open security finding(s) count and are never accepted: ${security.map(s => s.text).join(' | ')}` }; } return { code: 0, text: placeSection(text, renderSection(accept(open, 'stop-rule'))), message: `${open.length} open finding(s) of round ${v.round} accepted as known issues under the stop rule` }; } 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` }; return { code: 0, text: placeSection(text, renderSection(accept(majors))), message: `${majors.length} open MAJOR(s) of round ${v.round} accepted` }; } /** * Re-check each known_outside entry against the brief (E 2.4): backed -> a * warning; not backed -> a stop reason (it counts in full). * @param {object[]} entries - the verdict's known_outside * @param {{usable: boolean, why: string|null, threatModel: object}} use - threat-model.mjs `usable` */ function checkKnownOutside(entries, use, reasons, warnings) { const { backed, unbacked, why } = splitBacked(entries, use, e => ({ rule_key: e.rule_key, ...(Array.isArray(e.rule_keys) ? { rule_keys: e.rule_keys } : {}), threat_model_ref: e.line })); if (backed.length > 0) { warnings.push({ code: 'PLAN_GATE_KNOWN_OUTSIDE', message: `${backed.length} security finding(s) outside the brief's threat model, listed as known issues: ${backed.map(e => `${e.text} (Outside: ${e.line})`).join(' | ')}`, }); } if (unbacked.length > 0) { reasons.push({ code: 'PLAN_GATE_OUTSIDE_NOT_BACKED', message: `${unbacked.length} finding(s) recorded as outside the threat model are not backed by the brief now (${why}), so they count in full: ${unbacked.map(e => e.text).join(' | ')}`, }); } } /** * K3 — decide whether the plan may go on. * @param {string} rawText - the plan * @param {{threatModel?: {usable: boolean, why: string|null, threatModel: object}}} [opts] * threatModel = the brief's threat model as threat-model.mjs `usable` gives it; * absent, the gate reads the brief at the verdict's `brief_path` itself * (only when the verdict lists `known_outside`). * @returns {{code: 0|1, status: 'pass'|'stop', reasons: object[], warnings: object[], verdict: object|null, stop_rule: object|null, validation: object}} */ export function gatePlan(rawText, opts = {}) { 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 { const stop = v.stop_rule?.fired === true ? v.stop_rule : null; if (v.plan_sha256 !== planHash(text)) { reasons.push({ code: 'PLAN_GATE_VERDICT_STALE', message: stop ? `The plan changed after critic round ${v.round}, and the stop rule fired, so there is no next round; restore the reviewed text, or stop and take the direction to the operator.` : `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 (stop) { // The stop rule replaces the open-blocker and open-major reasons: no new // round, the operator decides (decision 220). Covered = accepted under // the stop rule for this round and text, with no security finding open. const open = [...blockers, ...majors]; const a = v.accepted_open; const covered = open.length > 0 && a?.under === 'stop-rule' && acceptanceCovers(v, open) && v.plan_sha256 === planHash(text) && !open.some(isSecurity); if (open.length > 0 && !covered) { reasons.push({ code: 'PLAN_GATE_STOP_RULE', message: describeStop(stop) }); } else if (covered) { if (blockers.length > 0) { warnings.push({ code: 'PLAN_GATE_OPEN_BLOCKER', message: `${blockers.length} BLOCKER(s) open in round ${v.round}, accepted by the operator as known issues after the stop rule (${a.accepted_at}): ${blockers.map(b => b.text).join(' | ')}` }); } if (majors.length > 0) { warnings.push({ code: 'PLAN_GATE_OPEN_MAJOR', message: `${majors.length} MAJOR finding(s) open in round ${v.round}, accepted by the operator as known issues after the stop rule (${a.accepted_at}): ${majors.map(m => m.text).join(' | ')}` }); } } } else 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.` }); } } const known = Array.isArray(v.known_outside) ? v.known_outside : []; if (known.length > 0) { // brief_path is relative to the repo's top level, so the gate reads the // same brief from any working directory inside the repo. const use = opts.threatModel ?? (typeof v.brief_path === 'string' ? usableInRepo(v.brief_path) : { usable: false, why: 'the verdict names no brief_path', threatModel: null }); checkKnownOutside(known, use, reasons, warnings); } if (!stop && 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, stop_rule: v?.stop_rule ?? null, validation, }; } // ---- CLI -------------------------------------------------------------------- function out(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); } const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024; /** `/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 `/.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 [--json] | --hash | --diff | --record --reviewed [--extra-round] [--brief ] < dedup.json | --accept-open '; const known = new Set(['--json', '--hash', '--diff', '--record', '--reviewed', '--extra-round', '--accept-open', '--brief']); const rIdx = args.indexOf('--reviewed'); const reviewed = rIdx >= 0 ? args[rIdx + 1] : undefined; // --brief takes a value, kept out of the positional arguments like --reviewed's. const bIdx = args.indexOf('--brief'); const briefArg = bIdx >= 0 ? args[bIdx + 1] : undefined; const valueAt = i => (rIdx >= 0 && i === rIdx + 1) || (bIdx >= 0 && i === bIdx + 1); const positional = args.filter((a, i) => !a.startsWith('--') && !valueAt(i)); 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('--'))) || (bIdx >= 0 && (!briefArg || briefArg.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) }; if (briefArg !== undefined) { // Stored relative to the repo's top level, never absolute; read from there // (one top-level lookup for both). const top = resolveTopLevel(); opts.briefPath = repoRelativeBrief(briefArg, top); opts.threatModel = usableInRepo(opts.briefPath, top); } 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, stop_rule: r.verdict?.stop_rule ?? r.stop_rule ?? null, reason: r.reason ?? null, known_outside: r.verdict?.known_outside?.length ?? 0, threat_model: r.threat_model ?? null, }); 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, stop_rule: g.stop_rule, 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); }