feat(plan): the plan gate keeps each reviewed text in the git directory and prints the diff for round 2+ [skip-docs]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7b46cdd788
commit
fb9a551ed8
3 changed files with 567 additions and 5 deletions
|
|
@ -20,6 +20,25 @@
|
|||
// 2 = could not run: bad stdin, a reviewer missing from the dedup input,
|
||||
// the plan changed since --hash (the verdict would bless unreviewed text),
|
||||
// an unreadable plan or a failed write. Nothing is written on 1 or 2.
|
||||
// From round 2 on it counts against the snapshot named by the previous
|
||||
// verdict's plan_sha256 (rule A, below); a missing, oversized, misnamed or
|
||||
// tampered snapshot means full counting, said in one `diff_note` line. On
|
||||
// 0 it keeps this round's operative text as a snapshot (tmp + rename)
|
||||
// before the plan is written. Output: one JSON line, which adds
|
||||
// `diff_base`, `diff_note`, `lowered` (a number) and `snapshot` ("kept: ..."
|
||||
// or "not kept: <reason>"; a failed snapshot write does not fail the round).
|
||||
//
|
||||
// node plan-gate.mjs --diff <plan.md>
|
||||
// Prints what the next critic round reviews: `diff-base: <sha256> (round N)`
|
||||
// and each hunk as `@@ lines A-B @@` in the plan's full-file line numbers,
|
||||
// `-` old lines and `+NNN ` new lines; or `diff-base: none — <reason>;
|
||||
// review the whole plan`. 0 = printed; 2 = unreadable plan, bad arguments.
|
||||
//
|
||||
// Snapshots: `<git dir>/voyage/plan-snapshots/<sha256>.md`, the operative text
|
||||
// the round reviewed, named by its own sha256. The git dir is found from the
|
||||
// plan file's own directory only (no cwd fallback); a plan in no repository
|
||||
// keeps none. git never tracks its own directory, so a snapshot is never
|
||||
// committed, whatever the repo's .gitignore or artifact_store says.
|
||||
//
|
||||
// node plan-gate.mjs --accept-open <plan.md>
|
||||
// After the operator's explicit yes to "build as is": records the open
|
||||
|
|
@ -71,12 +90,16 @@
|
|||
// rewrite the previous verdict's plan_sha256 and plant a matching base; rule A
|
||||
// raises what such a forgery buys, and an unchanged base lowers nothing.
|
||||
|
||||
import { readFileSync, existsSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
readFileSync, existsSync, writeFileSync, renameSync, rmSync, statSync, mkdirSync, realpathSync,
|
||||
} from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { validatePlanContent } from './plan-validator.mjs';
|
||||
import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from '../parsers/plan-schema.mjs';
|
||||
import { normalizeSeverity } from '../review/plan-review-dedup.mjs';
|
||||
import { countRound } from '../review/critic-diff.mjs';
|
||||
import { countRound, lineDiff } from '../review/critic-diff.mjs';
|
||||
import { resolveRepo } from '../verification/repo-tools.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const MAX_ROUNDS = 3;
|
||||
|
|
@ -417,10 +440,113 @@ function out(obj) {
|
|||
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
}
|
||||
|
||||
const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
|
||||
const oneLine = s => String(s).replace(/\s*[\r\n]+\s*/g, ' ').trim();
|
||||
|
||||
/** `<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 === planHash(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> | --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 usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --diff <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json | --accept-open <plan.md>';
|
||||
const known = new Set(['--json', '--hash', '--diff', '--record', '--reviewed', '--extra-round', '--accept-open']);
|
||||
const rIdx = args.indexOf('--reviewed');
|
||||
const reviewed = rIdx >= 0 ? args[rIdx + 1] : undefined;
|
||||
const positional = args.filter((a, i) => !a.startsWith('--') && !(rIdx >= 0 && i === rIdx + 1));
|
||||
|
|
@ -443,6 +569,10 @@ if (isMain(import.meta.url)) {
|
|||
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) {
|
||||
|
|
@ -465,8 +595,18 @@ if (isMain(import.meta.url)) {
|
|||
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 });
|
||||
const dir = snapshotDir(planPath);
|
||||
const opts = { extraRound: args.includes('--extra-round'), reviewed, planPaths: planPathsOf(planPath) };
|
||||
const lb = loadBase(text, dir);
|
||||
if (lb.verdict) {
|
||||
opts.base = lb.base;
|
||||
if (lb.reason) opts.baseNote = `${lb.reason}; full counting`;
|
||||
}
|
||||
const r = recordVerdict(text, dedup, opts);
|
||||
let snapshot = null;
|
||||
if (r.code === 0) {
|
||||
// The snapshot first, then the plan: a verdict never names a text that was not kept on purpose.
|
||||
snapshot = keepSnapshot(dir, text, r.verdict.plan_sha256);
|
||||
const tmp = `${planPath}.plan-gate.tmp`;
|
||||
try {
|
||||
writeFileSync(tmp, r.text);
|
||||
|
|
@ -481,6 +621,8 @@ if (isMain(import.meta.url)) {
|
|||
code: r.code, recorded: r.code === 0, message: r.message,
|
||||
round: r.verdict?.round ?? null, max_rounds: r.verdict?.max_rounds ?? null,
|
||||
verdict: r.verdict?.verdict ?? null, counts: r.verdict?.counts ?? null,
|
||||
diff_base: r.verdict?.diff_base ?? null, diff_note: r.verdict?.diff_note ?? null,
|
||||
lowered: r.code === 0 ? r.lowered : null, snapshot,
|
||||
});
|
||||
process.exit(r.code);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue