voyage/tests/lib/skill-routing.test.mjs
Kjell Tore Guttormsen cc832fd430
feat(skills): skill workshop, test-first and debugging preloaded into the step helper
The workshop (ordre 7a, contracts S1 and S2): a form check
(lib/skills/skill-lint.mjs) that follows Claude Code's own skill fields,
and a model-free routing test (lib/skills/routing.mjs) over every skill,
the 7 commands and the 21 spawnable agents, reading the eval cases
themselves (evals/skill-*, 3 fire / 2 quiet / 1 effect, tagged with the
event they came from). Thresholds pinned, never lowered.

The two skills were written against failures measured first in helpers
without them: red only as an import crash, the whole suite run from a
hand-made file list instead of the repo's own command, and an
unreproduced fix shipped under authority pressure. step-implementer
preloads both through its skills: line; no tool list change.

Credits: addyosmani/agent-skills @9d0c60d, obra/superpowers @8ca22db,
mattpocock/skills @c55ee46 (MIT), Anthropic skill-creator (Apache-2.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:38:58 +02:00

215 lines
10 KiB
JavaScript

// tests/lib/skill-routing.test.mjs
// The model-free routing test (P10, "Bygg nå"): lib/skills/routing.mjs ranks
// the descriptions of every skill, the 7 commands and the 21 spawnable agents
// against prompts, with no model. A collision between two descriptions fails;
// each skill needs 3 prompts where it should fire (top 3) and 2 where it
// should not, and for those a named owner must rank higher. The thresholds are
// pinned here: they are never lowered.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
THRESHOLDS, tokenize, buildCorpus, rank, collisions, loadEntries, loadCases, checkRouting,
} from '../../lib/skills/routing.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const CLI = join(ROOT, 'lib', 'skills', 'routing.mjs');
const skillMd = (name, description) =>
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`;
/** Plugin-shaped temp root: skills, commands, agents, evals. */
function plugin({ skills = {}, commands = {}, agents = {}, cases = {} }) {
const root = mkdtempSync(join(tmpdir(), 'skill-routing-'));
for (const [n, d] of Object.entries(skills)) {
mkdirSync(join(root, 'skills', n), { recursive: true });
writeFileSync(join(root, 'skills', n, 'SKILL.md'), skillMd(n, d));
}
mkdirSync(join(root, 'commands'), { recursive: true });
for (const [n, d] of Object.entries(commands)) {
writeFileSync(join(root, 'commands', `${n}.md`), `---\nname: ${n}\ndescription: ${d}\n---\n`);
}
mkdirSync(join(root, 'agents'), { recursive: true });
for (const [n, d] of Object.entries(agents)) {
writeFileSync(join(root, 'agents', `${n}.md`), `---\nname: ${n}\ndescription: |\n ${d}\nmodel: opus\n---\n`);
}
for (const [n, { tags, prompt }] of Object.entries(cases)) {
mkdirSync(join(root, 'evals', n, 'graders'), { recursive: true });
writeFileSync(join(root, 'evals', n, 'prompt.md'),
`---\ndescription: "case ${n}"\ntags: [${tags.join(', ')}]\nruns: 1\n---\n\n${prompt}\n`);
}
return root;
}
function withPlugin(spec, fn) {
const root = plugin(spec);
try { return fn(root); } finally { rmSync(root, { recursive: true, force: true }); }
}
const codes = (r) => r.errors.map((e) => e.code);
// ---- the test that fails first (ordre 7a) ----
test('routing: two test skills with near-identical descriptions are red (collision)', () => {
withPlugin({
skills: {
'tidy-imports': 'Sorts and groups the import lines of a source file. Use when imports are out of order.',
'order-imports': 'Sorts and groups the import lines in a source file. Use when the imports are out of order.',
},
}, (root) => {
const r = checkRouting(root);
assert.ok(codes(r).includes('ROUTING_COLLISION'), JSON.stringify(r.errors));
const c = r.errors.find((e) => e.code === 'ROUTING_COLLISION');
assert.deepEqual([c.a, c.b].sort(), ['skill:order-imports', 'skill:tidy-imports']);
});
});
test('routing: distinct descriptions do not collide (known negative)', () => {
const corpus = buildCorpus([
{ id: 'skill:tidy-imports', name: 'tidy-imports', description: 'Sorts the import lines of a source file. Use when imports are out of order.' },
{ id: 'skill:rotate-logs', name: 'rotate-logs', description: 'Compresses and deletes old log files on disk. Use when the log directory grows.' },
]);
assert.deepEqual(collisions(corpus).filter((c) => c.level === 'error'), []);
});
// ---- the pieces ----
test('routing: thresholds are pinned and never lowered', () => {
assert.deepEqual({ ...THRESHOLDS }, {
COLLISION_ERROR: 0.75,
COLLISION_WARN: 0.5,
POSITIVE_TOP_K: 3,
MIN_POSITIVE: 3,
MIN_NEGATIVE: 2,
MIN_EFFECT: 1,
MIN_RANK1_RATE: 1,
});
assert.ok(Object.isFrozen(THRESHOLDS));
});
test('routing: the tokenizer folds case, drops stop words and light-stems', () => {
assert.deepEqual(tokenize('Use when the Tests are FAILING'), tokenize('test fail'));
assert.deepEqual(tokenize('a an the of'), []);
});
test('routing: rank puts the matching description first, and a no-overlap prompt scores 0', () => {
const corpus = buildCorpus([
{ id: 'skill:tidy-imports', name: 'tidy-imports', description: 'Sorts the import lines of a source file.' },
{ id: 'skill:rotate-logs', name: 'rotate-logs', description: 'Compresses and deletes old log files on disk.' },
]);
assert.equal(rank('the log files fill the disk', corpus)[0].id, 'skill:rotate-logs');
assert.ok(rank('zebra quantum', corpus).every((r) => r.score === 0));
});
test('routing: the reader takes block-scalar agent descriptions and skips orchestrator reference docs', () => {
withPlugin({
agents: { 'plan-critic': 'Use this agent when a plan needs adversarial review.' },
commands: { trekplan: 'Deep implementation planning from a task brief.' },
}, (root) => {
writeFileSync(join(root, 'agents', 'planning-orchestrator.md'),
'---\nname: planning-orchestrator\ndescription: Reference document, not a spawnable capability.\n---\n');
const ids = loadEntries(root).map((e) => e.id).sort();
assert.deepEqual(ids, ['agent:plan-critic', 'command:trekplan']);
assert.equal(loadEntries(root).find((e) => e.id === 'agent:plan-critic').description.trim(),
'Use this agent when a plan needs adversarial review.');
});
});
test('routing: cases come from evals/ tags; a skill below 3 / 2 / 1 is red', () => {
withPlugin({
skills: { 'tidy-imports': 'Sorts the import lines of a source file. Use when imports are out of order.' },
cases: {
'skill-tidy-imports-fires-1': { tags: ['skill-trigger', 'skill=tidy-imports', 'from=test'], prompt: 'sort the import lines in main.js' },
},
}, (root) => {
const cases = loadCases(root);
assert.equal(cases.length, 1);
assert.deepEqual([cases[0].kind, cases[0].skill, cases[0].from], ['trigger', 'tidy-imports', 'test']);
assert.ok(codes(checkRouting(root)).includes('ROUTING_BELOW_MINIMUM'));
});
});
test('routing: a positive outside the top 3 is red; a negative won by the skill itself is red', () => {
const spec = {
skills: { 'tidy-imports': 'Sorts the import lines of a source file. Use when imports are out of order.' },
commands: {
'rotate-logs': 'Compresses and deletes old log files on disk.',
'scan-ports': 'Lists the open network ports of a host.',
'count-words': 'Counts the words of a text document.',
},
cases: {
'p1': { tags: ['skill-trigger', 'skill=tidy-imports', 'from=test'], prompt: 'sort the import lines' },
'p2': { tags: ['skill-trigger', 'skill=tidy-imports', 'from=test'], prompt: 'import lines are out of order' },
'p3': { tags: ['skill-trigger', 'skill=tidy-imports', 'from=test'], prompt: 'compress the old logs on disk and list ports and count words' },
'n1': { tags: ['skill-no-trigger', 'skill=tidy-imports', 'owner=rotate-logs', 'from=test'], prompt: 'delete old log files' },
'n2': { tags: ['skill-no-trigger', 'skill=tidy-imports', 'owner=rotate-logs', 'from=test'], prompt: 'sort the import lines of the log file' },
'e1': { tags: ['skill-effect', 'skill=tidy-imports', 'from=test'], prompt: 'x' },
},
};
withPlugin(spec, (root) => {
const r = checkRouting(root);
assert.ok(r.errors.some((e) => e.code === 'ROUTING_POSITIVE_MISSED' && e.case === 'p3'), JSON.stringify(r.errors));
assert.ok(r.errors.some((e) => e.code === 'ROUTING_NEGATIVE_WON' && e.case === 'n2'), JSON.stringify(r.errors));
assert.ok(!r.errors.some((e) => e.case === 'n1'), JSON.stringify(r.errors));
});
});
test('routing: a negative must name an owner that exists and outranks the skill', () => {
withPlugin({
skills: { 'tidy-imports': 'Sorts the import lines of a source file. Use when imports are out of order.' },
cases: {
'n1': { tags: ['skill-no-trigger', 'skill=tidy-imports', 'from=test'], prompt: 'delete old log files' },
'n2': { tags: ['skill-no-trigger', 'skill=tidy-imports', 'owner=nobody', 'from=test'], prompt: 'delete old log files' },
},
}, (root) => {
const r = checkRouting(root);
assert.ok(r.errors.some((e) => e.code === 'ROUTING_NEGATIVE_NO_OWNER' && e.case === 'n1'), JSON.stringify(r.errors));
assert.ok(r.errors.some((e) => e.code === 'ROUTING_UNKNOWN_OWNER' && e.case === 'n2'), JSON.stringify(r.errors));
});
});
test('routing: every case names the event it came from (P10)', () => {
withPlugin({
skills: { 'tidy-imports': 'Sorts the import lines of a source file. Use when imports are out of order.' },
cases: { 'p1': { tags: ['skill-trigger', 'skill=tidy-imports'], prompt: 'sort the import lines' } },
}, (root) => assert.ok(codes(checkRouting(root)).includes('ROUTING_CASE_NO_SOURCE')));
});
test('routing CLI: 0 clean, 1 findings, 2 could not run', () => {
withPlugin({
skills: {
'tidy-imports': 'Sorts and groups the import lines of a source file. Use when imports are out of order.',
'order-imports': 'Sorts and groups the import lines in a source file. Use when the imports are out of order.',
},
}, (root) => {
const b = spawnSync('node', [CLI, '--root', root, '--json'], { encoding: 'utf8' });
assert.equal(b.status, 1, b.stdout + b.stderr);
assert.ok(JSON.parse(b.stdout).errors.length > 0);
const n = spawnSync('node', [CLI, '--root', join(root, 'nope')], { encoding: 'utf8' });
assert.equal(n.status, 2, n.stdout + n.stderr);
});
});
// ---- Voyage's own catalogue ----
test('routing: Voyage\'s catalogue holds every skill, the 7 commands and the 21 spawnable agents', () => {
const entries = loadEntries(ROOT);
const by = (k) => entries.filter((e) => e.kind === k);
assert.equal(by('command').length, 7, by('command').map((e) => e.name).join(','));
assert.equal(by('agent').length, 21, by('agent').map((e) => e.name).join(','));
assert.ok(by('skill').length >= 2, by('skill').map((e) => e.name).join(','));
for (const e of entries) assert.ok(e.description.trim().length > 20, `${e.id}: description read as "${e.description}"`);
assert.equal(new Set(entries.map((e) => e.name)).size, entries.length, 'names must be unique across kinds');
});
test('routing: Voyage\'s catalogue is green: no collision, every case routes, rank-1 floor held', () => {
const r = checkRouting(ROOT);
assert.deepEqual(r.errors, [], JSON.stringify(r.errors, null, 2));
assert.ok(r.stats.positives >= 6 && r.stats.negatives >= 4, JSON.stringify(r.stats));
});