UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

550 lines (505 loc) • 21.8 kB
import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { parse } from "yaml"; import { redactSecrets } from "./redact.ts"; /** * Governed skill candidate lifecycle (v0.5, issue #39). * * Runtime experience becomes a *candidate*; candidates never become promoted * skills without passing static/provenance review, sandbox execution, and * protected functional + safety evaluation. Promoted skills are version * controlled and immutable to run-time agents: behavior changes require a * new candidate/eval cycle. Skill text may inform behavior but never grants * tool or permission authority. * * Storage layout under the workspace root (default `.kxm/skills`): * candidates/<id>/SKILL.md + metadata.json * promoted/<id>/SKILL.md + metadata.json * quarantined/<id>/SKILL.md + metadata.json * history/<id>.jsonl — append-only audit trail per skill lineage */ export const SKILL_CANDIDATE_SCHEMA = "kxm.skill-candidate.v1"; export const SKILL_EVALUATION_SCHEMA = "kxm.skill-evaluation.v1"; export const SKILL_DECISION_SCHEMA = "kxm.skill-decision.v1"; export const MAX_SKILL_NAME_CHARS = 64; export const MAX_SKILL_CONTENT_CHARS = 32_000; export const MAX_SKILL_EVIDENCE_REFS = 32; export const MAX_SKILL_MODELS = 16; export type SkillState = "candidate" | "promoted" | "quarantined" | "rejected"; export type SkillDecision = "promoted" | "quarantined" | "rejected"; export type SkillEvaluationKind = "static-review" | "sandbox" | "functional" | "safety" | "optimization"; /** Evaluation kinds that must pass before promotion. */ export const PROMOTION_REQUIRED_EVALUATIONS: readonly SkillEvaluationKind[] = [ "static-review", "sandbox", "functional", "safety", ]; export interface SkillSources { runIds: string[]; journalEntryIds: string[]; evidenceReceipts: string[]; } export interface SkillCandidateMetadata { schema: typeof SKILL_CANDIDATE_SCHEMA; id: string; name: string; description: string; contentSha256: string; version: number; sources: SkillSources; /** Explicit cross-model/cross-harness compatibility record. */ compatibility: { harness: string; models: string[]; }; createdBy: string; createdAt: string; /** Prior candidate or promoted skill this candidate supersedes. */ supersedes?: string; } export interface SkillEvaluationRecord { schema: typeof SKILL_EVALUATION_SCHEMA; candidateId: string; kind: SkillEvaluationKind; evaluatorVersion: string; passed: boolean; score?: number; details?: string; evaluatedAt: string; } export interface SkillDecisionRecord { schema: typeof SKILL_DECISION_SCHEMA; candidateId: string; decision: SkillDecision; decidedBy: string; reason: string; evidenceRefs: string[]; decidedAt: string; } export interface SkillHistoryEvent { schema: "kxm.skill-history-event.v1"; event: string; by?: string; supersedes?: string; at: string; } export type SkillHistoryRecord = SkillEvaluationRecord | SkillDecisionRecord | SkillHistoryEvent; export class SkillLifecycleError extends Error { readonly code: string; constructor(code: string, message: string) { super(message); this.name = "SkillLifecycleError"; this.code = code; } } export function skillContentSha256(content: string): string { return createHash("sha256").update(content, "utf8").digest("hex"); } /** Deterministic skill ID: slug + content hash prefix. Changed content means * a new candidate; identical content is idempotent. */ export function skillIdFor(name: string, contentSha256: string): string { const slug = name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40); if (!slug) throw new SkillLifecycleError("invalid_skill_name", "skill name must contain alphanumeric characters"); return `${slug}.${contentSha256.slice(0, 12)}`; } export function parseSkillFrontmatter(content: string): { frontmatter: Record<string, unknown> | null; body: string; } { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!match) { return { frontmatter: null, body: content }; } const rawFm = match[1]; const rawBody = match[2]; if (rawFm === undefined || rawBody === undefined) { return { frontmatter: null, body: content }; } try { const parsed = parse(rawFm); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return { frontmatter: parsed as Record<string, unknown>, body: rawBody }; } } catch { // Malformed yaml frontmatter } return { frontmatter: null, body: content }; } export function ensureSkillFrontmatter(content: string, name: string, description: string): string { const parsed = parseSkillFrontmatter(content); if (parsed.frontmatter) { const fmName = typeof parsed.frontmatter.name === "string" && parsed.frontmatter.name.trim() ? parsed.frontmatter.name.trim() : name; const fmDesc = typeof parsed.frontmatter.description === "string" && parsed.frontmatter.description.trim() ? parsed.frontmatter.description.trim() : (description || `Governed skill for ${fmName}`); const rest = parsed.body.replace(/^(\r?\n)+/, ""); return `---\nname: ${fmName}\ndescription: ${fmDesc}\n---\n\n${rest}`; } const desc = description || `Governed skill for ${name}`; const rest = content.replace(/^(\r?\n)+/, ""); return `---\nname: ${name}\ndescription: ${desc}\n---\n\n${rest}`; } export function createUnifiedPatch(relativePath: string, content: string): string { const lines = content.split("\n"); const count = lines.length; const header = [ `diff --git a/${relativePath} b/${relativePath}`, "new file mode 100644", "--- /dev/null", `+++ b/${relativePath}`, `@@ -0,0 +1,${count} @@`, ]; const body = lines.map((l) => `+${l}`); return [...header, ...body, ""].join("\n"); } export interface CreateSkillInput { name: string; description?: string; content: string; sources: Partial<SkillSources>; compatibility: { harness: string; models: string[] }; createdBy: string; supersedes?: string; version?: number; } export interface SkillLifecycleOptions { /** Opt-in hook for skillopt/WikiSkill-style optimization behind protected * evals. When unset, `optimization` evaluations are rejected. */ allowOptimizationEvals?: boolean; now?: () => string; /** Validate and record every change in `planned` without touching disk. */ dryRun?: boolean; } export class SkillLifecycle { private readonly root: string; private readonly now: () => string; private readonly allowOptimizationEvals: boolean; private readonly dryRun: boolean; /** What a `dryRun` lifecycle would have written or moved, in order. */ readonly planned: Array<{ action: "write" | "move"; target: string }> = []; constructor(root: string, options: SkillLifecycleOptions = {}) { this.root = root; this.now = options.now ?? (() => new Date().toISOString()); this.allowOptimizationEvals = options.allowOptimizationEvals === true; this.dryRun = options.dryRun === true; } private write(file: string, content: string): void { if (this.dryRun) { this.planned.push({ action: "write", target: file }); return; } mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, content); } private dir(state: SkillState): string { return join(this.root, state === "candidate" ? "candidates" : `${state}s`.replace("rejecteds", "rejected").replace("promoteds", "promoted")); } private historyFile(id: string): string { return join(this.root, "history", `${id}.jsonl`); } private paths(state: SkillState, id: string): { dir: string; metadata: string; skill: string } { const dir = join(this.dir(state), id); return { dir, metadata: join(dir, "metadata.json"), skill: join(dir, "SKILL.md") }; } private appendHistory(id: string, record: unknown): void { const line = `${JSON.stringify(record)}\n`; if (existsSync(this.historyFile(id))) { // Bound the history file: append within the audit limit. const existing = readFileSync(this.historyFile(id), "utf8"); const lines = existing.split("\n").filter((entry) => entry.trim()); this.write(this.historyFile(id), [...lines.slice(-499), line.trim()].join("\n") + "\n"); } else { this.write(this.historyFile(id), line); } } history(id: string): SkillHistoryRecord[] { const file = this.historyFile(id); if (!existsSync(file)) return []; return readFileSync(file, "utf8") .split("\n") .filter((line) => line.trim()) .map((line) => JSON.parse(line) as SkillHistoryRecord); } private readMetadata(state: SkillState, id: string): SkillCandidateMetadata { const { metadata } = this.paths(state, id); if (!existsSync(metadata)) { throw new SkillLifecycleError("skill_not_found", `skill ${id} not found in ${state}`); } return JSON.parse(readFileSync(metadata, "utf8")) as SkillCandidateMetadata; } private move(from: SkillState, to: SkillState, id: string): void { const fromDir = join(this.dir(from), id); const toDir = join(this.dir(to), id); if (!existsSync(fromDir)) { throw new SkillLifecycleError("skill_not_found", `skill ${id} not found in ${from}`); } if (this.dryRun) { this.planned.push({ action: "move", target: `${fromDir} -> ${toDir}` }); return; } mkdirSync(this.dir(to), { recursive: true }); if (existsSync(toDir)) rmSync(toDir, { recursive: true, force: true }); renameSync(fromDir, toDir); } /** Submit a new skill candidate. Content is redacted of secret material at * creation; the ID is derived from name + content hash. */ create(input: CreateSkillInput): SkillCandidateMetadata { const name = input.name?.trim(); if (!name || name.length > MAX_SKILL_NAME_CHARS) { throw new SkillLifecycleError("invalid_skill_name", `skill name must be 1-${MAX_SKILL_NAME_CHARS} characters`); } const rawContent = redactSecrets(input.content ?? ""); if (!rawContent.trim() || rawContent.length > MAX_SKILL_CONTENT_CHARS) { throw new SkillLifecycleError("invalid_skill_content", `skill content must be 1-${MAX_SKILL_CONTENT_CHARS} characters`); } const rawDesc = redactSecrets(input.description?.trim() ?? ""); const content = ensureSkillFrontmatter(rawContent, name, rawDesc); if (content.length > MAX_SKILL_CONTENT_CHARS) { throw new SkillLifecycleError("invalid_skill_content", `skill content must be 1-${MAX_SKILL_CONTENT_CHARS} characters`); } const { frontmatter } = parseSkillFrontmatter(content); const description = (typeof frontmatter?.description === "string" && frontmatter.description.trim()) ? frontmatter.description.trim() : (rawDesc || `Governed skill for ${name}`); const sources: SkillSources = { runIds: boundedList(input.sources?.runIds, "runIds"), journalEntryIds: boundedList(input.sources?.journalEntryIds, "journalEntryIds"), evidenceReceipts: boundedList(input.sources?.evidenceReceipts, "evidenceReceipts"), }; if (sources.runIds.length === 0 && sources.journalEntryIds.length === 0 && sources.evidenceReceipts.length === 0) { throw new SkillLifecycleError( "skill_sources_required", "a skill candidate must reference at least one source run, journal entry, or evidence receipt", ); } const createdBy = input.createdBy?.trim(); if (!createdBy) throw new SkillLifecycleError("invalid_skill_author", "createdBy is required"); const models = boundedList(input.compatibility?.models, "compatibility.models"); if (models.length === 0 || models.length > MAX_SKILL_MODELS) { throw new SkillLifecycleError("invalid_skill_compatibility", `compatibility.models must list 1-${MAX_SKILL_MODELS} models`); } const harness = input.compatibility?.harness?.trim(); if (!harness) throw new SkillLifecycleError("invalid_skill_compatibility", "compatibility.harness is required"); const contentSha256 = skillContentSha256(content); const id = skillIdFor(name, contentSha256); const { metadata, skill } = this.paths("candidate", id); if (existsSync(metadata)) { throw new SkillLifecycleError( "skill_candidate_exists", `identical candidate ${id} already exists; changed behavior requires changed content`, ); } const record: SkillCandidateMetadata = { schema: SKILL_CANDIDATE_SCHEMA, id, name, description, contentSha256, version: input.version ?? 1, sources, compatibility: { harness, models }, createdBy, createdAt: this.now(), ...(input.supersedes ? { supersedes: input.supersedes } : {}), }; this.write(skill, content); this.write(metadata, `${JSON.stringify(record, null, 2)}\n`); this.appendHistory(id, { schema: "kxm.skill-history-event.v1", event: "candidate_created", by: createdBy, supersedes: input.supersedes, at: record.createdAt }); return record; } /** Record a protected evaluation. A failed functional or safety evaluation * deterministically quarantines the candidate. */ evaluate(candidateId: string, input: { kind: SkillEvaluationKind; evaluatorVersion: string; passed: boolean; score?: number; details?: string; evaluatedBy?: string; }): { evaluation: SkillEvaluationRecord; quarantined: boolean } { if (input.kind === "optimization" && !this.allowOptimizationEvals) { throw new SkillLifecycleError( "skill_optimization_disabled", "optimization evaluations are disabled; enable them explicitly behind protected evals", ); } const metadata = this.readMetadata("candidate", candidateId); const evaluatorVersion = input.evaluatorVersion?.trim(); if (!evaluatorVersion) throw new SkillLifecycleError("invalid_skill_evaluation", "evaluatorVersion is required"); const evaluation: SkillEvaluationRecord = { schema: SKILL_EVALUATION_SCHEMA, candidateId, kind: input.kind, evaluatorVersion, passed: input.passed === true, ...(input.score !== undefined ? { score: input.score } : {}), ...(input.details ? { details: redactSecrets(input.details.slice(0, 2_000)) } : {}), evaluatedAt: this.now(), }; this.appendHistory(candidateId, evaluation); let quarantined = false; if (!evaluation.passed && (input.kind === "functional" || input.kind === "safety")) { // A functional regression or safety failure quarantines automatically: // the evaluator records the decision, a human may later reject fully. const decision: SkillDecisionRecord = { schema: SKILL_DECISION_SCHEMA, candidateId, decision: "quarantined", decidedBy: input.evaluatedBy?.trim() || `evaluator:${evaluatorVersion}`, reason: `automatic quarantine: ${input.kind} evaluation failed (${evaluatorVersion})`, evidenceRefs: [`evaluation:${input.kind}:${evaluatorVersion}`], decidedAt: this.now(), }; this.move("candidate", "quarantined", candidateId); this.appendHistory(candidateId, decision); quarantined = true; } return { evaluation, quarantined }; } private evaluationsFor(candidateId: string): SkillEvaluationRecord[] { return this.history(candidateId).filter( (record): record is SkillEvaluationRecord => (record as SkillEvaluationRecord).schema === SKILL_EVALUATION_SCHEMA, ); } /** Promote a candidate that passed every protected evaluation. The * promoter must differ from the author, cite durable evidence, and the * promoted content is hash-pinned and immutable. Emits a unified diff patch * instead of moving the candidate directory. */ promote(candidateId: string, decision: { decidedBy: string; reason: string; evidenceRefs: string[]; }): SkillCandidateMetadata & { patch: string; patchPath: string } { const metadata = this.readMetadata("candidate", candidateId); const decidedBy = decision.decidedBy?.trim(); if (!decidedBy) throw new SkillLifecycleError("invalid_skill_decision", "decidedBy is required"); if (decidedBy === metadata.createdBy) { throw new SkillLifecycleError("skill_promotion_invalid", "the author of a skill candidate cannot promote it"); } const evidenceRefs = boundedList(decision.evidenceRefs, "evidenceRefs"); if (evidenceRefs.length === 0) { throw new SkillLifecycleError("skill_promotion_invalid", "promotion requires durable evidence references"); } const evaluations = this.evaluationsFor(candidateId); const missing: string[] = []; for (const kind of PROMOTION_REQUIRED_EVALUATIONS) { const latest = [...evaluations].reverse().find((record) => record.kind === kind); if (!latest || !latest.passed) missing.push(kind); } if (missing.length > 0) { throw new SkillLifecycleError( "skill_evaluations_incomplete", `promotion requires passing ${missing.join(", ")} evaluations`, ); } // Integrity check before promotion: content on disk matches the hash. this.verify("candidate", candidateId); const record: SkillDecisionRecord = { schema: SKILL_DECISION_SCHEMA, candidateId, decision: "promoted", decidedBy, reason: decision.reason?.trim() || "passed protected evaluation", evidenceRefs, decidedAt: this.now(), }; // Instead of moving directory, write promoted directory and generate patch const candidatePaths = this.paths("candidate", candidateId); const promotedPaths = this.paths("promoted", candidateId); const skillContent = readFileSync(candidatePaths.skill, "utf8"); const metadataContent = readFileSync(candidatePaths.metadata, "utf8"); this.write(promotedPaths.skill, skillContent); this.write(promotedPaths.metadata, metadataContent); const patchPath = join(this.root, "patches", `${candidateId}.patch`); const relSkillPath = `.kxm/skills/promoted/${candidateId}/SKILL.md`; const relMetaPath = `.kxm/skills/promoted/${candidateId}/metadata.json`; const patch = `${createUnifiedPatch(relSkillPath, skillContent)}${createUnifiedPatch(relMetaPath, metadataContent)}`; this.write(patchPath, patch); this.appendHistory(candidateId, record); return { ...metadata, patch, patchPath }; } reject(candidateId: string, decision: { decidedBy: string; reason: string }): SkillCandidateMetadata { const metadata = this.readMetadata("candidate", candidateId); const record: SkillDecisionRecord = { schema: SKILL_DECISION_SCHEMA, candidateId, decision: "rejected", decidedBy: decision.decidedBy?.trim() || "kxm-admin", reason: decision.reason?.trim() || "rejected", evidenceRefs: [], decidedAt: this.now(), }; // Rejected candidates remain in history for future learning; the // candidate directory is removed and its full lineage stays queryable. this.move("candidate", "rejected", candidateId); this.appendHistory(candidateId, record); return metadata; } /** Verify content integrity of a stored skill (any state). Detects * out-of-band edits to promoted skills and verifies standard YAML frontmatter. */ verify(state: SkillState, id: string): SkillCandidateMetadata { const metadata = this.readMetadata(state, id); const { skill } = this.paths(state, id); const content = readFileSync(skill, "utf8"); if (skillContentSha256(content) !== metadata.contentSha256) { throw new SkillLifecycleError( "skill_integrity_violation", `skill ${id} content does not match its pinned hash; promoted skills are immutable and require a new candidate/eval cycle`, ); } const { frontmatter } = parseSkillFrontmatter(content); if ( !frontmatter || typeof frontmatter.name !== "string" || !frontmatter.name.trim() || typeof frontmatter.description !== "string" || !frontmatter.description.trim() ) { throw new SkillLifecycleError( "invalid_skill_frontmatter", `skill ${id} must contain valid YAML frontmatter with 'name' and 'description'`, ); } return metadata; } list(state: SkillState): SkillCandidateMetadata[] { const dir = this.dir(state); if (!existsSync(dir)) return []; const ids = readdirSorted(dir); return ids .map((id) => { try { return this.readMetadata(state, id); } catch { return undefined; } }) .filter((metadata): metadata is SkillCandidateMetadata => metadata !== undefined); } read(state: SkillState, id: string): { metadata: SkillCandidateMetadata; content: string } { const metadata = this.readMetadata(state, id); const { skill } = this.paths(state, id); return { metadata, content: readFileSync(skill, "utf8") }; } } function boundedList(value: string[] | undefined, field: string): string[] { if (value === undefined) return []; if (!Array.isArray(value)) { throw new SkillLifecycleError("invalid_skill_input", `${field} must be an array of strings`); } const refs = value.map((ref) => String(ref).trim()).filter((ref) => ref.length > 0); if (refs.length > MAX_SKILL_EVIDENCE_REFS) { throw new SkillLifecycleError("invalid_skill_input", `${field} exceeds ${MAX_SKILL_EVIDENCE_REFS} references`); } return [...new Set(refs)]; } function readdirSorted(dir: string): string[] { return readdirSync(dir) .filter((entry) => statSync(join(dir, entry)).isDirectory()) .sort(); }