Files
brain/tools/skill-contract.mjs
T

142 lines
9.2 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.
#!/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 };
}