ms-ai-architect/scripts/kb-eval/lib/skill-ops.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

833 lines
36 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.

// 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, readFileSync, writeFileSync, existsSync, statSync, mkdirSync, renameSync, rmSync } from 'node:fs';
import { join, relative, basename, dirname, sep } from 'node:path';
import { isDeepStrictEqual } from 'node:util';
import {
loadDecisions,
saveDecisions,
recordAction,
actionKey,
setActionStatus,
} from '../../kb-update/lib/decisions-io.mjs';
import {
loadTaxonomy,
saveTaxonomy,
applyCategoryReassignments,
applyCategoryAdditions,
} from '../../kb-update/lib/taxonomy.mjs';
import {
splitFrontmatter,
extractDescription,
checkK2,
checkK3,
checkK5K6,
checkRefConsistency,
} from '../eval.mjs';
import {
computeOverlapFromInputs,
perSkillSiblingOverlap,
K10_OVERLAP_THRESHOLD,
} from './sibling-overlap.mjs';
export const MERGE_OPERATION = 'merge_skills';
export const SANITIZE_OPERATION = 'sanitize_skill';
export const RETIRE_OPERATION = 'retire_skill';
export const CREATE_OPERATION = 'create_skill';
// ---------------------------------------------------------------------------
// 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<string,string[]>, descriptions?: Record<string,string>,
* categorySkill?: Record<string,string>, 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 };
}
// ---------------------------------------------------------------------------
// Pure core — planSanitizeSkill (remove ONLY dead content)
// ---------------------------------------------------------------------------
/**
* Plan a skill sanitize: remove dead reference files (kb-integrity orphan /
* dead-path) and NOTHING else. Pure; mutates nothing; reads no disk. The
* binding guardrail (mirror of merge's collision rule) is that every removal
* must be a flagged dead file — a request to remove a CURATED ref fails ok.
* Dateless-but-curated files are NOT dead and are never removed here.
*
* @param {string} skill skill whose dead content is pruned
* @param {{ refs?: string[], orphans?: string[], removals?: string[],
* decided_at?: string|null, note?: string }} [ctx]
* refs = all references-relative .md paths under the skill
* orphans = references-relative paths kb-integrity flags as orphan/dead
* removals = optional explicit removal list (default = all dead-in-skill)
* @returns {{ entry: object, diff: object, guardrail: object }}
*/
export function planSanitizeSkill(skill, ctx = {}) {
if (!skill) throw new Error('planSanitizeSkill: skill is required');
const refs = ctx.refs ?? [];
const orphans = ctx.orphans ?? [];
const refSet = new Set(refs);
const orphanSet = new Set(orphans);
// Removable universe = dead files that actually exist under this skill.
// Default removal = sanitize ALL dead content surfaced by kb-integrity.
const deadInSkill = [...new Set(orphans.filter((r) => refSet.has(r)))].sort();
const removals = [...new Set(ctx.removals ?? deadInSkill)].sort();
// BINDING GUARDRAIL: a removal must be a kb-integrity-flagged dead file AND a
// real file under the skill. Anything else would delete curated content.
const illegalRemovals = removals.filter((r) => !orphanSet.has(r)).sort();
const missingRemovals = removals.filter((r) => !refSet.has(r)).sort();
const preCount = refs.length;
const removalCount = removals.length;
const curatedCount = refs.filter((r) => !orphanSet.has(r)).length;
const postCount = preCount - removalCount;
const guardrail = {
method:
'only kb-integrity-flagged orphan/dead-path files may be removed; curated refs are never touched. ' +
'illegalRemovals = requested removals NOT in the dead-set (curated content) — must be empty. ' +
'missingRemovals = removals absent from disk — must be empty. postCount = preCount - removals.',
preCount,
deadCount: deadInSkill.length,
removalCount,
curatedCount,
postCount,
illegalRemovals,
missingRemovals,
ok: illegalRemovals.length === 0 && missingRemovals.length === 0,
};
const diff = {
removals: removals.map((r) => ({
path: `skills/${skill}/references/${r}`,
reason: 'orphan/dead-path: basename unreferenced by any agent or SKILL.md (kb-integrity)',
})),
retainedCount: curatedCount,
note: 'Sanitize removes only dead content surfaced by kb-integrity. Curated references (incl. dateless) are preserved untouched.',
};
const entry = {
operation_type: SANITIZE_OPERATION,
status: 'pending',
decided_at: ctx.decided_at ?? null, // never Date.now() in a pure lib
targets: { skill },
guardrail,
note:
ctx.note ??
`Dry-run: sanitize ${skill} — remove ${removalCount} dead file(s) (${guardrail.ok ? 'guardrail OK' : 'GUARDRAIL FAILED'}). Applies nothing.`,
};
return { entry, diff, guardrail };
}
// ---------------------------------------------------------------------------
// Pure core — planRetireSkill (whole-skill retire WITH mandatory archival)
// ---------------------------------------------------------------------------
/**
* Plan a whole-skill retire: archive every reference file + SKILL.md, then
* remove the skills/ directory. Pure; mutates nothing; reads no disk. Archival
* is MANDATORY — the binding guardrail refuses a hard-delete request (ok=false)
* so curated content is never destroyed without a recoverable copy.
*
* @param {string} skill skill to retire
* @param {{ refs?: string[], categorySkill?: Record<string,string>,
* archiveDir?: string, hardDelete?: boolean,
* decided_at?: string|null, note?: string }} [ctx]
* @returns {{ entry: object, diff: object, guardrail: object }}
*/
export function planRetireSkill(skill, ctx = {}) {
if (!skill) throw new Error('planRetireSkill: skill is required');
const refs = ctx.refs ?? [];
const categorySkill = ctx.categorySkill ?? {};
const hardDelete = ctx.hardDelete === true;
// Hard delete is refused -> no archive destination; otherwise default to a
// recoverable archive path under archive/skills/<skill>.
const archiveDir = hardDelete ? null : (ctx.archiveDir ?? `archive/skills/${skill}`);
const archiveMoves = archiveDir
? [
{ from: `skills/${skill}/SKILL.md`, to: `${archiveDir}/SKILL.md` },
...refs.map((r) => ({
from: `skills/${skill}/references/${r}`,
to: `${archiveDir}/references/${r}`,
})),
]
: [];
const expectedArchiveCount = refs.length + 1; // refs + SKILL.md
const archivedCount = archiveMoves.length;
// Categories this skill owns lose their owner on retire — manual reassignment.
const taxonomyOrphaned = Object.keys(categorySkill)
.filter((c) => categorySkill[c] === skill)
.sort()
.map((c) => ({ category: c, wasOwner: skill, note: 'owner removed — reassign manually' }));
const guardrail = {
method:
'whole-skill retire requires archival — every reference file + SKILL.md is MOVED to archive/, never hard-deleted. ' +
'ok iff an archive destination is set AND archivedCount === refCount + 1 (SKILL.md). A hard-delete request is refused.',
refCount: refs.length,
expectedArchiveCount,
archivedCount,
archiveDir,
hardDeleteRequested: hardDelete,
ok: Boolean(archiveDir) && archivedCount === expectedArchiveCount && !hardDelete,
};
const diff = {
archiveMoves,
removeSkillDir: `skills/${skill}`,
taxonomyOrphaned,
note: 'Retire archives the entire skill, then removes its skills/ directory. Categories it owned need manual reassignment.',
};
const entry = {
operation_type: RETIRE_OPERATION,
status: 'pending',
decided_at: ctx.decided_at ?? null, // never Date.now() in a pure lib
targets: { skill },
guardrail,
note:
ctx.note ??
`Dry-run: retire ${skill} — archive ${expectedArchiveCount} file(s) to ${archiveDir ?? '(none)'} ` +
`(${guardrail.ok ? 'guardrail OK' : 'GUARDRAIL FAILED — hard delete refused'}). Applies nothing.`,
};
return { entry, diff, guardrail };
}
// ---------------------------------------------------------------------------
// Pure core — planCreateSkill (CONSTRUCTIVE: born compliant or refused)
// ---------------------------------------------------------------------------
/**
* Canonical "dvelende" scaffold spec. create_skill ships no new domain skill
* (operator answer 3 — the mechanism is proven, not exercised on production):
* this throwaway test-domain spec is what both CLIs and the tests scaffold to
* demonstrate a skill that passes the deterministic rubric from birth.
*/
export const TEST_DOMAIN_SPEC = {
description:
'Use this skill when validating the create_skill scaffolding mechanism against a throwaway test domain. ' +
'Triggers on: "create-skill self-test", "scaffold validation", "dvelende create probe".',
categories: [
{
category: 'core',
files: [
{ file: 'overview.md', title: 'Test-domene oversikt', source: 'internal://create-skill-self-test' },
{ file: 'mechanism.md', title: 'Scaffold-mekanikk', source: 'internal://create-skill-self-test' },
],
},
{
category: 'patterns',
files: [
{ file: 'routing.md', title: 'Progressiv disclosure-mønster', source: 'internal://create-skill-self-test' },
],
},
],
};
/** Title-case a kebab/snake skill name for the SKILL.md heading. */
function titleCase(name) {
return name
.split(/[-_]/)
.filter(Boolean)
.map((w) => w[0].toUpperCase() + w.slice(1))
.join(' ');
}
/**
* Generate a SKILL.md that satisfies eval.mjs's deterministic rubric: a
* use-when/quoted description (K2), a short body (K3), a routing table that
* cites per-folder counts (refCountConsistency) AND names every reference file
* (K5 namedRatio + K6 named start files).
*/
function buildSkillMd(name, description, categories) {
const tableRows = categories
.map((c) => `| \`references/${c.category}/\` | ${c.files.length} |`)
.join('\n');
const startHere = categories
.flatMap((c) => c.files.map((f) => `- \`references/${c.category}/${f.file}\`${f.title ?? f.file}`))
.join('\n');
return (
`---\n` +
`name: ${name}\n` +
`description: ${description}\n` +
`---\n\n` +
`> **INSTRUKSJON:** Test-domene-skill generert av create_skill-mekanismen (dvelende). ` +
`Bruk kunnskapsbasen i \`references/\` for detaljert veiledning.\n\n` +
`# ${titleCase(name)}\n\n` +
`Scaffold generert av create_skill — født compliant mot K1K10 (deterministisk delmengde). ` +
`Dette er et test-domene; ingen ny domene-skill shippes.\n\n` +
`## Referansekart\n\n` +
`| Område | Antall filer |\n` +
`|--------|--------------|\n` +
`${tableRows}\n\n` +
`### Start her\n` +
`${startHere}\n`
);
}
/** Minimal curated-shaped reference body (Source-header hygiene; content is not
* part of the deterministic gate — eval reads SKILL.md, not ref bodies). */
function buildRefBody(f) {
return (
`# ${f.title ?? f.file}\n\n` +
`**Source:** ${f.source ?? 'internal://create-skill-self-test'}\n\n` +
`Generert referansefil for create_skill test-domene (dvelende). ` +
`Erstattes av kuratert innhold dersom en ekte domene-skill noen gang opprettes.\n`
);
}
/**
* Plan a new-skill scaffold. Pure; mutates nothing; reads no disk. The
* CONSTRUCTIVE guardrail is the mirror of the destructive ones: merge/sanitize/
* retire prove "no curated value LOST" (preservation); create proves "no
* sub-bar skill BORN" (quality floor). It refuses (ok=false) when the generated
* scaffold would fail any deterministic criterion eval.mjs enforces (K2/K3/K5/
* K6/refCountConsistency), when the new description overlaps a sibling at/above
* the K10 threshold, or when the name already belongs to a skill. The verdict is
* re-derived from eval.mjs's OWN checkers — the rubric has a single source.
*
* @param {string} name new skill directory name (kebab-case)
* @param {{ description?: string,
* categories?: Array<{ category: string, files: Array<{file: string, title?: string, source?: string}> }>,
* existingSkills?: string[], existingDescriptions?: Record<string,string>,
* promptSet?: object, threshold?: number, decided_at?: string|null, note?: string }} [ctx]
* @returns {{ entry: object, scaffold: object, guardrail: object }}
*/
export function planCreateSkill(name, ctx = {}) {
if (!name) throw new Error('planCreateSkill: name is required');
const description = ctx.description ?? '';
const categories = ctx.categories ?? [];
const existingSkills = ctx.existingSkills ?? [];
const existingDescriptions = ctx.existingDescriptions ?? {};
const promptSet = ctx.promptSet ?? {};
const threshold = ctx.threshold ?? K10_OVERLAP_THRESHOLD;
// Build the scaffold artifacts.
const skillMdContent = buildSkillMd(name, description, categories);
const files = categories.flatMap((c) =>
c.files.map((f) => ({
path: `skills/${name}/references/${c.category}/${f.file}`,
content: buildRefBody(f),
})),
);
const scaffold = {
skillMd: { path: `skills/${name}/SKILL.md`, content: skillMdContent },
files,
taxonomyAdditions: categories.map((c) => ({ category: c.category, skill: name })),
};
// CONSTRUCTIVE guardrail — re-run eval.mjs's deterministic checkers against the
// generated SKILL.md (single source of truth for the rubric).
const { frontmatter, body } = splitFrontmatter(skillMdContent);
const parsedDescription = extractDescription(frontmatter);
const perFolder = {};
for (const c of categories) perFolder[c.category] = c.files.length;
const totalRefFiles = files.length;
const k2 = checkK2(parsedDescription);
const k3 = checkK3(body);
const { K5: k5, K6: k6 } = checkK5K6(body, totalRefFiles);
const refConsistency = checkRefConsistency(body, perFolder);
// K10 — sibling-scope-non-overlap of the NEW description vs the existing skills
// (cross-skill; same overlap core as eval's attachSiblingOverlap).
const descriptionsBySkill = { ...existingDescriptions, [name]: parsedDescription };
const overlap = computeOverlapFromInputs(descriptionsBySkill, promptSet);
const k10map = perSkillSiblingOverlap(overlap.pairs, { threshold });
const k10 = k10map[name] ?? { maxCombined: 0, worstSibling: null, pass: true, threshold };
const nameAvailable = !existingSkills.includes(name);
const failed = [];
if (!nameAvailable) failed.push('name');
if (!k2.pass) failed.push('K2');
if (!k3.pass) failed.push('K3');
if (!k5.pass) failed.push('K5');
if (!k6.pass) failed.push('K6');
if (!refConsistency.consistent) failed.push('refConsistency');
if (!k10.pass) failed.push('K10');
const guardrail = {
method:
'constructive (mirror of the destructive guardrails): no sub-bar skill is born. The scaffold must pass ' +
"eval.mjs's deterministic rubric from birth (K2/K3/K5/K6/refCountConsistency), must not overlap a sibling " +
'at/above the K10 threshold, and the name must be free. ok iff failed === [].',
nameAvailable,
k2,
k3,
k5,
k6,
refConsistency,
k10,
failed,
ok: failed.length === 0,
};
const entry = {
operation_type: CREATE_OPERATION,
status: 'pending',
decided_at: ctx.decided_at ?? null, // never Date.now() in a pure lib
targets: { name },
guardrail,
note:
ctx.note ??
`Dry-run: create ${name} — scaffold ${
guardrail.ok ? 'passes K1K10 (deterministic) — born compliant' : 'FAILS rubric (' + failed.join(',') + ')'
}. Applies nothing.`,
};
return { entry, scaffold, 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<string,string[]>}
*/
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<string,string>, categorySkill?: Record<string,string> }} 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;
}
/**
* Read-only: { skill -> [references-relative dead paths] }. A reference is
* "dead" iff its basename appears in NO agent file and NO SKILL.md — exactly
* kb-integrity's orphan semantics (grep -l basename over agents/ + SKILL.md).
* @param {string} skillsDir absolute path to the skills/ root
* @param {string} [agentsDir] absolute path to the agents/ dir (optional)
* @returns {Record<string,string[]>}
*/
export function loadSkillOrphans(skillsDir, agentsDir) {
const skillRefs = loadSkillRefs(skillsDir);
// Build the reference haystack: every agent file + every SKILL.md body.
let haystack = '';
if (agentsDir && existsSync(agentsDir)) {
for (const e of readdirSync(agentsDir, { withFileTypes: true })) {
if (e.isFile() && e.name.endsWith('.md')) haystack += readFileSync(join(agentsDir, e.name), 'utf8') + '\n';
}
}
for (const skill of Object.keys(skillRefs)) {
const md = join(skillsDir, skill, 'SKILL.md');
if (existsSync(md)) haystack += readFileSync(md, 'utf8') + '\n';
}
const out = {};
for (const [skill, refs] of Object.entries(skillRefs)) {
out[skill] = refs.filter((r) => !haystack.includes(basename(r)));
}
return out;
}
/**
* Drive a sanitize dry-run against real disk: load refs + orphans read-only,
* plan the removal, and (only with write:true) record the PENDING entry in
* decisions.json. Writes NOTHING under skills/.
* @param {string} skill
* @param {{ skillsDir: string, agentsDir?: string, dataDir: string, write?: boolean,
* decided_at?: string|null, removals?: string[] }} opts
* @returns {{ entry: object, diff: object, guardrail: object }}
*/
export function runSanitizePlan(skill, opts) {
const { skillsDir, agentsDir, dataDir, write = false, decided_at = null, removals } = opts;
const refs = loadSkillRefs(skillsDir)[skill] ?? [];
const orphans = loadSkillOrphans(skillsDir, agentsDir)[skill] ?? [];
const result = planSanitizeSkill(skill, { refs, orphans, removals, decided_at });
if (write) {
saveDecisions(recordAction(loadDecisions(dataDir), result.entry), dataDir);
}
return result;
}
/**
* Drive a retire dry-run against real disk: load refs read-only, plan the
* whole-skill archive+removal, and (only with write:true) record the PENDING
* entry in decisions.json. Writes NOTHING under skills/.
* @param {string} skill
* @param {{ skillsDir: string, dataDir: string, write?: boolean, decided_at?: string|null,
* archiveDir?: string, hardDelete?: boolean, categorySkill?: Record<string,string> }} opts
* @returns {{ entry: object, diff: object, guardrail: object }}
*/
export function runRetirePlan(skill, opts) {
const { skillsDir, dataDir, write = false, decided_at = null, archiveDir, hardDelete, categorySkill } = opts;
const refs = loadSkillRefs(skillsDir)[skill] ?? [];
const result = planRetireSkill(skill, { refs, categorySkill, archiveDir, hardDelete, decided_at });
if (write) {
saveDecisions(recordAction(loadDecisions(dataDir), result.entry), dataDir);
}
return result;
}
/**
* Drive a create dry-run: read existing skill NAMES read-only (name-collision
* check), plan the scaffold, and (only with write:true) record the PENDING entry
* in decisions.json. Writes NOTHING under skills/ — the constructive apply is a
* separate approved step. The scaffold content comes from the caller (the CLI's
* TEST_DOMAIN_SPEC); K10 siblings come from the caller's existing descriptions.
* @param {string} name
* @param {{ skillsDir: string, dataDir: string, write?: boolean, decided_at?: string|null,
* description?: string, categories?: object[],
* existingDescriptions?: Record<string,string>, promptSet?: object, threshold?: number }} opts
* @returns {{ entry: object, scaffold: object, guardrail: object }}
*/
export function runCreatePlan(name, opts) {
const {
skillsDir, dataDir, write = false, decided_at = null,
description, categories, existingDescriptions, promptSet, threshold,
} = opts;
const existingSkills = existsSync(skillsDir)
? readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
: [];
const result = planCreateSkill(name, {
description, categories, existingSkills, existingDescriptions, promptSet, threshold, decided_at,
});
if (write) {
saveDecisions(recordAction(loadDecisions(dataDir), result.entry), dataDir);
}
return result;
}
// ===========================================================================
// Apply-path (Sesjon 1819) — 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: 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.
// ===========================================================================
/**
* Pure: re-check an operator-approved entry against a FRESH re-plan. Refuses if
* the entry is not approved, the op-type differs, the fresh guardrail is not ok,
* or the fresh guardrail differs from the approved snapshot (disk drifted since
* approval). Reads no disk — the caller supplies the fresh plan.
* @param {object} approvedEntry the ledger entry (status must be 'approved')
* @param {{ entry: object, guardrail: object }} freshResult output of a fresh re-plan
* @returns {{ ok: boolean, drift: boolean, freshOk: boolean, reason: string|null }}
*/
export function revalidateApply(approvedEntry, freshResult) {
const reasons = [];
const status = approvedEntry?.status;
if (status !== 'approved') reasons.push(`entry status "${status}" is not "approved"`);
const op = approvedEntry?.operation_type;
const freshOp = freshResult?.entry?.operation_type;
if (op !== freshOp) reasons.push(`operation_type mismatch (approved=${op}, fresh=${freshOp})`);
const freshOk = freshResult?.guardrail?.ok === true;
if (!freshOk) reasons.push('fresh guardrail not ok — applying would lose or clobber content');
const drift = !isDeepStrictEqual(approvedEntry?.guardrail, freshResult?.guardrail);
if (drift) reasons.push('disk drifted since approval (fresh guardrail differs from approved snapshot)');
return { ok: reasons.length === 0, drift, freshOk, reason: reasons.length ? reasons.join('; ') : null };
}
/** 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 skill-lifecycle op into a real skills/ mutation.
* Re-plans from fresh disk, revalidates (drift-safe), and only with apply:true
* 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,
* taxonomyDataDir?: string, apply?: boolean, decided_at?: string|null,
* categorySkill?: Record<string,string> }} 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, taxonomyDataDir,
apply = false, decided_at = null, categorySkill,
createSpec, existingDescriptions, promptSet,
} = opts;
const op = approvedEntry?.operation_type;
const target = approvedEntry?.targets ?? {};
// 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 if (op === CREATE_OPERATION) {
// create has no prior on-disk state to re-derive: the scaffold spec is
// re-supplied by the caller; the disk-dependent part is name-availability.
const spec = createSpec ?? {};
fresh = runCreatePlan(target.name, {
skillsDir, dataDir, write: false, decided_at: null,
description: spec.description, categories: spec.categories,
existingDescriptions, promptSet,
});
} else {
throw new Error(`applyApprovedAction: unsupported operation_type "${op}" — applies sanitize/retire/merge/create only`);
}
// 2. Idempotent revalidation against fresh disk — refuse on any failure.
const revalidate = revalidateApply(approvedEntry, fresh);
if (!revalidate.ok || !apply) {
return { applied: false, preview: !apply, revalidate, plan: fresh };
}
// 3a-create — CONSTRUCTIVE: write the born-compliant scaffold + persist
// taxonomy ownership additively. No archival (nothing pre-existed); the
// guardrail's nameAvailable made real is that skills/<name> is created fresh.
if (op === CREATE_OPERATION) {
const allFiles = [fresh.scaffold.skillMd, ...fresh.scaffold.files];
for (const f of allFiles) {
const abs = join(pluginRoot, f.path);
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, f.content);
}
// Persist taxonomy ownership for the new categories (additive — never
// clobbers a sibling's owned category; that would be a merge, not a create).
const taxDir = taxonomyDataDir ?? dataDir;
const additions = fresh.scaffold.taxonomyAdditions;
if (additions.length) {
saveTaxonomy(applyCategoryAdditions(loadTaxonomy(taxDir), additions), taxDir);
}
// 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,
filesWritten: allFiles.length,
taxonomyAdditions: additions.length,
createdDir: `skills/${target.name}`,
ledgerStatus: 'applied',
},
};
}
// 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 }));
const archived = [];
for (const m of moves) {
moveFile(join(pluginRoot, m.from), join(pluginRoot, m.to));
archived.push(m.to);
}
let removedDir = null;
if (op === RETIRE_OPERATION) {
removedDir = fresh.diff.removeSkillDir;
rmSync(join(pluginRoot, removedDir), { recursive: true, force: true });
}
// Flip the ledger entry approved -> applied (gated write; audit record kept).
const key = actionKey(approvedEntry);
saveDecisions(setActionStatus(loadDecisions(dataDir), key, 'applied', decided_at), dataDir);
return {
applied: true,
revalidate,
report: { op, target, moves: moves.length, archived, removedDir, ledgerStatus: 'applied' },
};
}