ms-ai-architect/scripts/kb-eval/eval.mjs
Kjell Tore Guttormsen c1a09062d4 feat(ms-ai-architect): Spor D steg 2 — score-skill CLI (--json/--gate/--skill) (TDD)
Byggekloss for både SessionStart-surfacing og lifecycle-gate (D3).

- eval.mjs: ekstraher buildReport() (eksportert, testbar) ut av main() —
  samler hele korpus-rapporten (deterministisk + K10 + merget judge-cache).
- lib/skill-score.mjs: formatScoreReport() (ren) — sortert forbedringsrapport
  med score, status, floored/provisional/ujudget-tags og ⚑gulv-markering.
- score-skill.mjs (NY CLI): buildReport → scoreReport → render/json/gate.
  --skill scorer hele korpuset (K10 trenger alle søsken) men rapporterer/gater
  kun den valgte → inkrementell enkelt-skill-scoring for D3 lifecycle-gate.
- docs/development.md: ny «Skill-kvalitetsscore (Spor D)»-seksjon.

Live baseline: advisor 91, governance/security 96 (OK); engineering +
infrastructure 89 (floored av K10-gulv) UNDER mål. Gate exit=1.

Tester: kb-eval 134→142 (+8). validate 239, kb-update 316 uendret grønt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:16:44 +02:00

414 lines
17 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.

#!/usr/bin/env node
// eval.mjs — Skill quality baseline (rubrikk K1K9) for ms-ai-architect.
//
// READ-ONLY by default. Computes the deterministic criteria (K2/K3/K5/K6 +
// reference-count consistency) for every skill and extracts the inputs an LLM
// judge needs for the semantic criteria (K1/K4/K7/K8/K9). The baseline file is
// written ONLY with --write (operator gate), per the redesign spec.
//
// Usage:
// node scripts/kb-eval/eval.mjs # human-readable summary
// node scripts/kb-eval/eval.mjs --json # machine output (pure JSON to stdout)
// node scripts/kb-eval/eval.mjs --write # also persist data/eval-baseline.json
//
// Zero dependencies. Reuses kb-update/lib/atomic-write.mjs for the gated write.
import { readdirSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { atomicWriteJson } from '../kb-update/lib/atomic-write.mjs';
import {
computeOverlapFromInputs,
perSkillSiblingOverlap,
K10_OVERLAP_THRESHOLD,
} from './lib/sibling-overlap.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const SKILLS_DIR = join(PLUGIN_ROOT, 'skills');
const OUT_DIR = join(__dirname, 'data');
const OUT_FILE = join(OUT_DIR, 'eval-baseline.json');
const PROMPTS_FILE = join(OUT_DIR, 'k1-trigger-prompts.json');
// Thresholds — see spec "Rubrikk K1K10".
const K3_MAX_BODY_LINES = 500;
const K5_MIN_NAMED_RATIO = 0.2;
/** Recursively list .md files under dir. */
function listMarkdown(dir) {
const out = [];
if (!existsSync(dir)) return out;
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...listMarkdown(p));
else if (e.isFile() && e.name.endsWith('.md')) out.push(p);
}
return out;
}
/** Count .md files per immediate subfolder of references/. */
function refCountsPerFolder(refDir) {
const counts = {};
if (!existsSync(refDir)) return counts;
for (const e of readdirSync(refDir, { withFileTypes: true })) {
if (e.isDirectory()) counts[e.name] = listMarkdown(join(refDir, e.name)).length;
}
return counts;
}
/** Split YAML frontmatter from body. */
export function splitFrontmatter(content) {
if (!content.startsWith('---')) return { frontmatter: '', body: content };
const close = content.indexOf('\n---', 3);
if (close === -1) return { frontmatter: '', body: content };
const bodyStart = content.indexOf('\n', close + 1);
return {
frontmatter: content.slice(0, bodyStart === -1 ? content.length : bodyStart + 1),
body: bodyStart === -1 ? '' : content.slice(bodyStart + 1),
};
}
/** Extract the `name:` scalar from frontmatter (single-line value). */
export function extractName(frontmatter) {
for (const line of frontmatter.split('\n')) {
const m = /^name:\s*(.+?)\s*$/.exec(line);
if (m) return m[1].replace(/^["']|["']$/g, '').trim();
}
return '';
}
/** Extract the (possibly folded) description value from frontmatter. */
export function extractDescription(frontmatter) {
const lines = frontmatter.split('\n');
const out = [];
let collecting = false;
for (const line of lines) {
if (/^description:/.test(line)) {
collecting = true;
out.push(line.replace(/^description:\s*>?-?\s*/, ''));
continue;
}
if (collecting) {
if (/^[A-Za-z0-9_-]+:\s/.test(line) || line.trim() === '---' || line.trim() === '') {
if (line.trim() === '') continue; // folded blocks keep going across blanks
break;
}
out.push(line.trim());
}
}
return out.join(' ').replace(/\s+/g, ' ').trim();
}
/** K2 — description format: third-person/use-when OR >=3 quoted user phrases. */
export function checkK2(description) {
const quoted = (description.match(/"[^"]+"/g) || []).length;
const useWhenForm = /should be used when|use this skill|triggers on/i.test(description);
const imperativeStart = /^(use|bruk|run|kjør)\b/i.test(description);
const pass = useWhenForm || quoted >= 3;
return { quotedPhrases: quoted, useWhenForm, imperativeStart, pass };
}
/** K3 — SKILL.md body length. */
export function checkK3(body) {
const lines = body.replace(/\n+$/, '').split('\n').length;
return { bodyLines: lines, pass: lines <= K3_MAX_BODY_LINES };
}
/** K5/K6 — progressive disclosure: named .md links vs folder-only references. */
export function checkK5K6(body, totalRefFiles) {
const named = [...new Set((body.match(/references\/[A-Za-z0-9_\-/]+\.md/g) || []))];
// folder references like `references/foo/` not immediately followed by a filename
const folderRefs = [...new Set((body.match(/references\/[A-Za-z0-9_-]+\/(?![A-Za-z0-9_-]+\.md)/g) || []))];
const namedRatio = totalRefFiles > 0 ? named.length / totalRefFiles : 0;
return {
K5: {
namedFileLinks: named.length,
folderRefs: folderRefs.length,
totalRefFiles,
namedRatio: Number(namedRatio.toFixed(4)),
pass: namedRatio >= K5_MIN_NAMED_RATIO,
sampleNamed: named.slice(0, 5),
},
K6: {
// routing table = at least one named start file the model can Read directly
namedStartFiles: named.length,
pass: named.length >= 1,
},
};
}
/** Reference-count consistency: cited per-folder counts vs actual on disk. */
export function checkRefConsistency(body, actualPerFolder) {
const mismatches = [];
const seen = {};
// prose form: references/<folder>/ (N files) and `references/<folder>/` (N files)
const proseRe = /references\/([a-z0-9-]+)\/`?\s*\((\d+)\s*files?\)/gi;
// table form: `references/<folder>/` | N
const tableRe = /`references\/([a-z0-9-]+)\/`\s*\|\s*(\d+)/gi;
for (const re of [proseRe, tableRe]) {
let m;
while ((m = re.exec(body)) !== null) {
const folder = m[1];
const cited = Number(m[2]);
seen[folder] = seen[folder] || new Set();
seen[folder].add(cited);
const actual = actualPerFolder[folder];
if (actual !== undefined && cited !== actual) {
mismatches.push({ folder, cited, actual });
}
}
}
// also flag folders cited with two different numbers
for (const [folder, set] of Object.entries(seen)) {
if (set.size > 1) mismatches.push({ folder, citedMultiple: [...set], actual: actualPerFolder[folder] ?? null });
}
return { consistent: mismatches.length === 0, mismatches };
}
/** Light deterministic pre-scan for K9 (time-sensitive tokens in body). */
function scanK9Hints(body) {
const tokens = body.match(/\b(GA|preview|deprecat\w*|retir\w*|20\d\d|v\d+(?:\.\d+)?)\b/gi) || [];
return { timeSensitiveTokenHits: tokens.length, sample: [...new Set(tokens)].slice(0, 12) };
}
// --- N1-N5 — deterministic checks from Anthropic "Skill authoring best
// practices" that the K1-K10 rubric did not yet cover. See
// docs/skill-quality-scoring-plan.md §1 for exact rules + sources.
const N1_MAX_NAME_LEN = 64; // best-practices §Skill structure
const N1_RESERVED = ['claude', 'anthropic'];
const N2_MAX_DESC_LEN = 1024; // best-practices §Writing effective descriptions
const N4_LARGE_FILE_LINES = 100; // best-practices §Structure longer reference files
/** N1 — name validity: ≤64 chars, [a-z0-9-], no reserved words. Gerund is
* Anthropic-recommended but NOT required, so it is reported, never gated. */
export function checkN1(name) {
const n = (name || '').trim();
const withinLength = n.length > 0 && n.length <= N1_MAX_NAME_LEN;
const validChars = /^[a-z0-9-]+$/.test(n);
const lower = n.toLowerCase();
const noReserved = !N1_RESERVED.some((w) => lower.includes(w));
const gerund = /[a-z]ing(?:-|$)/.test(lower); // informational only
return { name: n, length: n.length, withinLength, validChars, noReserved, gerund, pass: withinLength && validChars && noReserved };
}
/** N2 — description present and within the 1024-char hard limit. */
export function checkN2(description) {
const d = description || '';
const nonEmpty = d.trim().length > 0;
const withinLimit = d.length <= N2_MAX_DESC_LEN;
return { length: d.length, nonEmpty, withinLimit, pass: nonEmpty && withinLimit };
}
/** Markdown links to a local .md file (nesting candidates). Skips external
* URLs and pure in-page anchors (#section). */
function localMdLinks(content) {
const out = [];
const re = /\]\(([^)\s]+?\.md)(?:#[^)]*)?\)/g;
let m;
while ((m = re.exec(content)) !== null) {
const target = m[1];
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) continue; // external URL
out.push(target);
}
return out;
}
/** N3 — references stay one level deep: a reference file must not link to
* another reference (.md) file. Input: [{ path, content }]. */
export function checkN3(refFiles) {
const nested = [];
for (const f of refFiles) {
const links = localMdLinks(f.content);
if (links.length > 0) nested.push({ file: f.path, links: [...new Set(links)].slice(0, 8) });
}
return { checked: refFiles.length, nested, pass: nested.length === 0 };
}
/** Does a reference file carry a table of contents? Either an explicit TOC
* heading or a cluster (≥3) of in-page anchor links near the top. */
function hasToc(content) {
if (/^#{1,3}\s*(table of contents|contents|innhold(?:sfortegnelse)?|toc)\b/im.test(content)) return true;
const head = content.split('\n').slice(0, 40).join('\n');
return (head.match(/\]\(#/g) || []).length >= 3;
}
/** N4 — reference files longer than 100 lines should have a table of contents.
* Sub-score = fraction of large files that have one (vacuous pass if none). */
export function checkN4(refFiles) {
const large = refFiles.filter((f) => f.content.replace(/\n+$/, '').split('\n').length > N4_LARGE_FILE_LINES);
const withToc = large.filter((f) => hasToc(f.content));
const ratio = large.length > 0 ? Number((withToc.length / large.length).toFixed(4)) : 1;
return {
largeFiles: large.length,
withToc: withToc.length,
ratio,
missing: large.filter((f) => !hasToc(f.content)).map((f) => f.path).slice(0, 12),
pass: large.length === 0 || withToc.length === large.length,
};
}
/** N5 — forward-slash paths only (no Windows-style backslash paths) in body. */
export function checkN5(body) {
const patterns = [
/[A-Za-z]:\\/g, // drive-letter path: C:\
/[\w-]+\\[\w-]+\.md/g, // backslash path ending in a .md file
/\\(references|skills|scripts|docs|assets)\b/gi, // backslash before a known dir
];
const hits = new Set();
for (const re of patterns) for (const m of body.match(re) || []) hits.add(m);
const windowsPaths = [...hits].slice(0, 12);
return { windowsPaths, pass: windowsPaths.length === 0 };
}
export function evalSkill(skillName) {
const skillDir = join(SKILLS_DIR, skillName);
const skillMd = join(skillDir, 'SKILL.md');
if (!existsSync(skillMd)) return null;
const content = readFileSync(skillMd, 'utf8');
const { frontmatter, body } = splitFrontmatter(content);
const name = extractName(frontmatter);
const description = extractDescription(frontmatter);
const refDir = join(skillDir, 'references');
const refFiles = listMarkdown(refDir);
const refFileContents = refFiles.map((p) => ({ path: relative(PLUGIN_ROOT, p), content: readFileSync(p, 'utf8') }));
const perFolder = refCountsPerFolder(refDir);
const K2 = checkK2(description);
const K3 = checkK3(body);
const { K5, K6 } = checkK5K6(body, refFiles.length);
const refConsistency = checkRefConsistency(body, perFolder);
const K9hints = scanK9Hints(body);
const N1 = checkN1(name);
const N2 = checkN2(description);
const N3 = checkN3(refFileContents);
const N4 = checkN4(refFileContents);
const N5 = checkN5(body);
return {
name: skillName,
skillMd: relative(PLUGIN_ROOT, skillMd),
refFilesActual: refFiles.length,
refCountsPerFolder: perFolder,
deterministic: {
K2_descriptionFormat: K2,
K3_bodyLength: K3,
K5_progressiveDisclosure: K5,
K6_routingTable: K6,
refCountConsistency: refConsistency,
K9_timeSensitiveHints: K9hints,
N1_nameValidity: N1,
N2_descriptionLength: N2,
N3_refNestingDepth: N3,
N4_refToc: N4,
N5_forwardSlashPaths: N5,
},
// Pointers for the LLM-judge pass (K1/K4/K7/K8/K9). The judge reads the files
// directly; we only carry the description + sampled ref files here.
judgeInputs: {
description,
bodyLines: K3.bodyLines,
refFileSample: refFiles.slice(0, 8).map((f) => relative(PLUGIN_ROOT, f)),
},
// Filled in by the operator-gated LLM-judge merge step. Null = not yet judged.
judge: null,
};
}
/**
* K10 — sibling-scope-non-overlap. Deterministic, computed ACROSS all skills
* (unlike K2/K3/K5/K6 which are per-skill), so it is attached after the
* per-skill pass. Reuses the B1 overlap core: combined = boundaryTension +
* df-weighted lexical overlap; each skill's K10 verdict is its worst sibling
* pair (perSkillSiblingOverlap). Mutates + returns the skills array.
*/
export function attachSiblingOverlap(skills, promptSet, { threshold = K10_OVERLAP_THRESHOLD } = {}) {
const descriptionsBySkill = {};
for (const s of skills) descriptionsBySkill[s.name] = s.judgeInputs.description;
const overlap = computeOverlapFromInputs(descriptionsBySkill, promptSet);
const k10 = perSkillSiblingOverlap(overlap.pairs, { threshold });
for (const s of skills) {
s.deterministic.K10_siblingScopeOverlap =
k10[s.name] || { maxCombined: 0, worstSibling: null, pass: true, threshold };
}
return skills;
}
/**
* Assemble the full corpus report: per-skill deterministic eval (incl. N1-N5) +
* cross-skill K10 sibling-overlap + merged operator-gated judge cache. This is
* the object the score-skill CLI consumes via scoreReport(). Reads disk.
*/
export function buildReport() {
const skillNames = readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter((e) => e.isDirectory() && existsSync(join(SKILLS_DIR, e.name, 'SKILL.md')))
.map((e) => e.name)
.sort();
const skills = skillNames.map(evalSkill).filter(Boolean);
// K10 — sibling-scope-non-overlap (deterministic, cross-skill). Attached after
// the per-skill pass since it needs every description + the curated prompt set.
if (existsSync(PROMPTS_FILE)) {
const promptSet = JSON.parse(readFileSync(PROMPTS_FILE, 'utf8'));
attachSiblingOverlap(skills, promptSet);
}
// Merge operator-gated LLM-judge results (K1/K4/K7/K8/K9) if present.
const judgeFile = join(OUT_DIR, 'judge-results.json');
if (existsSync(judgeFile)) {
const jr = JSON.parse(readFileSync(judgeFile, 'utf8'));
for (const s of skills) if (jr[s.name]) s.judge = jr[s.name];
}
return {
rubric: 'K1-K10',
note: 'Deterministic: K2,K3,K5,K6,refCountConsistency,K10(siblingScopeOverlap),N1-N5. LLM-judge (operator-gated): K1,K4,K7,K8,K9.',
skills,
};
}
function main() {
const args = process.argv.slice(2);
const jsonOut = args.includes('--json');
const doWrite = args.includes('--write');
const report = buildReport();
const skills = report.skills;
if (doWrite) {
mkdirSync(OUT_DIR, { recursive: true });
atomicWriteJson(OUT_FILE, report);
}
if (jsonOut) {
process.stdout.write(JSON.stringify(report) + '\n');
return;
}
// Human-readable summary
console.log(`\nKB skill eval — ${skills.length} skills (deterministic pass)\n`);
for (const s of skills) {
const d = s.deterministic;
const flag = (b) => (b ? 'PASS' : 'FAIL');
console.log(`${s.name} (${s.refFilesActual} ref-filer)`);
console.log(` K2 description : ${flag(d.K2_descriptionFormat.pass)} (quoted=${d.K2_descriptionFormat.quotedPhrases}, useWhen=${d.K2_descriptionFormat.useWhenForm})`);
console.log(` K3 body ≤500 : ${flag(d.K3_bodyLength.pass)} (${d.K3_bodyLength.bodyLines} linjer)`);
console.log(` K5 prog.disc. : ${flag(d.K5_progressiveDisclosure.pass)} (navngitte=${d.K5_progressiveDisclosure.namedFileLinks}, mapper=${d.K5_progressiveDisclosure.folderRefs}, ratio=${d.K5_progressiveDisclosure.namedRatio})`);
console.log(` K6 routing-tab : ${flag(d.K6_routingTable.pass)} (navngitte startfiler=${d.K6_routingTable.namedStartFiles})`);
const rc = d.refCountConsistency;
console.log(` ref-tall : ${flag(rc.consistent)}${rc.consistent ? '' : ' — ' + JSON.stringify(rc.mismatches)}`);
const k10 = d.K10_siblingScopeOverlap;
if (k10) {
console.log(` K10 søsken-ovl : ${flag(k10.pass)} (maks ${k10.maxCombined} mot ${k10.worstSibling}, terskel ${k10.threshold})`);
}
console.log(` K9 hints : ${d.K9_timeSensitiveHints.timeSensitiveTokenHits} tid-sensitive tokens i body`);
console.log('');
}
console.log(`(LLM-judge K1/K4/K7/K8/K9 kjøres som operatør-gated subagent-steg; baseline skrives med --write.)\n`);
}
// Run only when invoked directly (not when imported by tests).
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main();
}