Files
brain/tools/reading-discipline.mjs
T

203 lines
14 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
/**
* 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 };
}