feat(hooks): the merge question says how the run was tested and how it is undone

This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 21:23:24 +01:00
commit ec5b6483ec
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
10 changed files with 1371 additions and 6 deletions

View file

@ -2665,7 +2665,18 @@ git worktree list --porcelain
commits are already on main. Emit `main-merge-approved` and say in the
report that the review ran after the commits landed on main and could not
hold them back: run on a branch when the review should gate the merge.
- **Another branch:** merge it into main in ONE Bash call, with the values
- **Another branch:** first, before the merge call, write the run evidence
the guard's question reads, in a Bash call of its own:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/review/merge-evidence.mjs write --repo . --branch "{run branch}" --project "{project_dir}" --json
```
It derives every field from the run's artifacts (the branch head, the
plan's `End-to-end:` command and `progress.json`, the verdict gate on
`review.md`, the plan's or the CHANGELOG's door) and writes
`<git common dir>/voyage/merge-evidence/<branch head sha>.json`, internal
and never tracked; the guard reads it into a "Tested by" and an "Undone by"
line of its merge question, and its answer stays "ask". On `"written": false` (exit 1), print its `reason` in one line and continue: the question then says "no run evidence".
Then merge it into main in ONE Bash call, with the values
written out as literals (the guard reads the command; it cannot follow a
shell variable). When `git worktree list` shows main checked out in a
worktree, merge there:

View file

@ -639,3 +639,25 @@ The `next-session-prompt-validator` (`lib/validators/next-session-prompt-validat
| 7. session-state (multi-session resume) | required-fields + status enum + drift-WARN extras | this plugin | low — readers tolerate unknown keys; writers are owned by trekexecute Phase 8 + helper command |
When extending the plugin or adding a new pipeline stage, follow the same pattern: produce an artifact with a versioned frontmatter (or `schema_version` for JSON), write a validator under `lib/validators/`, add fixtures under `tests/fixtures/`, and add an entry to this document.
---
## Internal files
Files Voyage writes for its own use. They are no handover: no other stage reads
them, they carry no version promise, and they may change in any release.
**Merge evidence** (fix round 4.5b2 part B). `/trekexecute` Phase 8 runs
`lib/review/merge-evidence.mjs write` before it merges a run's branch into
main. It writes `<git common dir>/voyage/merge-evidence/<branch head sha>.json`:
inside the git directory, so it is never tracked and never published, and
shared by every worktree of the repo, so the guard finds it from main's tree.
The Bash guard reads it with `hooks/lib/merge-evidence.mjs` into a "Tested by"
and an "Undone by" line of its merge question; the guard's answer stays "ask",
and a missing, stale, foreign or unreadable file reads "no run evidence" or
"not measured". Fields: `v` (`1`), `branch`, `branch_head_sha`, `written_at`,
`end_to_end` (`{command, exit}`; `exit` is `0` when `progress.json` says
`verified`, else `null`), `verification` (`{passed, total}`), `review`
(`{verdict, sha}`) and `door` (`{type: one-way | two-way | null, text}`).
Files older than 30 days are removed when the next one is written. It is a new
internal file, not a field of `progress.json`: it is not part of Handover 5.

View file

@ -169,7 +169,7 @@ function classifyGit(call, ctx) {
return classifyPush(args, ctx);
case 'merge':
if (hasFlag(args, '--abort', '--continue', '--quit')) return [];
return MAIN.test(ctx.branch || '') ? [{ cls: 'merge', name: 'merge into main', kind: 'merge' }] : [];
return MAIN.test(ctx.branch || '') ? [{ cls: 'merge', name: 'merge into main', kind: 'merge', merge: { args } }] : [];
case 'tag': {
if (hasFlag(args, '-l', '--list', '-v', '--verify') || shortHas(args, 'l')) return [];
if (hasFlag(args, '-d', '--delete')) return [{ cls: 'release', name: 'delete a tag', kind: null }];
@ -323,7 +323,7 @@ function gitDirOf(found) {
}
// A worktree's git dir names the shared one in `commondir`; refs live there.
function commonDirOf(gitDir) {
export function commonDirOf(gitDir) {
try {
const c = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
return isAbsolute(c) ? c : resolve(gitDir, c);

View file

@ -0,0 +1,182 @@
// hooks/lib/merge-evidence.mjs
// The merge question's run evidence (fix round 4.5b2 part B, Step 11). Before
// it merges a run's branch into main, /trekexecute Phase 8 writes an evidence
// file (lib/review/merge-evidence.mjs write) under the git common dir, named
// by the branch's head sha. The Bash guard (hooks/scripts/pre-bash-executor.mjs)
// reads it here into two lines of the question it already asks:
// Tested by: End-to-end `<cmd>` exit <n>; Verification <passed>/<total>; review <verdict> at <sha8>.
// Undone by: `git revert -m 1 <merge>` (two-way). | (one-way: <text>). | (door not stated).
//
// Fail-safe by construction: the guard's `ask` decision is made before this
// runs, and this only returns text. It reads the filesystem only (no process
// is spawned, no module outside hooks/ is imported), never follows a symlink,
// reads at most 64 KiB of a regular file, and never throws: any error, a
// missing or foreign file, a head sha that does not match, or a field of the
// wrong type reads "no run evidence" or "not measured", never a green line.
// Text taken from the file prints with every non-printable or non-ASCII
// character as `?`.
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync } from 'node:fs';
import { isAbsolute, join, resolve } from 'node:path';
import { commonDirOf } from './git-approval.mjs';
export const MAX_EVIDENCE_BYTES = 64 * 1024;
const NO_EVIDENCE = 'Tested by: no run evidence.\nUndone by: no run evidence.';
const UNREADABLE = 'Tested by: not measured (the run evidence file could not be read).\nUndone by: not measured.';
const NOT = 'not measured';
const UNDO = 'Undone by: `git revert -m 1 <merge>`';
// `git merge` options that take a separate value: their value is not the branch.
const MERGE_VALUED = ['-m', '-F', '-s', '-X', '--message', '--file', '--strategy', '--strategy-option', '--into-name'];
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
const VERDICTS = ['ALLOW', 'WARN', 'BLOCK'];
/** Printable ASCII only; anything else prints as `?`. Cut to `max` characters. */
function clean(s, max = 200) {
const out = String(s).replace(/[^\x20-\x7e]/g, '?');
return out.length > max ? `${out.slice(0, max)}...` : out;
}
/** The merged branch: the one positional after the valued options; else null. */
export function mergedBranch(mergeArgs) {
if (!Array.isArray(mergeArgs)) return null;
const pos = [];
for (let i = 0; i < mergeArgs.length; i += 1) {
const a = mergeArgs[i];
if (typeof a !== 'string') return null;
if (MERGE_VALUED.includes(a)) { i += 1; continue; }
if (!a.startsWith('-')) pos.push(a);
}
if (pos.length !== 1) return null;
const name = pos[0];
// A plain branch name only: no `..`, no control character, no leading `-`.
if (name === '' || name.includes('..') || /[\x00-\x1f\x7f]/.test(name) || name.startsWith('-')) return null;
return name;
}
/** The git dir of the repo whose top is `root` (a `.git` dir, or a `.git` file naming one). */
// Same reading as gitDirOf in hooks/lib/git-approval.mjs (not exported there).
function gitDirAt(root) {
const dotGit = join(root, '.git');
let st;
try { st = lstatSync(dotGit); } catch { return null; }
if (st.isDirectory()) return dotGit;
if (!st.isFile()) return null;
const m = readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
if (!m) return null;
return isAbsolute(m[1]) ? m[1] : resolve(root, m[1]);
}
/** A small regular file's text, or null (missing, not regular, too big). */
function smallRegular(path, max) {
let st;
try { st = lstatSync(path); } catch { return null; }
if (!st.isFile() || st.size > max) return null;
return readFileSync(path, 'utf8');
}
/** The head sha of `refs/heads/<branch>`: the loose ref, else `packed-refs`; else null. */
function headSha(common, branch) {
const loose = smallRegular(join(common, 'refs', 'heads', ...branch.split('/')), 4096);
if (loose !== null) {
const sha = loose.trim();
if (SHA_RE.test(sha)) return sha;
}
const packed = smallRegular(join(common, 'packed-refs'), 16 * 1024 * 1024);
if (packed === null) return null;
for (const line of packed.split('\n')) {
const m = line.match(/^([0-9a-f]{40}|[0-9a-f]{64}) (.+)$/);
if (m && m[2] === `refs/heads/${branch}`) return m[1];
}
return null;
}
/**
* The evidence file's text: 'missing', null (there, but not a regular file of
* at most 64 KiB), or the text. Never follows a symlink and never opens a FIFO
* for a blocking read.
*/
function readEvidence(path) {
let st;
try {
st = lstatSync(path);
} catch (e) {
return e && e.code === 'ENOENT' ? 'missing' : null;
}
if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_EVIDENCE_BYTES) return null;
const flags = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
let fd;
try {
fd = openSync(path, flags);
const fst = fstatSync(fd);
if (!fst.isFile() || fst.size > MAX_EVIDENCE_BYTES) return null;
const buf = Buffer.alloc(MAX_EVIDENCE_BYTES + 1);
let n = 0;
for (;;) {
const got = readSync(fd, buf, n, buf.length - n, null);
if (got === 0) break;
n += got;
if (n > MAX_EVIDENCE_BYTES) return null;
}
return buf.subarray(0, n).toString('utf8');
} catch {
return null;
} finally {
if (fd !== undefined) { try { closeSync(fd); } catch { /* closed */ } }
}
}
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
const isCount = (n) => Number.isInteger(n) && n >= 0;
function testedLine(ev) {
const e2e = isObj(ev.end_to_end) ? ev.end_to_end : {};
const cmd = typeof e2e.command === 'string' && e2e.command.trim() !== '' ? `\`${clean(e2e.command)}\`` : `command ${NOT}`;
const exit = Number.isInteger(e2e.exit) ? String(e2e.exit) : NOT;
const ver = isObj(ev.verification) ? ev.verification : {};
const count = isCount(ver.passed) && isCount(ver.total) && ver.passed <= ver.total ? `${ver.passed}/${ver.total}` : NOT;
const rev = isObj(ev.review) ? ev.review : {};
const verdict = VERDICTS.includes(rev.verdict) ? rev.verdict : NOT;
const sha = typeof rev.sha === 'string' && SHA_RE.test(rev.sha) ? rev.sha.slice(0, 8) : NOT;
return `Tested by: End-to-end ${cmd} exit ${exit}; Verification ${count}; review ${verdict} at ${sha}.`;
}
function undoneLine(ev) {
const door = isObj(ev.door) ? ev.door : {};
if (door.type === 'two-way') return `${UNDO} (two-way).`;
if (door.type === 'one-way' && typeof door.text === 'string' && door.text.trim() !== '') {
return `${UNDO} (one-way: ${clean(door.text.trim())}).`;
}
return `${UNDO} (door not stated).`;
}
/**
* The two evidence lines for a merge, for the guard's question.
* @param {string} repoRoot top of the repo the merge runs in
* @param {string[]} mergeArgs the tokens after `merge`
* @returns {string} always two lines; never throws
*/
export function evidenceLines(repoRoot, mergeArgs) {
try {
const branch = mergedBranch(mergeArgs);
if (!branch || typeof repoRoot !== 'string' || repoRoot === '') return NO_EVIDENCE;
const gitDir = gitDirAt(repoRoot);
if (!gitDir) return NO_EVIDENCE;
const common = commonDirOf(gitDir);
const sha = headSha(common, branch);
if (!sha) return NO_EVIDENCE;
const text = readEvidence(join(common, 'voyage', 'merge-evidence', `${sha}.json`));
if (text === 'missing') return NO_EVIDENCE;
if (text === null) return UNREADABLE;
let ev;
try { ev = JSON.parse(text); } catch { return UNREADABLE; }
if (!isObj(ev)) return UNREADABLE;
// A file of another format, another branch or another head is not this run's evidence.
if (ev.v !== 1 || ev.branch !== branch || ev.branch_head_sha !== sha) return NO_EVIDENCE;
return `${testedLine(ev)}\n${undoneLine(ev)}`;
} catch {
return UNREADABLE;
}
}

View file

@ -779,13 +779,32 @@ if (approvals.length > 0) {
const unlogged = loggable.length > 0 && !recorded
? ' Note: this approval will not be logged (Voyage could not record the question).' : '';
const advisory = (warnings.length > 0 ? ` Advisory: ${warnings.map((w) => w.trim()).join(' ')}` : '') + unlogged;
process.stdout.write(JSON.stringify({
const answer = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'ask',
permissionDecisionReason: askReason(approvals) + advisory,
},
}));
};
// BEGIN merge-evidence
// A merge into main also says how the run was tested and how it is undone,
// read from the evidence file /trekexecute Phase 8 wrote (hooks/lib/merge-evidence.mjs).
// The decision above is already 'ask'; this block only adds text to the
// reason, and a reader that fails to load or throws leaves a note, never a
// green line.
const merge = approvals.find((m) => m.cls === 'merge');
if (merge) {
let evidence;
try {
const { evidenceLines } = await import('../lib/merge-evidence.mjs');
evidence = ' ' + evidenceLines(merge.subject, merge.merge && merge.merge.args);
} catch {
evidence = ' (run evidence not loaded)';
}
answer.hookSpecificOutput.permissionDecisionReason += evidence;
}
// END merge-evidence
process.stdout.write(JSON.stringify(answer));
}
process.exit(0);

View file

@ -0,0 +1,211 @@
// lib/review/merge-evidence.mjs
// The run evidence behind the merge question (fix round 4.5b2 part B, Step 11).
// /trekexecute Phase 8 runs this before it merges a run's branch into main.
// It writes one internal file, never tracked and never published:
// <git common dir>/voyage/merge-evidence/<branch head sha>.json
// The Bash guard (hooks/scripts/pre-bash-executor.mjs, through
// hooks/lib/merge-evidence.mjs) reads it into the two lines of its merge
// question: how the run was tested, and how the merge is undone.
//
// Every field is derived from artifacts, never from an argument the model types:
// - branch_head_sha: `git rev-parse --verify refs/heads/<branch>`;
// - end_to_end: the plan's first `End-to-end:` backticked command, with exit
// 0 when progress.json says `end_to_end: verified`, else null;
// - verification: progress.json `plan_verification.summary` passed/total;
// - review: the verdict gate (`decideVerdictFile` with the repo, R6 first)
// on {project}/review.md, and its `scope_sha_end` (`reviewedSha`); a review
// the gate does not decide (exit 2) has a null verdict;
// - door: the plan's `## Doors` list, else the CHANGELOG's first section when
// it holds exactly one door line, else null.
// A field that cannot be derived is null, and the guard prints "not measured".
// Before writing, evidence files in that directory older than 30 days are
// removed (regular files named `<hex>.json` only). The file is written
// atomically (a temp file in the same directory, then rename).
//
// CLI: node lib/review/merge-evidence.mjs write --repo <d> --branch <name> --project <dir> --json
// prints {"written": true, "path"} (exit 0) or {"written": false, "reason"} (exit 1).
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { isAbsolute, join, resolve } from 'node:path';
import { decideVerdictFile, EXIT } from './verdict-gate.mjs';
import { reviewedSha } from './reviewed-commit.mjs';
import { parseDoors } from '../util/doors.mjs';
import { isMain } from '../util/is-main.mjs';
export const EVIDENCE_VERSION = 1;
export const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const EVIDENCE_NAME_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})\.json$/;
const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
/** Why `name` is not a branch name this writer accepts, or null. */
export function branchNameProblem(name) {
if (typeof name !== 'string' || name === '') return 'no --branch given';
if (name.includes('..')) return `branch name ${JSON.stringify(name)} contains ..`;
if (/[\x00-\x1f\x7f]/.test(name)) return `branch name ${JSON.stringify(name)} contains a control character`;
if (name.startsWith('-')) return `branch name ${JSON.stringify(name)} starts with -`;
return null;
}
/** The plan's first `End-to-end:` field command (its first backticked span), or null. */
export function firstEndToEnd(planText) {
const m = /^[ \t]*(?:[-*][ \t]+)?(?:\*\*)?End-to-end:(?:\*\*)?[ \t]+`([^`\n]+)`/m.exec(String(planText ?? ''));
return m ? m[1].trim() : null;
}
/** The lines of the `## <title>` section of `text`, heading excluded, or null. */
function h2Section(text, title) {
const lines = String(text ?? '').split('\n');
const start = lines.findIndex((l) => new RegExp(`^##\\s+${title}\\s*$`).test(l));
if (start === -1) return null;
let end = lines.length;
for (let i = start + 1; i < lines.length; i += 1) if (/^#{1,2}\s/.test(lines[i])) { end = i; break; }
return lines.slice(start + 1, end).join('\n');
}
/** The CHANGELOG's first `## ` section, heading excluded, or null. */
function firstSection(text) {
const lines = String(text ?? '').split('\n');
const start = lines.findIndex((l) => /^##\s/.test(l));
if (start === -1) return null;
let end = lines.length;
for (let i = start + 1; i < lines.length; i += 1) if (/^##\s/.test(lines[i])) { end = i; break; }
return lines.slice(start + 1, end).join('\n');
}
/**
* The door: the plan's `## Doors` list (one-way wins over two-way), else the
* CHANGELOG's first section when it holds exactly one door line, else null.
* @returns {{type: 'one-way'|'two-way'|null, text: string|null}}
*/
export function doorOf(planText, changelogText) {
const planDoors = h2Section(planText, 'Doors');
if (planDoors !== null) {
const { entries, bad } = parseDoors(`### Doors\n${planDoors}`);
if (entries.length > 0 && bad.length === 0) {
const oneWay = entries.filter((e) => e.door === 'one-way');
if (oneWay.length > 0) return { type: 'one-way', text: oneWay.map((e) => e.text).join('; ') };
return { type: 'two-way', text: entries.map((e) => e.text).join('; ') };
}
}
const first = firstSection(changelogText);
if (first !== null) {
const { entries, bad } = parseDoors(first);
if (entries.length === 1 && bad.length === 0) return { type: entries[0].door, text: entries[0].text };
}
return { type: null, text: null };
}
function git(repo, args) {
return spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
}
const readText = (p) => { try { return existsSync(p) ? readFileSync(p, 'utf8') : null; } catch { return null; } };
/** Remove evidence files older than MAX_AGE_MS: regular files named `<hex>.json` only. */
export function removeOld(dir, now = Date.now()) {
let names;
try { names = readdirSync(dir); } catch { return []; }
const removed = [];
for (const name of names) {
if (!EVIDENCE_NAME_RE.test(name)) continue;
const p = join(dir, name);
try {
const st = lstatSync(p);
if (st.isFile() && now - st.mtimeMs > MAX_AGE_MS) { unlinkSync(p); removed.push(name); }
} catch { /* gone or unreadable: left alone */ }
}
return removed;
}
/**
* Derive the evidence for `branch` in `repo` from the run's artifacts in
* `projectDir`, and write it.
* @returns {{written: true, path: string} | {written: false, reason: string}}
*/
export function writeEvidence({ repo, branch, projectDir, now = Date.now() } = {}) {
const problem = branchNameProblem(branch);
if (problem) return { written: false, reason: problem };
if (typeof repo !== 'string' || repo === '' || !existsSync(repo)) return { written: false, reason: `--repo ${repo} does not exist` };
if (typeof projectDir !== 'string' || projectDir === '') return { written: false, reason: 'no --project given' };
const repoDir = resolve(repo);
const project = resolve(projectDir);
const head = git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
const sha = head.status === 0 ? head.stdout.trim() : '';
if (!SHA_RE.test(sha)) return { written: false, reason: `no branch ${branch} in ${repoDir}` };
const cd = git(repoDir, ['rev-parse', '--git-common-dir']);
const rawCommon = cd.status === 0 ? cd.stdout.trim() : '';
if (!rawCommon) return { written: false, reason: `git rev-parse --git-common-dir failed in ${repoDir}` };
const common = isAbsolute(rawCommon) ? rawCommon : resolve(repoDir, rawCommon);
const top = git(repoDir, ['rev-parse', '--show-toplevel']);
const topDir = top.status === 0 && top.stdout.trim() ? top.stdout.trim() : repoDir;
const planText = readText(join(project, 'plan.md'));
let progress = null;
try { progress = JSON.parse(readText(join(project, 'progress.json')) ?? 'null'); } catch { progress = null; }
if (!progress || typeof progress !== 'object' || Array.isArray(progress)) progress = {};
const summary = progress.plan_verification && typeof progress.plan_verification === 'object'
? progress.plan_verification.summary : null;
const count = (n) => (Number.isInteger(n) && n >= 0 ? n : null);
const reviewPath = join(project, 'review.md');
const reviewText = readText(reviewPath);
let verdict = null;
if (reviewText !== null) {
try {
const gate = decideVerdictFile(reviewPath, { repo: repoDir });
if (gate.exit === EXIT.PASS) verdict = gate.verdict ?? null;
else if (gate.exit === EXIT.BLOCK) verdict = 'BLOCK';
} catch { verdict = null; }
}
const evidence = {
v: EVIDENCE_VERSION,
branch,
branch_head_sha: sha,
written_at: new Date(now).toISOString(),
end_to_end: { command: firstEndToEnd(planText), exit: progress.end_to_end === 'verified' ? 0 : null },
verification: {
passed: summary && typeof summary === 'object' ? count(summary.passed) : null,
total: summary && typeof summary === 'object' ? count(summary.total) : null,
},
review: { verdict, sha: reviewText !== null ? (reviewedSha(reviewText) || null) : null },
door: doorOf(planText, readText(join(topDir, 'CHANGELOG.md'))),
};
const dir = join(common, 'voyage', 'merge-evidence');
const path = join(dir, `${sha}.json`);
const tmp = join(dir, `.${sha}.json.${process.pid}.tmp`);
try {
mkdirSync(dir, { recursive: true });
removeOld(dir, now);
writeFileSync(tmp, `${JSON.stringify(evidence, null, 2)}\n`, { flag: 'wx' });
renameSync(tmp, path);
} catch (e) {
try { rmSync(tmp, { force: true }); } catch { /* nothing to remove */ }
return { written: false, reason: `cannot write ${path}: ${e.message}` };
}
return { written: true, path };
}
function opt(args, name) {
const i = args.indexOf(name);
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
}
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
let result;
if (args[0] !== 'write') {
result = { written: false, reason: 'usage: merge-evidence.mjs write --repo <d> --branch <name> --project <dir> --json' };
} else {
try {
result = writeEvidence({ repo: opt(args, '--repo'), branch: opt(args, '--branch'), projectDir: opt(args, '--project') });
} catch (e) {
result = { written: false, reason: `merge-evidence crashed: ${e.message}` };
}
}
process.stdout.write(`${JSON.stringify(result)}\n`);
process.exit(result.written ? 0 : 1);
}

View file

@ -0,0 +1,368 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b2-b.sh
# End to end for fix round 4.5b2, part B (plan
# .claude/projects/2026-10-31-review-gate-4-5b2, Step 11, End-to-end:). On
# throwaway fixture repos it runs the verdict gate the way /trekexecute's
# merge lock does (node lib/review/verdict-gate.mjs --json <review> --repo <fx>)
# and the Bash guard the way Claude Code does:
# 1. an unanswered counting MAJOR keeps the gate at exit 2; after
# review-answers.mjs record --answer accepted it exits 0;
# 2. a MAJOR answered "fixed" keeps exit 2 (fixed-pending), and a round-2
# review (review-rounds.mjs prepare, then review-finalize.mjs) keeps
# exit 2 while the finding is carried and exits 0 once it is resolved;
# 3. a pre-existing BLOCKER (a line the range did not change) exits 1;
# 4. merge-evidence.mjs write records a fixture run, and a
# `git merge --no-ff feat` hook input into hooks/scripts/pre-bash-executor.mjs
# answers "ask" with the Tested by and Undone by lines.
# Every fixture keeps HEAD at the reviewed commit when it asks the gate (R6).
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per check and
# "e2e: ok" at the end, exit 0; else exit 1. Starts nothing but node, git and
# the usual file tools. bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
GATE="$ROOT/lib/review/verdict-gate.mjs"
ANSWERS="$ROOT/lib/review/review-answers.mjs"
ROUNDS="$ROOT/lib/review/review-rounds.mjs"
FINALIZE="$ROOT/lib/review/review-finalize.mjs"
FILL="$ROOT/lib/review/fill-finding-ids.mjs"
WRITER="$ROOT/lib/review/merge-evidence.mjs"
unset CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b2-b.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
export LC_ALL=C
export LANG=C
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
TRAILER='Co-Authored-By: Claude Test <noreply@example.invalid>'
PROJ_REL=.claude/projects/2026-11-02-e2e
mkdir -p "$TMP/hooks-none" || { echo "FAIL - setup: cannot make the temp directories"; exit 1; }
# A review_version 1.2 review with @@ID_<n>@@ placeholders, filled afterwards
# by fill-finding-ids.mjs as /trekreview Phase 7 fills them.
# node mkreview.mjs <out> <start> <end> <findings JSON> [rstep]: with rstep,
# finding 1 gets a "### Step R1" (every BLOCKER gets one anyway).
cat > "$TMP/mkreview.mjs" <<'EOF'
import { writeFileSync } from 'node:fs';
const [out, start, end, findingsJson, rstep] = process.argv.slice(2);
const list = JSON.parse(findingsJson).map((f, i) => ({
id: `@@ID_${i + 1}@@`, severity: f.severity, rule_key: f.rule_key || 'MISSING_TEST', file: f.file, line: f.line,
brief_ref: 'SC1', title: f.title, detail: f.detail || `detail ${i + 1}`, recommended_action: 'Fix it.',
}));
const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
for (const f of list) counts[f.severity] += 1;
const verdict = counts.BLOCKER > 0 ? 'BLOCK' : counts.MAJOR > 0 ? 'WARN' : 'ALLOW';
const sections = ['BLOCKER', 'MAJOR', 'MINOR', 'SUGGESTION'].map((sev) => {
const of = list.filter((f) => f.severity === sev);
if (of.length === 0) return '';
return `## Findings (${sev})\n\n${of.map((f) => `### ${f.id}\n\n- file: ${f.file}\n- line: ${f.line}\n- rule_key: ${f.rule_key}\n- title: ${f.title}\n`).join('\n')}\n`;
}).join('');
const stepFor = list.filter((f, i) => f.severity === 'BLOCKER' || (rstep === 'rstep' && i === 0));
const steps = stepFor.map((f, i) => `### Step R${i + 1}: fix ${f.title}\n\n- **Files:** \`${f.file}\`\n- **Source finding:** \`${f.id}\`\n- **Test first:** a test that fails today\n- **Verify:** re-run the review\n- **On failure:** escalate\n`).join('\n');
const json = JSON.stringify({ verdict, counts, allow_blocked_by: [], findings: list }, null, 2);
const fmList = list.length ? `\n${list.map((f) => ` - ${f.id}`).join('\n')}` : ' []';
writeFileSync(out, `---
type: trekreview
review_version: "1.2"
task: "t"
slug: e2e
project_dir: .claude/projects/2026-11-02-e2e/
brief_path: .claude/projects/2026-11-02-e2e/brief.md
scope_sha_start: ${start}
scope_sha_end: ${end}
reviewed_files_count: 2
verdict: ${verdict}
findings:${fmList}
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| a.mjs | deep-review | default |
${sections}## Declined to Judge
None.
Trimmed: 0
## Remediation Steps
${steps || 'None.\n'}
## Remediation Summary
x
\`\`\`json
${json}
\`\`\`
`);
EOF
# jfield <file> <expr>: prints the JS expression <expr> over the parsed JSON
# value `j` read from <file> (empty when it does not parse).
jfield() {
node -e '
const fs = require("fs");
let j;
try { j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) { process.exit(0); }
const v = Function("j", "return (" + process.argv[2] + ");")(j);
process.stdout.write(v === undefined || v === null ? "" : String(v));
' "$1" "$2"
}
# reviewjson <review> <expr>: <expr> over the review's trailing JSON block `j`.
reviewjson() {
node -e '
const fs = require("fs");
const t = fs.readFileSync(process.argv[1], "utf8");
const j = JSON.parse(t.slice(t.lastIndexOf("```json\n") + 8, t.lastIndexOf("```")));
const v = Function("j", "return (" + process.argv[2] + ");")(j);
process.stdout.write(v === undefined || v === null ? "" : String(v));
' "$1" "$2"
}
# ten <tag> [<line> <text>]: ten lines `export const <tag><n> = <n>;`, line <line> replaced.
ten() {
tag="$1"
at="${2:-0}"
txt="${3:-}"
n=1
while [ "$n" -le 10 ]; do
if [ "$n" -eq "$at" ]; then echo "$txt"; else echo "export const $tag$n = $n;"; fi
n=$((n + 1))
done
}
# mkrepo <dir>: a git repo on main with start (a.mjs, b.mjs) and c1 (line 3 of
# both changed); the project folder excluded from git. Sets START and C1.
mkrepo() {
r="$1"
mkdir -p "$r" || return 1
git -C "$r" init -q -b main || return 1
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' "$TMP/hooks-none" >> "$r/.git/config"
printf '.claude/projects/\n' >> "$r/.git/info/exclude"
ten a > "$r/a.mjs"
ten b > "$r/b.mjs"
git -C "$r" add a.mjs b.mjs && git -C "$r" commit -q -m 'chore: start' || return 1
START="$(git -C "$r" rev-parse HEAD)"
ten a 3 'export const a3 = 33;' > "$r/a.mjs"
ten b 3 'export const b3 = 33;' > "$r/b.mjs"
git -C "$r" add a.mjs b.mjs && git -C "$r" commit -q -m 'feat: round 1' || return 1
C1="$(git -C "$r" rev-parse HEAD)"
mkdir -p "$r/$PROJ_REL" || return 1
}
# gate <review> <repo> <out>: the merge lock's gate line; prints its exit status.
gate() {
node "$GATE" --json "$1" --repo "$2" > "$3" 2>&1
echo $?
}
# reviewer <project> <previous JSON>: the code-correctness reviewer's saved answer.
reviewer() {
printf 'Reviewer prose.\n\n```json\n{"reviewer": "code-correctness-reviewer", "findings": [], "previous": %s}\n```\n' "$2" \
> "$1/.review-out-code-correctness-reviewer.md"
}
MAJ='[{"severity":"MAJOR","file":"a.mjs","line":3,"title":"Parser drops the last token","detail":"parse() stops one token early"}]'
case_accepted() {
name="unanswered MAJOR, then accepted"
R="$TMP/accepted"
mkrepo "$R" || { bad "$name: setup"; return; }
P="$R/$PROJ_REL"
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" "$MAJ" && node "$FILL" "$P/review.md" > /dev/null \
|| { bad "$name: could not write the review"; return; }
reviewer "$P" '[]'
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/acc-fin.out" 2>&1 \
|| { bad "$name: finalize: $(cat "$TMP/acc-fin.out")"; return; }
c="$(gate "$P/review.md" "$R" "$TMP/acc-g1.out")"
if [ "$c" -eq 2 ] && grep -q '"reason":"review-unanswered:' "$TMP/acc-g1.out"; then
pass "$name: an unanswered MAJOR exits 2"
else
bad "$name: the unanswered MAJOR exited $c: $(cat "$TMP/acc-g1.out")"
fi
id="$(reviewjson "$P/review.md" 'j.findings[0].id')"
printf 'the parser keeps the old contract on purpose' | node "$ANSWERS" record --review "$P/review.md" --finding "$id" \
--answer accepted --repo "$R" --round 1 --store local --trailer "$TRAILER" > "$TMP/acc-rec.out" 2>&1
rc=$?
if [ "$rc" -ne 0 ]; then bad "$name: record exited $rc: $(cat "$TMP/acc-rec.out")"; return; fi
c="$(gate "$P/review.md" "$R" "$TMP/acc-g2.out")"
if [ "$c" -eq 0 ]; then
pass "$name: accepted, the gate exits 0"
else
bad "$name: after the accepted answer the gate exited $c: $(cat "$TMP/acc-g2.out")"
fi
}
case_fixed() {
name="MAJOR answered fixed"
R="$TMP/fixed"
mkrepo "$R" || { bad "$name: setup"; return; }
P="$R/$PROJ_REL"
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" "$MAJ" rstep && node "$FILL" "$P/review.md" > /dev/null \
|| { bad "$name: could not write the review"; return; }
reviewer "$P" '[]'
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/fix-fin1.out" 2>&1 \
|| { bad "$name: round-1 finalize: $(cat "$TMP/fix-fin1.out")"; return; }
id="$(reviewjson "$P/review.md" 'j.findings[0].id')"
printf 'the parser gets the missing token back' | node "$ANSWERS" record --review "$P/review.md" --finding "$id" \
--answer fixed --repo "$R" --round 1 --store local --trailer "$TRAILER" > "$TMP/fix-rec.out" 2>&1
rc=$?
if [ "$rc" -ne 0 ]; then bad "$name: record exited $rc: $(cat "$TMP/fix-rec.out")"; return; fi
c="$(gate "$P/review.md" "$R" "$TMP/fix-g1.out")"
if [ "$c" -eq 2 ] && grep -q '"reason":"review-unanswered: fixed-pending 1' "$TMP/fix-g1.out"; then
pass "$name: round 1 exits 2 (fixed-pending)"
else
bad "$name: round 1 exited $c: $(cat "$TMP/fix-g1.out")"
fi
# The fix: round 2's work, committed (only a.mjs; the local approval log stays untracked).
ten a 3 'export const a3 = 333;' > "$R/a.mjs"
git -C "$R" add a.mjs && git -C "$R" commit -q -m 'fix: the parser' || { bad "$name: commit"; return; }
C2="$(git -C "$R" rev-parse HEAD)"
(cd "$R" && node "$ROUNDS" prepare --project "$P" --since "$START" --json) > "$TMP/fix-prep.out" 2>&1 \
|| { bad "$name: prepare: $(cat "$TMP/fix-prep.out")"; return; }
round="$(jfield "$TMP/fix-prep.out" 'j.round')"
base="$(jfield "$TMP/fix-prep.out" 'j.round_base')"
prev="$(jfield "$TMP/fix-prep.out" 'j.previous')"
if [ "$round" != 2 ] || [ "$base" != "$C1" ] || [ -z "$prev" ]; then
bad "$name: prepare gave round $round base $base previous $prev"
return
fi
# Round 2, the reviewer not (yet) marking the finding resolved: carried, still open.
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C2" '[]' && node "$FILL" "$P/review.md" > /dev/null \
|| { bad "$name: could not write the round-2 review"; return; }
reviewer "$P" "[{\"id\": \"$id\", \"resolved\": false}]"
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 2 --round-base "$base" --previous "$P/$prev" \
> "$TMP/fix-fin2.out" 2>&1 || { bad "$name: round-2 finalize: $(cat "$TMP/fix-fin2.out")"; return; }
carried="$(reviewjson "$P/review.md" 'j.findings.map((f) => f.carried_from).join(",")')"
c="$(gate "$P/review.md" "$R" "$TMP/fix-g2.out")"
if [ "$carried" = "$id" ] && [ "$c" -eq 2 ]; then
pass "$name: round 2 carries the finding and keeps exit 2"
else
bad "$name: round 2 not resolved: carried [$carried], gate exit $c: $(cat "$TMP/fix-g2.out")"
fi
# Round 2 again, the reviewer marking it resolved.
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C2" '[]' && node "$FILL" "$P/review.md" > /dev/null \
|| { bad "$name: could not rewrite the round-2 review"; return; }
reviewer "$P" "[{\"id\": \"$id\", \"resolved\": true}]"
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 2 --round-base "$base" --previous "$P/$prev" \
> "$TMP/fix-fin3.out" 2>&1 || { bad "$name: round-2 finalize: $(cat "$TMP/fix-fin3.out")"; return; }
resolved="$(reviewjson "$P/review.md" '(j.resolved || []).join(",")')"
c="$(gate "$P/review.md" "$R" "$TMP/fix-g3.out")"
if [ "$resolved" = "$id" ] && [ "$c" -eq 0 ]; then
pass "$name: round 2 marks it resolved, the gate exits 0"
else
bad "$name: resolved [$resolved], gate exit $c: $(cat "$TMP/fix-g3.out")"
fi
}
case_preexisting() {
name="pre-existing BLOCKER"
R="$TMP/pre"
mkrepo "$R" || { bad "$name: setup"; return; }
P="$R/$PROJ_REL"
# b.mjs line 9: a line start..c1 did not change.
node "$TMP/mkreview.mjs" "$P/review.md" "$START" "$C1" \
'[{"severity":"BLOCKER","file":"b.mjs","line":9,"rule_key":"BROKEN_SUCCESS_CRITERION","title":"Build output is never written","detail":"write() returns before the flush"}]' \
&& node "$FILL" "$P/review.md" > /dev/null || { bad "$name: could not write the review"; return; }
reviewer "$P" '[]'
node "$FINALIZE" --review "$P/review.md" --repo "$R" --project "$P" --round 1 > "$TMP/pre-fin.out" 2>&1 \
|| { bad "$name: finalize: $(cat "$TMP/pre-fin.out")"; return; }
mark="$(reviewjson "$P/review.md" 'j.findings[0].pre_existing')"
c="$(gate "$P/review.md" "$R" "$TMP/pre-g.out")"
if [ "$mark" = true ] && [ "$c" -eq 1 ]; then
pass "$name: marked pre-existing, the gate exits 1"
else
bad "$name: pre_existing [$mark], gate exit $c: $(cat "$TMP/pre-g.out")"
fi
}
case_guard() {
name="merge question"
R="$TMP/merge"
mkdir -p "$R" || { bad "$name: setup"; return; }
git -C "$R" init -q -b main || { bad "$name: git init"; return; }
printf '[user]\n\tname = Test\n\temail = test@example.invalid\n[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "%s"\n' "$TMP/hooks-none" >> "$R/.git/config"
ten a > "$R/a.mjs"
git -C "$R" add a.mjs && git -C "$R" commit -q -m 'chore: start' || { bad "$name: commit"; return; }
MAIN_SHA="$(git -C "$R" rev-parse HEAD)"
git -C "$R" checkout -q -b feat || { bad "$name: branch"; return; }
ten a 3 'export const a3 = 33;' > "$R/a.mjs"
git -C "$R" add a.mjs && git -C "$R" commit -q -m 'feat: the work' || { bad "$name: commit"; return; }
FEAT="$(git -C "$R" rev-parse HEAD)"
# The run's artifacts, outside the repo.
P="$TMP/merge-project"
mkdir -p "$P" || { bad "$name: setup"; return; }
printf '# Plan\n\n### Step 1: End to end\n\n- **End-to-end:** `/bin/bash tests/e2e-demo.sh` -> expected: `e2e: ok`, exit 0.\n\n## Doors\n\n- two-way: a revert restores the old parser\n' > "$P/plan.md"
printf '{"schema_version": "1", "status": "completed", "end_to_end": "verified", "plan_verification": {"status": "passed", "summary": {"total": 3, "passed": 3, "failed": 0, "blocked": 0, "unrunnable": 0}}}\n' > "$P/progress.json"
node "$TMP/mkreview.mjs" "$P/review.md" "$MAIN_SHA" "$FEAT" '[]' && node "$FILL" "$P/review.md" > /dev/null \
|| { bad "$name: could not write the review"; return; }
# HEAD at the reviewed commit while the writer asks the verdict gate.
node "$WRITER" write --repo "$R" --branch feat --project "$P" --json > "$TMP/ev.out" 2>&1
rc=$?
written="$(jfield "$TMP/ev.out" 'j.written')"
path="$(jfield "$TMP/ev.out" 'j.path')"
if [ "$rc" -eq 0 ] && [ "$written" = true ] && [ "$path" = "$R/.git/voyage/merge-evidence/$FEAT.json" ] && [ -f "$path" ]; then
pass "$name: the evidence file is written under the git common dir"
else
bad "$name: the writer exited $rc: $(cat "$TMP/ev.out")"
fi
if [ -z "$(git -C "$R" status --porcelain --untracked-files=all)" ]; then
pass "$name: git status shows nothing new"
else
bad "$name: git status: $(git -C "$R" status --porcelain --untracked-files=all)"
fi
git -C "$R" checkout -q main || { bad "$name: checkout main"; return; }
node -e 'process.stdout.write(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: process.argv[1], tool_input: { command: "git merge --no-ff feat" } }));' "$R" \
| node "$HOOK" > "$TMP/hook.out" 2> "$TMP/hook.err"
rc=$?
decision="$(jfield "$TMP/hook.out" 'j.hookSpecificOutput.permissionDecision')"
reason="$(jfield "$TMP/hook.out" 'j.hookSpecificOutput.permissionDecisionReason')"
sha8="$(printf '%s' "$FEAT" | cut -c1-8)"
tested="Tested by: End-to-end \`/bin/bash tests/e2e-demo.sh\` exit 0; Verification 3/3; review ALLOW at $sha8."
undone='Undone by: `git revert -m 1 <merge>` (two-way).'
if [ "$rc" -eq 0 ] && [ "$decision" = ask ]; then
pass "$name: the guard asks"
else
bad "$name: the guard exited $rc with decision [$decision]: $(cat "$TMP/hook.out" "$TMP/hook.err")"
fi
case "$reason" in
*"$tested"*) pass "$name: the Tested by line" ;;
*) bad "$name: no Tested by line [$tested] in: $reason" ;;
esac
case "$reason" in
*"$undone"*) pass "$name: the Undone by line" ;;
*) bad "$name: no Undone by line [$undone] in: $reason" ;;
esac
}
case_accepted
case_fixed
case_preexisting
case_guard
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -22,7 +22,7 @@
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1141-1165 | profile, phase_models, profile_source | none |
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:829-862 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1307-1335 | profile, phase_models, parallel_agents, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:3005-3027 | profile, phase_models, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:3016-3038 | profile, phase_models, profile_source | none |
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:756-809 | profile, phase_models, profile_source | none |

View file

@ -0,0 +1,504 @@
// tests/hooks/merge-evidence.test.mjs
// [MEV] fix round 4.5b2 part B, Step 11: the guard's merge question says how
// the run was tested and how it is undone. Phase 8 of /trekexecute writes an
// evidence file (lib/review/merge-evidence.mjs write) under the git common
// dir, named by the head sha of the branch being merged; the Bash guard
// (hooks/scripts/pre-bash-executor.mjs) reads it with hooks/lib/merge-evidence.mjs
// into two lines of the question it already asks:
// Tested by: End-to-end `<cmd>` exit <n>; Verification <passed>/<total>; review <verdict> at <sha8>.
// Undone by: `git revert -m 1 <merge>` (two-way). | (one-way: <text>). | (door not stated).
// Fail-safe: the `ask` decision is computed before the reader runs and never
// changes; a missing, stale, foreign or hostile file, or a field of the wrong
// type, reads "no run evidence" or "not measured", never a green line.
// The decision is `ask` at the base too, so it is a Control in each case,
// never a tagged case of its own. Every child gets childEnv(); every fixture
// that reaches the verdict gate keeps HEAD at the reviewed commit (R6).
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import {
appendFileSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync,
symlinkSync, utimesSync, writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
import { childEnv } from '../helpers/child-env.mjs';
import { pluginCopy } from './guard-helpers.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
const READER = join(ROOT, 'hooks', 'lib', 'merge-evidence.mjs');
const WRITER = join(ROOT, 'lib', 'review', 'merge-evidence.mjs');
// The permission mode in which Claude Code shows no prompts, read from the
// guard's own constant: this file only feeds the guard its own value back.
const NO_PROMPT_MODE = (() => {
const m = readFileSync(PRE_BASH, 'utf8').match(/const NO_PROMPT_MODE = '([A-Za-z]+)';/);
assert.ok(m, 'the guard names its no-prompt permission mode');
return m[1];
})();
const UNDONE_TWO_WAY = 'Undone by: `git revert -m 1 <merge>` (two-way).';
const UNDONE_NOT_STATED = 'Undone by: `git revert -m 1 <merge>` (door not stated).';
const NO_EVIDENCE = 'Tested by: no run evidence.\nUndone by: no run evidence.';
const UNREADABLE = 'Tested by: not measured (the run evidence file could not be read).\nUndone by: not measured.';
const made = [];
after(() => { for (const b of made) rmSync(b, { recursive: true, force: true }); });
function g(cwd, ...args) {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
const run = (args, opts = {}) => spawnSync(process.execPath, args, { encoding: 'utf8', env: childEnv(), ...opts });
const parse = (r) => { try { return JSON.parse(r.stdout); } catch { return {}; } };
/**
* A throwaway repo on `main` (README) with a branch `feat` one commit ahead
* (a.mjs), checked out on main. No hooks, no signing.
*/
function mevRepo() {
const base = realpathSync(mkdtempSync(join(tmpdir(), 'merge-evidence-mev-')));
made.push(base);
const repo = join(base, 'repo');
mkdirSync(repo, { recursive: true });
mkdirSync(join(base, 'hooks-none'));
g(repo, 'init', '-q', '-b', 'main');
appendFileSync(join(repo, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n'
+ `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`);
writeFileSync(join(repo, 'README.md'), '# t\n');
g(repo, 'add', 'README.md');
g(repo, 'commit', '-q', '-m', 'init');
const mainSha = g(repo, 'rev-parse', 'HEAD');
g(repo, 'checkout', '-q', '-b', 'feat');
writeFileSync(join(repo, 'a.mjs'), 'export const a = 1;\n');
g(repo, 'add', 'a.mjs');
g(repo, 'commit', '-q', '-m', 'feat: the work');
const featSha = g(repo, 'rev-parse', 'HEAD');
g(repo, 'checkout', '-q', 'main');
const common = join(repo, '.git');
const evDir = join(common, 'voyage', 'merge-evidence');
return {
base, repo, mainSha, featSha, common, evDir,
evPath: (sha = featSha) => join(evDir, `${sha}.json`),
/** Write an evidence file by hand (`raw` text, or an object as JSON). */
put: (body, sha = featSha) => {
mkdirSync(evDir, { recursive: true });
writeFileSync(join(evDir, `${sha}.json`), typeof body === 'string' ? body : JSON.stringify(body, null, 2));
},
};
}
/** A complete evidence object for `sha` on branch feat. */
function evidence(sha, over = {}) {
return {
v: 1, branch: 'feat', branch_head_sha: sha, written_at: '2026-11-02T10:00:00.000Z',
end_to_end: { command: '/bin/bash tests/e2e.sh', exit: 0 },
verification: { passed: 5, total: 5 },
review: { verdict: 'ALLOW', sha },
door: { type: 'two-way', text: 'a revert restores the old gate' },
...over,
};
}
const tested = (sha, { cmd = '/bin/bash tests/e2e.sh', exit = '0', ver = '5/5', verdict = 'ALLOW' } = {}) =>
`Tested by: End-to-end \`${cmd}\` exit ${exit}; Verification ${ver}; review ${verdict} at ${sha.slice(0, 8)}.`;
/** The guard on a Bash call, run in `cwd`; `script` and `env` pick a plugin copy and its environment. */
async function ask(cwd, { command = 'git merge --no-ff feat', mode, script = PRE_BASH, env = {} } = {}) {
const input = { hook_event_name: 'PreToolUse', tool_name: 'Bash', cwd, tool_input: { command } };
if (mode) input.permission_mode = mode;
const r = await runHookWithEnv(script, input, env);
let out = null;
try { out = JSON.parse(String(r.stdout).trim()); } catch { out = null; }
return {
...r,
decision: out?.hookSpecificOutput?.permissionDecision ?? null,
reason: String(out?.hookSpecificOutput?.permissionDecisionReason ?? ''),
};
}
/** A minimal ALLOW review of `sha` (shape copied from tests/lib/review-answers.test.mjs `review`). */
function review(sha) {
const id = '1'.padStart(40, 'a');
const json = JSON.stringify({
verdict: 'ALLOW',
counts: { BLOCKER: 0, MAJOR: 0, MINOR: 1, SUGGESTION: 0 },
allow_blocked_by: [],
findings: [{ id, severity: 'MINOR', rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd', recommended_action: 'Add a test.' }],
}, null, 2);
return `---
type: trekreview
review_version: "1.1"
task: "t"
slug: s
project_dir: .claude/projects/2026-11-02-s/
brief_path: .claude/projects/2026-11-02-s/brief.md
scope_sha_end: ${sha}
reviewed_files_count: 1
verdict: ALLOW
findings:
- ${id}
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| a.mjs | summary-only | default |
## Remediation Summary
x
\`\`\`json
${json}
\`\`\`
`;
}
/** A plan with an End-to-end step and an optional `## Doors` list (lines). */
function plan({ e2e = '/bin/bash tests/e2e-x.sh', doors } = {}) {
return `# Plan
> Generated by trekplan v6.5.0 on 2026-11-02 — \`plan_version: 1.9\`
## Implementation Plan
### Step 1: the work
- **Files:** \`a.mjs\`
- **Verify:** \`node --test tests/a.test.mjs\` → expected: pass
### Step 2: End to end
- **End-to-end:** \`${e2e}\` → expected: \`e2e: ok\`, exit 0.
${doors ? `\n## Doors\n\n${doors.join('\n')}\n` : ''}
## Verification
\`\`\`bash
${e2e}
\`\`\`
`;
}
/** A project folder outside the repo; files written only when given. */
function project(fx, { planText, progress, reviewText } = {}) {
const dir = mkdtempSync(join(fx.base, 'project-'));
if (planText !== undefined) writeFileSync(join(dir, 'plan.md'), planText);
if (progress !== undefined) writeFileSync(join(dir, 'progress.json'), JSON.stringify(progress, null, 2));
if (reviewText !== undefined) writeFileSync(join(dir, 'review.md'), reviewText);
return dir;
}
const PROGRESS = {
schema_version: '1', status: 'completed', end_to_end: 'verified',
plan_verification: { status: 'passed', summary: { total: 7, passed: 7, failed: 0, blocked: 0, unrunnable: 0 }, failed_criteria: [] },
};
/** The evidence writer's CLI; the module must exist (so a missing one fails on this assertion, not in the child). */
function write(repo, branch, proj) {
assert.ok(existsSync(WRITER), 'lib/review/merge-evidence.mjs must exist');
return run([WRITER, 'write', '--repo', repo, '--branch', branch, '--project', proj, '--json']);
}
// ---- [MEV] the lines ---------------------------------------------------------------
test('[MEV] the lines: the merge question holds the Tested by and Undone by lines from an evidence file of the merged branch head', async () => {
const fx = mevRepo();
fx.put(evidence(fx.featSha));
const r = await ask(fx.repo);
assert.equal(r.decision, 'ask', `Control: the merge into main is asked about: ${r.stdout}${r.stderr}`);
assert.ok(r.reason.includes(`${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`), `the two lines, in order:\n${r.reason}`);
// Control: the question the guard already asks is still there, the lines after it.
assert.ok(r.reason.startsWith('Voyage asks before this command (P12 approval guard): merge into main.'), r.reason);
assert.ok(r.reason.indexOf('Way forward:') < r.reason.indexOf('Tested by:'), r.reason);
});
// ---- [MEV] the door ----------------------------------------------------------------
test('[MEV] the door: a one-way door in the plan\'s ## Doors prints it; none prints "door not stated"', async () => {
const fx = mevRepo();
const oneWay = project(fx, { planText: plan({ doors: ['- one-way: v1 files no longer read'] }), progress: PROGRESS });
const w = write(fx.repo, 'feat', oneWay);
assert.equal(w.status, 0, `writer: ${w.stdout}${w.stderr}`);
const r = await ask(fx.repo);
assert.equal(r.decision, 'ask', `Control: ${r.stdout}${r.stderr}`);
assert.ok(r.reason.includes('Undone by: `git revert -m 1 <merge>` (one-way: v1 files no longer read).'), r.reason);
const none = project(fx, { planText: plan(), progress: PROGRESS });
const w2 = write(fx.repo, 'feat', none);
assert.equal(w2.status, 0, `writer: ${w2.stdout}${w2.stderr}`);
const n = await ask(fx.repo);
assert.equal(n.decision, 'ask', `Control: ${n.stdout}${n.stderr}`);
assert.ok(n.reason.includes(UNDONE_NOT_STATED), n.reason);
// Control: with no door in the plan, the CHANGELOG's first section decides when it holds exactly one door line.
writeFileSync(join(fx.repo, 'CHANGELOG.md'), '# Changelog\n\n## Unreleased\n\n### Doors\n\n- two-way: the new lines can be reverted\n\n## v1.0.0\n\n### Doors\n\n- one-way: old\n');
assert.equal(write(fx.repo, 'feat', none).status, 0);
assert.ok((await ask(fx.repo)).reason.includes(UNDONE_TWO_WAY), 'Control: the CHANGELOG door line');
writeFileSync(join(fx.repo, 'CHANGELOG.md'), '# Changelog\n\n## Unreleased\n\n### Doors\n\n- two-way: a\n- one-way: b\n');
assert.equal(write(fx.repo, 'feat', none).status, 0);
assert.ok((await ask(fx.repo)).reason.includes(UNDONE_NOT_STATED), 'Control: two door lines in the CHANGELOG state no door');
});
// ---- [MEV] fail-safe ---------------------------------------------------------------
test('[MEV] fail-safe: no file, a stale or foreign file, an unreadable field or a reader that will not load says "no run evidence" or "not measured", and still asks', async () => {
const fx = mevRepo();
// No file.
const none = await ask(fx.repo);
assert.equal(none.decision, 'ask', `Control: ${none.stdout}${none.stderr}`);
assert.ok(none.reason.includes(NO_EVIDENCE), `no file: ${none.reason}`);
// Stale: the file at the branch head's name holds another head.
fx.put(evidence(fx.mainSha));
const stale = await ask(fx.repo);
assert.equal(stale.decision, 'ask', `Control: ${stale.stdout}${stale.stderr}`);
assert.ok(stale.reason.includes(NO_EVIDENCE), `stale head sha: ${stale.reason}`);
assert.ok(!stale.reason.includes('Tested by: End-to-end'), stale.reason);
// Stale: evidence for the old head, then the branch moved on.
fx.put(evidence(fx.featSha));
g(fx.repo, 'checkout', '-q', 'feat');
writeFileSync(join(fx.repo, 'b.mjs'), 'export const b = 2;\n');
g(fx.repo, 'add', 'b.mjs');
g(fx.repo, 'commit', '-q', '-m', 'feat: more');
g(fx.repo, 'checkout', '-q', 'main');
const moved = await ask(fx.repo);
assert.equal(moved.decision, 'ask', `Control: ${moved.stdout}${moved.stderr}`);
assert.ok(moved.reason.includes(NO_EVIDENCE), `the branch moved on: ${moved.reason}`);
const head = g(fx.repo, 'rev-parse', 'feat');
// Foreign: another format version, or another branch's name.
fx.put(evidence(head, { v: 2 }), head);
assert.ok((await ask(fx.repo)).reason.includes(NO_EVIDENCE), 'a file of another version');
fx.put(evidence(head, { branch: 'other' }), head);
assert.ok((await ask(fx.repo)).reason.includes(NO_EVIDENCE), 'a file for another branch');
// Unreadable fields: each reads "not measured", none prints a number or a verdict.
fx.put(evidence(head, {
end_to_end: { command: 42, exit: '0' }, verification: { passed: '7', total: 7 },
review: { verdict: 'GREEN', sha: 'abc' }, door: { type: 'sideways', text: 'x' },
}), head);
const bad = await ask(fx.repo);
assert.equal(bad.decision, 'ask', `Control: ${bad.stdout}${bad.stderr}`);
assert.ok(bad.reason.includes('Tested by: End-to-end command not measured exit not measured; Verification not measured; review not measured at not measured.'), bad.reason);
assert.ok(bad.reason.includes(UNDONE_NOT_STATED), bad.reason);
// Missing fields read the same.
fx.put({ v: 1, branch: 'feat', branch_head_sha: head }, head);
const missing = await ask(fx.repo);
assert.ok(missing.reason.includes('Tested by: End-to-end command not measured exit not measured; Verification not measured; review not measured at not measured.'), missing.reason);
// More passed than total is no count.
fx.put(evidence(head, { verification: { passed: 9, total: 7 } }), head);
assert.ok((await ask(fx.repo)).reason.includes('Verification not measured;'), 'passed > total');
// Non-printable and non-ASCII characters print as `?`.
fx.put(evidence(head, { end_to_end: { command: 'e2e\u0007ü\nrm', exit: 0 } }), head);
const odd = await ask(fx.repo);
assert.ok(odd.reason.includes('End-to-end `e2e???rm` exit 0;'), odd.reason);
// Bad JSON.
fx.put('{"v": 1, "branch": ', head);
const json = await ask(fx.repo);
assert.equal(json.decision, 'ask', `Control: ${json.stdout}${json.stderr}`);
assert.ok(json.reason.includes(UNREADABLE), `bad JSON: ${json.reason}`);
// A merge with no single branch named reads "no run evidence".
fx.put(evidence(head), head);
for (const command of ['git merge --no-ff', 'git merge --no-ff feat other', 'git merge -m feat']) {
const r = await ask(fx.repo, { command });
assert.equal(r.decision, 'ask', `Control: ${command}`);
assert.ok(r.reason.includes(NO_EVIDENCE), `${command}: ${r.reason}`);
}
// Control: option values are skipped, so the branch is still found.
const opts = await ask(fx.repo, { command: 'git merge --no-ff -s ort -X ours feat' });
assert.ok(opts.reason.includes(tested(head)), opts.reason);
// A reader that will not load: the question still asks and says so.
const plugin = pluginCopy({ 'hooks/lib/merge-evidence.mjs': 'missing' });
made.push(plugin);
const gone = await ask(fx.repo, { script: join(plugin, 'hooks', 'scripts', 'pre-bash-executor.mjs') });
assert.equal(gone.decision, 'ask', `Control: ${gone.stdout}${gone.stderr}`);
assert.ok(gone.reason.includes('(run evidence not loaded)'), gone.reason);
});
test('[MEV] a hostile evidence path still asks: a FIFO, a symlink and a 10 MiB file read "not measured"', async () => {
const fx = mevRepo();
// A good file elsewhere: followed, the symlink would print the green line.
const elsewhere = join(fx.base, 'good.json');
writeFileSync(elsewhere, JSON.stringify(evidence(fx.featSha)));
mkdirSync(fx.evDir, { recursive: true });
symlinkSync(elsewhere, fx.evPath());
const link = await ask(fx.repo);
assert.equal(link.decision, 'ask', `Control: ${link.stdout}${link.stderr}`);
assert.ok(link.reason.includes(UNREADABLE), `a symlink: ${link.reason}`);
assert.ok(!link.reason.includes(tested(fx.featSha)), link.reason);
rmSync(fx.evPath());
const fifo = spawnSync('mkfifo', [fx.evPath()], { encoding: 'utf8', env: childEnv() });
assert.equal(fifo.status, 0, `mkfifo: ${fifo.stderr}`);
const pipe = await ask(fx.repo);
assert.equal(pipe.decision, 'ask', `Control: a FIFO does not hang the guard: ${pipe.code} ${pipe.stdout}${pipe.stderr}`);
assert.ok(pipe.reason.includes(UNREADABLE), `a FIFO: ${pipe.reason}`);
rmSync(fx.evPath());
// Valid JSON with the good fields, padded past 10 MiB.
writeFileSync(fx.evPath(), JSON.stringify(evidence(fx.featSha)) + ' '.repeat(10 * 1024 * 1024));
const big = await ask(fx.repo);
assert.equal(big.decision, 'ask', `Control: ${big.stdout}${big.stderr}`);
assert.ok(big.reason.includes(UNREADABLE), `a 10 MiB file: ${big.reason}`);
assert.ok(!big.reason.includes(tested(fx.featSha)), big.reason);
// A directory at the evidence path.
rmSync(fx.evPath());
mkdirSync(fx.evPath());
const dir = await ask(fx.repo);
assert.equal(dir.decision, 'ask', `Control: ${dir.stdout}${dir.stderr}`);
assert.ok(dir.reason.includes(UNREADABLE), `a directory: ${dir.reason}`);
rmSync(fx.evPath(), { recursive: true });
// Control: unattended, the merge is still refused, evidence or not.
fx.put(evidence(fx.featSha));
const unattended = await ask(fx.repo, { mode: NO_PROMPT_MODE });
assert.equal(unattended.code, 2, `Control: unattended refuses the merge: ${unattended.stdout}${unattended.stderr}`);
assert.equal(unattended.decision, null, 'Control: no ask where no one can be asked');
assert.match(unattended.stderr, /BLOCKED: merge into main with no one to ask/);
});
// ---- [MEV] round trip --------------------------------------------------------------
test('[MEV] round trip: a file written by merge-evidence.mjs write is read back by the guard into the same two lines', async () => {
const fx = mevRepo();
const proj = project(fx, {
planText: plan({ doors: ['- two-way: a revert restores the old gate'] }),
progress: PROGRESS,
reviewText: review(fx.featSha),
});
// HEAD at the reviewed commit while the writer asks the verdict gate (R6).
g(fx.repo, 'checkout', '-q', 'feat');
const w = write(fx.repo, 'feat', proj);
assert.equal(w.status, 0, `writer: ${w.stdout}${w.stderr}`);
const out = parse(w);
assert.equal(out.written, true, w.stdout);
assert.equal(out.path, fx.evPath(), w.stdout);
const file = JSON.parse(readFileSync(fx.evPath(), 'utf8'));
assert.equal(file.v, 1);
assert.equal(file.branch, 'feat');
assert.equal(file.branch_head_sha, fx.featSha);
assert.match(String(file.written_at), /^\d{4}-\d\d-\d\dT/);
assert.deepEqual(file.end_to_end, { command: '/bin/bash tests/e2e-x.sh', exit: 0 });
assert.deepEqual(file.verification, { passed: 7, total: 7 });
assert.deepEqual(file.review, { verdict: 'ALLOW', sha: fx.featSha });
assert.deepEqual(file.door, { type: 'two-way', text: 'a revert restores the old gate' });
g(fx.repo, 'checkout', '-q', 'main');
const r = await ask(fx.repo);
assert.equal(r.decision, 'ask', `Control: ${r.stdout}${r.stderr}`);
assert.ok(r.reason.includes(`${tested(fx.featSha, { cmd: '/bin/bash tests/e2e-x.sh', ver: '7/7' })}\n${UNDONE_TWO_WAY}`), r.reason);
// Control: an End-to-end that is not verified writes no exit; a review the gate does not pass writes no verdict.
g(fx.repo, 'checkout', '-q', 'feat');
writeFileSync(join(proj, 'progress.json'), JSON.stringify({ ...PROGRESS, end_to_end: 'not-verified' }));
writeFileSync(join(proj, 'review.md'), review(fx.mainSha));
assert.equal(write(fx.repo, 'feat', proj).status, 0);
g(fx.repo, 'checkout', '-q', 'main');
const nv = await ask(fx.repo);
assert.ok(nv.reason.includes('Tested by: End-to-end `/bin/bash tests/e2e-x.sh` exit not measured; Verification 7/7; review not measured at '), nv.reason);
// Control: a branch name that is not a plain name is refused, and nothing is written.
for (const branch of ['..feat', 'fe..at', '-feat', 'fe\u0001at', 'nope']) {
const bad = write(fx.repo, branch, proj);
assert.equal(bad.status, 1, `refused ${JSON.stringify(branch)}: ${bad.stdout}${bad.stderr}`);
assert.equal(parse(bad).written, false, bad.stdout);
assert.equal(typeof parse(bad).reason, 'string', bad.stdout);
}
});
// ---- [MEV] where the file lives ----------------------------------------------------
test('[MEV] the evidence file sits under the git common dir, and git status --porcelain in the fixture shows nothing new', async () => {
const fx = mevRepo();
// The run in a linked worktree, main in the first one (Phase 8 merges with `git -C`).
const wt = join(fx.base, 'wt');
g(fx.repo, 'worktree', 'add', '-q', wt, 'feat');
const proj = project(fx, { planText: plan(), progress: PROGRESS, reviewText: review(fx.featSha) });
const before = [g(fx.repo, 'status', '--porcelain', '--untracked-files=all'), g(wt, 'status', '--porcelain', '--untracked-files=all')];
const w = write(wt, 'feat', proj);
assert.equal(w.status, 0, `writer: ${w.stdout}${w.stderr}`);
const raw = g(wt, 'rev-parse', '--git-common-dir');
const common = realpathSync(isAbsolute(raw) ? raw : resolve(wt, raw));
const path = parse(w).path;
assert.equal(realpathSync(path), join(common, 'voyage', 'merge-evidence', `${fx.featSha}.json`), w.stdout);
assert.ok(!realpathSync(path).startsWith(`${realpathSync(wt)}/`), 'not in the worktree');
const afterStatus = [g(fx.repo, 'status', '--porcelain', '--untracked-files=all'), g(wt, 'status', '--porcelain', '--untracked-files=all')];
assert.deepEqual(afterStatus, before, 'git status shows nothing new in either tree');
// Control: the guard in main's tree finds the file the run's tree wrote.
const r = await ask(fx.repo);
assert.equal(r.decision, 'ask', `Control: ${r.stdout}${r.stderr}`);
assert.ok(r.reason.includes(`${tested(fx.featSha, { cmd: '/bin/bash tests/e2e-x.sh', ver: '7/7' })}\n${UNDONE_NOT_STATED}`), r.reason);
});
// ---- [MEV] the reader's architecture rule ------------------------------------------
/** What the reader must never hold: a process spawn, a `../../lib/` import, a non-ASCII byte. */
function scanReader(path) {
const buf = readFileSync(path);
const text = buf.toString('latin1');
const found = [];
if (/child_process/.test(text)) found.push('child_process');
if (/['"`]\.\.\/\.\.\/lib\//.test(text)) found.push('../../lib/ import');
if (buf.some((b) => b > 0x7f)) found.push('non-ASCII byte');
return found;
}
test('[MEV] the reader is ASCII and spawns nothing', () => {
assert.ok(existsSync(READER), 'hooks/lib/merge-evidence.mjs must exist');
assert.deepEqual(scanReader(READER), [], 'the real reader');
const dir = mkdtempSync(join(tmpdir(), 'merge-evidence-scan-'));
made.push(dir);
const copy = join(dir, 'merge-evidence.mjs');
const plants = [
["import { spawnSync } from 'node:child_process';\n", 'child_process'],
["const x = require('node:child_process');\n", 'child_process'],
["import { parseDoors } from '../../lib/util/doors.mjs';\n", '../../lib/ import'],
['// café\n', 'non-ASCII byte'],
];
for (const [line, want] of plants) {
copyFileSync(READER, copy);
appendFileSync(copy, line);
assert.deepEqual(scanReader(copy), [want], `planted: ${line.trim()}`);
}
copyFileSync(READER, copy);
assert.deepEqual(scanReader(copy), [], 'the clean copy');
});
// ---- [MEV] the writer's clean-up ---------------------------------------------------
test('[MEV] the writer removes an evidence file older than 30 days and keeps a fresh one', () => {
const fx = mevRepo();
mkdirSync(fx.evDir, { recursive: true });
const old = join(fx.evDir, `${'a'.repeat(40)}.json`);
const fresh = join(fx.evDir, `${'b'.repeat(40)}.json`);
const notes = join(fx.evDir, 'notes.txt');
for (const p of [old, fresh, notes]) writeFileSync(p, '{}\n');
const longAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
utimesSync(old, longAgo, longAgo);
utimesSync(notes, longAgo, longAgo);
const w = write(fx.repo, 'feat', project(fx, { planText: plan(), progress: PROGRESS }));
assert.equal(w.status, 0, `writer: ${w.stdout}${w.stderr}`);
assert.ok(!existsSync(old), 'the evidence file older than 30 days is removed');
assert.ok(existsSync(fresh), 'the fresh evidence file is kept');
assert.ok(existsSync(notes), 'Control: a file not named <hex>.json is never removed');
assert.ok(existsSync(fx.evPath()), 'the new evidence file is written');
});

View file

@ -32,6 +32,10 @@
// DPR /trekreview Phase 4 says a changed instruction file at any depth,
// `docs/**` included, gets deep review, after the triage table and
// with its two pinned rows untouched.
// MEV /trekexecute Phase 8 writes the merge evidence file in a Bash call of
// its own before the merge call, and goes on when it is not written;
// docs/HANDOVER-CONTRACTS.md documents the file as internal: its path
// under the git common dir, never tracked, not part of Handover 5.
// The helpers below are copied, never imported from an earlier prose test.
// Fix the SOURCE a pin guards, not the pin.
@ -395,3 +399,47 @@ test('[DPR] /trekreview Phase 4 says a changed instruction file at any depth, do
assert.ok(lines.includes('| `summary-only` | `docs/**`, Markdown files at the repo root other than `CLAUDE.md` and `AGENTS.md` (README, CHANGELOG), `tests/fixtures/**`. |'),
'Control: the summary-only row is unchanged');
});
// --- MEV: the merge question's evidence -------------------------------------------
const PHASE_8 = '## Phase 8 — Final report';
const EVIDENCE_CMD = 'node ${CLAUDE_PLUGIN_ROOT}/lib/review/merge-evidence.mjs write --repo . --branch "{run branch}" --project "{project_dir}" --json';
test('[MEV] Phase 8 writes the evidence file before the merge call', () => {
const p8 = section(read(EXEC_CMD), PHASE_8);
const open = p8.indexOf('**2. Open: the merge, and the one question.**');
assert.ok(open >= 0, 'Phase 8 has its "2. Open" part');
const lines = p8.split('\n');
const at = lines.findIndex((l) => l.trim() === EVIDENCE_CMD);
assert.ok(at >= 0, `Phase 8 runs the evidence writer: ${EVIDENCE_CMD}`);
// A Bash call of its own: the command is the one line of its fenced block.
assert.match(lines[at - 1].trim(), /^```bash$/, 'the writer opens its own bash block');
assert.equal(lines[at + 1].trim(), '```', 'the writer is the only line of its block');
const writer = p8.indexOf(EVIDENCE_CMD);
assert.ok(writer > open, 'the writer runs in "2. Open"');
const merges = [p8.indexOf('merge --no-ff "{run branch}"'), p8.indexOf('git switch "{main}"')];
for (const m of merges) assert.ok(m > writer, 'the writer runs before each merge block');
assert.ok(sentences(section(read(EXEC_CMD), PHASE_8)).some((s) => s.includes('On `"written": false` (exit 1), print its `reason` in one line and continue')
&& /no run evidence/.test(s)), 'a refused write is said in one line and the run goes on; the question then says "no run evidence"');
// Control: the merge blocks are unchanged.
assert.ok(p8.includes(' if git -C "{main worktree path}" merge --no-ff "{run branch}" -m "merge: trekexecute {slug}"; then\n echo "MERGED"'),
'Control: the worktree merge block is unchanged');
assert.ok(p8.includes(' git switch "{main}" || { echo "MERGE_REFUSED"; exit 1; }\n if git merge --no-ff "{run branch}" -m "merge: trekexecute {slug}"; then'),
'Control: the switch merge block is unchanged');
});
test('[MEV] docs/HANDOVER-CONTRACTS.md documents the evidence file as internal', () => {
const text = read(CONTRACTS);
const note = section(text, '## Internal files');
const f = flat(note);
assert.ok(f.includes('`<git common dir>/voyage/merge-evidence/<branch head sha>.json`'), 'the note names the path under the git common dir');
assert.match(f, /never tracked/, 'never tracked');
assert.match(f, /never published/, 'never published');
assert.match(f, /not part of Handover 5/, 'not part of Handover 5');
for (const field of ['`v`', '`branch`', '`branch_head_sha`', '`written_at`', '`end_to_end`', '`verification`', '`review`', '`door`']) {
assert.ok(f.includes(field), `the note names the field ${field}`);
}
assert.ok(f.includes('lib/review/merge-evidence.mjs') && f.includes('hooks/lib/merge-evidence.mjs'), 'the note names the writer and the reader');
// Control: Handover 5 does not name the file.
assert.doesNotMatch(section(text, '## Handover 5'), /merge-evidence/, 'Control: Handover 5 is unchanged');
});