voyage/tests/commands/trekexecute.test.mjs
Kjell Tore Guttormsen 651be3468b
test(r8): move the live tests out of the round files
R8 of cleanup R-a (plan .claude/projects/2026-11-07-cleanup-r-a, appendix A).
The live tests of the round files move into the test named after their
source; each moved test was seen red under a mutation of its source.
New: tests/lib/lib-imports.test.mjs, tests/lib/timing.test.mjs (from
timing-under-load). The [#1] --gates rule and shim-line pins move into
tests/lib/autonomy-gate.test.mjs.

Removed: the round test files (retterunde, retterunde-prose-pins,
trial-mechanical-fixes, trial2-fixes, trial2-minor, trial2-minor-prose-pins,
fix-round-4-5a-prose, fix-round-4-5b2-b-prose, timing-under-load), the 8 R8
e2e scripts, and seven round test files on the R-a removal list that pinned
them (fix-round-4-5b-prose, fix-round-4-5b-part-2/3/4-scripts,
fix-round-4-5b-part-4-prose, fix-round-4-5b2-b/c-scripts). The diff-mode e2e
pin (test 24) and the red-script round-wrapper cases go too.

tests/synthetic/plan-run-C.md becomes tests/fixtures/plan-run-c.md,
regenerated from buildPlan({ version: '1.9' }). The red judge, reporter and
judge test headers now name the shared red script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:19:49 +01:00

377 lines
20 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 { findSection } from '../../lib/parsers/plan-schema.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 text of a heading's section, up to the next heading of the same or a
* higher level outside a fence. The heading must exist.
*/
function sectionText(text, heading) {
const level = heading.match(/^#+/)[0].length;
const s = findSection(text, heading.replace(/^#+ /, ''), level);
assert.ok(s, `missing ${heading}`);
const lines = s.text.split('\n');
let fenced = false;
for (let i = 1; i < lines.length; i++) {
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
const m = !fenced && lines[i].match(/^(#+) /);
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
}
return s.text;
}
/** 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, run in `repo` through bash(). */
function runLock(cmd, dir, repo) {
const filled = cmd.split('{review_path}').join(join(dir, 'review.md')).split('{project_dir}').join(dir);
return bash(filled, 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();
for (const cmd of lines) {
const ok = runLock(cmd, fx.review('ALLOW', fx.head), fx.repo);
assert.equal(ok.status, 0, `control: an ALLOW review of HEAD opens the lock: ${ok.stdout}${ok.stderr}`);
const r = runLock(cmd, fx.review('ALLOW', fx.reviewed), 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');
});