Round 2 (v6.2.0..0d044f6, BLOCK: 2 BLOCKER, 11 MAJOR, 1 MINOR), each fixed behind a test that failed first. - The git runner checks only words /bin/sh passes through unchanged (no quote, backslash, $, glob or brace) and refuses --output and its abbreviations: round 1's --output refusal was bypassed by quoting. - fieldPaths: backticked paths are kept as written, parentheses and all; in plain text every path-like word counts, and a parenthesised note does not. - plan-validator --steps-from reads the plan's ## Verification for an appended End-to-end step, and warns instead of failing under --soft. - regression-check prints progress_status, the value progress.json takes; /trekexecute copies it. - release-check: a plugin in a subdirectory is checked in its own directory, the suite is not run when a cheaper check already makes the answer exit 2, and a bad argument or a crash is exit 2. - Skill routing looks a skill up among skills only, and builds the corpus vectors once. - /trekexecute Phase 7.2 says plainly that remediation steps in the review template's own form stop with a re-plan; making them runnable is a later order (operator 2026-10-10). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
304 lines
14 KiB
JavaScript
304 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// lib/skills/routing.mjs
|
|
// The model-free routing test (praksisanalyse P10, "Bygg naa"; contract S1).
|
|
// Ranks the descriptions of every skill, command and spawnable agent against
|
|
// prompts, with no model, so a description that would steal or lose a prompt
|
|
// is caught in `npm test` instead of in a user's session.
|
|
//
|
|
// - A collision between two descriptions (cosine >= COLLISION_ERROR) fails.
|
|
// - Each skill has at least MIN_POSITIVE prompts where it should fire (it must
|
|
// rank in the top POSITIVE_TOP_K with a score > 0), MIN_NEGATIVE where it
|
|
// should not (it must not rank first, and a named owner must rank above it),
|
|
// and MIN_EFFECT effect cases.
|
|
// - The share of positives where the skill ranks first is held at or above
|
|
// MIN_RANK1_RATE.
|
|
// The thresholds are pinned by tests/lib/skill-routing.test.mjs. They are
|
|
// never lowered: a failing case is fixed in the description, never in a number.
|
|
//
|
|
// The cases are the plugin-eval cases themselves (evals/<case>/prompt.md):
|
|
// one source for the operator's model-run eval and this model-free check.
|
|
// A case is tagged in its prompt.md frontmatter:
|
|
// tags: [skill-trigger | skill-no-trigger | skill-effect, skill=<name>,
|
|
// owner=<command|agent|skill name> (negatives), from=<event>]
|
|
// `from=` names the event the case came from (P10: every case is marked).
|
|
//
|
|
// What this cannot see: how Claude Code really chooses. A ranking over word
|
|
// overlap is a proxy; the operator's eval run is the measurement.
|
|
//
|
|
// CLI: node lib/skills/routing.mjs [--root <plugin root>] [--json]
|
|
// exit 0 = green · 1 = findings · 2 = could not run
|
|
//
|
|
// Adapted from addyosmani/agent-skills @9d0c60d, scripts/run-evals.js (tier 2:
|
|
// stemmed TF-IDF cosine, name weighted twice, top-3 positives, owner-ranked
|
|
// negatives, collision and rank-1 floors) - MIT, Copyright (c) 2025 Addy
|
|
// Osmani. Reimplemented, no code copied; the reader here takes multi-line
|
|
// (`description: |`) descriptions, which the original reads as "|".
|
|
|
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { descriptionOf, readFrontmatterFields } from './skill-frontmatter.mjs';
|
|
|
|
export const THRESHOLDS = Object.freeze({
|
|
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,
|
|
});
|
|
|
|
// ---- text ----
|
|
|
|
const STOP = new Set([
|
|
'a', 'an', 'and', 'any', 'are', 'as', 'at', 'be', 'by', 'can', 'do', 'does', 'for', 'from', 'has', 'have',
|
|
'in', 'into', 'is', 'it', 'its', 'of', 'on', 'or', 'so', 'than', 'that', 'the', 'their', 'them', 'then',
|
|
'there', 'these', 'this', 'those', 'to', 'use', 'used', 'uses', 'using', 'was', 'were', 'what', 'when',
|
|
'which', 'while', 'who', 'will', 'with', 'you', 'your', 'we', 'our', 'me', 'my', 'i', 'not', 'no', 'one',
|
|
'all', 'each', 'every', 'only', 'also', 'just', 'via', 'per', 'agent', 'skill', 'command', 'should',
|
|
]);
|
|
|
|
function stem(t) {
|
|
for (const suf of ['ations', 'ation', 'ings', 'ing', 'ers', 'ies', 'ied', 'ed', 'es', 'er', 'ly']) {
|
|
if (t.length > suf.length + 3 && t.endsWith(suf)) {
|
|
t = suf === 'ies' || suf === 'ied' ? `${t.slice(0, -suf.length)}y` : t.slice(0, -suf.length);
|
|
break;
|
|
}
|
|
}
|
|
if (t.length > 3 && t.endsWith('s') && !t.endsWith('ss')) t = t.slice(0, -1);
|
|
if (t.length > 4 && t.endsWith('e')) t = t.slice(0, -1);
|
|
if (t.length > 4 && t[t.length - 1] === t[t.length - 2] && !'aeiou'.includes(t[t.length - 1])) t = t.slice(0, -1);
|
|
return t;
|
|
}
|
|
|
|
/** Lower-case word tokens, stop words dropped, light-stemmed. */
|
|
export function tokenize(text) {
|
|
return String(text)
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
.split(/[\s-]+/)
|
|
.filter((t) => t.length > 2 && !STOP.has(t))
|
|
.map(stem)
|
|
.filter((t) => t.length > 2 && !STOP.has(t));
|
|
}
|
|
|
|
function termFreq(tokens) {
|
|
const tf = new Map();
|
|
for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
|
|
return tf;
|
|
}
|
|
|
|
/**
|
|
* @param {{id: string, name: string, description: string}[]} entries
|
|
* @returns {{ docs: Map<string, Map<string, number>>, idf: (t: string) => number, entries: object[] }}
|
|
*/
|
|
export function buildCorpus(entries) {
|
|
const docs = new Map();
|
|
for (const e of entries) {
|
|
const nameTokens = tokenize(e.name.replace(/[-:]/g, ' '));
|
|
docs.set(e.id, termFreq([...nameTokens, ...nameTokens, ...tokenize(e.description)]));
|
|
}
|
|
const df = new Map();
|
|
for (const tf of docs.values()) for (const term of tf.keys()) df.set(term, (df.get(term) || 0) + 1);
|
|
const n = docs.size;
|
|
const idf = (term) => Math.log(1 + n / (1 + (df.get(term) || 0)));
|
|
// The corpus never changes: its vectors are built once, not per prompt or pair.
|
|
const vecs = new Map([...docs].map(([id, tf]) => [id, vec(tf, idf)]));
|
|
return { docs, idf, vecs, entries };
|
|
}
|
|
|
|
function vec(tf, idf) {
|
|
const v = new Map();
|
|
for (const [term, f] of tf) v.set(term, f * idf(term));
|
|
return v;
|
|
}
|
|
|
|
function cosine(a, b) {
|
|
let dot = 0; let na = 0; let nb = 0;
|
|
for (const [t, w] of a) { na += w * w; const bw = b.get(t); if (bw) dot += w * bw; }
|
|
for (const w of b.values()) nb += w * w;
|
|
return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
|
}
|
|
|
|
/** Every entry scored against a prompt, best first (ties by id, so the order is stable). */
|
|
export function rank(prompt, corpus) {
|
|
const pv = vec(termFreq(tokenize(prompt)), corpus.idf);
|
|
const out = [];
|
|
for (const [id, v] of corpus.vecs) out.push({ id, score: cosine(pv, v) });
|
|
return out.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
}
|
|
|
|
/** Pairs of descriptions too alike: level "error" at COLLISION_ERROR, "warn" at COLLISION_WARN. */
|
|
export function collisions(corpus, t = THRESHOLDS) {
|
|
const ids = [...corpus.docs.keys()];
|
|
const out = [];
|
|
for (let i = 0; i < ids.length; i++) {
|
|
for (let j = i + 1; j < ids.length; j++) {
|
|
const sim = cosine(corpus.vecs.get(ids[i]), corpus.vecs.get(ids[j]));
|
|
if (sim >= t.COLLISION_ERROR) out.push({ a: ids[i], b: ids[j], sim, level: 'error' });
|
|
else if (sim >= t.COLLISION_WARN) out.push({ a: ids[i], b: ids[j], sim, level: 'warn' });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---- loading ----
|
|
|
|
/** Orchestrator reference docs are not spawnable capabilities (CLAUDE.md, Inventory). */
|
|
const isReferenceDoc = (name, description) => /-orchestrator$/.test(name) || /^Reference document, not a spawnable/.test(description.trim());
|
|
|
|
/** Skills, commands and spawnable agents of a plugin root. */
|
|
export function loadEntries(root) {
|
|
const entries = [];
|
|
const skillsDir = join(root, 'skills');
|
|
if (existsSync(skillsDir)) {
|
|
for (const n of readdirSync(skillsDir).sort()) {
|
|
const f = join(skillsDir, n, 'SKILL.md');
|
|
if (!existsSync(f)) continue;
|
|
const text = readFileSync(f, 'utf8');
|
|
const fm = readFrontmatterFields(text).fields;
|
|
const name = fm.name ? String(fm.name.value) : n;
|
|
entries.push({ id: `skill:${name}`, kind: 'skill', name, description: descriptionOf(text) });
|
|
}
|
|
}
|
|
for (const [kind, dir] of [['command', 'commands'], ['agent', 'agents']]) {
|
|
const d = join(root, dir);
|
|
if (!existsSync(d)) continue;
|
|
for (const file of readdirSync(d).filter((x) => x.endsWith('.md')).sort()) {
|
|
const text = readFileSync(join(d, file), 'utf8');
|
|
const fm = readFrontmatterFields(text).fields;
|
|
const name = fm.name ? String(fm.name.value) : file.replace(/\.md$/, '');
|
|
const description = descriptionOf(text);
|
|
if (kind === 'agent' && isReferenceDoc(name, description)) continue;
|
|
entries.push({ id: `${kind}:${name}`, kind, name, description });
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
const KIND_TAGS = { 'skill-trigger': 'trigger', 'skill-no-trigger': 'no-trigger', 'skill-effect': 'effect' };
|
|
|
|
/** The routing-tagged eval cases of a plugin root (evals/<case>/prompt.md). */
|
|
export function loadCases(root) {
|
|
const d = join(root, 'evals');
|
|
if (!existsSync(d)) return [];
|
|
const out = [];
|
|
for (const name of readdirSync(d).sort()) {
|
|
const f = join(d, name, 'prompt.md');
|
|
if (!existsSync(f) || !statSync(join(d, name)).isDirectory()) continue;
|
|
const text = readFileSync(f, 'utf8');
|
|
const tagsField = readFrontmatterFields(text).fields.tags;
|
|
const tags = tagsField && Array.isArray(tagsField.value) ? tagsField.value.map(String) : [];
|
|
const kindTag = tags.find((t) => t in KIND_TAGS);
|
|
if (!kindTag) continue;
|
|
const val = (k) => { const t = tags.find((x) => x.startsWith(`${k}=`)); return t ? t.slice(k.length + 1) : null; };
|
|
const body = text.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
|
|
out.push({ case: name, kind: KIND_TAGS[kindTag], skill: val('skill'), owner: val('owner'), from: val('from'), prompt: body });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---- the check ----
|
|
|
|
/**
|
|
* Run the whole routing check over a plugin root.
|
|
* @returns {{ errors: object[], warnings: object[], stats: object }}
|
|
*/
|
|
export function checkRouting(root, t = THRESHOLDS) {
|
|
const entries = loadEntries(root);
|
|
const corpus = buildCorpus(entries);
|
|
const skills = entries.filter((e) => e.kind === 'skill');
|
|
// A skill is looked up among skills only: an agent or command with the same
|
|
// name must not take its place. An owner may be any kind; a skill wins a tie.
|
|
const skillByName = new Map(skills.map((e) => [e.name, e]));
|
|
const byName = new Map([...entries.filter((e) => e.kind !== 'skill'), ...skills].map((e) => [e.name, e]));
|
|
const cases = loadCases(root);
|
|
const errors = [];
|
|
const warnings = [];
|
|
let positives = 0; let rank1 = 0; let negatives = 0;
|
|
|
|
for (const c of collisions(corpus, t)) {
|
|
const entry = { code: c.level === 'error' ? 'ROUTING_COLLISION' : 'ROUTING_OVERLAP', a: c.a, b: c.b, sim: Number(c.sim.toFixed(3)),
|
|
message: `${c.a} and ${c.b}: descriptions ${(c.sim * 100).toFixed(0)}% alike` };
|
|
(c.level === 'error' ? errors : warnings).push(entry);
|
|
}
|
|
|
|
for (const c of cases) {
|
|
if (!c.from) errors.push({ code: 'ROUTING_CASE_NO_SOURCE', case: c.case, message: `${c.case}: tag the event the case came from (from=...)` });
|
|
const self = c.skill && skillByName.get(c.skill);
|
|
if (!self || self.kind !== 'skill') {
|
|
errors.push({ code: 'ROUTING_UNKNOWN_SKILL', case: c.case, message: `${c.case}: skill=${c.skill} is not a skill in skills/` });
|
|
continue;
|
|
}
|
|
if (c.kind === 'effect') continue;
|
|
const ranking = rank(c.prompt, corpus);
|
|
const at = ranking.findIndex((r) => r.id === self.id);
|
|
const hit = ranking[at];
|
|
const top = ranking.slice(0, 3).map((r) => `${r.id} ${r.score.toFixed(2)}`).join(', ');
|
|
if (c.kind === 'trigger') {
|
|
positives++;
|
|
if (at === 0 && hit.score > 0) rank1++;
|
|
if (!(at < t.POSITIVE_TOP_K && hit.score > 0)) {
|
|
errors.push({ code: 'ROUTING_POSITIVE_MISSED', case: c.case, rank: at + 1,
|
|
message: `${c.case}: ${self.id} ranks #${at + 1} (score ${hit.score.toFixed(2)}); needs top ${t.POSITIVE_TOP_K}. Top: ${top}` });
|
|
}
|
|
} else {
|
|
negatives++;
|
|
if (ranking[0].id === self.id && ranking[0].score > 0) {
|
|
errors.push({ code: 'ROUTING_NEGATIVE_WON', case: c.case, message: `${c.case}: ${self.id} ranks first on a prompt it must not take. Top: ${top}` });
|
|
}
|
|
if (!c.owner) {
|
|
errors.push({ code: 'ROUTING_NEGATIVE_NO_OWNER', case: c.case, message: `${c.case}: a negative case names the owner that should take the prompt (owner=...)` });
|
|
} else if (!byName.has(c.owner)) {
|
|
errors.push({ code: 'ROUTING_UNKNOWN_OWNER', case: c.case, message: `${c.case}: owner=${c.owner} is no skill, command or spawnable agent` });
|
|
} else {
|
|
const ownerAt = ranking.findIndex((r) => r.id === byName.get(c.owner).id);
|
|
if (ranking[ownerAt].score === 0 || ownerAt > at) {
|
|
errors.push({ code: 'ROUTING_OWNER_OUTRANKED', case: c.case,
|
|
message: `${c.case}: owner ${c.owner} (#${ownerAt + 1}) must rank above ${self.id} (#${at + 1}). Top: ${top}` });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const s of skills) {
|
|
const mine = cases.filter((c) => c.skill === s.name);
|
|
const n = (k) => mine.filter((c) => c.kind === k).length;
|
|
if (n('trigger') < t.MIN_POSITIVE || n('no-trigger') < t.MIN_NEGATIVE || n('effect') < t.MIN_EFFECT) {
|
|
errors.push({ code: 'ROUTING_BELOW_MINIMUM', skill: s.name,
|
|
message: `${s.name}: ${n('trigger')} trigger / ${n('no-trigger')} no-trigger / ${n('effect')} effect cases; needs ${t.MIN_POSITIVE} / ${t.MIN_NEGATIVE} / ${t.MIN_EFFECT}` });
|
|
}
|
|
}
|
|
|
|
const rate = positives ? rank1 / positives : 1;
|
|
if (positives && rate < t.MIN_RANK1_RATE) {
|
|
errors.push({ code: 'ROUTING_RANK1_FLOOR', message: `${rank1} of ${positives} positives rank their skill first (${(rate * 100).toFixed(0)}%); the floor is ${(t.MIN_RANK1_RATE * 100).toFixed(0)}%` });
|
|
}
|
|
return { errors, warnings, stats: { entries: entries.length, skills: skills.length, cases: cases.length, positives, negatives, rank1 } };
|
|
}
|
|
|
|
// ---- CLI ----
|
|
|
|
function isMain() {
|
|
if (!process.argv[1]) return false;
|
|
try { return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); } catch { return false; }
|
|
}
|
|
|
|
if (isMain()) {
|
|
const args = process.argv.slice(2);
|
|
const at = args.indexOf('--root');
|
|
const root = resolve(at >= 0 ? args[at + 1] || '' : join(dirname(fileURLToPath(import.meta.url)), '..', '..'));
|
|
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
process.stderr.write(`routing: not measured: ${root} is not a directory\n`);
|
|
process.exit(2);
|
|
}
|
|
const r = checkRouting(root);
|
|
if (args.includes('--json')) process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
else {
|
|
for (const e of r.errors) process.stdout.write(`FAIL ${e.code}: ${e.message}\n`);
|
|
for (const w of r.warnings) process.stdout.write(`warn ${w.code}: ${w.message}\n`);
|
|
process.stdout.write(`${r.stats.entries} descriptions, ${r.stats.cases} cases, ${r.stats.rank1}/${r.stats.positives} positives first; ${r.errors.length} finding(s)\n`);
|
|
}
|
|
process.exit(r.errors.length ? 1 : 0);
|
|
}
|