397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
9.2 KiB
JavaScript
142 lines
9.2 KiB
JavaScript
#!/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 };
|
||
}
|