397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
203 lines
14 KiB
JavaScript
203 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* reading-discipline (§5.8/§6.7/§5.7 спеки R6.3) — дисциплина чтения наставника.
|
||
* D определяет ТИП текста (ДР-5) и ВИД чтения; СКИЛ-читалку и «нет скила → воздержание»
|
||
* выбирает РОУТЕР [router-engine.mjs:113,148-149,152-153] — D не дублирует и не выдумывает.
|
||
* Чистое ядро (всё инъектируется). Wiring в стену/шов — sub-plan C2.
|
||
*/
|
||
|
||
/**
|
||
* Закрытый список типов контента (образец LEVEL_SKIP_CATEGORIES [router-engine.mjs:29]).
|
||
* SE4: каждый тип имеет производителя (метаданные Task 1 / grep-fallback Task 2)
|
||
* ИЛИ удалён — narrative-doc убран (производителя нет, generic .md → abstain → переспрос).
|
||
*/
|
||
export const READING_CONTENT_TYPES = Object.freeze([
|
||
'code', 'spec-design', 'legal', 'scientific', 'data', 'config',
|
||
]);
|
||
|
||
// V-3 PLACEHOLDER: наборы расширений/spec-папок — baseline, уточнить под реальный
|
||
// набор расширений проекта при wiring (C2).
|
||
const CODE_EXT = new Set(['.mjs', '.js', '.ts', '.tsx', '.vue', '.php', '.py', '.go', '.rs', '.sql', '.sh']);
|
||
const DATA_EXT = new Set(['.json', '.jsonl', '.csv', '.ndjson']);
|
||
// F-D5: '.env' в CONFIG_EXT достижим только при явном ext='.env' от caller —
|
||
// path.extname('.env') даёт '' → основной детект dotfile-конфигов: CONFIG_BASENAMES.
|
||
const CONFIG_EXT = new Set(['.yaml', '.yml', '.toml', '.ini', '.env', '.neon']);
|
||
const SPEC_PATH_PREFIXES = ['docs/superpowers/', 'docs/adr/'];
|
||
// SE12: dotfiles без расширения (extname('.env')==='') ловятся basename'ом.
|
||
const CONFIG_BASENAMES = new Set(['.env']);
|
||
|
||
/**
|
||
* Последний сегмент пути В НИЖНЕМ РЕГИСТРЕ (F-D5: Windows case-insensitive —
|
||
* '.ENV' тот же файл). Без node:path — модуль чистый; / и \ оба разделители.
|
||
*/
|
||
function basenameOf(p) {
|
||
const parts = String(p || '').split(/[\\/]/);
|
||
return (parts[parts.length - 1] || '').toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Тип контента из дешёвых метаданных (ext + path + graphNodeType). Уверенно →
|
||
* {contentType, abstain:false}; неоднозначно/неизвестно → {contentType:null, abstain:true}
|
||
* (зеркало воздержания 5.2 — роутер/владелец доразрешат). НЕ выдумываем legal/narrative
|
||
* по пути — что метаданные не покрывают, уходит в grepHeaderFallback (Task 2) → иначе abstain.
|
||
*/
|
||
export function classifyReadingContent({ ext = '', path = '', graphNodeType = '' } = {}) {
|
||
const e = String(ext || '').toLowerCase();
|
||
const p = String(path || '');
|
||
if (graphNodeType === 'code' || CODE_EXT.has(e)) return { contentType: 'code', abstain: false };
|
||
if (DATA_EXT.has(e)) return { contentType: 'data', abstain: false };
|
||
if (CONFIG_EXT.has(e) || CONFIG_BASENAMES.has(basenameOf(p))) return { contentType: 'config', abstain: false };
|
||
if (e === '.md' && SPEC_PATH_PREFIXES.some((x) => p.startsWith(x))) return { contentType: 'spec-design', abstain: false };
|
||
return { contentType: null, abstain: true, reason: 'тип не определён по метаданным — grep-fallback/переспрос (5.2)' };
|
||
}
|
||
|
||
/**
|
||
* Добор типа по первым строкам файла (headerText инъектируется — caller грепает).
|
||
* Маркеры консервативны и явны; нет совпадения → null (→ воздержание 5.2 у роутера/владельца).
|
||
* SE4 + решение владельца 2026-06-11: scientific ТОЛЬКО по надёжным приметам
|
||
* (Abstract / DOI: / arXiv); голое «Статья» неоднозначно (legal «Статья N» /
|
||
* публицистика) → null. Порядок: spec-design → legal → scientific.
|
||
*/
|
||
export function grepHeaderFallback({ headerText = '' } = {}) {
|
||
const t = String(headerText || '');
|
||
if (!t.trim()) return null;
|
||
if (/Implementation Plan|## §|design[- ]notes|spec\b/i.test(t)) return 'spec-design';
|
||
if (/Федеральн\w+ закон|№\s*\d+-ФЗ|\bСтатья\s+\d+|\bдоговор\b/i.test(t)) return 'legal';
|
||
if (/\bAbstract\b|\bDOI:|\barXiv\b/i.test(t)) return 'scientific';
|
||
return null;
|
||
}
|
||
|
||
/** 4 вида чтения (§5.8 CD-R6-B). Порядок = приоритет различения. */
|
||
export const READ_KINDS = Object.freeze(['graph-map', 'authorial-raw', 'critic-probe', 'harness-mandatory']);
|
||
|
||
const DEFAULT_GRAPH_PREFIX = '.claude/worktrees/graphify-spike/graphify-out/';
|
||
|
||
/**
|
||
* Различить вид чтения (CD-R6-B). Имя Read-tool НЕ отличает вид-2 (авторское сырьё)
|
||
* от вид-4 (harness) [enforce-supreme-gate.mjs:43 пускает все Read одинаково] →
|
||
* различаем по предикатам (инъектируемым; C2 свяжет с frozenPlan/plan-step/seam).
|
||
* SE5 DANGER: planAuthorizesPath ОБЯЗАН строиться из frozenPlan+stepPtr (производитель
|
||
* C2-W2 через actionMatchesStep [plan-lock.mjs:86]) — always-true стаб ОТКЛЮЧАЕТ гейт ДР-1.
|
||
* Дефолт null fail-safe: нет производителя → ветка вид-4 пропускается → сырьё → блок.
|
||
* F-D3 DANGER (класс S2, спека §12 round-4): isCriticProbe ОБЯЗАН производиться из
|
||
* вердикта/трассы НАСТАВНИКА (mentor-derived, производитель C) — controller-ставимый
|
||
* true обходит блок ДР-1 до probe-cap.
|
||
* F-D1: путь с сегментом '..' НЕ считается граф-картой (traversal-обход префикса закрыт;
|
||
* такой путь падает в authorial-raw → к блоку). Контракт C2-W7: путь нормализован.
|
||
* F-D2: исключение из planAuthorizesPath глотается → false (→ сырьё → блок, то же направление).
|
||
* Приоритет: граф-карта → авторизованный-шагом (harness) → критик-проверка → авторское сырьё.
|
||
*/
|
||
export function classifyReadKind({ path = '', frozenPlan = false, planAuthorizesPath = null,
|
||
isCriticProbe = false, graphPathPrefix = DEFAULT_GRAPH_PREFIX } = {}) {
|
||
const p = String(path || '');
|
||
const hasTraversal = p.split(/[\\/]/).includes('..'); // F-D1
|
||
if (!hasTraversal && p.startsWith(graphPathPrefix)) return 'graph-map';
|
||
if (frozenPlan && typeof planAuthorizesPath === 'function') {
|
||
let authorized = false;
|
||
try { authorized = planAuthorizesPath(p) === true; } catch { authorized = false; } // F-D2
|
||
if (authorized) return 'harness-mandatory';
|
||
}
|
||
if (isCriticProbe) return 'critic-probe';
|
||
return 'authorial-raw';
|
||
}
|
||
|
||
/**
|
||
* Гейт ДР-1 (§5.8): блок ТОЛЬКО на авторское сырьё-чтение (вид-2) в impl-режиме
|
||
* (frozenPlan). Виды 1/3/4 свободны. Разговорный режим — НЕ блок, а сигнал владельцу
|
||
* (read-LOG, SE-R7-5: фронт-лоад сырья до заморозки). F-D4: неизвестный/мусорный
|
||
* readKind трактуется как authorial-raw (консервативно, к блоку — не free-pass).
|
||
* @returns {{block, reason, signal}}
|
||
*/
|
||
export function readingGateDecision({ readKind, frozenPlan = false } = {}) {
|
||
const kind = READ_KINDS.includes(readKind) ? readKind : 'authorial-raw'; // F-D4
|
||
if (kind !== 'authorial-raw') return { block: false, reason: 'не авторское сырьё — свободно', signal: false };
|
||
if (frozenPlan) return { block: true, reason: 'авторское сырьё-чтение вне шага плана — гейт ДР-1 (impl-режим)', signal: false };
|
||
return { block: false, reason: 'разговорный режим — не блок', signal: true };
|
||
}
|
||
|
||
/** F-D6: повреждён ли read-LOG (reads есть, но не массив) либо несёт липкий corrupt-маркер. */
|
||
function isCorruptLog(logState) {
|
||
if (logState == null || typeof logState !== 'object') return false; // отсутствие = свежий старт
|
||
if (logState.corrupt === true) return true;
|
||
return logState.reads !== undefined && !Array.isArray(logState.reads);
|
||
}
|
||
|
||
/**
|
||
* Записать чтение в разговорный read-LOG (SE-R7-5). Считается ТОЛЬКО разговорное
|
||
* (frozenPlan=false) авторское сырьё (readKind='authorial-raw') — это фронт-лоад сырья
|
||
* до заморозки. Immutable: возвращает НОВОЕ состояние. F-D6: повреждённый вход не
|
||
* маскируется тихим сбросом — результат несёт липкий corrupt:true (сигнал, не энфорсмент).
|
||
* @returns {{reads:[{path}], corrupt?:true}}
|
||
*/
|
||
export function recordRead(logState, { path = '', readKind, frozenPlan = false } = {}) {
|
||
const corrupt = isCorruptLog(logState); // F-D6
|
||
const reads = Array.isArray(logState && logState.reads) ? logState.reads : [];
|
||
const next = (readKind === 'authorial-raw' && !frozenPlan)
|
||
? { reads: [...reads, { path: String(path || '') }] }
|
||
: { reads: [...reads] };
|
||
if (corrupt) next.corrupt = true;
|
||
return next;
|
||
}
|
||
|
||
/**
|
||
* Сигнал владельцу по read-LOG: фронт-лоад сырья превысил порог → warn (НЕ блок).
|
||
* F-D6: повреждённый/corrupt-меченый лог → warn безусловно (счёт недостоверен).
|
||
* @returns {{frontLoadCount, warn, message}}
|
||
*/
|
||
export function readLogSignal(logState, { threshold = 5 } = {}) {
|
||
const n = Array.isArray(logState && logState.reads) ? logState.reads.length : 0;
|
||
if (isCorruptLog(logState)) {
|
||
return { frontLoadCount: n, warn: true, message: 'read-LOG повреждён (reads не массив / corrupt-маркер) — счёт недостоверен (F-D6)' };
|
||
}
|
||
const warn = n > threshold;
|
||
return {
|
||
frontLoadCount: n,
|
||
warn,
|
||
message: warn ? `read-LOG: ${n} разговорных сырьё-чтений до заморозки (фронт-лоад, SE-R7-5)` : '',
|
||
};
|
||
}
|
||
|
||
/** ✅O19: лимит критик-чтения наставника на КРУГ (×MENTOR_ROUND_CAP=3 в C → макс 6/задача). */
|
||
export const MENTOR_PROBE_CAP = 2;
|
||
|
||
/**
|
||
* Можно ли наставнику ещё одно критик-чтение в этом круге. probeCountThisRound —
|
||
* сколько уже использовано (SE5 DANGER: реальный per-round счётчик — производитель
|
||
* C2-W2/W3; дефолт 0 в decideReadEvent отключает cap до wiring). fail-closed:
|
||
* не-целое/отрицательное → не allowed (не открыть free-reading backdoor мимо ДР-1).
|
||
* F-D7: cap валидируется (целое ≥1) — Infinity/строка/0 не отключают лимит молча.
|
||
* @returns {{allowed, reason}}
|
||
*/
|
||
export function checkProbeCap(probeCountThisRound, cap = MENTOR_PROBE_CAP) {
|
||
if (!Number.isInteger(cap) || cap < 1)
|
||
return { allowed: false, reason: 'невалидный cap (требуется целое ≥1) — fail-closed (F-D7)' };
|
||
if (!Number.isInteger(probeCountThisRound) || probeCountThisRound < 0)
|
||
return { allowed: false, reason: 'битый счётчик probe — fail-closed' };
|
||
return probeCountThisRound < cap
|
||
? { allowed: true, reason: `критик-чтение ${probeCountThisRound + 1}/${cap}` }
|
||
: { allowed: false, reason: `лимит критик-чтения исчерпан (${cap}/круг) — к владельцу/след. круг` };
|
||
}
|
||
|
||
/**
|
||
* Сборка для ОДНОГО read-события (готова к wiring C2-W2). Возвращает тип контента
|
||
* (→ роутер выберет скил/воздержится — вне D), вид чтения, решение гейта ДР-1,
|
||
* и статус probe-cap (только для критик-проверки; иначе null). Чистая; всё инъектируется.
|
||
* SE5 DANGER (cross-ref C2-W7): planAuthorizesPath ОБЯЗАН строиться из frozenPlan+stepPtr
|
||
* (always-true стаб ОТКЛЮЧАЕТ гейт ДР-1 — configuration cliff); probeCountThisRound
|
||
* ОБЯЗАН быть реальным per-round счётчиком (дефолт 0 отключает probe-cap). Оба — C2-W2/W3.
|
||
* @returns {{readKind, content, gate, probe:{allowed,reason}|null}}
|
||
*/
|
||
export function decideReadEvent({ ext = '', path = '', graphNodeType = '', headerText = '',
|
||
frozenPlan = false, planAuthorizesPath = null, isCriticProbe = false,
|
||
probeCountThisRound = 0, graphPathPrefix = DEFAULT_GRAPH_PREFIX } = {}) {
|
||
let content = classifyReadingContent({ ext, path, graphNodeType });
|
||
if (content.abstain && headerText) {
|
||
const fb = grepHeaderFallback({ headerText });
|
||
if (fb) content = { contentType: fb, abstain: false };
|
||
}
|
||
const readKind = classifyReadKind({ path, frozenPlan, planAuthorizesPath, isCriticProbe, graphPathPrefix });
|
||
const gate = readingGateDecision({ readKind, frozenPlan });
|
||
const probe = readKind === 'critic-probe' ? checkProbeCap(probeCountThisRound) : null;
|
||
return { readKind, content, gate, probe };
|
||
}
|