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>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-08 07:17:45 +02:00
commit 5f9faeed4a
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
12 changed files with 192 additions and 35 deletions

View file

@ -10,10 +10,13 @@
// 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
// `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.
// Frontmatter is outside the hash, so the intent approval stamp written after
// the review keeps it current; CRLF and trailing whitespace do not count.
// 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
@ -40,10 +43,15 @@ function split(text) {
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')}`;
// 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) {
@ -56,7 +64,7 @@ function fieldValue(front, name) {
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 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()}"`);
@ -70,7 +78,7 @@ 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);
const actual = computeReviewHash(parts.front, parts.body);
if (!stamped) return { state: 'absent', stamped: null, actual };
return { state: stamped === actual ? 'current' : 'stale', stamped, actual };
}

View file

@ -25,8 +25,8 @@
// After the operator's explicit yes to "build as is": records the open
// MAJORs of the current round as accepted (`accepted_open` in the verdict
// block, bound to the round and the plan hash). 0 = written; 1 = refused
// (a BLOCKER is open, no MAJOR is open, or the verdict is stale); 2 = could
// not run.
// (rounds left, a BLOCKER is open, no MAJOR is open, or the verdict is
// stale); 2 = could not run.
//
// Open MAJORs (operator decision 2026-10-08, voyage-lekerepo trial point 6):
// they used to pass as warnings, and after the last round there was no way to
@ -271,6 +271,10 @@ export function acceptOpen(rawText, opts = {}) {
if (v.findings.some(f => sevOf(f) !== 'major' && sevOf(f) !== 'minor')) return { code: 1, message: 'a BLOCKER is open; a blocker is never accepted' };
const majors = v.findings.filter(f => sevOf(f) === 'major');
if (majors.length === 0) return { code: 1, message: 'no MAJOR is open; nothing to accept' };
// Only the last round: with rounds left the answer is to revise, not accept
// (found by the independent review: acceptance at round 1 passed the gate).
const left = v.max_rounds + (v.extra_rounds ?? 0) - v.round;
if (left > 0) return { code: 1, message: `${left} critic round(s) left: revise the plan and run the next round; acceptance is only for after the last round` };
const accepted = {
...v,
accepted_open: {