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>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-20 21:37:19 +02:00
commit e47fc9bd59
5 changed files with 758 additions and 11 deletions

View file

@ -12,7 +12,7 @@
// 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 { 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 {
@ -22,11 +22,30 @@ import {
actionKey,
setActionStatus,
} from '../../kb-update/lib/decisions-io.mjs';
import { loadTaxonomy, saveTaxonomy, applyCategoryReassignments } from '../../kb-update/lib/taxonomy.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
@ -275,6 +294,192 @@ export function planRetireSkill(skill, ctx = {}) {
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
// ---------------------------------------------------------------------------
@ -398,6 +603,35 @@ export function runRetirePlan(skill, opts) {
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
@ -469,6 +703,7 @@ 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 ?? {};
@ -481,8 +716,17 @@ export function applyApprovedAction(approvedEntry, opts) {
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 only (create_skill staged later)`);
throw new Error(`applyApprovedAction: unsupported operation_type "${op}" — applies sanitize/retire/merge/create only`);
}
// 2. Idempotent revalidation against fresh disk — refuse on any failure.
@ -491,6 +735,38 @@ export function applyApprovedAction(approvedEntry, opts) {
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;