voyage/tests/commands/trekexecute.test.mjs
Kjell Tore Guttormsen efe981f7bc
refactor(cleanup): simplify pass on the R-a diff
engine-compare imports the coordinator's title limit and trim instead
of a copy whose reason went with Arm B; the autonomy gate exports one
parser and its test runs in-process with two CLI runs; one import
scanner in lib-imports; the fenced-section walker shared from
tests/helpers/md-section.mjs instead of six copies; plan-gate hash
in-process; living-paths scans once; routing drops the orchestrator
filter whose files are gone; small leftovers removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:31:30 +01:00

444 lines
24 KiB
JavaScript

// tests/commands/trekexecute.test.mjs
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekexecute.
// Low effort runs the plan as one session, step after step; it sets no --gates
// (fix round 4-5a, #33).
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
import { sectionText } from '../helpers/md-section.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekexecute.md');
const PHASE = 'execute';
function read() { return readFileSync(COMMAND_FILE, 'utf8'); }
function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); }
function frontmatterOf(text) {
const doc = parseDocument(text);
return doc.parsed && doc.parsed.frontmatter;
}
// --- Pattern D prose-pins ---
test('trekexecute — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => {
const text = read();
assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'),
'/trekexecute must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate');
assert.ok(text.includes('phase_signals'),
'/trekexecute must reference phase_signals (v5.1 composition rule)');
});
test('trekexecute — low-effort path runs one session, step after step, and sets no --gates open', () => {
const text = read();
const compIdx = text.indexOf('## Composition rule (v5.1)');
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
const section = text.slice(compIdx, compIdx + 2000);
assert.doesNotMatch(section, /--gates open/, 'Low effort must not set --gates open');
assert.match(section.replace(/\s+/g, ' '), /`effort == 'low'` runs the plan as one session, step after step \(Phase 2\.5\)/,
'Low-effort path must run the plan as one session, step after step');
});
// --- v5.1.1 runtime SC4 + SC7 ---
test('trekexecute — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => {
const fm = frontmatterOf(readFixture('brief-effort-low.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'low');
assert.equal(r.model, 'sonnet');
});
test('trekexecute — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => {
const fm = frontmatterOf(readFixture('brief-effort-standard.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'standard');
assert.equal(r.model, undefined);
});
test('trekexecute — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => {
const fm = frontmatterOf(readFixture('brief-effort-high.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'high');
assert.equal(r.model, 'opus');
});
test('trekexecute — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => {
const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true });
assert.equal(r.valid, false);
assert.ok(
r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'),
`sequencing gate must fire; errors=${JSON.stringify(r.errors)}`,
);
});
// --- S38 loop-discipline hardening: completion gate (Step 2) ---
test('trekexecute — machine-verifiable completion gate + stop-signal contract literals present (SC a)', () => {
const text = read();
assert.ok(text.includes('machine-verifiable completion gate'),
'completion-gate literal must be grep-able');
assert.ok(text.includes('stop-signal contract'),
'stop-signal contract literal must be grep-able');
});
test('trekexecute — completion gate anchors result:completed to Phase 7.5 audit + DONE-after-check (SC a)', () => {
const text = read();
const gateIdx = text.indexOf('machine-verifiable completion gate');
assert.ok(gateIdx >= 0, 'gate literal missing');
const section = text.slice(gateIdx, gateIdx + 1400);
assert.match(section, /Phase 7\.5/, 'gate must name Phase 7.5 as the objective predicate');
assert.match(section, /DONE/, 'gate must reference the DONE stop-signal token');
assert.match(section, /emitted AFTER/, 'gate must require DONE emitted AFTER the audit ran');
});
// --- S38 loop-discipline hardening: caps + global recovery budget (Step 3) ---
test('trekexecute — global recovery/retry budget TREKEXECUTE_MAX_RECOVERY_ITERATIONS default 25 (SC b)', () => {
const text = read();
assert.ok(text.includes('TREKEXECUTE_MAX_RECOVERY_ITERATIONS'),
'global recovery/retry budget constant must be documented');
assert.match(
text,
/TREKEXECUTE_MAX_RECOVERY_ITERATIONS[^\n]{0,40}\b25\b|default[^\n]{0,20}\b25\b[^\n]{0,40}aggregate/i,
'global budget must document numeric default 25 adjacent to the constant',
);
});
test('trekexecute — every recovery/retry loop bounded + 3-axis cap hierarchy (SC b)', () => {
const text = read();
// Wave 1 (P8): the per-step cap is at most 5 fix rounds, and they go UP (to the
// plan, then to the operator) - never sideways into a fourth guess, never down
// a ladder of cheaper models. The old "3 attempts" cap is gone.
assert.match(text, /maximum 5 fix rounds[^\n]*upward/i, 'per-step cap must be "maximum 5 fix rounds", escalating upward');
assert.doesNotMatch(text, /maximum 2 retries|Retry cap = 3 attempts/, 'the old 3-attempt retry cap must be gone');
assert.match(text, /recovery_depth < 2/, 'recovery-dispatch cap must be explicit');
const capIdx = text.indexOf('Iteration caps');
assert.ok(capIdx >= 0, 'cap-hierarchy section ("Iteration caps") must exist');
const section = text.slice(capIdx, capIdx + 1500);
assert.match(section, /fix_rounds/, 'hierarchy must name the per-step fix_rounds axis');
assert.match(section, /attempts/, 'hierarchy must name per-step attempts axis');
assert.match(section, /recovery_depth/, 'hierarchy must name per-session recovery_depth axis');
assert.match(section, /TREKEXECUTE_MAX_RECOVERY_ITERATIONS/, 'hierarchy must name the global budget axis');
});
// --- S38 loop-discipline hardening: iterations_remaining budget signal (Step 4) ---
test('trekexecute — iterations_remaining surfaced in progress schema + summary JSON, no (if wired) (SC c)', () => {
const text = read();
assert.ok(text.includes('iterations_remaining'), 'iterations_remaining must be present (SC c)');
assert.ok(!/iterations_remaining[^\n]*\(if wired\)/.test(text),
'no "(if wired)" conditional — field present in the spec at minimum');
const schemaIdx = text.indexOf('### Progress file schema');
assert.ok(schemaIdx >= 0, 'progress schema section missing');
assert.match(text.slice(schemaIdx, schemaIdx + 1400), /iterations_remaining/,
'progress schema must include iterations_remaining');
const sumIdx = text.indexOf('"trekexecute_summary": {');
assert.ok(sumIdx >= 0, 'summary JSON block missing');
assert.match(text.slice(sumIdx, sumIdx + 900), /iterations_remaining/,
'summary JSON must include iterations_remaining (observable)');
});
test('trekexecute — iterations_remaining decrement + backfill + gate cross-check documented (SC c)', () => {
const text = read();
assert.match(text, /decrement[^.\n]*iteration|every recovery\/retry iteration/i,
'decrement-per-iteration rule must be documented');
assert.match(text, /backfill|seed (it )?to (the )?cap/i,
'backfill-on-absent (legacy resume) rule must be documented');
const gateIdx = text.indexOf('machine-verifiable completion gate');
const gate = text.slice(gateIdx, gateIdx + 2200);
assert.match(gate, /iterations_remaining/, 'gate must cross-check iterations_remaining');
assert.match(gate, /recovery_depth/, 'gate cross-check must reconcile against recovery_depth');
assert.match(gate, /attempts/, 'gate cross-check must reconcile against attempts');
});
// --- helpers for the run-the-line tests below -------------------------------
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
function libLine(text, mod, verb = '') {
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
const re = new RegExp(`^\\s*node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/${esc}\\b`);
const hits = text.split('\n').filter((l) => re.test(l) && l.includes(verb));
assert.equal(hits.length, 1, `expected exactly one \`${mod}${verb ? ` ${verb}` : ''}\` line; found ${hits.length}`);
return hits[0].trim();
}
/** Run a command line from the prose with the plugin root filled in. */
function bash(cmd, cwd) {
return spawnSync('bash', ['-c', cmd.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT)], { cwd, encoding: 'utf8' });
}
// --- Phase 8: the state-file block runs from any working directory ----------
test('trekexecute Phase 8: the state-file block imports through the plugin root and runs outside it', () => {
const md = read();
assert.doesNotMatch(md, /from '\.\/lib\//, 'a relative import only works with cwd = plugin root');
const start = md.indexOf('node --input-type=module -e "\nimport path from \'node:path\';');
assert.ok(start > 0, 'the state-file block');
const end = md.indexOf('\n```', start);
const block = md.slice(start, end)
.replaceAll('${CLAUDE_PLUGIN_ROOT}', ROOT)
.replace("'{project_dir}' '{next_session_brief_path}' '{next_session_label}' '{status}'", '"$1" brief.md "Session 2" ready');
const elsewhere = mkdtempSync(join(tmpdir(), 'voyage-state-'));
try {
const r = spawnSync('/bin/bash', ['-c', block, 'x', elsewhere], { cwd: elsewhere, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
assert.ok(existsSync(join(elsewhere, '.session-state.local.json')));
assert.match(readFileSync(join(elsewhere, 'NEXT-SESSION-PROMPT.local.md'), 'utf8'), /produced_by: trekexecute/);
} finally {
rmSync(elsewhere, { recursive: true, force: true });
}
});
// --- Phase 1 --project: the brief check and the execute resolver -------------
// Phase 1's --project branch runs the two lines /trekplan runs, for the execute
// phase, and halts only on BRIEF_V51_MISSING_SIGNALS.
/** Phase 1's rule 1 (the --project branch), as lines. */
function projectRule() {
const sec = sectionText(read(), '## Phase 1 — Parse mode and validate input').split('\n');
const start = sec.findIndex((l) => l.startsWith('1. If arguments contain `--project <dir>`'));
assert.ok(start !== -1, 'Phase 1 must still open with the --project rule');
const end = sec.findIndex((l, i) => i > start && /^2\. /.test(l));
assert.ok(end !== -1, 'Phase 1 must still have a rule 2');
return sec.slice(start, end).join('\n');
}
test('trekexecute Phase 1 --project runs brief-validator --soft --json and the execute resolver, and halts only on BRIEF_V51_MISSING_SIGNALS', () => {
const rule = projectRule();
const validator = libLine(rule, 'validators/brief-validator.mjs', '--soft --json');
const resolver = libLine(rule, 'profiles/resolver.mjs', '--resolve-phase-model --phase execute');
assert.equal(validator, 'node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json "{dir}/brief.md"');
assert.equal(resolver,
'node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase execute --brief-path "{dir}/brief.md" [--profile {profile}] --json');
const f = flat(rule);
for (const s of [
'The validator exits 1 when its `errors` are not empty; read its JSON either way.',
'Halt only when the validator\'s `errors` hold `BRIEF_V51_MISSING_SIGNALS`, with the message from "Sequencing gate surface" below.',
'Any other error is printed in one line and the run continues.',
'A validator or resolver that cannot run (no JSON) is "not measured" in one line, and the run continues.',
'Capture the resolver\'s JSON as `phase_signal_result` (Composition rule below).',
'No `{dir}/brief.md`: skip both lines.',
]) assert.ok(f.includes(s), `the --project rule is missing: ${s}`);
// The two lines run as printed: the validator names the one halting code for a
// 2.1 brief without signals, and the resolver hands Phase 2.5 its low effort.
const dir = mkdtempSync(join(tmpdir(), 'trekexecute-project-'));
try {
copyFileSync(join(ROOT, 'tests', 'fixtures', 'brief-v21-no-signals.md'), join(dir, 'brief.md'));
const v = bash(validator.split('{dir}').join(dir), ROOT);
assert.equal(v.status, 1, `validator: ${v.stdout}${v.stderr}`);
assert.ok(JSON.parse(v.stdout).errors.some((e) => e.code === 'BRIEF_V51_MISSING_SIGNALS'), v.stdout);
copyFileSync(join(ROOT, 'tests', 'fixtures', 'brief-effort-low.md'), join(dir, 'brief.md'));
const r = bash(resolver.split('{dir}').join(dir).replace(' [--profile {profile}]', ''), ROOT);
assert.equal(r.status, 0, `resolver: ${r.stdout}${r.stderr}`);
assert.equal(JSON.parse(r.stdout).effort, 'low');
} finally { rmSync(dir, { recursive: true, force: true }); }
});
// --- The merge lock refuses a review of an older commit ---------------------
// The merge lock (Phase 7.2 step 4 and the Phase 8 Main-merge gate) runs the
// verdict gate with `--repo .`, so a review of an older commit than the one
// being merged is refused (`review-stale`), and only the closing run's own
// review opens the merge.
const MERGE_GATE = '### Main-merge gate (MAIN_MERGE_GATE — always pauses)';
const PHASE_72 = '## Phase 7.2 — Simplify, review, verdict (P11)';
const LOCK_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{review_path}" --repo .';
const STEP4_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/review/verdict-gate.mjs --json "{project_dir}/review.md" --repo .';
/** A review_version 1.1 review.md of `sha`: ALLOW with no findings, or BLOCK over one BLOCKER. */
function lockReview(verdict, sha) {
const id = 'd'.repeat(40);
const findings = verdict === 'BLOCK'
? [{ id, severity: 'BLOCKER', rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd', recommended_action: 'Add a test.' }]
: [];
const counts = { BLOCKER: findings.length, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
const block = JSON.stringify({ verdict, counts, allow_blocked_by: [], findings }, null, 2);
return [
'---', 'type: trekreview', 'review_version: "1.1"', 'task: "t"', 'slug: s',
'project_dir: .claude/projects/2026-10-23-s/', 'brief_path: .claude/projects/2026-10-23-s/brief.md',
`scope_sha_end: ${sha}`, 'reviewed_files_count: 1', `verdict: ${verdict}`,
findings.length ? `findings:\n - ${id}` : 'findings: []', '---', '', '# Review: t', '',
'## Executive Summary', '', 'x', '', '## Coverage', '', '| File | Treatment | Reason |', '|---|---|---|',
'| a.mjs | summary-only | default |', '', '## Remediation Summary', '', 'x', '', '```json', block, '```', '',
].join('\n');
}
/**
* A throwaway repo (no git hooks): the reviewed commit, then a later commit
* that changes source. `review(verdict, sha)` writes review.md outside the
* repo and returns its folder.
*/
function staleRepo() {
const base = mkdtempSync(join(tmpdir(), 'trekexecute-lock-'));
const repo = join(base, 'repo');
const out = join(base, 'project');
mkdirSync(repo);
mkdirSync(out);
mkdirSync(join(base, 'hooks-none'));
const g = (...args) => {
const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8', env: childEnv() });
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
g('init', '-q', '-b', 'main');
g('config', 'user.name', 'Test');
g('config', 'user.email', 'test@example.invalid');
g('config', 'commit.gpgsign', 'false');
g('config', 'core.hooksPath', join(base, 'hooks-none'));
writeFileSync(join(repo, 'a.mjs'), 'export const a = 1;\n');
g('add', 'a.mjs');
g('commit', '-q', '-m', 'feat: the reviewed work');
const reviewed = g('rev-parse', 'HEAD');
writeFileSync(join(repo, 'a.mjs'), 'export const a = 2;\n');
g('add', 'a.mjs');
g('commit', '-q', '-m', 'feat: a later source change');
const head = g('rev-parse', 'HEAD');
const review = (verdict, sha) => {
writeFileSync(join(out, 'review.md'), lockReview(verdict, sha));
return out;
};
return { repo, reviewed, head, review, cleanup: () => rmSync(base, { recursive: true, force: true }) };
}
let sharedStale = null;
after(() => sharedStale?.cleanup());
/** The file's one staleRepo, built on first use: its tests only rewrite review.md, outside the repo. */
function staleFixture() {
if (!sharedStale) sharedStale = staleRepo();
return sharedStale;
}
/** The lock line with its review path filled in for review.md in `dir`. */
const fillLock = (cmd, dir) => cmd.split('{review_path}').join(join(dir, 'review.md')).split('{project_dir}').join(dir);
/** The lock line, filled for `dir`, run in `repo` through bash(). */
const runLock = (cmd, dir, repo) => bash(fillLock(cmd, dir), repo);
test('trekexecute merge lock: the Main-merge gate and Phase 7.2 step 4 each hold exactly one verdict-gate.mjs line, with --repo .', () => {
const text = read();
assert.equal(libLine(sectionText(text, MERGE_GATE), 'review/verdict-gate.mjs'), LOCK_LINE,
'the Phase 8 lock does not run the verdict gate against the repo being merged');
assert.equal(libLine(sectionText(text, PHASE_72), 'review/verdict-gate.mjs'), STEP4_LINE,
'Phase 7.2 step 4 does not run the verdict gate against the repo being merged');
});
test('trekexecute merge lock: the extracted lock lines, run on an ALLOW review of an older commit with a later source commit, exit 2 with review-stale', () => {
const text = read();
const lines = [libLine(sectionText(text, MERGE_GATE), 'review/verdict-gate.mjs'), libLine(sectionText(text, PHASE_72), 'review/verdict-gate.mjs')];
const fx = staleFixture();
const filled = lines.map((cmd) => fillLock(cmd, fx.review('ALLOW', fx.head)));
assert.equal(filled[0], filled[1], 'both lock lines fill to the same command');
for (const cmd of new Set(filled)) {
fx.review('ALLOW', fx.head);
const ok = bash(cmd, fx.repo);
assert.equal(ok.status, 0, `control: an ALLOW review of HEAD opens the lock: ${ok.stdout}${ok.stderr}`);
fx.review('ALLOW', fx.reviewed);
const r = bash(cmd, fx.repo);
assert.equal(r.status, 2, `${cmd}: an ALLOW review of an older commit must exit 2: ${r.stdout}${r.stderr}`);
const reason = JSON.parse(r.stdout).reason;
assert.ok(reason.startsWith('review-stale'), `the reason must start review-stale: ${reason}`);
}
});
test('trekexecute merge lock: Phase 7.2 step 4 run on a BLOCK review of an older commit still exits 1, so the fix loop opens', () => {
const step4 = libLine(sectionText(read(), PHASE_72), 'review/verdict-gate.mjs');
const fx = staleFixture();
const r = runLock(step4, fx.review('BLOCK', fx.reviewed), fx.repo);
assert.equal(r.status, 1, `a BLOCK review must exit 1 before any staleness check: ${r.stdout}${r.stderr}`);
assert.equal(JSON.parse(r.stdout).verdict, 'BLOCK');
});
// --- The resolver runs in Phase 1; low effort is one session; no gates escalation
// (fix round 4-5a, #33).
test('[#33] the Composition rule says the resolver runs in Phase 1, and no line says Phase 2.4 runs it', () => {
assert.doesNotMatch(flat(read()), /invoked in Phase 2\.4/);
assert.ok(flat(sectionText(read(), '## Composition rule (v5.1)')).includes('invoked in Phase 1 alongside the sequencing-gate brief-validator call'),
'the Composition rule does not say the resolver is invoked in Phase 1');
});
test('[#33] sibling: Composition rule has no "uniformity and stats" and no gates auto-escalation', () => {
const f = flat(sectionText(read(), '## Composition rule (v5.1)'));
assert.doesNotMatch(f, /uniformity and stats/, 'the Composition rule still says the model is returned for stats');
assert.ok(f.includes('it is returned for cross-command uniformity; no stats record carries it.'),
'the Composition rule does not say no stats record carries the model');
assert.doesNotMatch(f, /gates auto-escalation/);
});
test('[#33] Phase 2.5 names low effort as one session, steps in order, never while a wave is in flight', () => {
const sec = sectionText(read(), '## Phase 2.5 — Execution strategy decision').split('\n');
const start = sec.findIndex((l) => l.startsWith('**Run as single session (foreground)** when ANY of these are true:'));
assert.ok(start !== -1, 'Phase 2.5 must still list when it runs as a single session');
const end = sec.findIndex((l, i) => i > start && l.trim() === '');
const bullets = sec.slice(start + 1, end);
assert.ok(bullets.includes("- `phase_signal_result.effort == 'low'` → one session, steps in order, unless a wave is in flight: when "
+ '`.claude/trekplan-sessions/{slug}/orders/wave.json` exists in the main checkout (the first entry of `git worktree list`), '
+ 'this bullet does not apply and the multi-session path below collects the wave. The check only reads the file and never stops the run.'),
`the single-session list does not name low effort: ${bullets.join(' | ')}`);
});
test('[#33] no gates_mode = \'closed\', "strictest" or --event gates_mode_flag_override is left, and high effort says it changes nothing', () => {
const text = read();
const f = flat(text);
assert.doesNotMatch(f, /gates_mode = 'closed'/);
assert.doesNotMatch(f, /strictest/);
assert.doesNotMatch(f, /halts immediately/);
assert.doesNotMatch(text, /--event gates_mode_flag_override/);
assert.doesNotMatch(f, /gates_mode = '(adaptive|open)'/);
assert.equal(flat(sectionText(text, '### High-effort behavior (v5.1.1)')),
'### High-effort behavior (v5.1.1) High effort changes nothing in `/trekexecute`: the step helper and the controller '
+ 'already run opus, and every gate already runs. No `gates_mode` is set from the effort.');
});
// --- Phase 8 writes the merge evidence file before the merge call (4.5b2, MEV) --
/** The text from `heading` up to the next `## ` heading; the heading must exist. */
function untilNextH2(text, heading) {
const start = text.indexOf(heading);
assert.ok(start >= 0, `heading missing: ${heading}`);
const next = text.indexOf('\n## ', start + heading.length);
return text.slice(start, next === -1 ? undefined : next);
}
/** Every sentence of `text` (flattened). */
const sentences = (text) => flat(text).split(/(?<=\.)\s+(?=[A-Z`"*(])/);
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 = untilNextH2(read(), 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(untilNextH2(read(), 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');
});