B1: gateSkill(report, name) — pure post-mutation verdict (blocked/provisional/ improvements), wired into apply-skill-op.mjs to re-score the affected skill from fresh disk after create/merge/sanitize-apply. K10 floor enforced immediately; unjudged → provisional + nudge to re-run the judge pass. retire → skipped. B2: buildScoreCache(result) — compact, deterministic cache shape + score-skill.mjs --write [path] → data/skill-score-report.json (always whole-corpus). Gitignored (derived/regenerable; avoids churn in the public repo). Consumed by Steg C surfacing. 8 new tests (tests/kb-eval/test-skill-score-gate.test.mjs). kb-eval 150 pass, validate 239 pass. Live score unchanged: advisor 91, eng/gov/infra/sec 96, 0 < 90. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
96 lines
4.4 KiB
JavaScript
96 lines
4.4 KiB
JavaScript
#!/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 <name> # 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();
|
|
}
|