voyage/tests/lib/agent-frontmatter.test.mjs
Kjell Tore Guttormsen 1ca48e0cd0 release(v5.10.1): drop gemini-bridge from the pipeline; correct the T1 §6 PoC status
gemini-bridge is dropped by operator directive (three repetitions). The drop is
permanent, not a wait-for-SDK-upgrade state, so the change clears every LIVE
surface that could still steer a run toward the agent and leaves the historical
record alone.

Live surfaces cleared: agents/gemini-bridge.md deleted; trekresearch (bridge
launch block, --local help, high-effort always-on pair, stats record,
degradation list), trekplan, trekbrief, research-orchestrator (mode table,
agent table, prompting block, summary line), README (feature prose, mode table,
agent prose, mermaid EXTERNAL node, conditional legend, optional-MCP
requirement, --local section), CLAUDE.md, settings.json (the whole
trekresearch.geminiBridge block), both templates, architecture.md,
command-modes.md.

trekplan high-effort Adversarial Pass 2 now degrades EXPLICITLY: it emits its
section with status "unavailable, skipped" instead of failing or vanishing. A
high-effort plan carrying no Pass 2 marker is indistinguishable from one whose
Pass 2 crashed, which is the failure mode this wording exists to prevent.

gemini_used is deliberately KEPT as a vestigial trekresearch stats field pinned
to false. Removing it would break the observability export schema for existing
consumers, and the directive was about the agent, not the field.

Not touched: CHANGELOG history and the measurement/decision docs keep their
gemini references. They record what a past version did or what was measured
then; rewriting them is the same defect class as bumping a version string
inside a measurement doc.

Driven test-first. Five new pins in tests/lib/doc-consistency.test.mjs, verified
RED before the edits, including a KNOWN-POSITIVE CONTROL asserting the
historical records still DO carry gemini references — so the empty result on
live surfaces is a measurement and not a broken query (Verifiseringsloven
ansikt 4). Agent inventory 24 -> 23 (20 spawnable + 3 orchestrator reference
docs); the <example>-block floor moves 34 -> 32 because an agent legitimately
left the inventory, not because examples went missing from a surviving one.

Docs: docs/T1-cc26-delegated-orchestration.md §8 item 3 claimed both the §6
synthesis-agent PoC and the §5 bake-off were "designed but unbuilt". That was
written in S7 and falsified the same afternoon by S12, which ran the §6 PoC and
recorded Δ main-context (faithful flow) = 0.0%, NEGATIVE. The stale wording is
what caused the settled PoC to be re-ordered as new work on 2026-09-02, so it is
struck rather than deleted and §6 gained a RUN AND DECLINED status block. The
finding is structural, not stochastic: Phase 5 spawns the exploration swarm
foreground (trekplan.md:158,338-341), so the outputs are already resident in
main before Phase 7 — delegating only the Phase-7 digest evicts nothing.

Also measured 2026-09-03 (CC 2.1.259): claude -p --output-format stream-json
runs on subscription auth with no ANTHROPIC_API_KEY and now emits a
subagent_stats block, so S12's environment-block premise is half stale. Recorded
in §8 item 4. It lowers the cost of §5; it changes nothing about §6.

Suite 1041 (1039/0/2), up from 1036 by exactly the five tests added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:29:39 +02:00

169 lines
6.5 KiB
JavaScript

// tests/lib/agent-frontmatter.test.mjs
// Pin the agent-frontmatter contract from Steps 1-3 of plan-v2:
// every agents/*.md MUST declare:
// - model: (one of opus | sonnet | haiku)
// - tools: (allowlist) OR disallowedTools: (denylist), at least one
// Orchestrator agents (planning/research/review) MUST be model: opus and
// MUST include the `Agent` tool in their tools allowlist (they spawn the swarm).
//
// When this test fails, fix the agent file — do NOT relax the assertion to
// hide drift. The contract is what /trek* commands rely on for
// disciplined model selection + tool scoping.
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';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const AGENTS_DIR = join(ROOT, 'agents');
const ORCHESTRATORS = new Set([
'planning-orchestrator.md',
'research-orchestrator.md',
'review-orchestrator.md',
]);
const ALLOWED_MODELS = new Set(['opus', 'sonnet', 'haiku']);
function read(rel) {
return readFileSync(join(ROOT, rel), 'utf-8');
}
function extractFrontmatter(text) {
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
return m ? m[1] : null;
}
function hasTopLevelKey(fm, key) {
return new RegExp(`^${key}\\s*:`, 'm').test(fm);
}
function getTopLevelValue(fm, key) {
const m = fm.match(new RegExp(`^${key}\\s*:\\s*(.+?)\\s*$`, 'm'));
return m ? m[1] : null;
}
const agentFiles = readdirSync(AGENTS_DIR).filter(f => f.endsWith('.md'));
test('every agents/*.md declares a model: field', () => {
assert.ok(agentFiles.length > 0, 'No agent files found under agents/');
for (const f of agentFiles) {
const fm = extractFrontmatter(read(`agents/${f}`));
assert.ok(fm, `agents/${f}: missing YAML frontmatter block`);
assert.ok(
hasTopLevelKey(fm, 'model'),
`agents/${f}: required \`model:\` field missing from frontmatter`,
);
const value = getTopLevelValue(fm, 'model');
assert.ok(
value && ALLOWED_MODELS.has(value),
`agents/${f}: model: "${value}" must be one of ${[...ALLOWED_MODELS].join(' | ')}`,
);
}
});
test('every agents/*.md declares tools: or disallowedTools:', () => {
for (const f of agentFiles) {
const fm = extractFrontmatter(read(`agents/${f}`));
assert.ok(fm, `agents/${f}: missing YAML frontmatter block`);
assert.ok(
hasTopLevelKey(fm, 'tools') || hasTopLevelKey(fm, 'disallowedTools'),
`agents/${f}: required \`tools:\` (allowlist) or \`disallowedTools:\` (denylist) field missing`,
);
}
});
test('every agents/*.md frontmatter name matches its filename', () => {
for (const f of agentFiles) {
const fm = extractFrontmatter(read(`agents/${f}`));
assert.ok(fm, `agents/${f}: missing frontmatter`);
const expected = f.replace(/\.md$/, '');
const value = getTopLevelValue(fm, 'name');
assert.equal(
value,
expected,
`agents/${f}: frontmatter name="${value}" should match filename "${expected}"`,
);
}
});
test('orchestrator agents are model: opus and do NOT include the Agent tool (reference-only, never spawned)', () => {
// S17 / Survivor #5: these orchestrator .md files are inline reference docs.
// No command invokes them, and the harness does not expose the Agent tool to
// sub-agents, so an Agent grant here is purely a latent dispatch risk. They
// must NOT carry it — the real orchestration runs in the main session.
for (const f of ORCHESTRATORS) {
const path = `agents/${f}`;
const fm = extractFrontmatter(read(path));
assert.ok(fm, `${path}: missing frontmatter`);
const model = getTopLevelValue(fm, 'model');
assert.equal(
model,
'opus',
`${path}: orchestrator must be model: opus (documents multi-agent swarm reasoning) — got "${model}"`,
);
const tools = getTopLevelValue(fm, 'tools');
assert.ok(
tools && !/\bAgent\b/.test(tools),
`${path}: orchestrator is reference-only — tools: must NOT include "Agent" (Survivor #5) — got ${tools}`,
);
}
});
test('non-orchestrator agents do NOT include the Agent tool (no recursive swarming)', () => {
for (const f of agentFiles) {
if (ORCHESTRATORS.has(f)) continue;
const fm = extractFrontmatter(read(`agents/${f}`));
assert.ok(fm, `agents/${f}: missing frontmatter`);
const tools = getTopLevelValue(fm, 'tools');
if (tools === null) continue; // disallowedTools-only agent — fine
assert.ok(
!/\bAgent\b/.test(tools),
`agents/${f}: non-orchestrator must NOT include the Agent tool ` +
`(only orchestrators spawn sub-agents) — got tools: ${tools}`,
);
}
});
// M4 (v5.7.1): examples-relocation invariant. The 34 <example> blocks belong in
// agent BODIES, not in the always-loaded `description:` frontmatter (voyage
// launches its agents by name, so the auto-selection examples are cost without
// function there). This pins the migration: examples cannot regress into
// frontmatter, and cannot be silently lost during the move.
function bodyOf(text) {
// Slice the file content AFTER the closing frontmatter `---`.
// Do NOT split on /^---$/m — a horizontal rule in the body would mis-split.
const m = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
return m ? text.slice(m[0].length) : text;
}
test('no agents/*.md frontmatter contains an <example> block (M4: examples live in the body)', () => {
for (const f of agentFiles) {
const fm = extractFrontmatter(read(`agents/${f}`));
assert.ok(fm !== null, `agents/${f}: missing frontmatter`);
assert.equal(
(fm.match(/<example>/g) || []).length,
0,
`agents/${f}: <example> blocks must live in the body, not the always-loaded description: frontmatter (M4)`,
);
}
});
// Floor lowered 34 -> 32 in v5.10.1: `gemini-bridge` (2 examples) was DELETED as an
// agent by operator directive, not relocated. The pin guards M4's "relocation moves,
// never deletes" invariant, so it must move only when an agent legitimately leaves the
// inventory — never to absorb examples that went missing from a surviving agent.
test('agent bodies retain at least 32 <example> blocks (M4: relocation moves, never deletes)', () => {
let total = 0;
for (const f of agentFiles) {
total += (bodyOf(read(`agents/${f}`)).match(/<example>/g) || []).length;
}
assert.ok(
total >= 32,
`expected >= 32 <example> blocks across agent bodies (16 agents x 2), got ${total} ` +
`— examples may have been deleted instead of relocated (M4)`,
);
});