#!/usr/bin/env node // score-skill.mjs — Skill-quality score CLI (Spor D, step 2). // // Runs the eval pipeline (buildReport: deterministic K2/K3/K5/K6/refCount/N1-N5 // + cross-skill K10 + merged operator-gated judge cache), scores every skill via // the PURE lib (scoreSkill/scoreReport), and reports a 0-100 quality score with // a sorted improvement list. The corpus invariant is: every skill stays >=90 %. // // Usage: // node scripts/kb-eval/score-skill.mjs # human summary // node scripts/kb-eval/score-skill.mjs --json # machine output (scoreReport) // node scripts/kb-eval/score-skill.mjs --gate 90 # non-zero exit if any skill < 90 // node scripts/kb-eval/score-skill.mjs --skill # scope output to one skill // # (still scores the full corpus // # so K10 sibling-overlap is correct) // node scripts/kb-eval/score-skill.mjs --write [path] # persist the whole-corpus cache // # (default: data/skill-score-report.json; // # consumed by the STEG C surfacing) // // K10 requires every sibling description, so even single-skill (incremental) // scoring evaluates the whole corpus and then filters the reported set — this is // the building block for the lifecycle-gate in apply-skill-op.mjs. The --write // cache is ALWAYS whole-corpus (surfacing needs every skill), independent of --skill. // // Zero dependencies. Pure scoring lives in lib/skill-score.mjs. import { writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { buildReport } from './eval.mjs'; import { scoreReport, formatScoreReport, buildScoreCache, TARGET } from './lib/skill-score.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DEFAULT_CACHE = join(__dirname, 'data', 'skill-score-report.json'); function argValue(args, flag) { const i = args.indexOf(flag); return i !== -1 && i + 1 < args.length ? args[i + 1] : null; } function main() { const args = process.argv.slice(2); const jsonOut = args.includes('--json'); const gateRaw = argValue(args, '--gate'); const gate = gateRaw !== null ? Number(gateRaw) : null; const skillFilter = argValue(args, '--skill'); // --write [path]: bare flag uses the default cache path; an explicit non-flag // argument overrides it (the tests write to a temp path to avoid clobbering). const writeIdx = args.indexOf('--write'); const doWrite = writeIdx !== -1; const writePath = doWrite && args[writeIdx + 1] && !args[writeIdx + 1].startsWith('--') ? args[writeIdx + 1] : DEFAULT_CACHE; const target = Number.isFinite(gate) ? gate : TARGET; const full = scoreReport(buildReport(), { target }); // The cache is whole-corpus regardless of --skill (surfacing needs every skill). if (doWrite) { const cache = buildScoreCache(full, { generatedAt: new Date().toISOString().slice(0, 10) }); writeFileSync(writePath, JSON.stringify(cache, null, 2) + '\n'); process.stdout.write(`✓ Skrev skill-score-cache → ${writePath} (${cache.scored.length} skills, ${cache.below.length} under mål).\n`); } // Scope the reported/gated set to one skill if requested (scoring already ran // over the whole corpus, so K10 is computed correctly). let scored = full.scored; if (skillFilter) { scored = scored.filter((s) => s.name === skillFilter); if (scored.length === 0) { process.stderr.write(`score-skill: ukjent skill '${skillFilter}'. Tilgjengelig: ${full.scored.map((s) => s.name).join(', ')}\n`); process.exitCode = 2; return; } } const below = scored.filter((s) => !s.meetsTarget); const result = { target, scored, below }; if (jsonOut) { process.stdout.write(JSON.stringify(result) + '\n'); } else { process.stdout.write(formatScoreReport(result) + '\n'); if (gate !== null) { process.stdout.write(below.length === 0 ? `✓ Gate ≥${target}: alle ${scored.length} skills består.\n` : `✗ Gate ≥${target}: ${below.length} skill(s) under mål — ${below.map((s) => s.name).join(', ')}.\n`); } } if (gate !== null && below.length > 0) process.exitCode = 1; } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { main(); }