voyage/lib/validators/brief-review-stamp.mjs
Kjell Tore Guttormsen 5f9faeed4a
fix: close the six defects an independent review found in the trial fixes
Independent read-only review of c56f852..2d29966 (2026-10-08), each defect
reproduced by the reviewer; each new test fails on that code.

- test-lock green ran ANY allowlisted command: `green --cmd "git status"`
  marked a red step green and the stop guard let it stop. The lock now
  stores the step's test command (`lock --cmd`); `green` runs only that
  one (a different --cmd, or a lock without one, exits 2). trekexecute D1
  calls `green` with no command, which also removes the quoting hazard.
- plan-gate --accept-open worked at round 1 and passed the gate; it now
  refuses while rounds are left.
- findings-schema --repo-root swallowed a following flag, or took a root
  that does not exist, and silently skipped the line check: exit 2.
- brief-review-stamp hashed only the body, so a changed `framing` or
  `research_topics` stayed "current"; it now covers the reviewed
  frontmatter too (only the post-review stamps are left out).
- red-proof red_kind read an assertion message naming SyntaxError as a
  load error; the signatures are now specific.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:17:45 +02:00

125 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/validators/brief-review-stamp.mjs
// The brief-review stamp: /trekplan reviews a brief again only when it changed.
//
// Operator decision 2026-10-08 (voyage-lekerepo trial, point 14): /trekplan
// Phase 4b re-ran brief-reviewer right after /trekbrief had run it on the same
// text. Now /trekbrief, when its brief-reviewer gate passes, stamps the brief's
// BODY (everything after the frontmatter) into two optional frontmatter fields:
//
// brief_reviewed_hash: "sha256:<64 hex>"
// brief_reviewed_at: "<ISO-8601>"
//
// /trekplan Phase 4b runs `--check`: `current` skips the second review,
// `stale` (the brief changed after the review) or `absent` (no stamp: a gate
// that did not pass, or a brief from another producer) runs it as before.
// The hash covers the body AND the frontmatter the reviewer judges
// (`framing`, `research_topics`, ...): an independent review showed a
// body-only hash staying current after `framing` changed. Only the stamps
// written after the review are left out (this one and the intent approval
// marker); CRLF and trailing whitespace do not count.
//
// WHAT THE STAMP DOES NOT PROVE: the same trust class as the intent marker
// (lib/validators/intent-approval.mjs) — any session that can edit the brief
// can write it. It is a trace that the review ran against this body, never a
// signature; the worst a forged stamp does is skip one duplicate review.
//
// CLI:
// node lib/validators/brief-review-stamp.mjs --stamp <brief.md> exit 0 stamped, 1 refused
// node lib/validators/brief-review-stamp.mjs --check [--json] <brief.md>
// exit 0 = current (skip the review); 1 = stale or absent (review); 2 = usage / unreadable
import { createHash } from 'node:crypto';
import { readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
export const REVIEWED_HASH_FIELD = 'brief_reviewed_hash';
export const REVIEWED_AT_FIELD = 'brief_reviewed_at';
const FRONTMATTER = /^(?---\r?\n)([\s\S]*?)(\r?\n---)(?:\r?\n|$)/;
function split(text) {
const m = FRONTMATTER.exec(String(text ?? ''));
if (!m) return null;
return { m, front: m[2], body: String(text).slice(m.index + m[0].length) };
}
// Frontmatter written AFTER the review; everything else is what was reviewed.
const POST_REVIEW_FIELDS = Object.freeze([REVIEWED_HASH_FIELD, REVIEWED_AT_FIELD, 'intent_approved_hash', 'intent_approved_at']);
const normText = (t) => String(t ?? '').replace(/\r\n?/g, '\n').split('\n').map((l) => l.replace(/\s+$/, '')).join('\n').replace(/\n+$/, '');
/** sha256 over the reviewed frontmatter + the body: CRLF -> LF, trailing whitespace dropped. */
export function computeReviewHash(front, body) {
const kept = normText(front).split('\n').filter((l) => !POST_REVIEW_FIELDS.some((f) => l.startsWith(`${f}:`))).join('\n');
return `sha256:${createHash('sha256').update(`${kept}\n---\n${normText(body)}`, 'utf8').digest('hex')}`;
}
function fieldValue(front, name) {
const line = front.split(/\r?\n/).find((l) => l.startsWith(`${name}:`));
if (!line) return null;
return line.slice(name.length + 1).trim().replace(/^["']|["']$/g, '');
}
/** @returns {{ stamped: boolean, text: string, hash?: string, reason?: string }} */
export function stampBriefReview(text, now = new Date()) {
const parts = split(text);
if (!parts) return { stamped: false, text, reason: 'no frontmatter block' };
const hash = computeReviewHash(parts.front, parts.body);
const kept = parts.front.split(/\r?\n/).filter((l) =>
!l.startsWith(`${REVIEWED_HASH_FIELD}:`) && !l.startsWith(`${REVIEWED_AT_FIELD}:`));
kept.push(`${REVIEWED_HASH_FIELD}: "${hash}"`, `${REVIEWED_AT_FIELD}: "${now.toISOString()}"`);
const { m } = parts;
const out = m[1] + kept.join('\n') + m[3] + '\n' + parts.body;
return { stamped: true, text: out, hash };
}
/** @returns {{ state: 'current'|'stale'|'absent', stamped: string|null, actual: string|null }} */
export function checkBriefReview(text) {
const parts = split(text);
if (!parts) return { state: 'absent', stamped: null, actual: null };
const stamped = fieldValue(parts.front, REVIEWED_HASH_FIELD);
const actual = computeReviewHash(parts.front, parts.body);
if (!stamped) return { state: 'absent', stamped: null, actual };
return { state: stamped === actual ? 'current' : 'stale', stamped, actual };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const args = process.argv.slice(2);
const mode = args.includes('--stamp') ? 'stamp' : args.includes('--check') ? 'check' : null;
const path = args.find((a) => !a.startsWith('--'));
if (!mode || !path) {
process.stderr.write('Usage: brief-review-stamp.mjs --stamp <brief.md> | --check [--json] <brief.md>\n');
process.exit(2);
}
let text;
try {
text = readFileSync(path, 'utf-8');
} catch (e) {
process.stderr.write(`brief-review-stamp: cannot read ${path}: ${e.message}\n`);
process.exit(2);
}
if (mode === 'stamp') {
const r = stampBriefReview(text);
if (!r.stamped) {
process.stderr.write(`brief-review-stamp: not stamped: ${r.reason}\n`);
process.exit(1);
}
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, r.text);
renameSync(tmp, path);
process.stdout.write(`brief-review-stamp: stamped ${path} (${r.hash})\n`);
process.exit(0);
}
const r = checkBriefReview(text);
if (args.includes('--json')) process.stdout.write(JSON.stringify(r) + '\n');
else process.stdout.write(`brief-review-stamp: ${r.state} ${path}\n`);
process.exit(r.state === 'current' ? 0 : 1);
}