The four profiles, the resolver, the profile validator and docs/profiles.md go; /trekbrief Phase 3.5 and the brief's phase_signals checks go (an older brief's key is ignored); MANIFEST_PROFILE_DRIFT and profile_used go. Every Profile, Composition rule, Sequencing gate and High-effort section leaves the commands. Decision 209: /trekresearch runs the full swarm with contrarian-researcher as wave 2 every run; /trekreview and the coordinator run all four passes with no high-effort normalization. Voyage agents are spawned with no model (their own model: opus applies); the two built-in spawns pass model "opus". --profile stays in the parser as a retired flag that swallows its value. §Model & effort moves to docs/architecture.md; the sequencing-gate eval case goes. Files removed: 44 (43 from the R3 list + tests/commands/trekreview.test.mjs, whose every test pinned removed text). Files edited: 43. Tests: 2467 (2466 pass / 0 fail / 1 skipped) before, 2309 (2309 pass / 0 fail / 0 skipped) after. Durable check: all three groups green, none pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
18 KiB
JavaScript
341 lines
18 KiB
JavaScript
// tests/commands/trekexecute.test.mjs
|
|
// Prose-pin and runtime tests for /trekexecute.
|
|
|
|
import { after, test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { 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 { 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');
|
|
|
|
function read() { return readFileSync(COMMAND_FILE, 'utf8'); }
|
|
|
|
// --- 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 -------------------------------------
|
|
// Phase 1's --project branch runs the brief-validator line /trekplan runs, and
|
|
// never halts on what it reports.
|
|
|
|
/** 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 never halts on its errors', () => {
|
|
const rule = projectRule();
|
|
const validator = libLine(rule, 'validators/brief-validator.mjs', '--soft --json');
|
|
assert.equal(validator, 'node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json "{dir}/brief.md"');
|
|
const f = flat(rule);
|
|
for (const s of [
|
|
'The validator exits 1 when its `errors` are not empty; read its JSON either way.',
|
|
'Any error is printed in one line and the run continues.',
|
|
'A validator that cannot run (no JSON) is "not measured" in one line, and the run continues.',
|
|
'No `{dir}/brief.md`: skip the line.',
|
|
]) assert.ok(f.includes(s), `the --project rule is missing: ${s}`);
|
|
|
|
// The line runs as printed: a brief with an error exits 1 and still prints JSON.
|
|
const dir = mkdtempSync(join(tmpdir(), 'trekexecute-project-'));
|
|
try {
|
|
writeFileSync(join(dir, 'brief.md'), '---\ntype: trekbrief\n---\n\n# Task\n');
|
|
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_MISSING_FIELD'), v.stdout);
|
|
} 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');
|
|
});
|
|
|
|
// --- No gates escalation (fix round 4-5a, #33).
|
|
|
|
test('[#33] no gates_mode = \'closed\', "strictest" or --event gates_mode_flag_override is left', () => {
|
|
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)'/);
|
|
});
|
|
|
|
// --- 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');
|
|
});
|