voyage/lib/validators/brief-review-stamp.mjs
Kjell Tore Guttormsen 29b4ac91b3
fix: round 7 of the release review of 6.3.0, for 6.3.1
Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.

- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
  text says so (red-proof, trekexecute Sub-step B, step-implementer,
  HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
  already in the tree when the step began no longer counts against it, and a
  plan placed outside .claude/ reads the same. The round-6 test that had the
  diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
  (", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
  the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
  not "could not start"; the release check tells a timeout from output past
  its buffer and from another signal. One reading of a stop
  (criteria-runner stoppedBy) for red proof, the criteria runner and the
  release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
  command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
  the realpath guard and the 20 modules still on the file:// guard, which
  exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
  three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
  passed at once; it fails when 127 is read as could-not-run); the prose now
  names the git revision `@~1` as the one leading-@ exception.

Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:11:57 +02:00

117 lines
5.4 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, renameSync, writeFileSync } from 'node:fs';
import { isMain } from '../util/is-main.mjs';
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 };
}
if (isMain(import.meta.url)) {
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);
}