// skill-ops.mjs — Spor B / B3: skill-lifecycle operation PLANNERS (lag-4-analog // at skill granularity). The pure core APPLIES NOTHING and reads no disk: it // returns { entry, diff, guardrail }. The operator gate (decisions.json) records // the PENDING entry; a later approved apply-session performs any skills/ mutation. // // Sesjon 16 ships merge_skills only (operator priority). sanitize/retire/create // follow in S17+ as further planners that emit the same kind of gated entry. // // Inherited arkitektur-invariant: detection/verification/transformation never // write to skills/ directly — only via the ledger after the operator gate. // Destructive ops carry mandatory guardrails that PROVE curated value is // preserved (ref-file count-invariant + set-equality) and refuse (ok=false) // when two reference files would clobber on the same target path. import { readdirSync, existsSync, statSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; import { loadDecisions, saveDecisions, recordAction, } from '../../kb-update/lib/decisions-io.mjs'; export const MERGE_OPERATION = 'merge_skills'; // --------------------------------------------------------------------------- // Pure core — planMergeSkills // --------------------------------------------------------------------------- /** * Plan a skill merge: `absorbed` is retired into `absorber`. Pure; mutates * nothing; reads no disk. Reference identity is the references/-relative path * (e.g. "rag-architecture/x.md"): two files sharing that identity across the * two skills would clobber on merge — the guardrail catches that and fails. * * @param {string} absorber skill that survives and receives all references * @param {string} absorbed skill that is retired; its refs move under absorber * @param {{ skillRefs?: Record, descriptions?: Record, * categorySkill?: Record, decided_at?: string|null, * note?: string }} [ctx] * @returns {{ entry: object, diff: object, guardrail: object }} */ export function planMergeSkills(absorber, absorbed, ctx = {}) { if (!absorber || !absorbed || absorber === absorbed) { throw new Error(`planMergeSkills: cannot merge a skill into itself (${absorber} / ${absorbed})`); } const skillRefs = ctx.skillRefs ?? {}; const descriptions = ctx.descriptions ?? {}; const categorySkill = ctx.categorySkill ?? {}; const aRefs = skillRefs[absorber] ?? []; const bRefs = skillRefs[absorbed] ?? []; const aSet = new Set(aRefs); // Collision = a references-relative path present under BOTH skills. On merge // the absorbed copy would overwrite the absorber copy (or vice versa): a // silent loss of curated content. The guardrail must surface and reject it. const collisions = [...new Set(bRefs.filter((r) => aSet.has(r)))].sort(); // Post-merge identity set = union of both ref sets by references-relative path. const postSet = new Set([...aRefs, ...bRefs]); const expectedPostCount = aRefs.length + bRefs.length; // no-loss target const postCount = postSet.size; const countInvariant = postCount === expectedPostCount; // holds iff no collision const setEquality = [...aSet, ...new Set(bRefs)].every((r) => postSet.has(r)) && collisions.length === 0; const guardrail = { method: 'no curated value lost: postSet = union(absorber refs, absorbed refs) by references-relative path. ' + 'count-invariant = postCount === absorber+absorbed (fails on collision); ' + 'set-equality = every source ref survives AND no two map to the same target.', preCountAbsorber: aRefs.length, preCountAbsorbed: bRefs.length, expectedPostCount, postCount, countInvariant, setEquality, collisions, ok: countInvariant && setEquality && collisions.length === 0, }; const fileMoves = bRefs.map((r) => ({ from: `skills/${absorbed}/references/${r}`, to: `skills/${absorber}/references/${r}`, })); const taxonomyReassignments = Object.keys(categorySkill) .filter((category) => categorySkill[category] === absorbed) .sort() .map((category) => ({ category, from: absorbed, to: absorber })); const diff = { fileMoves, taxonomyReassignments, descriptionReconciliation: { absorber: descriptions[absorber] ?? null, absorbed: descriptions[absorbed] ?? null, note: 'Descriptions must be reconciled manually / by judge — the planner does not auto-merge semantic scope.', }, retire: absorbed, }; const entry = { operation_type: MERGE_OPERATION, status: 'pending', decided_at: ctx.decided_at ?? null, // never Date.now() in a pure lib targets: { absorber, absorbed }, guardrail, note: ctx.note ?? `Dry-run: merge ${absorbed} -> ${absorber} (${guardrail.ok ? 'guardrail OK' : 'GUARDRAIL FAILED'}). Applies nothing.`, }; return { entry, diff, guardrail }; } // --------------------------------------------------------------------------- // Impure shell — read-only disk load + gated ledger write // --------------------------------------------------------------------------- /** Recursively list references/-relative .md paths (POSIX separators) under one skill. */ function listSkillRefs(refDir) { const out = []; if (!existsSync(refDir)) return out; const walk = (dir) => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) walk(p); else if (e.isFile() && e.name.endsWith('.md')) out.push(relative(refDir, p).split(sep).join('/')); } }; walk(refDir); return out.sort(); } /** * Read-only: { skill -> [references-relative .md paths] } for every skill dir * that has a references/ folder. * @param {string} skillsDir absolute path to the skills/ root * @returns {Record} */ export function loadSkillRefs(skillsDir) { const out = {}; for (const e of readdirSync(skillsDir, { withFileTypes: true })) { if (!e.isDirectory()) continue; const refDir = join(skillsDir, e.name, 'references'); if (existsSync(refDir) && statSync(refDir).isDirectory()) out[e.name] = listSkillRefs(refDir); } return out; } /** * Drive a merge dry-run against real disk: load refs read-only, plan the merge, * and (only with write:true) record the PENDING entry in decisions.json. Writes * NOTHING under skills/ — the destructive apply is a separate approved step. * @param {string} absorber * @param {string} absorbed * @param {{ skillsDir: string, dataDir: string, write?: boolean, decided_at?: string|null, * descriptions?: Record, categorySkill?: Record }} opts * @returns {{ entry: object, diff: object, guardrail: object }} */ export function runMergePlan(absorber, absorbed, opts) { const { skillsDir, dataDir, write = false, decided_at = null, descriptions, categorySkill } = opts; const skillRefs = loadSkillRefs(skillsDir); const result = planMergeSkills(absorber, absorbed, { skillRefs, descriptions, categorySkill, decided_at }); if (write) { const ledger = recordAction(loadDecisions(dataDir), result.entry); saveDecisions(ledger, dataDir); } return result; }