Session 5 of voyage-rebrand (V6). Operator-authorized cross-plugin scope. - git mv plugins/ultraplan-local plugins/voyage (rename detected, history preserved) - .claude-plugin/marketplace.json: voyage entry replaces ultraplan-local - CLAUDE.md: voyage row in plugin list, voyage in design-system consumer list - README.md: bulk rename ultra*-local commands -> trek* commands; ultraplan-local refs -> voyage; type discriminators (type: trekbrief/trekreview); session-title pattern (voyage:<command>:<slug>); v4.0.0 release-note paragraph - plugins/voyage/.claude-plugin/plugin.json: homepage/repository URLs point to monorepo voyage path - plugins/voyage/verify.sh: drop URL whitelist exception (no longer needed) Closes voyage-rebrand. bash plugins/voyage/verify.sh PASS 7/7. npm test 361/361.
125 lines
4.3 KiB
JavaScript
125 lines
4.3 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 include the Agent tool', () => {
|
|
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 (drives multi-agent swarm reasoning) — got "${model}"`,
|
|
);
|
|
const tools = getTopLevelValue(fm, 'tools');
|
|
assert.ok(
|
|
tools && /\bAgent\b/.test(tools),
|
|
`${path}: orchestrator tools: must include "Agent" so it can spawn the swarm — 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}`,
|
|
);
|
|
}
|
|
});
|