ms-ai-architect/scripts/kb-eval/lib/skill-ops.mjs
Kjell Tore Guttormsen de3a9a483b feat(ms-ai-architect): Sesjon 19 — B3 merge-apply (cross-skill + taksonomi-persist) [skip-docs]
Merge-apply = den tyngste destruktive op-en: eksekver en operatør-godkjent
merge_skills-entry → faktisk cross-skill skills/-mutasjon. Tre mekanismer
som sanitize/retire ikke trengte: kuratert relokasjon (ikke arkiv),
taksonomi-persistering, retire-av-absorbert.

- taxonomy.mjs (ny persist): saveTaxonomy (atomisk .tmp+rename, speiler
  saveDecisions) + ren applyCategoryReassignments (repointer KUN der eier
  === from; absent/allerede-flyttet = no-op). Lag-0 er normalt read-only;
  ENESTE writer = gated merge-apply.
- applyApprovedAction merge-gren (skill-ops.mjs): re-plan fersk → revalidér
  (kollisjon → guardrail.ok=false → abort) → flytt absorbed-refs → absorber
  (moveFile; flyttingen ER bevaringen set-equality beviser) → persister
  taksonomi-repoint fra FERSK plan (drift-sikker) → arkivér absorbed-SKILL.md
  FØR rmdir → flipp ledger approved→applied. Description-forsoning forblir
  MANUELL (planner-kontrakt: apply rører aldri absorber-SKILL.md).
  archiveMove→moveFile (generisk: arkiv + kuratert flytt).
- CLI apply-skill-op.mjs merge <absorber> <absorbed> — lookup via actionKey
  (merge_skills:<sortert par>); default PREVIEW, --apply = dobbel-gate.

TDD, tmpdir-fixtures: 0 ekte skills/-mutasjon. Ny test-skill-ops-merge-apply
(7) + 2 taksonomi-tester; fjernet foreldet merge-throws fra S18-fila. Tester:
kb-eval 78→84, kb-update 137→139; validate 239 · kb-integrity 192/192 uendret.
eval --json deterministisk uendret (K10 eng+infra FAIL, øvrige PASS).

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

557 lines
25 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.

// 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, 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 } from '../../kb-update/lib/taxonomy.mjs';
export const MERGE_OPERATION = 'merge_skills';
export const SANITIZE_OPERATION = 'sanitize_skill';
export const RETIRE_OPERATION = 'retire_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 };
}
// ---------------------------------------------------------------------------
// 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;
}
// ===========================================================================
// 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,
} = 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 {
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.
const revalidate = revalidateApply(approvedEntry, fresh);
if (!revalidate.ok || !apply) {
return { applied: false, preview: !apply, revalidate, plan: fresh };
}
// 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' },
};
}