469 lines
22 KiB
JavaScript
469 lines
22 KiB
JavaScript
// lib/validators/plan-gate.mjs
|
|
// K3 — the plan gate: may this plan go on to execution?
|
|
//
|
|
// node plan-gate.mjs <plan.md> [--json]
|
|
// 0 = the plan may go on
|
|
// 1 = stop: invalid plan (plan-validator --strict, plan_version >= 1.8),
|
|
// no critic verdict, a malformed verdict, a verdict the plan has
|
|
// moved away from (stale), an open BLOCKER, or an open MAJOR the
|
|
// operator has not accepted
|
|
// 2 = could not run (missing/unreadable plan, bad arguments)
|
|
//
|
|
// node plan-gate.mjs --hash <plan.md>
|
|
// Prints the sha256 of the plan's operative text. Phase 9 takes it BEFORE
|
|
// the reviewers read the plan and hands it to --record.
|
|
//
|
|
// node plan-gate.mjs --record <plan.md> --reviewed <sha256> [--extra-round]
|
|
// stdin = plan-review-dedup.mjs output. Writes this round's verdict into
|
|
// the plan. 0 = written; 1 = refused: the round cap is spent (stop and ask
|
|
// the operator; `--extra-round` only after their yes, and only then);
|
|
// 2 = could not run: bad stdin, a reviewer missing from the dedup input,
|
|
// the plan changed since --hash (the verdict would bless unreviewed text),
|
|
// an unreadable plan or a failed write. Nothing is written on 1 or 2.
|
|
//
|
|
// node plan-gate.mjs --accept-open <plan.md>
|
|
// After the operator's explicit yes to "build as is": records the open
|
|
// MAJORs of the current round as accepted (`accepted_open` in the verdict
|
|
// block, bound to the round and the plan hash). 0 = written; 1 = refused
|
|
// (rounds left, a BLOCKER is open, no MAJOR is open, or the verdict is
|
|
// stale); 2 = could not run.
|
|
//
|
|
// Open MAJORs (operator decision 2026-10-08, voyage-lekerepo trial point 6):
|
|
// they used to pass as warnings, and after the last round there was no way to
|
|
// fix one — a weakness the critics had named was built and came back from the
|
|
// review as MAJOR. Now an open MAJOR stops: with rounds left, revise and run
|
|
// the next round; after the last one, ask the operator — fix now (one extra
|
|
// round) or build as is (`--accept-open`).
|
|
//
|
|
// Where the verdict lives: INSIDE the plan, as the `## Critic Verdict` section
|
|
// with one fenced json block. Claude Code's plan approval hands a hook the
|
|
// plan file's text (`tool_input.plan`, `planFilePath`) and nothing next to
|
|
// it, so a verdict in a sibling file would be invisible there.
|
|
//
|
|
// What the verdict is bound to: sha256 of the plan's operative text — the plan
|
|
// minus its review log, with CRLF and line-end whitespace normalized. The
|
|
// review log is the `> **Plan quality:` header line and the level-2 sections
|
|
// titled exactly Critic Verdict, Revisions, Plan Quality Score and
|
|
// "Adversarial Pass 2 (v5.1.1 high-effort)" — each only up to its first
|
|
// other heading, so a step or section placed under a log heading is hashed.
|
|
// Edit a step, the design or the verification after the critic ran, and the
|
|
// verdict is stale until the next round.
|
|
//
|
|
// The loop is bounded (Kiro's bounded review loop): MAX_ROUNDS critic rounds,
|
|
// then stop and ask; never silently on. Unrated findings count as blockers.
|
|
// The verdict is a trace of a process, not a signature: anything that can
|
|
// write the plan can write the block.
|
|
|
|
import { readFileSync, existsSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
import { createHash } from 'node:crypto';
|
|
import { validatePlanContent } from './plan-validator.mjs';
|
|
import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from '../parsers/plan-schema.mjs';
|
|
import { normalizeSeverity } from '../review/plan-review-dedup.mjs';
|
|
import { isMain } from '../util/is-main.mjs';
|
|
|
|
export const MAX_ROUNDS = 3;
|
|
export const VERDICT_SCHEMA = 'voyage.plan-verdict/1';
|
|
export const VERDICT_HEADING = '## Critic Verdict';
|
|
export const REQUIRED_REVIEWERS = Object.freeze(['plan-critic', 'scope-guardian']);
|
|
// Level-2 sections that are review log, not plan: excluded from the hash.
|
|
export const REVIEW_LOG_SECTIONS = Object.freeze([
|
|
'Critic Verdict', 'Revisions', 'Plan Quality Score', 'Adversarial Pass 2 (v5.1.1 high-effort)',
|
|
]);
|
|
// The quality line alone; a line that also carries `plan_version`/`ceremony` stays hashed.
|
|
const QUALITY_LINE = /^> \*\*Plan quality:[^`]*$/;
|
|
|
|
const isLogTitle = t => t !== null && REVIEW_LOG_SECTIONS.includes(t);
|
|
const norm = t => String(t).replace(/\r\n?/g, '\n');
|
|
|
|
/** A log section counts as log only up to its first other heading (outside code). */
|
|
function logPart(sectionText) {
|
|
const lines = sectionText.split('\n');
|
|
const fence = fenceTracker();
|
|
for (let i = 1; i < lines.length; i++) {
|
|
if (fence.feed(lines[i])) continue;
|
|
if (/^#{1,6} /.test(lines[i])) return { log: lines.slice(0, i).join('\n'), rest: lines.slice(i).join('\n') };
|
|
}
|
|
return { log: sectionText, rest: '' };
|
|
}
|
|
|
|
/** The plan minus its review log, normalized (LF, no line-end whitespace, one trailing newline). */
|
|
export function operativeText(text) {
|
|
const kept = splitSections(norm(text), 2)
|
|
.map(s => (isLogTitle(s.title) ? logPart(s.text).rest : s.text))
|
|
.join('\n');
|
|
return kept
|
|
.split('\n')
|
|
.filter(l => !QUALITY_LINE.test(l))
|
|
.map(l => l.replace(/[ \t]+$/, ''))
|
|
.join('\n')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim() + '\n';
|
|
}
|
|
|
|
/**
|
|
* The operative text with its line map, built from the same transforms as
|
|
* `operativeText` (whose output it returns unchanged).
|
|
* @returns {{text: string, fullToOp: Array<number|null>, opToFull: Array<number|null>}}
|
|
* 1-based both ways (index 0 is null). fullToOp[i] is null for a review-log
|
|
* line, the quality line or a collapsed blank; opToFull[j] is null for an
|
|
* operative line no file line carries (a blank left where a log section stood).
|
|
*/
|
|
export function operativeLines(text) {
|
|
const src = norm(text);
|
|
const full = src.split('\n');
|
|
const excluded = new Set();
|
|
for (const s of splitSections(src, 2)) {
|
|
if (!isLogTitle(s.title)) continue;
|
|
const n = logPart(s.text).log.split('\n').length;
|
|
for (let i = s.start; i < s.start + n; i++) excluded.add(i);
|
|
}
|
|
full.forEach((l, i) => { if (QUALITY_LINE.test(l)) excluded.add(i); });
|
|
const op = operativeText(text);
|
|
const opL = op.split('\n');
|
|
const opCount = opL.length - 1; // the text ends with '\n'; the last split element is not a line
|
|
const fullToOp = new Array(full.length + 1).fill(null);
|
|
const opToFull = new Array(opCount + 1).fill(null);
|
|
let j = 0;
|
|
for (let i = 0; i < full.length && j < opCount; i++) {
|
|
if (excluded.has(i)) continue;
|
|
let l = full[i].replace(/[ \t]+$/, '');
|
|
if (j === 0) l = l.replace(/^\s+/, '');
|
|
// Only blanks lose their partner, so a non-blank kept line facing an operative
|
|
// blank means that blank has no file line: step past it.
|
|
while (l !== '' && opL[j] === '' && j < opCount - 1) j += 1;
|
|
const want = opL[j];
|
|
if (l === want || (j === opCount - 1 && l.trimEnd() === want)) {
|
|
fullToOp[i + 1] = j + 1;
|
|
opToFull[j + 1] = i + 1;
|
|
j += 1;
|
|
}
|
|
}
|
|
return { text: op, fullToOp, opToFull };
|
|
}
|
|
|
|
export function planHash(text) {
|
|
return createHash('sha256').update(operativeText(text)).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* @returns {{found: boolean, placeholder: boolean, verdict: object|null, error: string|null}}
|
|
* placeholder = a `## Critic Verdict` heading with no fenced block at all
|
|
* (copied prose, not a verdict): --record replaces it, the gate stops on it.
|
|
*/
|
|
export function extractVerdict(text) {
|
|
const secs = splitSections(norm(text), 2).filter(s => s.title === 'Critic Verdict');
|
|
const none = { found: false, placeholder: false, verdict: null };
|
|
if (secs.length === 0) return { ...none, error: null };
|
|
if (secs.length > 1) return { ...none, found: true, error: `${secs.length} "## Critic Verdict" sections; expected one` };
|
|
const log = logPart(secs[0].text).log;
|
|
const m = log.match(/```json[ \t]*\n([\s\S]*?)\n[ \t]*```/);
|
|
if (!m) {
|
|
const placeholder = !/^\s{0,3}(```|~~~)/m.test(log);
|
|
return { ...none, found: true, placeholder, error: 'no ```json block in ## Critic Verdict' };
|
|
}
|
|
let v;
|
|
try { v = JSON.parse(m[1]); } catch (e) { return { ...none, found: true, error: `verdict JSON does not parse: ${e.message}` }; }
|
|
const okShape = v && typeof v === 'object' && v.schema === VERDICT_SCHEMA && Number.isInteger(v.round) && v.round >= 1 &&
|
|
Number.isInteger(v.max_rounds) && v.max_rounds >= 1 && v.max_rounds <= MAX_ROUNDS &&
|
|
Number.isInteger(v.extra_rounds ?? 0) && (v.extra_rounds ?? 0) >= 0 &&
|
|
typeof v.plan_sha256 === 'string' && Array.isArray(v.findings) &&
|
|
v.findings.every(f => f && typeof f === 'object' && typeof f.severity === 'string');
|
|
if (!okShape) {
|
|
return { ...none, found: true, error: `verdict block is not ${VERDICT_SCHEMA} (schema, round, max_rounds <= ${MAX_ROUNDS}, extra_rounds, plan_sha256, findings with severity)` };
|
|
}
|
|
return { found: true, placeholder: false, verdict: v, error: null };
|
|
}
|
|
|
|
function verdictFor(counts) {
|
|
if (counts.blocker > 0) return 'BLOCK';
|
|
if (counts.major > 0) return 'REVISE';
|
|
return 'PASS';
|
|
}
|
|
|
|
function renderSection(v) {
|
|
return [
|
|
VERDICT_HEADING,
|
|
'',
|
|
`<!-- Written by lib/validators/plan-gate.mjs --record (round ${v.round} of ${v.max_rounds}` +
|
|
`${v.extra_rounds ? ` + ${v.extra_rounds} extra` : ''}). Do not edit by hand: the gate re-hashes the plan and reads this block. -->`,
|
|
'',
|
|
'```json',
|
|
JSON.stringify(v, null, 2),
|
|
'```',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
/** Replace (or append) the `## Critic Verdict` section; anything after its log part is kept. */
|
|
function placeSection(text, section) {
|
|
const secs = splitSections(norm(text), 2);
|
|
const idx = secs.findIndex(s => s.title === 'Critic Verdict');
|
|
if (idx === -1) return norm(text).replace(/\s*$/, '\n\n') + section;
|
|
const parts = secs.map(s => s.text);
|
|
const { rest } = logPart(parts[idx]);
|
|
parts[idx] = section.replace(/\n$/, '') + (rest ? `\n\n${rest}` : '');
|
|
return parts.join('\n').replace(/\s*$/, '\n');
|
|
}
|
|
|
|
/** Both reviewers must have been present in the dedup input; an absent one is not a clean review. */
|
|
function missingReviewers(dedup) {
|
|
const present = new Set(
|
|
(Array.isArray(dedup.sources) ? dedup.sources : []).filter(s => s && s.present === true).map(s => s.agent),
|
|
);
|
|
return REQUIRED_REVIEWERS.filter(r => !present.has(r));
|
|
}
|
|
|
|
/**
|
|
* Record one critic round into the plan text.
|
|
* @param {string} text - the plan
|
|
* @param {{findings: Array<object>, sources: Array<{agent: string, present: boolean}>}} dedup
|
|
* plan-review-dedup.mjs output
|
|
* @param {{reviewed?: string, extraRound?: boolean, maxRounds?: number, now?: string}} [opts]
|
|
* reviewed = planHash of the text the reviewers read (from --hash)
|
|
* @returns {{code: 0|1|2, text?: string, verdict?: object, 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 current = planHash(text);
|
|
if (typeof opts.reviewed !== 'string' || !/^[0-9a-f]{64}$/.test(opts.reviewed)) {
|
|
return { code: 2, message: '--reviewed <sha256> is required: take it with --hash before the reviewers read the plan' };
|
|
}
|
|
if (opts.reviewed !== current) {
|
|
return { code: 2, message: 'the plan changed after the reviewers read it (--reviewed differs from the plan now); rerun the round on the current plan' };
|
|
}
|
|
const prev = extractVerdict(text);
|
|
if (prev.error && !prev.placeholder) return { code: 2, message: `existing verdict unreadable: ${prev.error}` };
|
|
const maxRounds = Math.min(prev.verdict?.max_rounds ?? opts.maxRounds ?? MAX_ROUNDS, MAX_ROUNDS);
|
|
const prevExtra = prev.verdict?.extra_rounds ?? 0;
|
|
const round = (prev.verdict?.round ?? 0) + 1;
|
|
const capped = round > maxRounds + prevExtra;
|
|
if (opts.extraRound && !capped) {
|
|
return { code: 2, message: `--extra-round refused: round ${round} is within the cap; it is only for a round after the cap, after the operator's yes` };
|
|
}
|
|
if (capped && !opts.extraRound) {
|
|
return {
|
|
code: 1,
|
|
message: `round ${round} refused: ${maxRounds} critic rounds${prevExtra ? ` (+${prevExtra} extra)` : ''} are spent. ` +
|
|
'Stop and ask the operator: one more round (rerun with --extra-round after their yes), or stop here.',
|
|
};
|
|
}
|
|
const extra = prevExtra + (capped ? 1 : 0);
|
|
const findings = dedup.findings.map((f) => {
|
|
const sev = normalizeSeverity(f.severity);
|
|
const out = {
|
|
id: f.id ?? null,
|
|
severity: sev ?? 'blocker',
|
|
rule_key: f.rule_key ?? null,
|
|
file: f.file ?? null,
|
|
line: f.line ?? null,
|
|
text: typeof f.text === 'string' ? f.text : '',
|
|
raised_by: Array.isArray(f.raised_by) ? f.raised_by : [],
|
|
};
|
|
if (sev === null) out.severity_defaulted = true;
|
|
return out;
|
|
});
|
|
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,
|
|
verdict: verdictFor(counts),
|
|
counts,
|
|
findings,
|
|
};
|
|
return { code: 0, text: placeSection(text, renderSection(verdict)), verdict, message: `round ${round} recorded: ${verdict.verdict}` };
|
|
}
|
|
|
|
const sevOf = f => normalizeSeverity(f.severity);
|
|
const majorKey = f => `${f.id ?? ''}\u0000${f.text ?? ''}`;
|
|
|
|
/** The verdict's acceptance names this round, this plan text and every open MAJOR. */
|
|
function acceptanceCovers(v, majors) {
|
|
const a = v.accepted_open;
|
|
if (!a || typeof a !== 'object' || a.round !== v.round || a.plan_sha256 !== v.plan_sha256 || !Array.isArray(a.findings)) return false;
|
|
const accepted = new Set(a.findings.map(majorKey));
|
|
return majors.every(m => accepted.has(majorKey(m)));
|
|
}
|
|
|
|
/**
|
|
* Record the operator's "build as is" for the current round's open MAJORs.
|
|
* Only after their explicit yes; never for a BLOCKER.
|
|
* @returns {{code: 0|1|2, text?: string, message: string}}
|
|
*/
|
|
export function acceptOpen(rawText, opts = {}) {
|
|
const text = norm(rawText);
|
|
const ex = extractVerdict(text);
|
|
if (!ex.found || ex.error) return { code: 2, message: `no readable verdict: ${ex.error ?? 'no ## Critic Verdict'}` };
|
|
const v = ex.verdict;
|
|
if (v.plan_sha256 !== planHash(text)) return { code: 1, message: 'the verdict is stale: the plan changed after the last round; run the next round instead' };
|
|
if (v.findings.some(f => sevOf(f) !== 'major' && sevOf(f) !== 'minor')) return { code: 1, message: 'a BLOCKER is open; a blocker is never accepted' };
|
|
const majors = v.findings.filter(f => sevOf(f) === 'major');
|
|
if (majors.length === 0) return { code: 1, message: 'no MAJOR is open; nothing to accept' };
|
|
// Only the last round: with rounds left the answer is to revise, not accept
|
|
// (found by the independent review: acceptance at round 1 passed the gate).
|
|
const left = v.max_rounds + (v.extra_rounds ?? 0) - v.round;
|
|
if (left > 0) return { code: 1, message: `${left} critic round(s) left: revise the plan and run the next round; acceptance is only for after the last round` };
|
|
const accepted = {
|
|
...v,
|
|
accepted_open: {
|
|
round: v.round,
|
|
plan_sha256: v.plan_sha256,
|
|
accepted_at: opts.now ?? new Date().toISOString(),
|
|
findings: majors.map(m => ({ id: m.id ?? null, text: m.text ?? '' })),
|
|
},
|
|
};
|
|
return { code: 0, text: placeSection(text, renderSection(accepted)), message: `${majors.length} open MAJOR(s) of round ${v.round} accepted` };
|
|
}
|
|
|
|
/**
|
|
* K3 — decide whether the plan may go on.
|
|
* @returns {{code: 0|1, status: 'pass'|'stop', reasons: object[], warnings: object[], verdict: object|null, validation: object}}
|
|
*/
|
|
export function gatePlan(rawText) {
|
|
const text = norm(rawText);
|
|
const reasons = [];
|
|
const warnings = [];
|
|
const validation = validatePlanContent(text, { strict: true });
|
|
if (!validation.valid) {
|
|
reasons.push({ code: 'PLAN_GATE_INVALID_PLAN', message: `plan-validator --strict: ${validation.errors.map(e => e.code).join(', ')}`, errors: validation.errors });
|
|
}
|
|
const version = extractPlanVersion(text);
|
|
if (!versionAtLeast(version, '1.8')) {
|
|
reasons.push({ code: 'PLAN_GATE_VERSION', message: `plan_version ${version ?? '(none)'}: the gate passes only plans written to plan_version 1.8 or later` });
|
|
}
|
|
const ex = extractVerdict(text);
|
|
const v = ex.verdict;
|
|
if (!ex.found) {
|
|
reasons.push({ code: 'PLAN_GATE_NO_VERDICT', message: 'No "## Critic Verdict": the plan has not been through the critic (run Phase 9).' });
|
|
} else if (ex.error) {
|
|
reasons.push({ code: 'PLAN_GATE_VERDICT_MALFORMED', message: ex.error });
|
|
} else {
|
|
if (v.plan_sha256 !== planHash(text)) {
|
|
reasons.push({ code: 'PLAN_GATE_VERDICT_STALE', message: `The plan changed after critic round ${v.round}; run the next round.` });
|
|
}
|
|
const sev = f => normalizeSeverity(f.severity);
|
|
const blockers = v.findings.filter(f => sev(f) !== 'major' && sev(f) !== 'minor');
|
|
const majors = v.findings.filter(f => sev(f) === 'major');
|
|
if (blockers.length > 0) {
|
|
reasons.push({ code: 'PLAN_GATE_OPEN_BLOCKER', message: `${blockers.length} open BLOCKER(s) in critic round ${v.round}: ${blockers.map(b => b.text).join(' | ')}` });
|
|
if (v.round >= v.max_rounds + (v.extra_rounds ?? 0)) {
|
|
reasons.push({ code: 'PLAN_GATE_ROUNDS_EXHAUSTED', message: `All ${v.round} critic rounds are spent with a BLOCKER open: stop and ask the operator.` });
|
|
}
|
|
}
|
|
if (majors.length > 0) {
|
|
const exhausted = v.round >= v.max_rounds + (v.extra_rounds ?? 0);
|
|
const list = majors.map(m => m.text).join(' | ');
|
|
if (acceptanceCovers(v, majors) && v.plan_sha256 === planHash(text)) {
|
|
warnings.push({ code: 'PLAN_GATE_OPEN_MAJOR', message: `${majors.length} MAJOR finding(s) open in round ${v.round}, accepted by the operator (${v.accepted_open.accepted_at}): ${list}` });
|
|
} else if (exhausted) {
|
|
reasons.push({
|
|
code: 'PLAN_GATE_OPEN_MAJOR_FINAL',
|
|
message: `${majors.length} MAJOR finding(s) open after the last critic round (${v.round}): ${list}. ` +
|
|
'Stop and ask the operator: fix now (revise, then --record --extra-round after their yes) or build as is (--accept-open after their yes).',
|
|
});
|
|
} else {
|
|
reasons.push({ code: 'PLAN_GATE_OPEN_MAJOR', message: `${majors.length} MAJOR finding(s) open in round ${v.round}: ${list}. Revise the plan and run the next round.` });
|
|
}
|
|
}
|
|
}
|
|
return { code: reasons.length === 0 ? 0 : 1, status: reasons.length === 0 ? 'pass' : 'stop', reasons, warnings, verdict: v ?? null, validation };
|
|
}
|
|
|
|
// ---- CLI --------------------------------------------------------------------
|
|
|
|
function out(obj) {
|
|
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
}
|
|
|
|
if (isMain(import.meta.url)) {
|
|
const args = process.argv.slice(2);
|
|
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json | --accept-open <plan.md>';
|
|
const known = new Set(['--json', '--hash', '--record', '--reviewed', '--extra-round', '--accept-open']);
|
|
const rIdx = args.indexOf('--reviewed');
|
|
const reviewed = rIdx >= 0 ? args[rIdx + 1] : undefined;
|
|
const positional = args.filter((a, i) => !a.startsWith('--') && !(rIdx >= 0 && i === rIdx + 1));
|
|
const unknown = args.filter(a => a.startsWith('--') && !known.has(a));
|
|
const planPath = positional[0];
|
|
if (!planPath || positional.length > 1 || unknown.length > 0 || (rIdx >= 0 && (!reviewed || reviewed.startsWith('--')))) {
|
|
process.stderr.write(`${usage}\n`);
|
|
out({ code: 2, status: 'error', message: unknown.length ? `unknown argument: ${unknown.join(' ')}` : 'bad arguments' });
|
|
process.exit(2);
|
|
}
|
|
let text;
|
|
try {
|
|
if (!existsSync(planPath)) throw new Error('file not found');
|
|
text = readFileSync(planPath, 'utf-8');
|
|
} catch (e) {
|
|
out({ code: 2, status: 'error', plan: planPath, message: `cannot read plan: ${e.message}` });
|
|
process.exit(2);
|
|
}
|
|
if (args.includes('--hash')) {
|
|
process.stdout.write(`${planHash(text)}\n`);
|
|
process.exit(0);
|
|
}
|
|
if (args.includes('--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 r = recordVerdict(text, dedup, { extraRound: args.includes('--extra-round'), reviewed });
|
|
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, 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,
|
|
});
|
|
process.exit(r.code);
|
|
}
|
|
const g = gatePlan(text);
|
|
if (args.includes('--json')) {
|
|
process.stdout.write(JSON.stringify({ code: g.code, status: g.status, plan: planPath, reasons: g.reasons, warnings: g.warnings, verdict: g.verdict }, null, 2) + '\n');
|
|
} else {
|
|
process.stdout.write(
|
|
`plan-gate: ${g.status.toUpperCase()} ${planPath}\n` +
|
|
g.reasons.map(r => ` STOP [${r.code}] ${r.message}\n`).join('') +
|
|
g.warnings.map(w => ` WARN [${w.code}] ${w.message}\n`).join(''),
|
|
);
|
|
}
|
|
process.exit(g.code);
|
|
}
|