ms-ai-architect/scripts/kb-eval/plan-skill-op.mjs
Kjell Tore Guttormsen e47fc9bd59 feat(ms-ai-architect): Sesjon 20 — B3 create_skill (dvelende, konstruktiv op) [skip-docs]
B3 + Spor B (krav 5+6) KOMPLETT. create_skill er speilbildet av de
destruktive ops: merge/saner/retire beviser "ingen kuratert verdi TAPT";
create beviser "ingen sub-bar skill FØDT" (kvalitetsgulv). Ingen ny
domene-skill shippet (svar 3) — mekanismen bevist, ikke brukt.

- planCreateSkill (ren): genererer scaffold + selv-validerer mot eval.mjs
  sine EGNE checkers (K2/K3/K5/K6/refTall + K10) — rubrikken har én kilde.
  Guardrail ok iff alle deterministiske K passerer + K10<terskel + navn ledig.
- TEST_DOMAIN_SPEC: kanonisk dvelende-scaffold (born compliant, K5 ratio 1.0).
- runCreatePlan (uren): gated dry-run, 0 skriving til skills/.
- applyApprovedAction create-gren: re-plan fersk → revalider (drift+kollisjon)
  → skriv scaffold + persister taksonomi-tillegg → flipp ledger.
- applyCategoryAdditions (taxonomy.mjs): additiv, klobrer aldri eksisterende eier.
- CLI create-verb i plan-skill-op + apply-skill-op.

TDD: ny test-skill-ops-create.test.mjs (16). kb-eval 84→100.
Suiter uendret: validate 239 · kb-update 139 · kb-integrity 192/192.
Deterministisk eval K1–K10 for de 5 ekte: 0 regresjon. 0 ekte skills/-mutasjon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:37:19 +02:00

200 lines
9.8 KiB
JavaScript
Raw Permalink 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
// plan-skill-op.mjs — Spor B / B3 CLI: GATED DRY-RUN for skill-lifecycle ops.
//
// Ships merge_skills (S16) + sanitize_skill + retire_skill (S17). Each dry-run
// loads refs/orphans/taxonomy READ-ONLY, plans the op, prints the full diff +
// guardrail, and with --write records a PENDING entry in decisions.json (the
// operator gate's queue). It NEVER writes under skills/ — the destructive apply
// is a separate approved step. Verify after a run: `git status skills/` clean.
//
// Usage:
// node scripts/kb-eval/plan-skill-op.mjs merge <absorber> <absorbed> [--json] [--write [--date YYYY-MM-DD]]
// node scripts/kb-eval/plan-skill-op.mjs sanitize <skill> [--json] [--write [--date YYYY-MM-DD]]
// node scripts/kb-eval/plan-skill-op.mjs retire <skill> [--hard] [--json] [--write [--date YYYY-MM-DD]]
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { splitFrontmatter, extractDescription } from './eval.mjs';
import { loadTaxonomy } from '../kb-update/lib/taxonomy.mjs';
import { runMergePlan, runSanitizePlan, runRetirePlan, runCreatePlan, 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 TAX_DATA_DIR = LEDGER_DATA_DIR;
const PROMPTS_FILE = join(__dirname, 'data', 'k1-trigger-prompts.json');
/** Read the SKILL.md descriptions from disk (read-only). */
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')) : {};
}
function printPlan({ entry, diff, guardrail }) {
const g = guardrail;
console.log(`\nmerge_skills — DRY-RUN: ${entry.targets.absorbed} -> ${entry.targets.absorber}\n`);
console.log(`Guardrail (ingen kuratert verdi tapt):`);
console.log(` absorber-refs=${g.preCountAbsorber} absorbed-refs=${g.preCountAbsorbed} forventet post=${g.expectedPostCount} faktisk post=${g.postCount}`);
console.log(` count-invariant=${g.countInvariant} set-equality=${g.setEquality} kollisjoner=${g.collisions.length ? g.collisions.join(', ') : '0'}`);
console.log(` => ${g.ok ? 'OK (trygt å merge)' : 'FAILED (ville tapt/klobret innhold — blokkert)'}\n`);
console.log(`Diff:`);
console.log(` fil-flytt: ${diff.fileMoves.length} (alle ${entry.targets.absorbed}/references/* -> ${entry.targets.absorber}/references/*)`);
if (diff.taxonomyReassignments.length) {
console.log(` taksonomi-reassign:`);
for (const t of diff.taxonomyReassignments) console.log(`${t.category}: ${t.from} -> ${t.to}`);
}
console.log(` retire: ${diff.retire}`);
console.log(` description-reconciliation: MANUELL (planner auto-merger aldri semantisk scope)`);
console.log(` ${entry.targets.absorber}: ${diff.descriptionReconciliation.absorber?.slice(0, 80) ?? '(mangler)'}`);
console.log(` ${entry.targets.absorbed}: ${diff.descriptionReconciliation.absorbed?.slice(0, 80) ?? '(mangler)'}\n`);
console.log(`Status: ${entry.status} (forslag — anvender INGENTING; krever operatør-godkjenning + apply-sesjon).\n`);
}
function printSanitizePlan({ entry, diff, guardrail }) {
const g = guardrail;
console.log(`\nsanitize_skill — DRY-RUN: ${entry.targets.skill}\n`);
console.log(`Guardrail (kun dødt innhold; aldri kuratert):`);
console.log(` refs=${g.preCount} dead(orphan)=${g.deadCount} fjernes=${g.removalCount} kuratert-beholdt=${g.curatedCount} post=${g.postCount}`);
console.log(` ulovlige-fjerninger(kuratert)=${g.illegalRemovals.length ? g.illegalRemovals.join(', ') : '0'} manglende=${g.missingRemovals.length ? g.missingRemovals.join(', ') : '0'}`);
console.log(` => ${g.ok ? 'OK (trygt å sanere)' : 'FAILED (ville rørt kuratert/manglende innhold — blokkert)'}\n`);
console.log(`Diff — fjernes (${diff.removals.length}):`);
for (const r of diff.removals.slice(0, 20)) console.log(`${r.path}`);
if (diff.removals.length > 20) console.log(` … +${diff.removals.length - 20} til`);
console.log(` beholdt (kuratert): ${diff.retainedCount}\n`);
console.log(`Status: ${entry.status} (forslag — anvender INGENTING; krever operatør-godkjenning + apply-sesjon).\n`);
}
function printRetirePlan({ entry, diff, guardrail }) {
const g = guardrail;
console.log(`\nretire_skill — DRY-RUN: ${entry.targets.skill}\n`);
console.log(`Guardrail (obligatorisk arkivering; aldri hard delete):`);
console.log(` refs=${g.refCount} forventet-arkivert=${g.expectedArchiveCount} (refs + SKILL.md) faktisk-arkivert=${g.archivedCount}`);
console.log(` arkiv-mål=${g.archiveDir ?? '(ingen)'} hard-delete-bedt=${g.hardDeleteRequested}`);
console.log(` => ${g.ok ? 'OK (arkivert — trygt å pensjonere)' : 'FAILED (hard delete uten arkiv — blokkert)'}\n`);
console.log(`Diff:`);
console.log(` arkiv-flytt: ${diff.archiveMoves.length} (alle skills/${entry.targets.skill}/* -> ${g.archiveDir}/*)`);
console.log(` fjern katalog: ${diff.removeSkillDir}`);
if (diff.taxonomyOrphaned.length) {
console.log(` taksonomi forelder-løs (manuell reassign):`);
for (const t of diff.taxonomyOrphaned) console.log(`${t.category} (var eid av ${t.wasOwner})`);
}
console.log(`\nStatus: ${entry.status} (forslag — anvender INGENTING; krever operatør-godkjenning + apply-sesjon).\n`);
}
function printCreatePlan({ entry, scaffold, guardrail }) {
const g = guardrail;
console.log(`\ncreate_skill — DRY-RUN: ${entry.targets.name}\n`);
console.log(`Guardrail (konstruktiv — født compliant ELLER nektet):`);
console.log(
` navn-ledig=${g.nameAvailable} K2=${g.k2.pass} K3=${g.k3.pass} K5=${g.k5.pass} ` +
`K6=${g.k6.pass} refTall=${g.refConsistency.consistent} K10=${g.k10.pass}`,
);
console.log(` feilede=${g.failed.length ? g.failed.join(', ') : '0'}`);
console.log(` => ${g.ok ? 'OK (scaffold passerer K1K10 deterministisk — trygt å opprette)' : 'FAILED (sub-bar skill — blokkert)'}\n`);
console.log(`Scaffold:`);
console.log(` ${scaffold.skillMd.path}`);
console.log(` ref-filer: ${scaffold.files.length}`);
for (const f of scaffold.files) console.log(`${f.path}`);
if (scaffold.taxonomyAdditions.length) {
console.log(` taksonomi-tillegg (additivt):`);
for (const t of scaffold.taxonomyAdditions) console.log(`${t.category} -> ${t.skill}`);
}
console.log(`\nStatus: ${entry.status} (forslag — anvender INGENTING; krever operatør-godkjenning + apply-sesjon).\n`);
}
const USAGE =
'Usage:\n' +
' node scripts/kb-eval/plan-skill-op.mjs merge <absorber> <absorbed> [--json] [--write [--date YYYY-MM-DD]]\n' +
' node scripts/kb-eval/plan-skill-op.mjs sanitize <skill> [--json] [--write [--date YYYY-MM-DD]]\n' +
' node scripts/kb-eval/plan-skill-op.mjs retire <skill> [--hard] [--json] [--write [--date YYYY-MM-DD]]\n' +
' node scripts/kb-eval/plan-skill-op.mjs create <name> [--json] [--write [--date YYYY-MM-DD]]';
function main() {
const args = process.argv.slice(2);
const op = args[0];
const positional = args.filter((a) => !a.startsWith('--'));
const jsonOut = args.includes('--json');
const doWrite = args.includes('--write');
const hardDelete = args.includes('--hard');
const dateIdx = args.indexOf('--date');
const decided_at = doWrite ? (dateIdx >= 0 ? args[dateIdx + 1] : new Date().toISOString().slice(0, 10)) : null;
let result;
let printer;
let label;
if (op === 'merge' && positional.length >= 3) {
const [, absorber, absorbed] = positional;
result = runMergePlan(absorber, absorbed, {
skillsDir: SKILLS_DIR,
dataDir: LEDGER_DATA_DIR,
write: doWrite,
decided_at,
descriptions: loadDescriptions(),
categorySkill: loadTaxonomy(TAX_DATA_DIR).category_skill,
});
printer = printPlan;
label = 'merge_skills';
} else if (op === 'sanitize' && positional.length >= 2) {
result = runSanitizePlan(positional[1], {
skillsDir: SKILLS_DIR,
agentsDir: AGENTS_DIR,
dataDir: LEDGER_DATA_DIR,
write: doWrite,
decided_at,
});
printer = printSanitizePlan;
label = 'sanitize_skill';
} else if (op === 'retire' && positional.length >= 2) {
result = runRetirePlan(positional[1], {
skillsDir: SKILLS_DIR,
dataDir: LEDGER_DATA_DIR,
write: doWrite,
decided_at,
hardDelete,
categorySkill: loadTaxonomy(TAX_DATA_DIR).category_skill,
});
printer = printRetirePlan;
label = 'retire_skill';
} else if (op === 'create' && positional.length >= 2) {
result = runCreatePlan(positional[1], {
skillsDir: SKILLS_DIR,
dataDir: LEDGER_DATA_DIR,
write: doWrite,
decided_at,
...TEST_DOMAIN_SPEC,
existingDescriptions: loadDescriptions(),
promptSet: loadPromptSet(),
});
printer = printCreatePlan;
label = 'create_skill';
} else {
console.error(USAGE);
process.exit(2);
}
if (jsonOut) {
process.stdout.write(JSON.stringify(result) + '\n');
return;
}
printer(result);
if (doWrite) console.log(`(Pending ${label}-entry skrevet til decisions.json — 0 skriving til skills/.)\n`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main();
}