68 lines
3.9 KiB
JavaScript
68 lines
3.9 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* skill-contract-registry — сборка набора контрактов скилов. Чистый buildRegistry
|
||
* (над записями {contract, currentContent?}) + loadRegistry (диск, fs инъектируется).
|
||
* Валидирует каждый, для external сверяет дрейф G4. Вход для машины охвата 3-C.
|
||
*/
|
||
import fsDefault from 'node:fs';
|
||
import { validateContract, normalizeContract, checkContractDrift } from './skill-contract.mjs';
|
||
import { unknownTokens } from './capability-vocabulary.mjs';
|
||
|
||
/** Чистая сборка: валидирует, ловит дубли, помечает дрейф external.
|
||
* vocabTokens (Set токенов словаря) — опциональный замок §3: передан → неизвестный
|
||
* токен в needs/produces валит контракт в errors; null = замок выключен (стадийная
|
||
* раскатка §8, пока ~144 контракта в прозе). */
|
||
export function buildRegistry(entries, { vocabTokens = null } = {}) {
|
||
const contracts = [], errors = [], driftFlags = [], seen = new Set();
|
||
for (const e of entries || []) {
|
||
const c = normalizeContract(e.contract);
|
||
const v = validateContract(c);
|
||
if (!v.ok) { errors.push({ skill: c.skill || '(?)', errors: v.errors }); continue; }
|
||
if (seen.has(c.skill)) { errors.push({ skill: c.skill, errors: ['duplicate skill contract'] }); continue; }
|
||
if (vocabTokens) {
|
||
const unknown = unknownTokens(c, vocabTokens);
|
||
if (unknown.length) {
|
||
errors.push({ skill: c.skill, errors: unknown.map((u) => `${u.field}: неизвестный токен "${u.token}" (нет в capability-vocabulary)`) });
|
||
continue;
|
||
}
|
||
}
|
||
seen.add(c.skill);
|
||
if (c.kind === 'external') {
|
||
const d = checkContractDrift({ contract: c, currentContent: e.currentContent });
|
||
if (d.drifted) driftFlags.push({ skill: c.skill, reason: d.reason, fallback: d.fallback });
|
||
}
|
||
contracts.push(c);
|
||
}
|
||
return { contracts, errors, driftFlags };
|
||
}
|
||
|
||
/**
|
||
* G3 — детерминированная диспетчеризация по id скила поверх собранного реестра:
|
||
* скил в driftFlags (устарел/дрейф G4) ИЛИ нет адаптера → mode 'soft-reasoning'
|
||
* (молча не доверять, откат на мягкое рассуждение судьи); есть+свежий → mode
|
||
* 'exact' + сам контракт. Чистая функция над выходом buildRegistry/loadRegistry.
|
||
*/
|
||
export function dispatchContract(registry, skill) {
|
||
const reg = registry || {};
|
||
const drift = (reg.driftFlags || []).find((d) => d.skill === skill);
|
||
if (drift) return { mode: 'soft-reasoning', skill, reason: drift.reason };
|
||
const c = (reg.contracts || []).find((x) => x.skill === skill);
|
||
if (!c) return { mode: 'soft-reasoning', skill, reason: 'нет адаптера для скила — мягкое рассуждение' };
|
||
return { mode: 'exact', skill, contract: c };
|
||
}
|
||
|
||
/** Загрузка с диска: dir/*.contract.json. Для external читает source.path
|
||
* (актуальный SKILL.md) для дрейф-сверки G4, если путь задан и доступен. */
|
||
export function loadRegistry({ dir, fsImpl = fsDefault, vocabTokens = null }) {
|
||
const files = fsImpl.readdirSync(dir).filter((f) => f.endsWith('.contract.json'));
|
||
const entries = files.map((f) => {
|
||
const raw = JSON.parse(fsImpl.readFileSync(`${dir}/${f}`, 'utf8'));
|
||
let currentContent;
|
||
if (raw && raw.kind === 'external' && raw.source && raw.source.path) {
|
||
try { currentContent = fsImpl.readFileSync(raw.source.path, 'utf8'); } catch { currentContent = undefined; }
|
||
}
|
||
return { contract: raw, currentContent };
|
||
});
|
||
return buildRegistry(entries, { vocabTokens });
|
||
}
|