Files
brain/tools/skill-contract.mjs
T

142 lines
9.2 KiB
JavaScript
Raw Normal View History

#!/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 };
}