voyage/tests/lib/doc-consistency.test.mjs
Kjell Tore Guttormsen b208e4ee04 fix(voyage): S21 — close IPv4-mapped IPv6 SSRF bypass + security/safety audit (blind spot #2)
S21 = devil's-advocate blind spot #2 (security/safety), operator scope
"security-core": fix genuine defects, honestly record the rest.

SSRF fix (CWE-918). validateOtlpEndpoint classified the host by literal
string match. Decimal/hex/octal/trailing-dot encodings are already caught
(the WHATWG URL parser canonicalizes them), but IPv4-mapped IPv6 literals
(::ffff:127.0.0.1, ::ffff:192.168.x, ::ffff:169.254.169.254) render as
::ffff:HHHH:HHHH and matched neither the loopback set, the RFC-1918 regex,
link-local, nor HARD_BLOCKED_HOSTS — they passed over https and would reach
loopback / private / cloud-metadata, defeating the comment's "PERMANENTLY
block metadata" promise. Added mappedV4() to decode the embedded IPv4
(dotted + hex-pair forms) and classify on it. TDD: 4 tests failing-first —
mapped loopback/RFC-1918/metadata rejected (metadata stays HARD_BLOCKED even
with VOYAGE_OTEL_ALLOW_PRIVATE=1), mapped public ::ffff:8.8.8.8 still valid
(no over-block). Threat model narrow (endpoint is operator-set env; export
opt-in; a brief cannot set env).

Survivor #18 honest-residual. T2-bakeoff §3 "Classifier interference: 0"
read as satisfied, but the auto/bypass-mode re-run that matters for headless
trekreview was never run (mode is operator-set, not settable in-session) —
footnoted, not gating. Per recommendation #9, moved to an OPEN RESIDUAL
(untested), guarded by a new doc-consistency pin.

Audit record. ## S21 resolution block in devils-advocate-results.md
dispositions all four sub-questions: SSRF (fixed), hooks-block (verified;
advisory-rail residuals: Write-only matcher, Bash-redirect, regex gaps — by
design), malicious-brief-headless (catastrophe-blocked, exfil NOT blocked —
inherent limit). S21b flagged (NOT done): operations.md:15 mis-describes
autonomy-gate.mjs state machine + --gates table.

Test count: real baseline 700 (698 pass / 2 skip), NOT 715 — the node:test
headline carried in S20 commit msgs was stale/miscounted (census figures
were correct). Now 705 (703 pass / 2 skip / 0 fail); census behavior
601->605, doc-pins 71->72, total 672->677.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqBYc8Ltrk7LipyJmGxXiB
2026-06-19 20:02:56 +02:00

945 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// tests/lib/doc-consistency.test.mjs
// Pin invariants between prose (CLAUDE.md, README.md) and source files
// (agents/*.md, commands/*.md, templates/, settings.json).
//
// When this test fails, fix the source-of-truth — do NOT rewrite the test to
// hide drift. Borrowed pattern from llm-security commit 97c5c9d.
//
// Two kinds of pin live here (devil's-advocate audit §Top changes #8, S19):
// • STRUCTURAL cross-file invariants — derive one side from a source-of-truth
// (file counts, dimension counts from `### N.` headers, profile tables from
// the yaml, version-sync) and survive harmless rewording. These are the
// load-bearing ones; keep and grow them.
// • PROSE / EXISTENCE pins — assert a literal string or file fact. Most still
// guard a real wiring (CLI shims, annotate.mjs, anti-stale CHANGELOG,
// anti-false-claim), but they break on reword. Prefer a structural form
// when one exists; do not add a prose-pin that a structural test subsumes.
// All tests in this file count as "doc-consistency pins" in the suite census
// (lib/util/test-census.mjs) so the cited test total stays honest about how much
// is behavior coverage vs documentation pinning.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
import { resolveProfile, loadProfile } from '../../lib/profiles/resolver.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
function read(rel) { return readFileSync(join(ROOT, rel), 'utf-8'); }
function listMd(rel) { return readdirSync(join(ROOT, rel)).filter(f => f.endsWith('.md')); }
function listMjs(rel) { return readdirSync(join(ROOT, rel)).filter(f => f.endsWith('.mjs')); }
// Normalize a "<N|word> dimensions" descriptor to an integer (four..ten + digits).
const NUMWORDS = { four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10 };
function dimCounts(text, anchor) {
// Every dimension count stated on a line mentioning `anchor`.
return text.split('\n')
.filter(l => l.includes(anchor) && /dimensions/.test(l))
.map(l => l.match(/(\d+|four|five|six|seven|eight|nine|ten)\s+(?:numbered\s+)?dimensions/i))
.filter(Boolean)
.map(m => /^\d+$/.test(m[1]) ? Number(m[1]) : NUMWORDS[m[1].toLowerCase()]);
}
test('CLAUDE.md agents table row count == agents/*.md file count', () => {
const md = read('CLAUDE.md');
const agentFiles = listMd('agents');
const agentTable = md.split('## Agents')[1] || '';
const tableSection = agentTable.split('\n## ')[0];
const dataRows = tableSection
.split('\n')
.filter(l => l.startsWith('|') && !l.match(/^\|[\s-]+\|/) && !l.match(/^\|\s*Agent\s*\|/));
assert.equal(
dataRows.length,
agentFiles.length,
`Drift: ${agentFiles.length} agent files vs ${dataRows.length} CLAUDE.md table rows. ` +
`Sync agents/ ↔ CLAUDE.md.`,
);
});
test('README architecture block agent-count == agents/*.md file count', () => {
const md = read('README.md');
const m = md.match(/agents\/\s+(\d+)\s+specialized agents/);
assert.ok(m, 'README architecture block must state "N specialized agents"');
assert.equal(
Number(m[1]),
listMd('agents').length,
`Drift: README says ${m[1]} specialized agents vs ${listMd('agents').length} agents/*.md files.`,
);
});
test('README architecture block hook-count == hooks/scripts/*.mjs file count', () => {
const md = read('README.md');
const m = md.match(/hooks\/\s+(\d+)\s+hooks\s*\(/);
assert.ok(m, 'README architecture block must state "N hooks (...)"');
assert.equal(
Number(m[1]),
listMjs('hooks/scripts').length,
`Drift: README says ${m[1]} hooks vs ${listMjs('hooks/scripts').length} hooks/scripts/*.mjs files.`,
);
});
test('plan-critic dimension count: README + CLAUDE.md match the agent definition', () => {
const dims = (read('agents/plan-critic.md').match(/^### \d+\./gm) || []).length;
assert.ok(dims > 0, 'agents/plan-critic.md must define numbered "### N." dimensions');
const stated = [...dimCounts(read('README.md'), 'plan-critic'), ...dimCounts(read('CLAUDE.md'), 'plan-critic')];
assert.ok(stated.length >= 2, 'README + CLAUDE.md must cite plan-critic (N dimensions)');
for (const n of stated) {
assert.equal(n, dims, `Drift: a doc cites plan-critic (${n} dimensions) vs ${dims} numbered dims in agents/plan-critic.md.`);
}
});
test('brief-reviewer dimension count: README matches the agent definition', () => {
const agentDims = dimCounts(read('agents/brief-reviewer.md'), 'dimensions');
assert.ok(agentDims.length >= 1, 'agents/brief-reviewer.md must state "N dimensions"');
const expected = agentDims[0];
// Only the line stating brief-reviewer's OWN count — exclude agent-list lines
// that co-mention `plan-critic (N dimensions)` (those carry plan-critic's count).
const readmeDims = read('README.md').split('\n')
.filter(l => l.includes('brief-reviewer') && /dimensions/.test(l) && !l.includes('plan-critic'))
.map(l => l.match(/(\d+|four|five|six|seven|eight|nine|ten)\s+dimensions/i))
.filter(Boolean)
.map(m => /^\d+$/.test(m[1]) ? Number(m[1]) : NUMWORDS[m[1].toLowerCase()]);
assert.ok(readmeDims.length >= 1, 'README must cite brief-reviewer (N dimensions)');
for (const n of readmeDims) {
assert.equal(n, expected, `Drift: README cites brief-reviewer (${n} dimensions) vs ${expected} in agents/brief-reviewer.md.`);
}
});
test('CLAUDE.md commands table mentions every commands/*.md file', () => {
const md = read('CLAUDE.md');
const commandFiles = listMd('commands');
for (const f of commandFiles) {
const cmdName = `/${f.replace(/\.md$/, '')}`;
assert.ok(
md.includes(cmdName),
`commands/${f} not mentioned in CLAUDE.md (looked for ${cmdName})`,
);
}
});
test('every command frontmatter name matches its filename', () => {
for (const f of listMd('commands')) {
const text = read(`commands/${f}`);
const doc = parseDocument(text);
if (!doc.valid) continue;
const expected = f.replace(/\.md$/, '');
if (doc.parsed.frontmatter && doc.parsed.frontmatter.name !== undefined) {
assert.equal(
doc.parsed.frontmatter.name,
expected,
`commands/${f} frontmatter.name="${doc.parsed.frontmatter.name}" should be "${expected}"`,
);
}
}
});
test('templates/plan-template.md declares plan_version: 1.7', () => {
const tpl = read('templates/plan-template.md');
assert.match(tpl, /plan_version:\s*['"]?1\.7['"]?/);
});
// S19 prune (audit §Top changes #8): removed a tautological prose-pin —
// "commands/trekexecute.md still parses v1.7 plan schema" was an OR-chain
// (`includes('plan_version') || includes('Step N:') || includes('### Step ')`)
// that is ~always true and asserts almost nothing. The real v1.7 coverage lives
// in the structural pin "templates/plan-template.md declares plan_version: 1.7"
// (above) + the plan-validator / plan-schema behavior tests.
test('settings.json has only known top-level scopes after Spor 0 cleanup', () => {
const cfg = JSON.parse(read('settings.json'));
const known = ['trekplan', 'trekresearch'];
for (const k of Object.keys(cfg)) {
assert.ok(known.includes(k), `Unknown top-level scope in settings.json: ${k}`);
}
});
test('settings.json no longer carries vestigial exploration block', () => {
const cfg = JSON.parse(read('settings.json'));
assert.equal(cfg.trekplan?.exploration, undefined,
'exploration block was vestigial — should be deleted in v3.1.0 Spor 0');
assert.equal(cfg.trekplan?.agentTeam, undefined,
'agentTeam block was vestigial — should be deleted in v3.1.0 Spor 0');
});
// S19 prune (audit §Top changes #8): removed "CLAUDE.md mentions all six
// pipeline commands" — a hardcoded six-string list fully subsumed by the
// structural pin "CLAUDE.md commands table mentions every commands/*.md file"
// above, which derives the command set from the filesystem and auto-covers any
// new command. The hardcoded list was the weaker, reword-fragile form.
test('HANDOVER-CONTRACTS.md contains Handover 6 section', () => {
const text = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(
text.includes('## Handover 6'),
'docs/HANDOVER-CONTRACTS.md should document Handover 6 (review → plan)',
);
});
test('HANDOVER-CONTRACTS.md contains Handover 7 section (session-state)', () => {
const text = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(
text.includes('## Handover 7'),
'docs/HANDOVER-CONTRACTS.md should document Handover 7 (.session-state.local.json) ' +
'consumed by /trekcontinue',
);
assert.ok(
text.includes('.session-state.local.json'),
'Handover 7 section should name the artifact path',
);
});
test('review-validator has CLI shim', () => {
const text = read('lib/validators/review-validator.mjs');
assert.ok(
text.includes('import.meta.url === '),
'lib/validators/review-validator.mjs should expose the standard CLI shim ' +
'(if (import.meta.url === `file://${process.argv[1]}`)) so commands can call it from Bash',
);
});
test('session-state-validator has CLI shim', () => {
const text = read('lib/validators/session-state-validator.mjs');
assert.ok(
text.includes('import.meta.url === '),
'lib/validators/session-state-validator.mjs should expose the standard CLI shim ' +
'(if (import.meta.url === `file://${process.argv[1]}`)) so /trekcontinue can call it from Bash',
);
});
test('next-session-prompt-validator has CLI shim', () => {
const text = read('lib/validators/next-session-prompt-validator.mjs');
assert.ok(
text.includes('import.meta.url === '),
'lib/validators/next-session-prompt-validator.mjs should expose the standard CLI shim ' +
'(if (import.meta.url === `file://${process.argv[1]}`)) so /trekcontinue Phase 1.5 can call it from Bash',
);
});
test('HANDOVER-CONTRACTS.md Handover 7 documents § Lifecycle subsection', () => {
const text = read('docs/HANDOVER-CONTRACTS.md');
const h7Start = text.indexOf('## Handover 7');
assert.ok(h7Start >= 0, 'Handover 7 heading missing');
const h7End = text.indexOf('## Stability summary', h7Start);
assert.ok(h7End > h7Start, 'Stability summary heading missing — could not bound Handover 7');
const h7 = text.slice(h7Start, h7End);
assert.ok(
h7.includes('Lifecycle'),
'Handover 7 section should include a § Lifecycle subsection (SC-5 stale-file principle)',
);
});
test('HANDOVER-CONTRACTS.md Handover 7 § Lifecycle names --cleanup and produced_by contract', () => {
const text = read('docs/HANDOVER-CONTRACTS.md');
const h7Start = text.indexOf('## Handover 7');
const h7End = text.indexOf('## Stability summary', h7Start);
const h7 = text.slice(h7Start, h7End);
assert.ok(
h7.includes('--cleanup'),
'Handover 7 § Lifecycle should mention --cleanup as the operator-invoked stale-file remover',
);
assert.ok(
h7.includes('produced_by'),
'Handover 7 § Lifecycle should document the produced_by frontmatter contract for NEXT-SESSION-PROMPT.local.md',
);
});
// S19 prune (audit §Top changes #8): removed "CLAUDE.md mentions /trekcontinue
// command" — subsumed by "CLAUDE.md commands table mentions every commands/*.md
// file" (commands/trekcontinue.md exists, so that loop already asserts the
// /trekcontinue reference). Single-command existence pin → redundant.
test('rule-catalogue has exactly 12 entries', async () => {
const mod = await import('../../lib/review/rule-catalogue.mjs');
assert.strictEqual(
mod.RULE_CATALOGUE.length,
12,
'lib/review/rule-catalogue.mjs RULE_CATALOGUE size invariant: must be 12 (v1.0 baseline)',
);
});
test('headless-launch-template.md mirrors Phase 2.6 hardenings', () => {
const tpl = read('templates/headless-launch-template.md');
for (const needle of [
'GIT_OPTIONAL_LOCKS',
'--max-turns',
'--max-budget-usd',
'--append-system-prompt-file',
'SHARED_CONTEXT_FILE',
'SAFETY_PREAMBLE',
'git push origin',
'GH #36071',
'push-before-cleanup',
]) {
assert.ok(
tpl.includes(needle),
`templates/headless-launch-template.md should include "${needle}" (Step 10 mirrors Phase 2.6)`,
);
}
});
test('Phase 9 prose mandates parallel single-message dispatch + inline dedup', () => {
const cmd = read('commands/trekplan.md');
const orch = read('agents/planning-orchestrator.md');
// Single-message reinforcement appears in both (command + orchestrator)
assert.ok(
cmd.includes('single assistant message turn'),
'commands/trekplan.md Phase 9 should reinforce single-message parallel dispatch',
);
assert.ok(
orch.includes('single assistant message turn'),
'agents/planning-orchestrator.md Phase 6 should mirror the single-message parallel-dispatch contract',
);
// Dedup CLI shim is wired in both
assert.ok(
cmd.includes('plan-review-dedup.mjs'),
'commands/trekplan.md Phase 9 should call lib/review/plan-review-dedup.mjs after both reviewers complete',
);
assert.ok(
orch.includes('plan-review-dedup.mjs'),
'agents/planning-orchestrator.md Phase 6 should reference the dedup helper',
);
});
// --- S20 — anti-false-claim: orchestrator docs must not claim ALL sub-agents run on Sonnet ---
//
// All named swarm agents are pinned `model: opus` (operator decision 40d8742;
// structural source-of-truth = agent-frontmatter.test.mjs). The orchestrator
// reference docs carried a pre-pinning "Use Sonnet for all sub-agents" cost rule
// that contradicts that pinning. This anti-false-claim pin keeps the prose honest.
// Legitimate sonnet uses (economy profile; low-effort `phase_signals.model`) are
// per-phase overrides, NOT a blanket "all sub-agents" default — unaffected here.
test('orchestrator docs do not claim ALL sub-agents run on Sonnet (agents are model: opus) [S20]', () => {
const orchestrators = listMd('agents').filter(f => f.endsWith('orchestrator.md'));
assert.ok(orchestrators.length >= 3, `expected ≥ 3 orchestrator reference docs, got ${orchestrators.length}`);
for (const f of orchestrators) {
assert.ok(
!/Use Sonnet for all sub-agents/i.test(read(`agents/${f}`)),
`agents/${f}: stale "Use Sonnet for all sub-agents" contradicts the all-opus agent pinning — fix the prose, not this test`,
);
}
});
// --- S20 — anti-false-claim: operations.md custom-profile prose must match findProfilePath ---
//
// findProfilePath (lib/profiles/resolver.mjs:57) resolves built-in FIRST, then
// repo-root voyage-profiles/, then ~/.claude/voyage-profiles/. Custom profiles
// live in voyage-profiles/ (NOT lib/profiles/), must use NEW names, and CANNOT
// shadow a built-in. The old prose claimed the opposite (lib/profiles/<custom>;
// "override built-ins of the same name"; "alphabetical … precedence") — guard it.
test('operations.md custom-profile prose matches findProfilePath resolution order [S20]', () => {
const ops = read('docs/operations.md');
for (const bad of ['lib/profiles/<custom>', 'alphabetical', 'override built-ins of the same name']) {
assert.ok(
!ops.includes(bad),
`docs/operations.md: false custom-profile claim "${bad}" contradicts findProfilePath — fix the prose, not this test`,
);
}
assert.ok(
ops.includes('voyage-profiles/'),
'docs/operations.md must point custom profiles at voyage-profiles/ (not lib/profiles/)',
);
});
// --- v4.1 Step 21 — pin --profile + phase_models on the 6 commands ---
//
// CLAUDE.md / README.md pinning is deferred to Step 22 (post-write of
// those documents). Step 21 only verifies command-file content, which
// was written in Step 7 (Wave 3).
const PIPELINE_COMMANDS = [
'trekbrief.md',
'trekresearch.md',
'trekplan.md',
'trekexecute.md',
'trekreview.md',
'trekcontinue.md',
];
test('every pipeline command-file documents the --profile flag (SC #20)', () => {
for (const f of PIPELINE_COMMANDS) {
const text = read(`commands/${f}`);
assert.match(
text,
/--profile\b/,
`commands/${f}: --profile flag is required documentation in v4.1`,
);
}
});
test('command-files mentioning model profiles use canonical name `phase_models`', () => {
// Reject legacy / brainstormed alternatives that would confuse readers.
const FORBIDDEN = ['model_per_phase', 'phase_to_model', 'profile_phase_models'];
for (const f of PIPELINE_COMMANDS) {
const text = read(`commands/${f}`);
for (const bad of FORBIDDEN) {
assert.ok(
!text.includes(bad),
`commands/${f}: forbidden alias "${bad}" — canonical name is "phase_models"`,
);
}
}
});
test('at least one pipeline command-file references `phase_models` canonical name', () => {
// Sanity: not every command has to enumerate phase_models inline (e.g.
// trekbrief and trekcontinue may only mention --profile), but ≥ 1
// command-file must spell out the canonical name so the regression test
// pins drift.
let mentioned = 0;
for (const f of PIPELINE_COMMANDS) {
if (read(`commands/${f}`).includes('phase_models')) mentioned += 1;
}
assert.ok(
mentioned >= 1,
`expected ≥ 1 command-file to mention canonical name "phase_models", got ${mentioned}`,
);
});
// --- v4.1 Step 22 — post-write CLAUDE.md / README.md pinning ---
//
// Plan-critic Blocker 2 fix: Step 21 only pinned commands/*.md (which
// are written in Step 7 / Wave 3). Step 22 writes the top-level docs
// and extends pinning here so doc-consistency stays green AFTER Step 22.
test('CLAUDE.md documents --profile flag', () => {
const md = read('CLAUDE.md');
assert.match(
md,
/--profile\b/,
'CLAUDE.md must document the --profile flag (v4.1 SC #20)',
);
});
test('canonical name `phase_models` documented in docs/profiles.md', () => {
// v5.x doc consolidation (67f6dd5) moved profile/phase detail OUT of
// CLAUDE.md and into docs/. docs/profiles.md is now the canonical home for
// phase_models; CLAUDE.md only links to it via "Reference docs". Assert the
// canonical name lives in the canonical doc and no legacy alias crept in.
// (was: read('CLAUDE.md') — v4.1 SC #20.)
const md = read('docs/profiles.md');
assert.match(
md,
/phase_models/,
'docs/profiles.md must use canonical name "phase_models" (v4.1 SC #20)',
);
for (const bad of ['model_per_phase', 'phase_to_model', 'profile_phase_models']) {
assert.ok(
!md.includes(bad),
`docs/profiles.md must NOT use legacy alias "${bad}"`,
);
}
});
test('README.md documents --profile flag for all 6 commands', () => {
// SG1: README flag-table coverage is gating for SC #20. README is the
// primary discovery surface for new users.
const md = read('README.md');
// Top-level Profile system section is required so the flag is
// discoverable independent of per-command tables.
assert.match(md, /## Profile system/, 'README.md missing top-level "## Profile system" section');
// Every per-command Modes table must include --profile (count of
// --profile occurrences should be ≥ 6 — one per command + Profile
// system section).
const profileMentions = (md.match(/--profile\b/g) || []).length;
assert.ok(
profileMentions >= 6,
`README.md must mention --profile ≥ 6 times (one per command + section), got ${profileMentions}`,
);
});
test('CHANGELOG.md has v4.1.0 entry', () => {
const cl = read('CHANGELOG.md');
assert.match(
cl,
/## v4\.1\.0\b/,
'CHANGELOG.md must include "## v4.1.0" entry per Keep-a-Changelog 1.1.0',
);
});
test('docs/profiles.md exists and documents Custom.yaml authoring', () => {
const dp = read('docs/profiles.md');
assert.ok(dp.length > 1000, 'docs/profiles.md must be substantive (> 1000 chars)');
// Must document custom-profile authoring (Step 22 manifest must_contain
// pattern: "Custom.yaml" — case-insensitive match handled here as
// /[Cc]ustom[. ]/ to allow either "custom.yaml" or "Custom profile" prose).
assert.match(
dp,
/[Cc]ustom\.yaml|[Cc]ustom profile|<custom>\.yaml/,
'docs/profiles.md must document custom profile authoring',
);
});
test('commands/trekplan.md Phase 8 seals Opus-4.7 schema-drift defense', () => {
const cmd = read('commands/trekplan.md');
// Locate Phase 8 section
const phase8Start = cmd.indexOf('## Phase 8');
assert.ok(phase8Start >= 0, 'Phase 8 heading missing');
const phase8End = cmd.indexOf('## Phase 9', phase8Start);
assert.ok(phase8End > phase8Start, 'Phase 9 heading missing — could not bound Phase 8');
const phase8 = cmd.slice(phase8Start, phase8End);
// Required regex source-of-truth references
assert.ok(
phase8.includes('STEP_HEADING_REGEX'),
'Phase 8 should inline STEP_HEADING_REGEX so format contract survives without orchestrator-doc loading',
);
assert.ok(
phase8.includes('FORBIDDEN_HEADING_REGEX'),
'Phase 8 should inline FORBIDDEN_HEADING_REGEX (Step 7 — schema-drift seal)',
);
// Required validator self-check
assert.ok(
phase8.includes('plan-validator.mjs --strict'),
'Phase 8 should mandate post-write `plan-validator.mjs --strict` self-check',
);
// Forbidden-headings list (literal "FORBIDDEN" appears more than once: in regex const + in human-readable list)
assert.ok(
/FORBIDDEN/.test(phase8),
'Phase 8 should explicitly enumerate FORBIDDEN headings',
);
});
// --- v5.0.0 / v5.0.1 — bespoke playground removed; /playground invocation explicit ---
//
// v5.0.0 removed the bespoke playground SPA, /trekrevise, and Handover 8.
// v5.0.1 dropped the v5.0.0 stop-gap (scripts/render-artifact.mjs) and made
// the producing commands print a literal, copy-paste-ready /playground
// document-critique invocation instead. These pins lock both removals in
// AND pin the new copy-paste invocation as the operator-facing contract.
import { existsSync } from 'node:fs';
test('playground/ directory no longer exists (removed in v5.0.0)', () => {
assert.ok(
!existsSync(join(ROOT, 'playground')),
'plugins/voyage/playground/ should be deleted — the bespoke playground was retired in v5.0.0',
);
});
test('commands/trekrevise.md no longer exists (removed in v5.0.0)', () => {
assert.ok(
!existsSync(join(ROOT, 'commands/trekrevise.md')),
'/trekrevise was removed in v5.0.0 — its command file should be gone',
);
});
test('Handover 8 deleted from HANDOVER-CONTRACTS.md (back to seven handovers)', () => {
const text = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(!text.includes('## Handover 8'), 'Handover 8 section should be removed in v5.0.0');
assert.ok(text.includes('## Handover 7'), 'Handover 7 must remain');
});
test('scripts/render-artifact.mjs is still removed (v5.0.1 + v5.0.2)', () => {
assert.ok(
!existsSync(join(ROOT, 'scripts/render-artifact.mjs')),
'scripts/render-artifact.mjs should be deleted — v5.0.1 dropped the standalone HTML render; v5.0.2 kept it removed (annotate.mjs is the replacement)',
);
});
test('scripts/annotate.mjs exists (v5.0.2 operator-annotation HTML generator)', () => {
assert.ok(
existsSync(join(ROOT, 'scripts/annotate.mjs')),
'scripts/annotate.mjs is required — producing commands call it to build the operator-annotation HTML',
);
});
test('producing commands reference scripts/annotate.mjs (v5.0.2 render-and-link step)', () => {
// v5.0.0 → v5.0.1 → v5.0.2 chain: v5.0.0 added an HTML render that didn't
// afford annotation; v5.0.1 pointed at /playground document-critique (which
// pre-generates Claude's suggestions, not operator-driven annotation); v5.0.2
// ships scripts/annotate.mjs — an operator-driven annotation surface where
// the OPERATOR clicks lines and writes their own notes. Pin the wiring.
for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
assert.ok(
read(`commands/${f}`).includes('scripts/annotate.mjs'),
`commands/${f} must invoke scripts/annotate.mjs to build the operator-annotation HTML (v5.0.2)`,
);
}
});
test('producing commands no longer print the v5.0.1 /playground document-critique line', () => {
// v5.0.1 told operators to copy-paste "/playground build a document-critique
// playground for X" — but that flow pre-generates Claude's suggestions. The
// operator asked for their own annotations, not a critique of Claude's.
// v5.0.2 removes that line from the producing commands' final report.
for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
assert.ok(
!read(`commands/${f}`).includes('/playground build a document-critique'),
`commands/${f} must not print the v5.0.1 /playground document-critique invocation — v5.0.2 replaces it with annotate.mjs`,
);
}
});
test('producing commands tell the operator the flow is THEIR own annotations', () => {
// Pin language: every producing command's prose must mention that the
// OPERATOR drives annotation, not Claude. Phrase variants are allowed
// ("YOUR OWN note", "operator drives", etc.) — we look for the operator-
// ownership signal.
for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
const text = read(`commands/${f}`);
assert.ok(
/YOUR OWN|operator drives|your own/i.test(text),
`commands/${f} must signal that the operator drives annotation (v5.0.2 contract)`,
);
}
});
test('producing commands emit file:// link in final report (operator-UX contract, 2026-05-13)', () => {
// Operator runs Ghostty / iTerm2 / modern Terminal.app — all support cmd+click
// on file:// URLs. Producing commands MUST emit both forms: (a) plain file://
// line in the report block, (b) `open file://...` copy-pasteable command.
// Both must reference $ANNOT_HTML (absolute path from scripts/annotate.mjs).
for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
const text = read(`commands/${f}`);
assert.ok(
/file:\/\/\{\$ANNOT_HTML\}/.test(text),
`commands/${f} must include "file://{$ANNOT_HTML}" plain URL in the final report block`,
);
assert.ok(
/open file:\/\/\{\$ANNOT_HTML\}/.test(text),
`commands/${f} must include "open file://{$ANNOT_HTML}" copy-pasteable command in the final report block`,
);
}
});
test('package.json still has no "npm run render" script (removed in v5.0.1)', () => {
const pkg = JSON.parse(read('package.json'));
assert.equal(
pkg.scripts && pkg.scripts.render,
undefined,
'package.json scripts.render should remain gone',
);
});
test('CHANGELOG.md has v5.0.0 entry', () => {
const cl = read('CHANGELOG.md');
assert.match(cl, /## v5\.0\.0\b/, 'CHANGELOG.md must include "## v5.0.0" entry');
});
test('CHANGELOG.md has v5.0.1 entry', () => {
const cl = read('CHANGELOG.md');
assert.match(cl, /## v5\.0\.1\b/, 'CHANGELOG.md must include "## v5.0.1" entry');
});
test('CHANGELOG.md has v5.0.2 entry', () => {
const cl = read('CHANGELOG.md');
assert.match(cl, /## v5\.0\.2\b/, 'CHANGELOG.md must include "## v5.0.2" entry');
});
test('CHANGELOG.md retains v4.2.0 entry (history is not rewritten)', () => {
const cl = read('CHANGELOG.md');
assert.match(cl, /## v4\.2\.0\b/, 'CHANGELOG.md must keep the historical "## v4.2.0" entry');
});
test('CHANGELOG.md has v5.5.0 entry (S13 coordinated release)', () => {
const cl = read('CHANGELOG.md');
assert.match(cl, /## v5\.5\.0\b/, 'CHANGELOG.md must include "## v5.5.0" entry — the coordinated release that lands brief_version 2.2 + NW1NW3');
});
test('version is consistent across plugin.json, package.json, README badge, and CHANGELOG top entry', () => {
// versjonssync invariant (CLAUDE.md rule): every file that names the plugin
// version must agree. Bumping one without the others is a release defect.
const pluginVersion = JSON.parse(read('.claude-plugin/plugin.json')).version;
const pkgVersion = JSON.parse(read('package.json')).version;
const badgeVersion = (read('README.md').match(/badge\/version-([\d.]+)-/) || [])[1];
const changelogTop = (read('CHANGELOG.md').match(/## v(\d+\.\d+\.\d+)\b/) || [])[1];
assert.ok(pluginVersion, 'plugin.json missing version');
assert.equal(pkgVersion, pluginVersion, `package.json version ${pkgVersion} != plugin.json ${pluginVersion}`);
assert.equal(badgeVersion, pluginVersion, `README badge ${badgeVersion} != plugin.json ${pluginVersion}`);
assert.equal(changelogTop, pluginVersion, `CHANGELOG top entry v${changelogTop} != plugin.json ${pluginVersion}`);
});
test('operational files no longer reference trekrevise (v5.0.0 removal)', () => {
// Templates, the touched command/orchestrator files, settings.json, and the
// handover-contracts doc must be fully scrubbed. CLAUDE.md / README.md are
// intentionally allowed to mention /trekrevise in their "removed in v5.0.0"
// prose — those are historical notes, not live references.
const targets = [
'settings.json',
'docs/HANDOVER-CONTRACTS.md',
'templates/plan-template.md', 'templates/trekbrief-template.md', 'templates/trekreview-template.md',
'commands/trekplan.md', 'commands/trekbrief.md', 'commands/trekreview.md',
'agents/planning-orchestrator.md',
];
for (const t of targets) {
assert.ok(
!read(t).includes('trekrevise'),
`${t} still references trekrevise — it was removed in v5.0.0`,
);
}
});
// --- v5.1 — phase_signals + brief_version 2.1 ---
test('v5.5 — templates/trekbrief-template.md declares brief_version: "2.2" (quoted)', () => {
const t = read('templates/trekbrief-template.md');
assert.match(t, /^brief_version: "2\.2"$/m,
'trekbrief-template.md must declare brief_version: "2.2" (quoted) — unquoted parses as Number and bypasses sequencing gate; v5.5 bumped 2.1→2.2 for framing enforcement');
});
test('v5.1 — templates/trekbrief-template.md contains phase_signals: block', () => {
const t = read('templates/trekbrief-template.md');
assert.match(t, /^phase_signals:$/m,
'trekbrief-template.md must contain a phase_signals: block in frontmatter');
});
test('v5.1 — HANDOVER-CONTRACTS.md schema row includes phase_signals + phase_signals_partial', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(t.includes('| `phase_signals` |'),
'HANDOVER-CONTRACTS must add a phase_signals row to the Handover 1 schema table');
assert.ok(t.includes('| `phase_signals_partial` |'),
'HANDOVER-CONTRACTS must add a phase_signals_partial row to the Handover 1 schema table');
});
test('v5.1 — phase_signals documented in docs/HANDOVER-CONTRACTS.md', () => {
// v5.x doc consolidation (67f6dd5) moved phase_signals out of CLAUDE.md.
// phase_signals is a Handover 1 (brief) field, so docs/HANDOVER-CONTRACTS.md
// is its canonical home (the schema rows are pinned separately above).
// CLAUDE.md only links to it. (was: read('CLAUDE.md') — v5.1.)
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(t.includes('phase_signals'),
'docs/HANDOVER-CONTRACTS.md must document phase_signals (v5.1)');
});
test('v5.1 — voyage README.md mentions phase_signals', () => {
const t = read('README.md');
assert.ok(t.includes('phase_signals'),
'voyage README.md must mention phase_signals (v5.1 "What\'s new" bullet)');
});
// --- v5.1.1 — High-effort behavior sub-section per command (Step 10) ---
test('v5.1.1 — commands/trekplan.md contains ### High-effort behavior (v5.1.1) sub-section', () => {
const t = read('commands/trekplan.md');
assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m,
'trekplan.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (Decision B + gemini-bridge)');
});
test('v5.1.1 — commands/trekresearch.md contains ### High-effort behavior (v5.1.1) sub-section', () => {
const t = read('commands/trekresearch.md');
assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m,
'trekresearch.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (contrarian-researcher + gemini-bridge always-on)');
});
test('v5.1.1 — commands/trekreview.md contains ### High-effort behavior (v5.1.1) sub-section', () => {
const t = read('commands/trekreview.md');
assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m,
'trekreview.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (skip Pass 3 + coordinator normalization)');
});
test('v5.1.1 — commands/trekexecute.md contains ### High-effort behavior (v5.1.1) sub-section', () => {
const t = read('commands/trekexecute.md');
assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m,
'trekexecute.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (gates_mode = closed)');
});
// --- v5.4 — brief.md formalized as PUBLIC CONTRACT (Handover 1) ---
// S5: the brief schema is the only public producer↔Voyage integration boundary
// (Trinity asymmetry invariant). These pin the formalization as enforced doc-truth,
// not prose — same pattern as the v5.1 Handover-1 pins above. Freeze, not promote:
// phase_signals stays optional; brief_version 2.1 is the frozen baseline (S3 decision).
test('v5.4 — HANDOVER-CONTRACTS.md Handover 1 heading is labeled (PUBLIC CONTRACT)', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.match(t, /^## Handover 1 —.*\(PUBLIC CONTRACT\)\s*$/m,
'Handover 1 heading must carry the (PUBLIC CONTRACT) label — symmetric with Handover 3 (EXTERNAL CONTRACT)');
});
test('v5.4 — Handover 1 declares the PUBLIC CONTRACT callout + breaking-for-downstream guarantee', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(t.includes('Stability tier: PUBLIC CONTRACT'),
'Handover 1 must open with a "Stability tier: PUBLIC CONTRACT" callout');
assert.ok(t.includes('breaking change for every downstream consumer'),
'the callout must state that a brief-schema change is a breaking change for every downstream consumer');
});
test('v5.5 — brief_version 2.2 is current in the public-contract schema table (no stale 2.0/2.1)', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(!t.includes('`"2.0"` (current)'),
'Handover 1 schema table must not still mark brief_version "2.0" as current (stale)');
assert.ok(!t.includes('`"2.1"` (current)'),
'Handover 1 schema table must not still mark brief_version "2.1" as current (stale — v5.5 bumped to 2.2)');
assert.ok(t.includes('`"2.2"` (current)'),
'Handover 1 schema table must mark brief_version "2.2" as current');
});
test('v5.5 — phase_signals stays optional: not promoted to required', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(!t.includes('v5.4 may promote'),
'the speculative "v5.4 may promote phase_signals to required" line must be resolved (S3 froze the shape)');
assert.ok(!/v5\.4/.test(t),
'the contract doc must not cite the phantom v5.4 release (v5.25.4 never shipped; S16 → v5.5.0)');
assert.ok(t.includes('established `2.1` as the public-contract baseline'),
'the Versioning note must record the v5.5.0 contract formalization establishing 2.1 as the baseline');
});
// --- v5.5 — framing enforcement: brief_version 2.2 (Handover 1 contract evolution) ---
// S6: the framing-alignment invariant (CLAUDE.md) ships as a controlled brief_version
// bump 2.1→2.2 adding two required-at-2.2 elements (framing field + ## TL;DR section),
// version-gated so 2.0/2.1 briefs stay valid. These pin the contract-doc evolution.
test('v5.5 — Handover 1 schema table adds the framing field row', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(t.includes('| `framing` |'),
'HANDOVER-CONTRACTS must add a framing row to the Handover 1 schema table');
assert.ok(t.includes('preserve \\| refine \\| replace \\| new-direction'),
'the framing row must list the four canonical enum values');
});
test('v5.5 — Handover 1 documents the framing gate + required-at-2.2 TL;DR section', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(t.includes('v5.5 framing gate'),
'validation-strategy table must add a v5.5 framing gate row');
assert.ok(/##\s*TL;DR.*required at `brief_version ≥ 2\.2`/.test(t) || t.includes('`## TL;DR` — **required at `brief_version ≥ 2.2`**'),
'body-invariants must mark ## TL;DR as required at brief_version ≥ 2.2');
});
test('v5.5 — Versioning note records the 2.2 framing-enforcement evolution', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(t.includes('v5.5 → `2.2` (framing enforcement)'),
'the Versioning note must document the v5.5 → 2.2 framing-enforcement evolution');
});
test('v5.5 — voyage README.md mentions framing enforcement / brief_version 2.2', () => {
const t = read('README.md');
assert.ok(t.includes('framing') && t.includes('brief_version 2.2'),
'voyage README.md must carry a "What\'s new" note for framing enforcement (brief_version 2.2)');
});
// --- S15 — default-profile name + per-phase model claims (cost-claim truth-pass) ---
// The resolver's hardcoded default (lib/profiles/resolver.mjs) is the single
// source of truth for which built-in profile a fresh user gets. README,
// docs/profiles.md, and docs/operations.md must all mark THAT profile as the
// default and must not mark any other built-in as default. Guards the
// code-vs-docs contradiction the S14 audit surfaced (Addendum finding #3).
const PROFILE_DOCS = ['README.md', 'docs/profiles.md', 'docs/operations.md'];
test('S15: default-profile name is consistent across resolver + all profile docs', () => {
// Empty env so the operator's VOYAGE_PROFILE does not mask the hardcoded default.
const { profile: def, profile_source } = resolveProfile({}, {});
assert.equal(profile_source, 'default', 'resolveProfile({}, {}) must report source=default');
assert.equal(def, 'premium', 'resolver hardcoded default is premium (operator decision 2026-05-13, commit 40d8742)');
const OTHERS = ['economy', 'balanced', 'premium'].filter((p) => p !== def);
for (const doc of PROFILE_DOCS) {
const body = read(doc);
assert.ok(
body.includes(`\`${def}\` (default)`),
`${doc}: profile table must mark \`${def}\` as "(default)" to match resolver`,
);
for (const other of OTHERS) {
assert.ok(
!body.includes(`\`${other}\` (default)`),
`${doc}: must NOT mark \`${other}\` as "(default)" — resolver default is ${def}`,
);
assert.ok(
!body.includes(`Default \`${other}\``),
`${doc}: lookup-order must not say "Default \`${other}\`" — resolver default is ${def}`,
);
}
}
});
test('S15: profile tables encode each built-in yaml phase_models exactly', () => {
// Column order in every profile table: Profile | Brief | Research | Plan | Execute | Review | Continue | Use case
const PHASES = ['brief', 'research', 'plan', 'execute', 'review', 'continue'];
for (const name of ['economy', 'balanced', 'premium']) {
const pm = loadProfile(name).phase_models; // {brief:'opus', ...}
const expected = PHASES.map((ph) => pm[ph]);
for (const doc of PROFILE_DOCS) {
const row = read(doc)
.split('\n')
.find((l) => new RegExp(`^\\|\\s*\`${name}\``).test(l));
assert.ok(row, `${doc}: profile table is missing a \`${name}\` row`);
const cells = row.split('|').map((c) => c.trim());
// cells[0] is '' (leading pipe), cells[1] is the name, cells[2..7] are the 6 models
const docModels = cells.slice(2, 8);
assert.deepEqual(
docModels,
expected,
`${doc}: \`${name}\` row models ${JSON.stringify(docModels)} must equal lib/profiles/${name}.yaml ${JSON.stringify(expected)}`,
);
}
}
});
test('S15: README cost prose does not resurrect the false Sonnet-swarm claim', () => {
// All 24 sub-agents are model: opus (operator-pinned, commit 40d8742) and the
// model is uniform per phase — there is no "Opus orchestrates / Sonnet runs
// the swarms" split. These phrases asserted the false split and must stay out.
const md = read('README.md');
const BANNED = [
'Sonnet exploration',
'Sonnet runs the exploration',
'front-loads cheap Sonnet',
'exploration agents stay on Sonnet',
];
for (const phrase of BANNED) {
assert.ok(
!md.includes(phrase),
`README.md must not claim "${phrase}" — sub-agents are opus-pinned; default profile runs Opus on every phase`,
);
}
});
// NOTE (S17): the "reference-only orchestrators must not grant the Agent tool"
// invariant (Survivor #5) is guarded in tests/lib/agent-frontmatter.test.mjs
// (its canonical frontmatter home), not duplicated here.
test('S17: NW2 bake-off verdict is not labeled unqualified POSITIVE (raw data un-archived)', () => {
// Survivor #3: the per-run JSON (a1..b3.json) was never committed
// (git log --diff-filter=A is empty), so the medians/jaccards cannot be
// re-derived, audited, or falsified. The verdict must be qualified as
// opt-in-defensible, and the un-reproducibility must be disclosed.
const doc = read('docs/T2-bakeoff-results.md');
assert.ok(
!/VERDICT:\s*\*\*POSITIVE\*\*/.test(doc),
'T2-bakeoff-results.md still labels the verdict bare POSITIVE — downgrade to opt-in-defensible (Survivor #3).',
);
assert.ok(
/un-archived|not committed|cannot be re-derived/i.test(doc),
'T2-bakeoff-results.md must disclose the raw per-run data is un-archived / un-reproducible.',
);
});
test('S21: T2-bakeoff §3 marks the auto/bypass classifier check as an OPEN RESIDUAL, not satisfied (Survivor #18)', () => {
// The W1 charter made an auto/bypass-mode re-run an explicit guard (trekreview
// runs headless under those modes). It was never performed and was footnoted
// rather than gating. Per recommendation #9, the classifier-interference
// result must read as an open residual / untested — not "metric satisfied".
const doc = read('docs/T2-bakeoff-results.md');
assert.ok(
/open residual/i.test(doc) && /untested/i.test(doc),
'T2-bakeoff §3 must mark the auto/bypass classifier check as an OPEN RESIDUAL / untested (Survivor #18 / S21).',
);
});
// ── S18: framing-hardening cross-file invariants ───────────────────────────
test('S18: --min-brief-version is documented at the trekplan + trekresearch boundary', () => {
// The opt-in version floor must be discoverable wherever flags live: both
// consuming commands and the central flag reference.
for (const f of ['commands/trekplan.md', 'commands/trekresearch.md', 'docs/command-modes.md']) {
assert.ok(
read(f).includes('--min-brief-version'),
`${f} must document the --min-brief-version flag (S18 framing-hardening)`,
);
}
});
test('S18: HANDOVER-CONTRACTS documents the pre-2.2 zero-framing-enforcement hole', () => {
// The framing defense is producer-elective: a brief declaring ≤ 2.1 sidesteps
// it entirely. Handover 1 (PUBLIC CONTRACT) must disclose this and name the remedy.
const h = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(
/BRIEF_VERSION_BELOW_MINIMUM/.test(h),
'HANDOVER-CONTRACTS.md must name the BRIEF_VERSION_BELOW_MINIMUM gate as the pre-2.2 remedy',
);
assert.ok(
/zero framing enforcement|no framing enforcement|sidestep/i.test(h),
'HANDOVER-CONTRACTS.md must state that pre-2.2 briefs receive zero framing enforcement',
);
});