voyage/lib/validators/brief-review-stamp.mjs
Kjell Tore Guttormsen 1b5218e8f4
fix(plan): every exploration agent runs; the brief is reviewed again only when it changed
voyage-lekerepo trial (2026-10-06) point 14, operator decision 2026-10-08
(«alle agenter, ny briefsjekk bare ved endring»):

- trekplan: the "Adaptive" hard rule (a 10-file project does not need 7
  agents) contradicted Phase 5 ("All agents run for all codebase sizes");
  it is gone, in line with quality before cost.
- brief-review-stamp.mjs: /trekbrief stamps the brief body when its
  brief-reviewer gate passes; /trekplan Phase 4b skips its own review only
  while the stamp is current. Stale (edited) or absent (another producer,
  or a gate that did not pass) reviews as before. Additive optional
  frontmatter fields, documented in Handover 1.

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

117 lines
5 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 body changed after the review) or `absent` (no stamp: a gate
// that did not pass, or a brief from another producer) runs it as before.
// Frontmatter is outside the hash, so the intent approval stamp written after
// the review keeps it current; 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) };
}
/** sha256 over the body: CRLF -> LF, trailing whitespace per line and at the end dropped. */
export function computeBodyHash(body) {
const norm = String(body ?? '').replace(/\r\n?/g, '\n').split('\n').map((l) => l.replace(/\s+$/, '')).join('\n').replace(/\n+$/, '');
return `sha256:${createHash('sha256').update(norm, '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 = computeBodyHash(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 = computeBodyHash(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);
}