#!/usr/bin/env node /** * skill-contract — схема контракта скила (C-13 + L-серия) + механический * валидатор формы + G4 хеш-страж дрейфа чужих скилов. Чистый модуль (без LLM): * полнота/тип слотов — операции над структурой («умный не баран»). Фундамент * для машины охвата 3-C (needs/produces = рёбра графа) и входа роутера 3-D. */ import { createHash } from 'node:crypto'; // Канонические поля (C-13 базовые needs/produces/constraints + L-поля). export const CONTRACT_FIELDS = Object.freeze([ 'skill', 'kind', 'needs', 'produces', 'constraints', 'preview-form', 'defaults', 'key-decisions', 'acceptance-criteria', 'inherent', ]); // L1 preview-form — закрытый список форм дешёвого образца ('none' для мелочи). export const PREVIEW_FORMS = Object.freeze(['none', 'outline', 'mockup', 'sample', 'dry-run', 'diagram']); // Маркеры «прошлого промаха» (гард R4): rationale про природу навыка, НЕ про то, // что мы прошлый раз прошляпили. Generic (не project-specific), инъецируемо. export const DEFAULT_MISTAKE_MARKERS = Object.freeze([ 'забыл', 'забыли', 'прошлый раз', 'в прошлый раз', 'пропустил', 'пропустили', 'промах', 'прошляпил', 'упустил', 'не учли', 'в прошлом', ]); const ARRAY_FIELDS = Object.freeze(['needs', 'produces', 'constraints', 'defaults', 'key-decisions', 'acceptance-criteria']); function isStringArray(v) { return Array.isArray(v) && v.every((x) => typeof x === 'string'); } /** * Механический валидатор контракта → {ok, errors[]}. Проверяет ФОРМУ (наличие/тип * полей, enum preview-form, source{version,hash} у external), НЕ «полноту под * задачу» (это машина охвата 3-C). */ export function validateContract(c, { mistakeMarkers = DEFAULT_MISTAKE_MARKERS } = {}) { if (!c || typeof c !== 'object') return { ok: false, errors: ['contract is not an object'] }; const errors = []; if (typeof c.skill !== 'string' || !c.skill.trim()) errors.push('skill: non-empty string required'); if (c.kind !== 'own' && c.kind !== 'external') errors.push("kind: must be 'own' or 'external'"); for (const f of ARRAY_FIELDS) if (!isStringArray(c[f])) errors.push(`${f}: array of strings required`); if (!PREVIEW_FORMS.includes(c['preview-form'])) errors.push(`preview-form: one of ${PREVIEW_FORMS.join('|')}`); if (c.kind === 'external') { const s = c.source; if (!s || typeof s !== 'object') errors.push('external: source{version,hash} required'); else { if (typeof s.version !== 'string' || !s.version.trim()) errors.push('source.version: non-empty string required'); if (typeof s.hash !== 'string' || !/^[0-9a-f]{64}$/.test(s.hash)) errors.push('source.hash: sha256 hex required'); } } if (c.inherent != null) { if (!Array.isArray(c.inherent)) { errors.push('inherent: array required'); } else { c.inherent.forEach((it, i) => { if (!it || typeof it !== 'object' || typeof it.need !== 'string' || !it.need.trim()) errors.push(`inherent[${i}].need: non-empty string required`); else if (typeof it.rationale !== 'string' || !it.rationale.trim()) errors.push(`inherent[${i}].rationale: non-empty (зачем по природе) required — гард R4`); else { const low = it.rationale.toLowerCase(); const hit = (mistakeMarkers || []).find((m) => typeof m === 'string' && m && low.includes(m.toLowerCase())); if (hit) errors.push(`inherent[${i}].rationale: похоже на прошлый промах (маркер «${hit}»), нужна «природа навыка» — гард R4`); } }); } } return { ok: errors.length === 0, errors }; } /** Нормализация формы перед чтением/валидацией: отсутствующие массивы → [], * preview-form → 'none', trim skill. Не «исправляет» невалидное — только форма. */ export function normalizeContract(raw) { const c = { ...(raw || {}) }; if (typeof c.skill === 'string') c.skill = c.skill.trim(); for (const f of ARRAY_FIELDS) if (c[f] == null) c[f] = []; if (c['preview-form'] == null) c['preview-form'] = 'none'; if (c.inherent == null) c.inherent = []; return c; } /** Нужды контракта (копия) — рёбра графа 3-C. */ export function needsOf(c) { return Array.isArray(c?.needs) ? [...c.needs] : []; } /** Что производит (копия) — рёбра графа 3-C. */ export function producesOf(c) { return Array.isArray(c?.produces) ? [...c.produces] : []; } /** Нужды, присущие навыку по природе (#1) — список need-строк для сверщика полноты. */ export function inherentNeedsOf(c) { return Array.isArray(c?.inherent) ? c.inherent.map((it) => it?.need).filter((n) => typeof n === 'string' && n.trim()) : []; } /** sha256 содержания — отпечаток чужого скила для G4. */ export function contractHash(content) { return createHash('sha256').update(String(content ?? '')).digest('hex'); } /** * G4 — хеш-страж дрейфа (C-13 ключевой гард-рейл): сверяет сохранённый отпечаток * (contract.source.hash) с актуальным содержанием чужого SKILL.md. Расхождение → * drifted=true + fallback 'soft-reasoning' (откат на мягкое рассуждение судьи). * Своим (kind='own') не сторожим — контракт пишем сами. */ export function checkContractDrift({ contract, currentContent }) { if (!contract || contract.kind !== 'external') return { ok: true, drifted: false, reason: 'own/нет внешнего источника — дрейф не сторожится' }; // G-E: нет локального источника (path пуст) И нечего сравнивать (content не прочитан) → // сторожить нечего, G4 инертен. Это ровно прод-случай зеро-хеша (Р5 MCP/marketplace): // loadRegistry при пустом path не читает content → currentContent === undefined. // Прямой вызов с поданным currentContent (тест/иной потребитель) → drift сверяется как обычно. if (!contract.source?.path && currentContent == null) return { ok: true, drifted: false, reason: 'external без локального source.path и без содержания — дрейф не сторожится (G4 инертен)' }; const stored = contract.source?.hash; const actual = contractHash(currentContent); if (!stored) return { ok: false, drifted: true, reason: 'нет сохранённого отпечатка внешнего скила', fallback: 'soft-reasoning' }; if (stored !== actual) return { ok: false, drifted: true, reason: `дрейф чужого скила: ${stored.slice(0, 12)}… ≠ ${actual.slice(0, 12)}…`, fallback: 'soft-reasoning' }; return { ok: true, drifted: false, reason: 'отпечаток совпал' }; } /** * G1 — мягкий страж нейтральности этикетки: контракт должен быть про САМ скил, без * проектных фактов («лендингу нужен teal» → хардкод). Список проектных терминов * ИНЪЕЦИРУЕТСЯ (портативно, без зашитых проектных слов в коде); пустой список → * neutral (нечего судить). Совпадение (без регистра, подстрока) по любому * строковому полю → not neutral + перечень. Это страж авторства, не блок формы. */ export function checkContractNeutrality(contract, { projectTerms = [] } = {}) { const terms = (projectTerms || []).filter((t) => typeof t === 'string' && t.trim()); if (terms.length === 0) return { neutral: true, hits: [] }; // F4: «любое строковое поле» — массивы + inherent{need,rationale} + имя скила, // а не только ARRAY_FIELDS (проектный термин мог прятаться в inherent-rationale). const fromArrays = ARRAY_FIELDS.flatMap((f) => (Array.isArray(contract?.[f]) ? contract[f] : [])); const fromInherent = Array.isArray(contract?.inherent) ? contract.inherent.flatMap((it) => [it?.need, it?.rationale].filter((x) => typeof x === 'string')) : []; const fromSkill = typeof contract?.skill === 'string' ? [contract.skill] : []; const haystack = [...fromArrays, ...fromInherent, ...fromSkill].join('\n').toLowerCase(); const hits = [...new Set(terms.filter((t) => haystack.includes(t.toLowerCase())))]; return { neutral: hits.length === 0, hits }; }