#!/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 }; // A (2026-06-18): гейт ДР-1 в impl-режиме СНЯТ — чтение под опечатанным планом свободно. Оно не двигает // очередь шагов и не расширяет набор разрешённых мутаций (настоящая защита — шаг-гейт); запрет лишь // заставлял работать вслепую. Не блок, но логируется (signal) для ретро. Секреты держит отдельный // read-path-deny, не ДР-1. if (frozenPlan) return { block: false, reason: 'impl-режим: авторское чтение свободно (ДР-1 снят, A) — логируется', signal: true }; 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 : []; let next; if (readKind === 'authorial-raw' && !frozenPlan) { next = { reads: [...reads, { path: String(path || '') }] }; // разговорный фронт-лоад (SE-R7-5) } else if (readKind === 'authorial-raw' && frozenPlan) { next = { reads: [...reads, { path: String(path || ''), impl: true }] }; // A: impl-чтение для ретро (помечено) } else { next = { 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 } = {}) { // A: фронт-лоад считает ТОЛЬКО разговорные сырьё-чтения; impl-чтения (impl:true) легитимны — не считаются. const arr = Array.isArray(logState && logState.reads) ? logState.reads : []; const n = arr.filter((r) => r && !r.impl).length; 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 }; }