diff --git a/scripts/kb-eval/apply-skill-op.mjs b/scripts/kb-eval/apply-skill-op.mjs index 0dd3c26..d4badc5 100644 --- a/scripts/kb-eval/apply-skill-op.mjs +++ b/scripts/kb-eval/apply-skill-op.mjs @@ -2,27 +2,30 @@ // 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. Single-skill ops only this -// session: sanitize_skill + retire_skill (merge_skills is staged to S19). +// 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. // -// Apply archives every file under archive/ BEFORE removing it, then flips the -// ledger entry approved -> applied. Verify before/after: `git status`. +// 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 [--apply] [--date YYYY-MM-DD] // node scripts/kb-eval/apply-skill-op.mjs retire [--apply] [--date YYYY-MM-DD] +// node scripts/kb-eval/apply-skill-op.mjs merge [--apply] [--date YYYY-MM-DD] import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; 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 } from './lib/skill-ops.mjs'; +import { applyApprovedAction, SANITIZE_OPERATION, RETIRE_OPERATION, MERGE_OPERATION } from './lib/skill-ops.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); @@ -30,13 +33,14 @@ const SKILLS_DIR = join(PLUGIN_ROOT, 'skills'); const AGENTS_DIR = join(PLUGIN_ROOT, 'agents'); const LEDGER_DATA_DIR = join(__dirname, '..', 'kb-update', 'data'); -const OP_BY_VERB = { sanitize: SANITIZE_OPERATION, retire: RETIRE_OPERATION }; +const OP_BY_VERB = { sanitize: SANITIZE_OPERATION, retire: RETIRE_OPERATION, merge: MERGE_OPERATION }; const USAGE = 'Usage:\n' + ' node scripts/kb-eval/apply-skill-op.mjs list\n' + ' node scripts/kb-eval/apply-skill-op.mjs sanitize [--apply] [--date YYYY-MM-DD]\n' + - ' node scripts/kb-eval/apply-skill-op.mjs retire [--apply] [--date YYYY-MM-DD]'; + ' node scripts/kb-eval/apply-skill-op.mjs retire [--apply] [--date YYYY-MM-DD]\n' + + ' node scripts/kb-eval/apply-skill-op.mjs merge [--apply] [--date YYYY-MM-DD]'; /** List approved actions awaiting apply (operator's apply queue). */ function listApproved() { @@ -54,24 +58,38 @@ function listApproved() { console.log(''); } -function printResult(res, verb, skill, apply) { +function printResult(res, verb, label, apply) { const r = res.revalidate; - console.log(`\n${verb}_skill — ${apply ? 'APPLY' : 'PREVIEW'}: ${skill}\n`); + 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 ?? {}; - 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.`); + 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 { + 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:`); - console.log(` arkivert: ${rep.moves} fil(er) -> archive/`); + 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 { + 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`); @@ -88,14 +106,31 @@ function main() { const decided_at = dateIdx >= 0 ? args[dateIdx + 1] : new Date().toISOString().slice(0, 10); const op = OP_BY_VERB[verb]; - if (!op || positional.length < 2) { + if (!op) { console.error(USAGE); process.exit(2); } - const skill = positional[1]; + + // Resolve targets + ledger key per op arity (merge takes two skills). + 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 (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 key = `${op}:${skill}`; const entry = led.actions?.[key]; if (!entry) { console.error(`Fant ingen ledger-handling "${key}". Kjør 'apply-skill-op.mjs list' for å se godkjente.`); @@ -106,16 +141,19 @@ function main() { process.exit(1); } + // retire + merge both consult the taxonomy (owned-category awareness / repoint). + const needsTaxonomy = op === RETIRE_OPERATION || op === MERGE_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: op === RETIRE_OPERATION ? loadTaxonomy(LEDGER_DATA_DIR).category_skill : undefined, + categorySkill: needsTaxonomy ? loadTaxonomy(LEDGER_DATA_DIR).category_skill : undefined, }); - printResult(res, verb, skill, apply); + printResult(res, verb, label, apply); } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { diff --git a/scripts/kb-eval/lib/skill-ops.mjs b/scripts/kb-eval/lib/skill-ops.mjs index a9b9f3b..69cf9c1 100644 --- a/scripts/kb-eval/lib/skill-ops.mjs +++ b/scripts/kb-eval/lib/skill-ops.mjs @@ -22,6 +22,7 @@ import { actionKey, setActionStatus, } from '../../kb-update/lib/decisions-io.mjs'; +import { loadTaxonomy, saveTaxonomy, applyCategoryReassignments } from '../../kb-update/lib/taxonomy.mjs'; export const MERGE_OPERATION = 'merge_skills'; export const SANITIZE_OPERATION = 'sanitize_skill'; @@ -398,16 +399,23 @@ export function runRetirePlan(skill, opts) { } // =========================================================================== -// Apply-path (Sesjon 18) — the DESTRUCTIVE frontier. An operator-approved -// ledger entry is executed into a real skills/ mutation. Single-skill ops only -// (sanitize/retire); merge-apply is staged to S19 (needs taxonomy persistence). +// Apply-path (Sesjon 18–19) — the DESTRUCTIVE frontier. An operator-approved +// ledger entry is executed into a real skills/ mutation. Single-skill ops +// (sanitize/retire) shipped in S18; merge-apply (cross-skill, the heaviest op) +// in S19. create_skill (dvelende) comes last. // // Invariants: // - Idempotent revalidation: re-plan from FRESH disk and refuse unless the // fresh guardrail is ok AND deep-equals the approved snapshot (no drift). -// - Archive BEFORE delete: every removal is a rename skills/… -> archive/…, -// which moves (archives) and removes atomically; retire then rmdir's the +// - Archive BEFORE delete: a removal is a rename skills/… -> archive/…, which +// moves (archives) and removes atomically; retire/merge then rmdir the // emptied skill directory last. +// - Merge preserves curated value by RELOCATION, not archival: the absorbed +// skill's references move under the absorber (the guardrail's set-equality +// made real on disk), the taxonomy's category ownership is repointed + +// persisted, and only the now-empty absorbed shell (SKILL.md) is archived. +// Description reconciliation stays MANUAL (planner contract): merge-apply +// never edits the absorber's SKILL.md. // =========================================================================== /** @@ -433,37 +441,48 @@ export function revalidateApply(approvedEntry, freshResult) { return { ok: reasons.length === 0, drift, freshOk, reason: reasons.length ? reasons.join('; ') : null }; } -/** Archive a single file: mkdir -p the destination dir, then rename (atomic move). */ -function archiveMove(absFrom, absTo) { +/** Move a single file: mkdir -p the destination dir, then rename (atomic). Used + * both for archival (skills/… -> archive/…) and curated relocation (merge). */ +function moveFile(absFrom, absTo) { mkdirSync(dirname(absTo), { recursive: true }); renameSync(absFrom, absTo); } /** - * Execute an operator-approved single-skill op into a real skills/ mutation. + * Execute an operator-approved skill-lifecycle op into a real skills/ mutation. * Re-plans from fresh disk, revalidates (drift-safe), and only with apply:true - * performs the move(s) — archiving every file under archive/ BEFORE removing it, - * then (retire) removing the emptied skill dir. On success the ledger entry is + * mutates. Single-skill ops (sanitize/retire) archive every file under archive/ + * BEFORE removing it, then (retire) drop the emptied skill dir. merge_skills + * RELOCATES the absorbed skill's references under the absorber (preservation), + * persists the taxonomy's category-ownership reassignment, then archives the + * absorbed shell (SKILL.md) + removes its dir. On success the ledger entry is * flipped approved -> applied. apply:false is a preview that mutates nothing. * * @param {object} approvedEntry ledger entry with status:'approved' * @param {{ pluginRoot: string, skillsDir: string, agentsDir?: string, dataDir: string, - * apply?: boolean, decided_at?: string|null, categorySkill?: Record }} opts + * taxonomyDataDir?: string, apply?: boolean, decided_at?: string|null, + * categorySkill?: Record }} opts + * taxonomyDataDir — where domain-taxonomy.json lives (merge persist); defaults to dataDir. * @returns {{ applied: boolean, preview?: boolean, revalidate: object, plan?: object, report?: object }} */ export function applyApprovedAction(approvedEntry, opts) { - const { pluginRoot, skillsDir, agentsDir, dataDir, apply = false, decided_at = null, categorySkill } = opts; + const { + pluginRoot, skillsDir, agentsDir, dataDir, taxonomyDataDir, + apply = false, decided_at = null, categorySkill, + } = opts; const op = approvedEntry?.operation_type; const target = approvedEntry?.targets ?? {}; - // 1. Re-plan from fresh disk (read-only) for the supported single-skill ops. + // 1. Re-plan from fresh disk (read-only) for the supported ops. let fresh; if (op === SANITIZE_OPERATION) { fresh = runSanitizePlan(target.skill, { skillsDir, agentsDir, dataDir, write: false }); } else if (op === RETIRE_OPERATION) { fresh = runRetirePlan(target.skill, { skillsDir, dataDir, write: false, categorySkill }); + } else if (op === MERGE_OPERATION) { + fresh = runMergePlan(target.absorber, target.absorbed, { skillsDir, dataDir, write: false, categorySkill }); } else { - throw new Error(`applyApprovedAction: unsupported operation_type "${op}" — S18 applies sanitize/retire only (merge staged to S19)`); + throw new Error(`applyApprovedAction: unsupported operation_type "${op}" — applies sanitize/retire/merge only (create_skill staged later)`); } // 2. Idempotent revalidation against fresh disk — refuse on any failure. @@ -472,16 +491,52 @@ export function applyApprovedAction(approvedEntry, opts) { return { applied: false, preview: !apply, revalidate, plan: fresh }; } - // 3. Build the move list (archive destination = archive/ + repo-relative path). + // 3a. merge — curated RELOCATION (not archival) + taxonomy persist + shell archive. + if (op === MERGE_OPERATION) { + const { absorber, absorbed } = target; + // Relocate every absorbed reference under the absorber — the move IS the + // preservation the guardrail's set-equality proved. + for (const m of fresh.diff.fileMoves) moveFile(join(pluginRoot, m.from), join(pluginRoot, m.to)); + // Persist taxonomy ownership repoint (absorbed -> absorber). Reassignments + // come from the FRESH plan, so this stays drift-safe by construction. + const taxDir = taxonomyDataDir ?? dataDir; + const reassignments = fresh.diff.taxonomyReassignments; + if (reassignments.length) { + saveTaxonomy(applyCategoryReassignments(loadTaxonomy(taxDir), reassignments), taxDir); + } + // Archive the now-empty absorbed shell (SKILL.md) BEFORE removing its dir. + let archivedSkillMd = null; + if (existsSync(join(pluginRoot, `skills/${absorbed}/SKILL.md`))) { + archivedSkillMd = `archive/skills/${absorbed}/SKILL.md`; + moveFile(join(pluginRoot, `skills/${absorbed}/SKILL.md`), join(pluginRoot, archivedSkillMd)); + } + const removedDir = `skills/${absorbed}`; + rmSync(join(pluginRoot, removedDir), { recursive: true, force: true }); + // Flip the ledger entry approved -> applied (gated write; audit record kept). + saveDecisions(setActionStatus(loadDecisions(dataDir), actionKey(approvedEntry), 'applied', decided_at), dataDir); + return { + applied: true, + revalidate, + report: { + op, target, + refMoves: fresh.diff.fileMoves.length, + taxonomyReassignments: reassignments.length, + archivedSkillMd, + removedDir, + ledgerStatus: 'applied', + }, + }; + } + + // 3b. sanitize/retire — archive (rename) every file FIRST, then (retire) drop the dir. const moves = op === SANITIZE_OPERATION ? fresh.diff.removals.map((r) => ({ from: r.path, to: join('archive', r.path) })) : fresh.diff.archiveMoves.map((m) => ({ from: m.from, to: m.to })); - // 4. Execute: archive (rename) every file FIRST, then (retire) drop the dir. const archived = []; for (const m of moves) { - archiveMove(join(pluginRoot, m.from), join(pluginRoot, m.to)); + moveFile(join(pluginRoot, m.from), join(pluginRoot, m.to)); archived.push(m.to); } let removedDir = null; @@ -490,7 +545,7 @@ export function applyApprovedAction(approvedEntry, opts) { rmSync(join(pluginRoot, removedDir), { recursive: true, force: true }); } - // 5. Flip the ledger entry approved -> applied (gated write; audit record kept). + // Flip the ledger entry approved -> applied (gated write; audit record kept). const key = actionKey(approvedEntry); saveDecisions(setActionStatus(loadDecisions(dataDir), key, 'applied', decided_at), dataDir); diff --git a/scripts/kb-update/lib/taxonomy.mjs b/scripts/kb-update/lib/taxonomy.mjs index 75c376f..4c5bb99 100644 --- a/scripts/kb-update/lib/taxonomy.mjs +++ b/scripts/kb-update/lib/taxonomy.mjs @@ -4,7 +4,7 @@ // skill/category owns content, and update priority. poll-sitemaps, // discover-new-urls and report-changes READ this instead of embedding copies. -import { readFileSync } from 'node:fs'; +import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -21,6 +21,41 @@ export function loadTaxonomy(dataDir = DEFAULT_DATA_DIR) { return JSON.parse(readFileSync(path, 'utf8')); } +/** + * Save the domain taxonomy atomically (write to .tmp, then rename) — mirrors + * saveDecisions. The taxonomy is lag-0 truth and is normally read-only; the ONE + * writer is the gated merge-apply path (Sesjon 19), which repoints category + * ownership from an absorbed skill to its absorber. Detection scripts must NOT + * import this. + * @param {object} tax — taxonomy object to persist + * @param {string} [dataDir] + */ +export function saveTaxonomy(tax, dataDir = DEFAULT_DATA_DIR) { + if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true }); + const path = join(dataDir, 'domain-taxonomy.json'); + const tmp = path + '.tmp'; + writeFileSync(tmp, JSON.stringify(tax, null, 2) + '\n', 'utf8'); + renameSync(tmp, path); +} + +/** + * Repoint category ownership for a skill merge. Pure — returns a new taxonomy, + * mutates nothing. For each { category, from, to }, the category's owner is + * changed to `to` ONLY when it currently equals `from` (defensive: a category + * already repointed, or absent, is a no-op — never clobbered). All other fields + * survive untouched. + * @param {object} tax + * @param {Array<{category: string, from: string, to: string}>} reassignments + * @returns {object} new taxonomy + */ +export function applyCategoryReassignments(tax, reassignments) { + const next = { ...tax.category_skill }; + for (const { category, from, to } of reassignments) { + if (next[category] === from) next[category] = to; + } + return { ...tax, category_skill: next }; +} + /** * Target sitemap child-name prefixes to scan/poll. * @param {object} tax diff --git a/tests/kb-eval/test-skill-ops-apply.test.mjs b/tests/kb-eval/test-skill-ops-apply.test.mjs index 071569d..83521de 100644 --- a/tests/kb-eval/test-skill-ops-apply.test.mjs +++ b/tests/kb-eval/test-skill-ops-apply.test.mjs @@ -1,9 +1,9 @@ // tests/kb-eval/test-skill-ops-apply.test.mjs // Spor B / B3 (Sesjon 18): the APPLY-PATH — the destructive frontier. An // operator-approved (status:approved) ledger entry is executed into a real -// skills/ mutation. Single-skill ops only this session (sanitize + retire); -// merge-apply is staged to S19 (it needs taxonomy persistence + cross-skill -// moves). create_skill comes last. +// skills/ mutation. This file covers the SINGLE-SKILL ops (sanitize + retire); +// merge-apply (cross-skill, Sesjon 19) lives in test-skill-ops-merge-apply.test.mjs; +// create_skill comes last. // // Binding contract proven here: // 1. revalidateApply (pure) re-checks an approved entry against a FRESH plan: @@ -30,7 +30,6 @@ import { planSanitizeSkill, SANITIZE_OPERATION, RETIRE_OPERATION, - MERGE_OPERATION, } from '../../scripts/kb-eval/lib/skill-ops.mjs'; import { loadDecisions, @@ -269,18 +268,3 @@ test('applyApprovedAction — a guardrail-failing approved entry never applies', assert.ok(existsSync(join(skillsDir, 'skill-a/references/cat1/live.md')), 'curated ref still safe'); }); }); - -test('applyApprovedAction — merge_skills is unsupported this session (staged to S19) and throws', () => { - withFixture(({ root, skillsDir, agentsDir, dataDir }) => { - const approved = { - operation_type: MERGE_OPERATION, - status: 'approved', - targets: { absorber: 'skill-a', absorbed: 'skill-b' }, - guardrail: { ok: true }, - }; - assert.throws( - () => applyApprovedAction(approved, { pluginRoot: root, skillsDir, agentsDir, dataDir, apply: true }), - /merge|unsupported|S19/i, - ); - }); -}); diff --git a/tests/kb-eval/test-skill-ops-merge-apply.test.mjs b/tests/kb-eval/test-skill-ops-merge-apply.test.mjs new file mode 100644 index 0000000..f2c5197 --- /dev/null +++ b/tests/kb-eval/test-skill-ops-merge-apply.test.mjs @@ -0,0 +1,225 @@ +// tests/kb-eval/test-skill-ops-merge-apply.test.mjs +// Spor B / B3 (Sesjon 19): merge-apply — the HEAVIEST destructive op. An +// operator-approved (status:approved) merge_skills entry is executed into a real +// skills/ mutation: the absorbed skill's curated references MOVE under the +// absorber, the taxonomy's category_skill ownership is REPOINTED + persisted, +// and the now-empty absorbed shell is archived (SKILL.md) + its dir removed. +// +// Binding contract proven here: +// 1. Curated preservation: every absorbed reference survives under the absorber +// (the guardrail's set-equality made real on disk — a MOVE, not an archive). +// 2. Taxonomy persistence: categories owned by the absorbed skill are repointed +// to the absorber and written back to domain-taxonomy.json (drift-safe: the +// reassignments are recomputed from a FRESH plan at apply time). +// 3. Archive-before-delete for the absorbed shell: SKILL.md lands under archive/ +// before the skills/ dir is removed; absorber + siblings untouched. +// 4. Refuses (zero mutation, ledger untouched) on: collision guardrail-fail, +// disk drift since approval, or a non-approved entry. +// +// Every scenario runs against a tmpdir fixture — NEVER the real skills/ tree. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + applyApprovedAction, + runMergePlan, +} from '../../scripts/kb-eval/lib/skill-ops.mjs'; +import { loadTaxonomy, getCategorySkill } from '../../scripts/kb-update/lib/taxonomy.mjs'; +import { + loadDecisions, + saveDecisions, + recordAction, + actionKey, +} from '../../scripts/kb-update/lib/decisions-io.mjs'; + +// --------------------------------------------------------------------------- +// Fixture: absorber (skill-a) + absorbed (skill-b) under one root, plus a +// domain-taxonomy.json in dataDir whose category_skill assigns cat3 to the +// absorbed skill (so the merge must repoint + persist it). archive/ lands at the +// same root so rename stays on one filesystem. `extraBRef` lets a test inject a +// colliding reference-relative path (present under BOTH skills). +// --------------------------------------------------------------------------- + +function withFixture(fn, { extraBRef } = {}) { + const root = mkdtempSync(join(tmpdir(), 'mergeapply-')); + const skillsDir = join(root, 'skills'); + const dataDir = join(root, 'data'); + const mkRef = (skill, cat, file, body) => { + const dir = join(skillsDir, skill, 'references', cat); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, file), body); + }; + const mkSkillMd = (skill, body) => { + mkdirSync(join(skillsDir, skill), { recursive: true }); + writeFileSync(join(skillsDir, skill, 'SKILL.md'), body); + }; + // absorber: 2 curated refs under cat1 + mkRef('skill-a', 'cat1', 'a1.md', '# A1\n'); + mkRef('skill-a', 'cat1', 'a2.md', '# A2\n'); + mkSkillMd('skill-a', '# skill-a (absorber)\n'); + // absorbed: 1 curated ref under cat3 + mkRef('skill-b', 'cat3', 'b1.md', '# B1\n'); + mkSkillMd('skill-b', '# skill-b (absorbed)\n'); + if (extraBRef) mkRef('skill-b', extraBRef.cat, extraBRef.file, extraBRef.body ?? '# collide\n'); + // taxonomy: cat1 owned by absorber, cat3 owned by absorbed + mkdirSync(dataDir, { recursive: true }); + writeFileSync( + join(dataDir, 'domain-taxonomy.json'), + JSON.stringify({ version: 1, category_skill: { cat1: 'skill-a', cat3: 'skill-b' } }, null, 2) + '\n', + ); + try { + return fn({ root, skillsDir, dataDir }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const CATEGORY_SKILL = { cat1: 'skill-a', cat3: 'skill-b' }; + +// Seed an operator-approved merge entry by snapshotting a dry-run's guardrail — +// exactly what the operator gate would write after reviewing the dry-run. +function approveMerge(absorber, absorbed, { skillsDir, dataDir, status = 'approved' }) { + const { entry } = runMergePlan(absorber, absorbed, { + skillsDir, + dataDir, + write: false, + decided_at: '2026-06-20', + categorySkill: CATEGORY_SKILL, + }); + const seeded = { ...entry, status }; + saveDecisions(recordAction(loadDecisions(dataDir), seeded), dataDir); + return seeded; +} + +const applyOpts = (root, skillsDir, dataDir, apply) => ({ + pluginRoot: root, + skillsDir, + dataDir, + taxonomyDataDir: dataDir, + categorySkill: CATEGORY_SKILL, + apply, + decided_at: apply ? '2026-06-21' : null, +}); + +// =========================================================================== +// (A) Happy path — refs moved, taxonomy persisted, absorbed archived+removed +// =========================================================================== + +test('merge-apply — absorbed refs move under absorber; absorber + siblings untouched', () => { + withFixture(({ root, skillsDir, dataDir }) => { + const approved = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir }); + const res = applyApprovedAction(approved, applyOpts(root, skillsDir, dataDir, true)); + + assert.equal(res.applied, true); + assert.equal(res.revalidate.ok, true); + + // Absorbed ref relocated under the absorber (preservation = a MOVE). + assert.ok(existsSync(join(skillsDir, 'skill-a/references/cat3/b1.md')), 'absorbed ref now under absorber'); + // Absorber's own refs untouched. + assert.ok(existsSync(join(skillsDir, 'skill-a/references/cat1/a1.md')), 'absorber ref a1 preserved'); + assert.ok(existsSync(join(skillsDir, 'skill-a/references/cat1/a2.md')), 'absorber ref a2 preserved'); + }); +}); + +test('merge-apply — taxonomy ownership repointed to absorber and persisted to disk', () => { + withFixture(({ root, skillsDir, dataDir }) => { + const approved = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir }); + const res = applyApprovedAction(approved, applyOpts(root, skillsDir, dataDir, true)); + + assert.equal(res.applied, true); + assert.equal(res.report.taxonomyReassignments, 1, 'one category (cat3) repointed'); + + const tax = loadTaxonomy(dataDir); + assert.equal(getCategorySkill(tax, 'cat3'), 'skill-a', 'absorbed category now owned by absorber'); + assert.equal(getCategorySkill(tax, 'cat1'), 'skill-a', 'absorber category unchanged'); + }); +}); + +test('merge-apply — absorbed shell archived (SKILL.md), then its dir removed; ledger applied', () => { + withFixture(({ root, skillsDir, dataDir }) => { + const approved = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir }); + const res = applyApprovedAction(approved, applyOpts(root, skillsDir, dataDir, true)); + + assert.equal(res.applied, true); + // Archive-before-delete: the absorbed SKILL.md is recoverable under archive/. + assert.ok(existsSync(join(root, 'archive/skills/skill-b/SKILL.md')), 'absorbed SKILL.md archived'); + // The absorbed skill dir is gone entirely. + assert.equal(existsSync(join(skillsDir, 'skill-b')), false, 'absorbed skill dir removed'); + // Ledger flipped approved -> applied. + assert.equal(loadDecisions(dataDir).actions[actionKey(approved)].status, 'applied'); + }); +}); + +// =========================================================================== +// (B) Safety — collision, drift, preview, non-approved +// =========================================================================== + +test('merge-apply — a colliding reference path fails the guardrail and applies nothing', () => { + withFixture( + ({ root, skillsDir, dataDir }) => { + // skill-b now also has cat1/a1.md — the same references-relative path as the + // absorber. The merge guardrail is ok=false (would clobber on the target). + const approved = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir }); + assert.equal(approved.guardrail.ok, false, 'precondition: collision makes the dry-run guardrail fail'); + + const res = applyApprovedAction(approved, applyOpts(root, skillsDir, dataDir, true)); + assert.equal(res.applied, false, 'a failing guardrail must never reach the filesystem'); + assert.equal(res.revalidate.freshOk, false); + assert.ok(existsSync(join(skillsDir, 'skill-b')), 'absorbed skill untouched'); + assert.equal(existsSync(join(root, 'archive')), false, 'nothing archived'); + assert.equal(loadDecisions(dataDir).actions[actionKey(approved)].status, 'approved', 'ledger left at approved'); + }, + { extraBRef: { cat: 'cat1', file: 'a1.md' } }, + ); +}); + +test('merge-apply — DRIFT after approval aborts with zero mutation and an untouched ledger', () => { + withFixture(({ root, skillsDir, dataDir }) => { + const approved = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir }); + // Disk drifts after approval: a new ref appears under the absorbed skill, so a + // fresh plan's guardrail counts differ from the approved snapshot. + mkdirSync(join(skillsDir, 'skill-b/references/cat3'), { recursive: true }); + writeFileSync(join(skillsDir, 'skill-b/references/cat3/b2.md'), '# B2 (new)\n'); + + const res = applyApprovedAction(approved, applyOpts(root, skillsDir, dataDir, true)); + assert.equal(res.applied, false, 'drift must abort the apply'); + assert.equal(res.revalidate.drift, true); + assert.match(res.revalidate.reason, /drift/i); + assert.ok(existsSync(join(skillsDir, 'skill-b')), 'absorbed skill untouched'); + assert.equal(existsSync(join(root, 'archive')), false, 'nothing archived on a drift abort'); + assert.equal(loadDecisions(dataDir).actions[actionKey(approved)].status, 'approved'); + // Taxonomy untouched too. + assert.equal(getCategorySkill(loadTaxonomy(dataDir), 'cat3'), 'skill-b'); + }); +}); + +test('merge-apply — preview (apply:false) mutates nothing; ledger + taxonomy unchanged', () => { + withFixture(({ root, skillsDir, dataDir }) => { + const approved = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir }); + const res = applyApprovedAction(approved, applyOpts(root, skillsDir, dataDir, false)); + + assert.equal(res.applied, false); + assert.equal(res.preview, true); + assert.equal(res.revalidate.ok, true, 'disk unchanged -> revalidation would pass'); + assert.ok(existsSync(join(skillsDir, 'skill-b/references/cat3/b1.md')), 'absorbed ref still in place (preview only)'); + assert.equal(existsSync(join(root, 'archive')), false, 'preview archives nothing'); + assert.equal(getCategorySkill(loadTaxonomy(dataDir), 'cat3'), 'skill-b', 'taxonomy unchanged in preview'); + assert.equal(loadDecisions(dataDir).actions[actionKey(approved)].status, 'approved'); + }); +}); + +test('merge-apply — refuses a non-approved (pending) entry with zero mutation', () => { + withFixture(({ root, skillsDir, dataDir }) => { + const pending = approveMerge('skill-a', 'skill-b', { skillsDir, dataDir, status: 'pending' }); + const res = applyApprovedAction(pending, applyOpts(root, skillsDir, dataDir, true)); + + assert.equal(res.applied, false); + assert.match(res.revalidate.reason, /approved/i); + assert.ok(existsSync(join(skillsDir, 'skill-b')), 'no mutation'); + assert.equal(existsSync(join(root, 'archive')), false); + assert.equal(getCategorySkill(loadTaxonomy(dataDir), 'cat3'), 'skill-b'); + }); +}); diff --git a/tests/kb-update/test-taxonomy.test.mjs b/tests/kb-update/test-taxonomy.test.mjs index 45b6ad7..69fdcd5 100644 --- a/tests/kb-update/test-taxonomy.test.mjs +++ b/tests/kb-update/test-taxonomy.test.mjs @@ -7,12 +7,17 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { loadTaxonomy, + saveTaxonomy, getSitemapPrefixes, getCategorySkill, makeClassifier, makePriorityFn, + applyCategoryReassignments, } from '../../scripts/kb-update/lib/taxonomy.mjs'; const tax = loadTaxonomy(); @@ -123,3 +128,40 @@ test('getFilePriority — order: cost (critical) outranks governance (high)', () // contains both 'cost' and 'governance' — critical rule is evaluated first assert.equal(prio('skills/x/references/cost-governance-tradeoffs.md'), 'critical'); }); + +// --- (e) taxonomy persistence (Sesjon 19, B3 merge-apply) --- +// merge-apply repoints every category owned by the absorbed skill to the +// absorber and must PERSIST that to domain-taxonomy.json. Two new primitives: +// a pure reassign helper + an atomic save mirroring saveDecisions. + +test('applyCategoryReassignments — repoints only matching from->to; pure (no mutation)', () => { + const before = JSON.parse(JSON.stringify(tax.category_skill)); + const next = applyCategoryReassignments(tax, [ + { category: 'rag-architecture', from: 'ms-ai-engineering', to: 'ms-ai-advisor' }, // matches → repointed + { category: 'responsible-ai', from: 'ms-ai-NONMATCH', to: 'ms-ai-advisor' }, // current != from → no-op + { category: 'does-not-exist', from: 'whatever', to: 'ms-ai-advisor' }, // absent category → no-op + ]); + assert.equal(next.category_skill['rag-architecture'], 'ms-ai-advisor', 'matching entry repointed'); + assert.equal(next.category_skill['responsible-ai'], 'ms-ai-governance', 'non-matching from is a no-op'); + assert.equal(next.category_skill['does-not-exist'], undefined, 'absent category stays absent'); + // purity: original object + its nested map are untouched, a fresh object is returned + assert.deepEqual(tax.category_skill, before); + assert.notEqual(next, tax); + assert.notEqual(next.category_skill, tax.category_skill); +}); + +test('saveTaxonomy + loadTaxonomy — atomic round-trip preserves content', () => { + const dir = mkdtempSync(join(tmpdir(), 'tax-save-')); + try { + const next = applyCategoryReassignments(tax, [ + { category: 'rag-architecture', from: 'ms-ai-engineering', to: 'ms-ai-advisor' }, + ]); + saveTaxonomy(next, dir); + const reloaded = loadTaxonomy(dir); + assert.equal(getCategorySkill(reloaded, 'rag-architecture'), 'ms-ai-advisor', 'reassignment persisted'); + assert.equal(getCategorySkill(reloaded, 'responsible-ai'), 'ms-ai-governance', 'untouched entries survive'); + assert.equal(reloaded.version, tax.version, 'non-category_skill fields survive the round-trip'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});