The verifier's look at ff46f57 found three fail-safe MINOR findings, all
text that did not yet say decision 221 or said it inaccurately:
- commands/trekplan.md Phase 9 and the plan-gate.mjs comment said only that a
backed finding leaves the count and the stop rule; both now say it applies
to the round's new findings, and a finding counted in an earlier round
stays counted until a reviewer marks it resolved.
- Handover 4 said a known_outside entry "left the counted findings"; it now
says that holds for a new finding of its round, and that a carried copy of
the same id stays counted, so one id can sit in both findings and
known_outside.
- The CHANGELOG Known issues bullet said the loop "then stops" and that the
way on is the operator's choice at the stop rule. The stop rule fires only
when the blocking findings do not get fewer, and a counting security
finding is never accepted (PLAN_GATE_SECURITY_OPEN): the way on is a
reviewer marking it resolved or "New mechanism". The bullet also names the
shared id (two findings at one line and key, one mark resolves both).
No code change.
Part 2 of the E plan, decision 221.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
893 lines
46 KiB
JavaScript
893 lines
46 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] [--brief <brief.md>]
|
|
// 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: <reason>"; 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 <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. 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 <brief.md>` 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, backedLine } from '../review/threat-model.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']);
|
|
// 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') &&
|
|
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,
|
|
'',
|
|
`<!-- 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[],
|
|
* 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 <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}` };
|
|
// 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 tm = use && use.usable === true ? use.threatModel : null;
|
|
const backed = [];
|
|
const counted = [];
|
|
for (const f of dedup.findings) {
|
|
const line = tm ? backedLine(f, tm) : null;
|
|
if (line === null) { counted.push(f); continue; }
|
|
// rule_keys kept, so the bare gate re-checks the entry with the same keys (keysOf).
|
|
backed.push({
|
|
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,
|
|
});
|
|
}
|
|
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;
|
|
if (v.plan_sha256 !== planHash(text)) {
|
|
return {
|
|
code: 1,
|
|
message: v.stop_rule?.fired === true
|
|
? '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',
|
|
};
|
|
}
|
|
if (v.stop_rule?.fired === true) {
|
|
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(' | ')}` };
|
|
}
|
|
const accepted = {
|
|
...v,
|
|
accepted_open: {
|
|
round: v.round,
|
|
plan_sha256: v.plan_sha256,
|
|
accepted_at: opts.now ?? new Date().toISOString(),
|
|
under: 'stop-rule',
|
|
findings: open.map(m => ({ id: m.id ?? null, text: m.text ?? '' })),
|
|
},
|
|
};
|
|
return { code: 0, text: placeSection(text, renderSection(accepted)), 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` };
|
|
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` };
|
|
}
|
|
|
|
/**
|
|
* 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 tm = use?.usable === true ? use.threatModel : null;
|
|
const backed = [];
|
|
const unbacked = [];
|
|
for (const e of entries) {
|
|
const asFinding = { rule_key: e.rule_key, ...(Array.isArray(e.rule_keys) ? { rule_keys: e.rule_keys } : {}), threat_model_ref: e.line };
|
|
(tm && backedLine(asFinding, tm) !== null ? backed : unbacked).push(e);
|
|
}
|
|
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) {
|
|
const why = tm ? 'no `Outside:` line in the brief equals it, or it is not a Voyage security finding' : (use?.why ?? 'the threat model is not usable');
|
|
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 {
|
|
if (v.plan_sha256 !== planHash(text)) {
|
|
reasons.push({
|
|
code: 'PLAN_GATE_VERDICT_STALE',
|
|
message: v.stop_rule?.fired === true
|
|
? `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');
|
|
const stop = v.stop_rule?.fired === true ? v.stop_rule : null;
|
|
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;
|
|
|
|
/** `<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] [--brief <brief.md>] < dedup.json | --accept-open <plan.md>';
|
|
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.
|
|
opts.briefPath = repoRelativeBrief(briefArg);
|
|
opts.threatModel = usableInRepo(opts.briefPath);
|
|
}
|
|
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);
|
|
}
|