ms-ai-architect/scripts/kb-eval/apply-skill-op.mjs
Kjell Tore Guttormsen 92cd93771b feat(ms-ai-architect): Spor D Steg B — skill-quality lifecycle-gate + score-cache (TDD)
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>
2026-06-23 18:26:29 +02:00

272 lines
12 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
// apply-skill-op.mjs — Spor B / B3 (Sesjon 18) CLI: the DESTRUCTIVE apply-path.
//
// Executes an operator-APPROVED (status:approved) skill-lifecycle entry from
// decisions.json into a real skills/ mutation: sanitize_skill + retire_skill
// (single-skill, S18) and merge_skills (cross-skill, S19). create_skill comes last.
//
// Two gates, in order: (1) the entry must already be status:approved in the
// ledger (the operator's sign-off on a prior `plan-skill-op … --write` dry-run);
// (2) this CLI mutates skills/ ONLY with --apply. Without --apply it is a PREVIEW
// that re-plans from fresh disk, revalidates (drift-safe), and mutates nothing.
//
// sanitize/retire archive every file under archive/ BEFORE removing it. merge
// RELOCATES the absorbed skill's references under the absorber, persists the
// taxonomy ownership repoint, then archives the absorbed SKILL.md + removes its
// dir. Apply flips the ledger entry approved -> applied. Verify: `git status`.
//
// Usage:
// node scripts/kb-eval/apply-skill-op.mjs list
// node scripts/kb-eval/apply-skill-op.mjs sanitize <skill> [--apply] [--date YYYY-MM-DD]
// node scripts/kb-eval/apply-skill-op.mjs retire <skill> [--apply] [--date YYYY-MM-DD]
// node scripts/kb-eval/apply-skill-op.mjs merge <absorber> <absorbed> [--apply] [--date YYYY-MM-DD]
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { splitFrontmatter, extractDescription, buildReport } from './eval.mjs';
import { gateSkill } from './lib/skill-score.mjs';
import { loadTaxonomy } from '../kb-update/lib/taxonomy.mjs';
import { loadDecisions, listActions, actionKey } from '../kb-update/lib/decisions-io.mjs';
import {
applyApprovedAction,
SANITIZE_OPERATION,
RETIRE_OPERATION,
MERGE_OPERATION,
CREATE_OPERATION,
TEST_DOMAIN_SPEC,
} from './lib/skill-ops.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const SKILLS_DIR = join(PLUGIN_ROOT, 'skills');
const AGENTS_DIR = join(PLUGIN_ROOT, 'agents');
const LEDGER_DATA_DIR = join(__dirname, '..', 'kb-update', 'data');
const PROMPTS_FILE = join(__dirname, 'data', 'k1-trigger-prompts.json');
const OP_BY_VERB = {
sanitize: SANITIZE_OPERATION,
retire: RETIRE_OPERATION,
merge: MERGE_OPERATION,
create: CREATE_OPERATION,
};
const QUALITY_TARGET = 90; // corpus invariant: every skill stays >=90 (Spor D)
/** Read the SKILL.md descriptions from disk (read-only) — create's K10 siblings. */
function loadDescriptions() {
const out = {};
for (const e of readdirSync(SKILLS_DIR, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
const md = join(SKILLS_DIR, e.name, 'SKILL.md');
if (!existsSync(md)) continue;
out[e.name] = extractDescription(splitFrontmatter(readFileSync(md, 'utf8')).frontmatter);
}
return out;
}
/** Read the curated K1 trigger-prompt set (boundary-tension source for K10). */
function loadPromptSet() {
return existsSync(PROMPTS_FILE) ? JSON.parse(readFileSync(PROMPTS_FILE, 'utf8')) : {};
}
const USAGE =
'Usage:\n' +
' node scripts/kb-eval/apply-skill-op.mjs list\n' +
' node scripts/kb-eval/apply-skill-op.mjs sanitize <skill> [--apply] [--date YYYY-MM-DD]\n' +
' node scripts/kb-eval/apply-skill-op.mjs retire <skill> [--apply] [--date YYYY-MM-DD]\n' +
' node scripts/kb-eval/apply-skill-op.mjs merge <absorber> <absorbed> [--apply] [--date YYYY-MM-DD]\n' +
' node scripts/kb-eval/apply-skill-op.mjs create <name> [--apply] [--date YYYY-MM-DD]';
/** List approved actions awaiting apply (operator's apply queue). */
function listApproved() {
const led = loadDecisions(LEDGER_DATA_DIR);
const approved = listActions(led, 'approved');
const applied = listActions(led, 'applied');
console.log(`\nGodkjente handlinger som venter på apply (${approved.length}):`);
if (!approved.length) console.log(' (ingen — kjør plan-skill-op … --write og sett status:approved i decisions.json)');
for (const a of approved) {
const t = a.targets ?? {};
const tgt = t.skill ?? t.name ?? `${t.absorbed} -> ${t.absorber}`;
console.log(`${a.operation_type}: ${tgt} [guardrail ${a.guardrail?.ok ? 'OK' : 'FAILED'}]`);
}
if (applied.length) console.log(`\nAllerede anvendt (${applied.length}): ${applied.map((a) => actionKey(a)).join(', ')}`);
console.log('');
}
function printResult(res, verb, label, apply) {
const r = res.revalidate;
console.log(`\n${verb}${apply ? 'APPLY' : 'PREVIEW'}: ${label}\n`);
console.log(`Revalidering (mot fersk disk):`);
console.log(` fersk-guardrail-ok=${r.freshOk} drift=${r.drift} => ${r.ok ? 'OK' : 'BLOKKERT'}`);
if (r.reason) console.log(` grunn: ${r.reason}`);
if (!res.applied) {
if (res.preview && r.ok) {
const d = res.plan?.diff ?? {};
if (verb === 'merge') {
console.log(
`\n Ville flyttet ${d.fileMoves?.length ?? 0} ref-fil(er) -> absorber, reassignet ` +
`${d.taxonomyReassignments?.length ?? 0} kategori(er), arkivert absorbed-SKILL.md. ` +
`Kjør på nytt med --apply for å eksekvere.`,
);
} else if (verb === 'create') {
const sc = res.plan?.scaffold ?? {};
console.log(
`\n Ville opprettet skills/${label}/ — 1 SKILL.md + ${sc.files?.length ?? 0} ref-fil(er), ` +
`${sc.taxonomyAdditions?.length ?? 0} taksonomi-tillegg. Kjør på nytt med --apply for å eksekvere.`,
);
} else {
const n = verb === 'sanitize' ? (d.removals?.length ?? 0) : (d.archiveMoves?.length ?? 0);
console.log(`\n Ville arkivert+fjernet ${n} fil(er). Kjør på nytt med --apply for å eksekvere.`);
}
}
console.log(`\nStatus: INGEN mutasjon utført.${res.preview && r.ok ? '' : ' (revalidering blokkerte eller ingen --apply.)'}\n`);
return;
}
const rep = res.report;
console.log(`\nUtført:`);
if (rep.op === MERGE_OPERATION) {
console.log(` flyttet: ${rep.refMoves} ref-fil(er) -> absorber`);
console.log(` taksonomi: ${rep.taxonomyReassignments} kategori(er) reassignet + persistert`);
if (rep.archivedSkillMd) console.log(` arkivert: ${rep.archivedSkillMd}`);
} else if (rep.op === CREATE_OPERATION) {
console.log(` opprettet: ${rep.filesWritten} fil(er) under ${rep.createdDir} (1 SKILL.md + ${rep.filesWritten - 1} ref-filer)`);
console.log(` taksonomi: ${rep.taxonomyAdditions} tillegg (additivt)`);
} else {
console.log(` arkivert: ${rep.moves} fil(er) -> archive/`);
}
if (rep.removedDir) console.log(` fjernet katalog: ${rep.removedDir}`);
console.log(` ledger: ${actionKey({ operation_type: rep.op, targets: rep.target })} -> ${rep.ledgerStatus}`);
console.log(`\nStatus: APPLIED. Verifiser med 'git status' og re-kjør validate/kb-integrity/kb-eval.\n`);
}
/**
* The mutated skill that should be re-scored after an apply. create/sanitize keep
* a scorable skill; merge leaves the absorber; retire removes the skill entirely
* (nothing to score -> null, gate skipped). Spor D STEG B lifecycle-gate.
*/
function affectedSkill(op, targets) {
if (op === CREATE_OPERATION) return targets.name;
if (op === MERGE_OPERATION) return targets.absorber;
if (op === SANITIZE_OPERATION) return targets.skill;
return null; // RETIRE: skill removed
}
/**
* Re-score the affected skill from fresh disk after a successful apply and surface
* a quality verdict. The deterministic floor (K10) is enforced immediately as a
* loud warning; an unjudged skill is flagged provisional with a nudge to re-run
* the judge pass (the K1 floor cannot be enforced from the cache alone).
*/
function printQualityGate(op, targets) {
const affected = affectedSkill(op, targets);
if (!affected) {
console.log(`\nKvalitetsgate: ingen gjenværende skill å re-score (retire). Hopper over.`);
return;
}
const v = gateSkill(buildReport(), affected, { target: QUALITY_TARGET });
if (!v.found) {
console.log(`\nKvalitetsgate: fant ikke '${affected}' på disk for re-scoring (hoppet over).`);
return;
}
const tags = [];
if (v.floored) tags.push('floored');
if (v.provisional) tags.push('provisional');
const tagStr = tags.length ? ` [${tags.join(', ')}]` : '';
if (v.blocked) {
console.log(`\n⚠ KVALITETSGATE: '${affected}' scorer ${v.score}/100 — UNDER MÅL ${QUALITY_TARGET} %${tagStr}.`);
for (const imp of (v.improvements || []).slice(0, 3)) {
const floor = imp.floor ? ' ⚑gulv' : '';
console.log(`${imp.key} ${imp.label}${floor} (${imp.detail}) [-${imp.pointsLost.toFixed(2)}] → ${imp.fix}`);
}
console.log(` Rett før commit, eller dokumenter avviket eksplisitt.`);
} else {
console.log(`\nKvalitetsgate: '${affected}' scorer ${v.score}/100 — OK (≥${QUALITY_TARGET} %)${tagStr}.`);
}
if (v.provisional) {
console.log(` judge-cache mangler/utdatert for '${affected}' → K1-gulv ikke håndhevet. Kjør judge-passet (scripts/kb-eval/judge-prompt.md) og re-merge data/judge-results.json, deretter re-score.`);
}
}
function main() {
const args = process.argv.slice(2);
const verb = args[0];
if (verb === 'list') return listApproved();
const positional = args.filter((a) => !a.startsWith('--'));
const apply = args.includes('--apply');
const dateIdx = args.indexOf('--date');
const decided_at = dateIdx >= 0 ? args[dateIdx + 1] : new Date().toISOString().slice(0, 10);
const op = OP_BY_VERB[verb];
if (!op) {
console.error(USAGE);
process.exit(2);
}
// Resolve targets + ledger key per op arity (merge takes two skills; create
// is keyed on its new name; sanitize/retire on their one skill).
let targets, label;
if (op === MERGE_OPERATION) {
if (positional.length < 3) {
console.error(USAGE);
process.exit(2);
}
targets = { absorber: positional[1], absorbed: positional[2] };
label = `${targets.absorbed} -> ${targets.absorber}`;
} else if (op === CREATE_OPERATION) {
if (positional.length < 2) {
console.error(USAGE);
process.exit(2);
}
targets = { name: positional[1] };
label = positional[1];
} else {
if (positional.length < 2) {
console.error(USAGE);
process.exit(2);
}
targets = { skill: positional[1] };
label = positional[1];
}
const key = actionKey({ operation_type: op, targets });
const led = loadDecisions(LEDGER_DATA_DIR);
const entry = led.actions?.[key];
if (!entry) {
console.error(`Fant ingen ledger-handling "${key}". Kjør 'apply-skill-op.mjs list' for å se godkjente.`);
process.exit(1);
}
if (entry.status !== 'approved') {
console.error(`Handling "${key}" har status "${entry.status}" (må være "approved"). Avbryter.`);
process.exit(1);
}
// retire + merge both consult the taxonomy (owned-category awareness / repoint);
// create re-supplies its scaffold spec + K10 siblings for drift-safe revalidation.
const needsTaxonomy = op === RETIRE_OPERATION || op === MERGE_OPERATION;
const isCreate = op === CREATE_OPERATION;
const res = applyApprovedAction(entry, {
pluginRoot: PLUGIN_ROOT,
skillsDir: SKILLS_DIR,
agentsDir: AGENTS_DIR,
dataDir: LEDGER_DATA_DIR,
taxonomyDataDir: LEDGER_DATA_DIR,
apply,
decided_at: apply ? decided_at : null,
categorySkill: needsTaxonomy ? loadTaxonomy(LEDGER_DATA_DIR).category_skill : undefined,
createSpec: isCreate ? { description: TEST_DOMAIN_SPEC.description, categories: TEST_DOMAIN_SPEC.categories } : undefined,
existingDescriptions: isCreate ? loadDescriptions() : undefined,
promptSet: isCreate ? loadPromptSet() : undefined,
});
printResult(res, verb, label, apply);
// STEG B: re-score the affected skill from fresh disk after a real mutation.
// Preview never mutates -> nothing to re-score (create's projected skill isn't
// on disk), so the gate runs only on applied mutations.
if (res.applied) printQualityGate(op, targets);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main();
}