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>
117 lines
5.4 KiB
JavaScript
117 lines
5.4 KiB
JavaScript
// 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);
|
||
}
|