voyage/tests/commands/trekresearch.test.mjs
Kjell Tore Guttormsen 8295601965
chore(cleanup): remove the June trials, orchestrator docs, dormant agent, caller-less code and the fix-round chain
R4-R7 of the cleanup list (decision 188): the June trial docs and
fixtures, the three orchestrator reference docs, the dormant
synthesis-agent, code with no caller (project-discovery, bash-normalize,
fidelity-diff, test-census, the next-session prompt validator, arm B and
the --workflow flag) and the fix-round chain. Living docs repointed; the
reasons for in-session orchestration and the all-opus pin are now written
in docs/architecture.md and docs/profiles.md.

New tests/lib/living-paths.test.mjs: living tracked text names only
plugin paths that exist (seen red with 22 dead pointers before this
commit). docs/T1-cc26-delegated-orchestration.md and
lib/review/gold-scorer.mjs stay until R-b: the frozen end-state registry
names them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:51:54 +01:00

350 lines
16 KiB
JavaScript

// tests/commands/trekresearch.test.mjs
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekresearch.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekresearch.md');
const PHASE = 'research';
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('trekresearch — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => {
const text = read();
assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'),
'/trekresearch must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate');
assert.ok(text.includes('phase_signals'),
'/trekresearch must reference phase_signals (v5.1 composition rule)');
});
test('trekresearch — low-effort path references --quick equivalent', () => {
const text = read();
// Bound the Composition rule section by the next `###` heading rather than a
// magic 2000-character window: a fixed count silently drops the match as soon
// as prose is inserted above it, turning a real pin into a no-op.
const sectionOf = (doc) => {
const compIdx = doc.indexOf('## Composition rule (v5.1)');
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
const nextHeading = doc.indexOf('\n### ', compIdx);
return nextHeading > compIdx ? doc.slice(compIdx, nextHeading) : doc.slice(compIdx);
};
// (a) positive: the low-effort path is documented inside the bounded section.
assert.match(sectionOf(text), /--quick/, 'Low-effort path must mention --quick equivalent');
// (b) negative: an actual removal must still be caught — a bound that can
// never fail proves nothing.
const mutated = text.replace(/--quick/g, '--removed');
assert.doesNotMatch(sectionOf(mutated), /--quick/,
'heading-bounded slice must still fail on a genuine removal');
});
// --- Step 7: Phase 5 bounded conversation loop (heading-bounded slices) ---
// Same bounding discipline as the Composition-rule pin above: slice from the
// phase heading to the NEXT phase heading, never a fixed character window.
function phaseSlice(doc, startHeading, endHeading) {
const start = doc.indexOf(startHeading);
assert.ok(start >= 0, `${startHeading} missing`);
const end = doc.indexOf(endHeading, start);
assert.ok(end > start, `${endHeading} missing — could not bound ${startHeading}`);
return doc.slice(start, end);
}
function phase5(doc) {
return phaseSlice(doc, '## Phase 5 —', '## Phase 6 —');
}
test('trekresearch — Phase 5 loop is gated on effort == high and names both primitives', () => {
const p5 = phase5(read());
assert.match(p5, /effort == 'high'/, 'Phase 5 loop must be gated on effort == \'high\'');
assert.match(p5, /research-loop-cap\.mjs/, 'Phase 5 must call the loop-cap shim per turn');
assert.match(p5, /query-privacy-gate\.mjs/, 'Phase 5 must route outbound queries through the privacy gate');
assert.match(p5, /\$\{CLAUDE_PLUGIN_ROOT\}/, 'shim invocations must use the ${CLAUDE_PLUGIN_ROOT} path form');
});
// CLAUDE_PLUGIN_DATA and CLAUDE_PLUGIN_ROOT are substituted in this command's
// TEXT but are EMPTY in the Bash tool's process env. Every snippet below runs
// in that env, so each needs a resolution that does not depend on it.
test('trekresearch — the scope-marker snippets resolve a root instead of requiring CLAUDE_PLUGIN_DATA', () => {
const p5 = phase5(read());
const blocks = [...p5.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1]);
const write = blocks.find((b) => b.includes('trekresearch-loop-scope') && b.includes('printf'));
const remove = blocks.find((b) => b.includes('trekresearch-loop-scope') && b.includes('rm -f'));
assert.ok(write, 'Phase 5 must carry the scope-marker write snippet');
assert.ok(remove, 'Phase 5 must carry the scope-marker removal snippet');
for (const [name, block] of [['write', write], ['remove', remove]]) {
assert.match(
block,
/\$\{CLAUDE_PLUGIN_DATA:-\$HOME\/\.claude\/voyage\}/,
`the ${name} snippet must fall back to the same root research-loop-cap.mjs resolves`,
);
assert.match(
block,
/case .* in\s*\n?\s*\/\*\)/,
`the ${name} snippet must guard on ONE absolute-path test — write and remove must not disagree on what counts as usable`,
);
}
// Unset, ${CLAUDE_CODE_SESSION_ID} composes a marker named `.json`, which no
// hook lookup and no TTL sweep ever matches or cleans up.
assert.match(
write,
/-n "\$\{?CLAUDE_CODE_SESSION_ID/,
'the write snippet must require a non-empty CLAUDE_CODE_SESSION_ID before composing the marker path',
);
});
test('trekresearch — the per-turn gates separate "gate could not run" from "gate says no"', () => {
const p5 = phase5(read());
assert.match(
p5,
/VOYAGE_ROOT/,
'the gate snippet must resolve a plugin root rather than interpolating ${CLAUDE_PLUGIN_ROOT} straight into `node`',
);
assert.match(
p5,
/exit 2|could not run/i,
'an unresolvable gate must be distinguishable from a denial — otherwise every query reads as a privacy violation no rewrite can clear',
);
assert.match(
p5,
/plugins\/cache/,
'the fallback must name the plugin cache location it searches',
);
});
test('trekresearch — Phase 5 declares the loop bound and all three exits', () => {
const p5 = phase5(read());
assert.match(p5, /### Loop bound/, 'Phase 5 must carry a `### Loop bound` sub-heading');
assert.match(
p5,
/\*\*Maximum 3 turns per under-illuminated dimension\.\*\*/,
'the bound must be stated verbatim',
);
// Three exits — converged / cap exhausted / operator stop.
assert.match(p5, /converged/i, 'exit 1 (converged) must be documented');
assert.match(p5, /exhaust/i, 'exit 2 (cap exhausted) must be documented');
assert.match(p5, /operator stop/i, 'exit 3 (operator stop) must be documented');
// Exhaustion must reach the operator — a silent cap is indistinguishable
// from convergence, which is the failure this loop exists to avoid.
assert.match(
p5,
/visibl|visible|print/i,
'cap exhaustion must be written visibly to the operator',
);
});
test('trekresearch — Phase 5 marks empty turns without re-targeting the same dimension', () => {
const p5 = phase5(read());
assert.match(p5, /`empty`/, 'a finding-less or citation-less turn must be marked `empty`');
assert.match(p5, /empty_turns/, 'empty turns must be counted (empty_turns)');
assert.match(
p5,
/does NOT re-target|not re-target/i,
'an empty turn must not re-target the same dimension',
);
});
test('trekresearch — Phase 5 states the no-brief default and the moot precedence matrix', () => {
const p5 = phase5(read());
// (a) no-brief default
assert.match(p5, /effort = 'standard'/, 'no-brief default effort must be stated');
assert.match(
p5,
/--project/,
'the no-brief default must be anchored to the absence of --project/brief.md',
);
// (b) precedence matrix — each entry independently makes the loop moot,
// mirroring the --engine moot gate in Phase 4.
for (const token of ['--quick', '--local', 'external_research_enabled']) {
assert.ok(p5.includes(token), `moot matrix must name ${token}`);
}
assert.match(p5, /moot/i, 'the matrix must use the same moot vocabulary as the engine gate');
// (c) interaction rule — effort: high without model under a cheap profile.
assert.match(
p5,
/effort: high/,
'the interaction rule for a brief carrying effort: high without model must be stated',
);
});
test('trekresearch — Phase 5 restates the honesty rule for loop output', () => {
const p5 = phase5(read());
// Whitespace-tolerant: the pin is on the sentence, not on where the
// paragraph happens to wrap.
assert.match(
p5,
/more\s+turns\s+do\s+not\s+make\s+a\s+finding\s+more\s+credible/i,
'the honesty hard rule must be restated for the loop output',
);
});
test('trekresearch — Phase 5 pins survive only while the prose does (mutation control)', () => {
const text = read();
const mutated = text.replace(/research-loop-cap\.mjs/g, 'removed-cap.mjs');
assert.doesNotMatch(
phase5(mutated),
/research-loop-cap\.mjs/,
'heading-bounded Phase 5 slice must still fail on a genuine removal',
);
});
test('trekresearch — High-effort behavior gives standard effort the wave-2 sentence and keeps the low effort sentence verbatim', () => {
const text = read();
const flatText = text.replace(/\s+/g, ' ');
assert.ok(
!flatText.includes('use the existing conditional triggers'),
'standard effort must not point at a conditional trigger that nothing defines',
);
assert.ok(
flatText.includes('Standard effort (or absent): the selected agents run as in Phase 4, and `contrarian-researcher`, when selected, runs as wave 2 after the other agents return (see Launch rules).'),
'the standard-effort sentence must say the contrarian runs as wave 2 when selected',
);
assert.ok(
text.includes('Low effort: inline research only, no agent swarm'),
'the low-effort sentence must survive the Phase 5 rewrite verbatim',
);
});
// --- Step 8: Phase 4.5 dimension discovery + Independence amendment ---
test('trekresearch — Phase 4.5 exists between Phase 4 and Phase 5 with the effort skip-guard', () => {
const text = read();
const p45 = text.indexOf('## Phase 4.5 —');
assert.ok(p45 >= 0, 'Phase 4.5 heading missing');
const p4 = text.indexOf('## Phase 4 —');
const p5 = text.indexOf('## Phase 5 —');
assert.ok(p4 >= 0 && p5 > p45 && p45 > p4, 'Phase 4.5 must sit between Phase 4 and Phase 5');
const slice = text.slice(p45, p5);
assert.match(
slice,
/\*\*Skip this phase entirely unless `phase_signal_result\.effort == 'high'`/,
'Phase 4.5 must carry the bolded skip-guard in the Phase 3.5 form',
);
assert.match(slice, /query-privacy-gate\.mjs/,
'Phase 4.5 must name the privacy gate as its compensating control');
assert.match(slice, /maxDimensions: 8|maxDimensions` *: *8/,
'Phase 4.5 must augment under the existing maxDimensions ceiling, not raise it');
});
// Phase 4.5 never invokes research-loop-cap.mjs, so the cap's own flag check
// does not reach it. Gating on effort alone means unsetting VOYAGE_STORM_ENABLED
// leaves half the mechanism live and the decline branch unreachable — while
// CLAUDE.md claims both phases go inert. The guard has to name both conditions.
test('trekresearch — Phase 4.5 skip-guard is gated on VOYAGE_STORM_ENABLED as well as effort', () => {
const text = read();
const p45 = text.indexOf('## Phase 4.5 —');
const p5 = text.indexOf('## Phase 5 —');
const slice = text.slice(p45, p5);
const guard = slice.slice(0, slice.indexOf('\n\n', slice.indexOf('**Skip this phase')));
assert.match(guard, /VOYAGE_STORM_ENABLED/,
'the Phase 4.5 skip-guard must name VOYAGE_STORM_ENABLED, not effort alone');
assert.match(guard, /\bAND\b|\*\*and\*\*/,
'the guard must be a conjunction — both conditions, not either');
assert.match(slice, /decline/i,
'Phase 4.5 must say why the flag gates it: the decline branch has to stay reachable');
});
test('trekresearch — Independence hard rule carries an explicit Phase 4.5 amendment', () => {
const text = read();
const rulesIdx = text.indexOf('## Hard rules');
assert.ok(rulesIdx >= 0, 'Hard rules section missing');
const rules = text.slice(rulesIdx);
const indIdx = rules.indexOf('**Independence:**');
assert.ok(indIdx >= 0, 'Independence hard rule missing');
// Bound the rule at the next bullet so the amendment must live inside it.
const nextBullet = rules.indexOf('\n- **', indIdx);
const independence = nextBullet > indIdx ? rules.slice(indIdx, nextBullet) : rules.slice(indIdx);
assert.match(independence, /Amend(ed|ment)/i,
'Independence must be explicitly amended, not silently contradicted');
assert.match(independence, /Phase 4\.5/, 'the amendment must name Phase 4.5 as the crossing');
assert.match(independence, /query-privacy-gate\.mjs/,
'the amendment must name the compensating control');
});
// --- v5.1.1 runtime SC4 + SC7 ---
test('trekresearch — 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('trekresearch — 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('trekresearch — 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('trekresearch — 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)}`,
);
});
// --- Spawn model, turn budget and stats writer ---
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/** From the line holding `marker` to the first blank line, flattened. */
function paragraphAt(text, marker) {
const lines = text.split('\n');
const start = lines.findIndex((l) => l.includes(marker));
assert.ok(start !== -1, `commands/trekresearch.md must hold "${marker}"`);
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) if (lines[i].trim() === '') { end = i; break; }
return flat(lines.slice(start, end).join(' '));
}
test('trekresearch — Phase 5 follow-ups take phase_signal_result.model, else opus', () => {
const text = read();
assert.doesNotMatch(flat(phase5(text)), /model: "opus"/);
const part = paragraphAt(text, 'launch a targeted follow-up agent');
assert.match(part, /launch a targeted follow-up agent \(model: phase_signal_result\.model, else opus — a follow-up may be one of Claude Code's built-in agents, which have no Voyage frontmatter to fall back to; see Launch rules\)/);
assert.match(flat(text), /captured as `phase_signal_result` and used at Agent-spawn sites in Phase 4 and Phase 5 to inject the resolved model/);
});
test('trekresearch — promises no maxTurns budget', () => {
const text = read();
assert.doesNotMatch(text, /maxTurns/);
assert.match(text, /- convention-scanner: medium\+ codebases only \(50\+ files\)/);
});
test('trekresearch — stats write through stats-append, never silently skipped', () => {
const md = read();
assert.match(md, /node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/stats\/stats-append\.mjs trekresearch <<'JSON'/);
assert.doesNotMatch(md, /skip (stats )?silently|silently skip/i);
assert.doesNotMatch(md, /\$\{CLAUDE_PLUGIN_DATA\}\/trekresearch-stats\.jsonl/);
});