diff --git a/docs/superpowers/specs/2026-06-18-wall-impl-read-freedom-design.md b/docs/superpowers/specs/2026-06-18-wall-impl-read-freedom-design.md new file mode 100644 index 0000000..e7583a4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-wall-impl-read-freedom-design.md @@ -0,0 +1,95 @@ +# Дизайн: свободное чтение под опечатанным планом (снятие гейта ДР-1 в impl-режиме) + +**Дата:** 2026-06-18 · **Репозиторий:** claude-brain (управляющий слой) · **Кодовая фраза:** «роутер-наставник». +**Источник:** баги `bags/2026-06-17-wall-read-block-bug.md` (+ `bug1.md`). Пункт **A** роадмапа допила эталона. +**Тип:** дизайн-доказательство (brainstorming → writing-plans). Правки `tools/*.mjs` — далее по церемонии TDD. + +--- + +## 1. Проблема (по факту, с цитатами) + +В режиме реализации (под опечатанным планом) чтение разрешено **только по пути текущего шага**. +Всё прочее — блок, включая собственный вывод запущенных инструментов и файлы, появившиеся по ходу. + +Корень — **одна строка**: [tools/reading-discipline.mjs:111-116](../../../tools/reading-discipline.mjs#L111-L116), +функция `readingGateDecision`: + +``` +if (kind !== 'authorial-raw') return { block: false … }; // граф-карта / путь шага / критик-проба — свободно +if (frozenPlan) return { block: true, reason: '…гейт ДР-1 (impl-режим)' }; // ← ЭТО блокирует +return { block: false … signal: true }; // разговорный режим — не блок, лог +``` + +Вызывается из стены: [tools/enforce-supreme-gate.mjs:271-281](../../../tools/enforce-supreme-gate.mjs#L271-L281) +(`decideReadEvent` → при `ev.gate.block` стена возвращает блок). + +### Почему правило бьёт по работе (10 случаев из бага) +Свой вывод сканера в temp-файле не прочитать; результат шага не проверить; ветвление по содержимому +невозможно; диагностика по внешнему логу закрыта; файл, родившийся по ходу или от соседней сессии, +недоступен. Итог — агент **действует вслепую**, чтобы не разворачивать план. + +## 2. Что уже сделано (НЕ переделывать) + +- **Десинк указателя F-J починен.** Стена двигает счётчик шага только при подтверждённом реальном шаге + (двухтактная «предварительная пометка»): [enforce-supreme-gate.mjs:446-448](../../../tools/enforce-supreme-gate.mjs#L446-L448). + Плюс floor-desync Δ7+: стена не двигает указатель на действие, которое зарубил бы пол + ([:315-323](../../../tools/enforce-supreme-gate.mjs#L315-L323)). +- Следствие: **чтение и так не двигает очередь** (чтение никогда не было шагом). Поэтому открыть чтение + безопасно — оно не может сдвинуть план. Главный риск из бага («наивный escape `read:` сдвинет очередь») + уже снят корнем. + +## 3. Решение + +**Во время реализации чтение не блокировать. Гейт ДР-1 из блокатора превращается в наблюдателя (лог).** + +Обоснование (разобрано с владельцем 2026-06-18): запрет на чтение лечил не ту задачу и не в тот момент. +Цель «прочитать всё для точной спеки» достигается **на этапе планирования**; после печати плана запрет +ничего не добавляет к точности — только мешает проверять. Настоящая защита стены — **совпадение действия +с шагом** (мутацию вне плана не выполнить), а не запрет чтения. Свободное чтение **не расширяет** набор +разрешённых действий, поэтому безопасно; запрет же лишь заставляет действовать вслепую (слепой агент +**опаснее** зрячего — не ловит свою ошибку). + +### 3.1 Что меняем (ядро) +1. **`readingGateDecision`** ([reading-discipline.mjs:111-116](../../../tools/reading-discipline.mjs#L111-L116)): + ветка `if (frozenPlan) → block:true` заменяется на **не-блок с сигналом** (`block:false, signal:true`). + В impl-режиме авторское сырьё-чтение становится **залогированным, но разрешённым**. +2. **`recordRead`** ([reading-discipline.mjs:132-140](../../../tools/reading-discipline.mjs#L132-L140)): + сейчас пишет в read-LOG только разговорное (`frozenPlan=false`) сырьё. Расширить: писать и impl-чтения + (`frozenPlan=true`) с пометкой режима — чтобы ретро видело, что читалось под планом. +3. **`enforce-supreme-gate`** (вызов на :271-281): после правки `ev.gate.block` для impl-сырья = false → + чтение проходит. Отдельной правки стены, скорее всего, не нужно (она лишь чтит `ev.gate.block`) — + подтвердить тестом интеграции. + +### 3.2 Что НЕ трогаем (границы ответственности) +- **Секрет-гарды.** Защита `.env`/паролей/секретов — **отдельный хук** (`read-path-deny` и секрет-сканеры), + не гейт ДР-1. Он работает в обоих режимах независимо. Снятие ДР-1 его не ослабляет. +- **Критик-проба наставника** (`MENTOR_PROBE_CAP=2/круг`, [reading-discipline.mjs:160-179](../../../tools/reading-discipline.mjs#L160-L179)) — + про чтения САМОГО наставника, отдельный механизм. Не трогаем. +- **Разговорный фронт-лоад-лог** (SE-R7-5, warn «много сырья до заморозки») — остаётся как есть. +- **Машинерия шагов / указатель** — не касаемся (чтение не шаг). + +### 3.3 Дисциплинарная заметка (норматив, не код) +В Pravila/GUIDE добавить строку: *если чтение по ходу противоречит замыслу плана — честный ход +«развернуть и перепланировать» (M7 Ф8 re-plan уже разрешён в impl), а не доделывать вслепую.* Стена это +не заставит; ловят наставник/судья/ретро по логу чтений. + +## 4. Критерий «починено» (из бага) +- Под опечатанным планом читаются: свой вывод инструментов, файлы, созданные после печати, лог упавшего + шага, файл соседней сессии — **без сдвига очереди шагов**. +- Падение/блок шага НЕ двигает указатель (F-J — регресс-тест держит). +- Секреты (`.env` и т.п.) по-прежнему закрыты `read-path-deny` (не регрессирует). +- read-LOG фиксирует impl-чтения (видно в ретро). + +## 5. Тесты (TDD-набросок) +- `readingGateDecision({readKind:'authorial-raw', frozenPlan:true})` → `block:false, signal:true` (был true). +- `readingGateDecision` для не-сырья — без изменений (граф-карта/путь шага/проба свободны). +- `recordRead` пишет impl-чтение с пометкой режима; разговорное — как раньше. +- Интеграция: под frozenPlan `decideReadEvent(authorial-raw)` → стена пускает Read (регресс на :271-281). +- Регресс F-J: блок шага со-хуком не двигает указатель (уже есть — не ломаем). +- Секрет-регресс: `.env` под планом всё равно режется отдельным гардом (smoke). + +## 6. Решения владельца (закрыто 2026-06-18) + риски +- **Q1 — РЕШЕНО: тихо.** impl-чтения логируются ТОЛЬКО для ретро, в баннер не выводятся. +- **Q2 — РЕШЕНО: порога-warn нет.** Чтение под планом легитимно — число не ограничиваем и не пугаем. +- Риск «дрейфа» (читаю и тихо доделываю кривой план вместо re-plan) держится не кодом, а + шаг-гейтом (off-plan действие не пройдёт) + дисциплинарной заметкой §3.3. diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index c5e0396..56786fa 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -666,10 +666,9 @@ describe('W4 — runGate прокидывает warn O18 в message (owner ви describe('W2 — reading-gate ДР-1 в decide (impl-режим)', () => { const plan = { artifact_id: null, steps: [{ n: 1, op: 'Edit', object: 'tools/foo.mjs' }] }; const base = (toolUse) => ({ toolUse, frozenPlan: plan, frozenArtifact: null, stepPtr: 0, key: 'k', verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (p) => String(p).toLowerCase() }); - it('авторское сырьё-чтение (Read файла кода ВНЕ шага плана) → блок ДР-1', () => { + it('A: авторское сырьё-чтение (Read файла кода ВНЕ шага) → allow (ДР-1 снят, observe-only)', () => { const r = decide(base({ name: 'Read', input: { file_path: 'app/Services/LeadRouter.php' } })); - expect(r.decision).toBe('block'); - expect(r.reason).toMatch(/ДР-1/); + expect(r.decision).toBe('allow'); }); it('Read файла из шага плана (harness-обязательное, op-агностично: шаг Edit) → allow', () => { const r = decide(base({ name: 'Read', input: { file_path: 'tools/foo.mjs' } })); @@ -679,8 +678,8 @@ describe('W2 — reading-gate ДР-1 в decide (impl-режим)', () => { const r = decide(base({ name: 'Read', input: { file_path: '.claude/worktrees/graphify-spike/graphify-out/graph.json' } })); expect(r.decision).toBe('allow'); }); - it('Grep сырья вне плана → блок; Glob не гейтится (W2 scope: Read/Grep)', () => { - expect(decide(base({ name: 'Grep', input: { path: 'app/Services/x.php' } })).decision).toBe('block'); + it('A: Grep сырья под планом → allow (ДР-1 снят); Glob тоже allow', () => { + expect(decide(base({ name: 'Grep', input: { path: 'app/Services/x.php' } })).decision).toBe('allow'); expect(decide(base({ name: 'Glob', input: { pattern: '**/*.php' } })).decision).toBe('allow'); }); it('без замороженного плана decide гейт ДР-1 не зовёт (разговорный — не его зона)', () => { diff --git a/tools/reading-discipline.mjs b/tools/reading-discipline.mjs index 1b78277..c0b3d38 100644 --- a/tools/reading-discipline.mjs +++ b/tools/reading-discipline.mjs @@ -111,7 +111,11 @@ export function classifyReadKind({ path = '', frozenPlan = false, planAuthorizes 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 }; + // 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 }; } @@ -132,9 +136,14 @@ function isCorruptLog(logState) { 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] }; + 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; } @@ -145,7 +154,9 @@ export function recordRead(logState, { path = '', readKind, frozenPlan = false } * @returns {{frontLoadCount, warn, message}} */ export function readLogSignal(logState, { threshold = 5 } = {}) { - const n = Array.isArray(logState && logState.reads) ? logState.reads.length : 0; + // 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)' }; } diff --git a/tools/reading-discipline.test.mjs b/tools/reading-discipline.test.mjs index a8c4855..d6478ed 100644 --- a/tools/reading-discipline.test.mjs +++ b/tools/reading-discipline.test.mjs @@ -113,10 +113,10 @@ describe('readingGateDecision (§5.8 impl-only)', () => { for (const rk of ['graph-map', 'critic-probe', 'harness-mandatory']) expect(readingGateDecision({ readKind: rk, frozenPlan: true }).block).toBe(false); }); - it('авторское сырьё в impl-режиме (frozenPlan) → БЛОК (гейт ДР-1)', () => { + it('авторское сырьё в impl-режиме (frozenPlan) → НЕ блок (A: ДР-1 снят), но сигнал', () => { const d = readingGateDecision({ readKind: 'authorial-raw', frozenPlan: true }); - expect(d.block).toBe(true); - expect(d.reason).toMatch(/ДР-1/); + expect(d.block).toBe(false); + expect(d.signal).toBe(true); }); it('авторское сырьё в разговорном (нет frozenPlan) → НЕ блок, но сигнал', () => { const d = readingGateDecision({ readKind: 'authorial-raw', frozenPlan: false }); @@ -129,12 +129,14 @@ describe('readingGateDecision (§5.8 impl-only)', () => { import { recordRead, readLogSignal } from './reading-discipline.mjs'; describe('read-LOG (SE-R7-5)', () => { - it('копит только разговорные авторские сырьё-чтения', () => { + it('копит разговорные сырьё-чтения (без метки) и impl-чтения (impl:true) для ретро', () => { let s = { reads: [] }; s = recordRead(s, { path: 'a.mjs', readKind: 'authorial-raw', frozenPlan: false }); - s = recordRead(s, { path: 'b.mjs', readKind: 'graph-map', frozenPlan: false }); // не считается - s = recordRead(s, { path: 'c.mjs', readKind: 'authorial-raw', frozenPlan: true }); // impl — не разговорный - expect(s.reads.map((r) => r.path)).toEqual(['a.mjs']); + s = recordRead(s, { path: 'b.mjs', readKind: 'graph-map', frozenPlan: false }); // не сырьё — не считается + s = recordRead(s, { path: 'c.mjs', readKind: 'authorial-raw', frozenPlan: true }); // A: impl — пишется с пометкой + expect(s.reads.map((r) => r.path)).toEqual(['a.mjs', 'c.mjs']); + expect(s.reads.find((r) => r.path === 'a.mjs').impl).toBeUndefined(); + expect(s.reads.find((r) => r.path === 'c.mjs').impl).toBe(true); }); it('immutable — не мутирует вход', () => { const s0 = { reads: [] }; @@ -150,6 +152,12 @@ describe('read-LOG (SE-R7-5)', () => { it('нет сигнала под порогом', () => { expect(readLogSignal({ reads: [{ path: '1' }] }, { threshold: 2 }).warn).toBe(false); }); + it('A: impl-чтения (impl:true) НЕ считаются во фронт-лоад порог', () => { + const s = { reads: [{ path: '1' }, { path: 'i1', impl: true }, { path: 'i2', impl: true }] }; + const sig = readLogSignal(s, { threshold: 2 }); + expect(sig.frontLoadCount).toBe(1); // только разговорный '1' + expect(sig.warn).toBe(false); + }); }); // Task 6 — probe-cap (ESM hoisting: import внизу легален) @@ -182,14 +190,14 @@ import { decideReadEvent } from './reading-discipline.mjs'; describe('decideReadEvent (сборка под wiring C2-W2)', () => { const gp = '.claude/worktrees/graphify-spike/graphify-out/'; - it('impl-режим, авторское сырьё кода → block + contentType=code', () => { + it('impl-режим, авторское сырьё кода → НЕ блок (A: ДР-1 снят) + contentType=code', () => { const d = decideReadEvent({ ext: '.mjs', path: 'tools/x.mjs', frozenPlan: true, planAuthorizesPath: () => false, graphPathPrefix: gp, }); expect(d.readKind).toBe('authorial-raw'); expect(d.content.contentType).toBe('code'); - expect(d.gate.block).toBe(true); + expect(d.gate.block).toBe(false); }); it('критик-проверка под лимитом → не блок + probe allowed', () => { const d = decideReadEvent({ @@ -231,7 +239,8 @@ describe('sharp-edges фиксы (F-D1..F-D7)', () => { planAuthorizesPath: () => false, graphPathPrefix: gp, }); expect(k).toBe('authorial-raw'); - expect(readingGateDecision({ readKind: k, frozenPlan: true }).block).toBe(true); + // A: ДР-1 снят в impl — authorial-raw (вкл. traversal-путь) больше не блок; классификация (не graph-map) держит. + expect(readingGateDecision({ readKind: k, frozenPlan: true }).block).toBe(false); }); it('F-D1: честный граф-путь без .. остаётся graph-map', () => { expect(classifyReadKind({ path: gp + 'graph.json', graphPathPrefix: gp })).toBe('graph-map'); @@ -243,8 +252,8 @@ describe('sharp-edges фиксы (F-D1..F-D7)', () => { }); expect(k).toBe('authorial-raw'); }); - it('F-D4: неизвестный readKind в impl-режиме → к блоку (не free-pass)', () => { - expect(readingGateDecision({ readKind: 'опечатка', frozenPlan: true }).block).toBe(true); + it('F-D4: неизвестный readKind в impl-режиме → authorial-raw → НЕ блок (A: ДР-1 снят)', () => { + expect(readingGateDecision({ readKind: 'опечатка', frozenPlan: true }).block).toBe(false); }); it('F-D4: неизвестный readKind в разговорном → сигнал, не блок', () => { const d = readingGateDecision({ readKind: undefined, frozenPlan: false }); diff --git a/tools/router-mentor-integration.test.mjs b/tools/router-mentor-integration.test.mjs index 4cbf84b..5454381 100644 --- a/tools/router-mentor-integration.test.mjs +++ b/tools/router-mentor-integration.test.mjs @@ -72,14 +72,14 @@ describe('W6 — SE2: graph vs graphSection не взаимозаменяемы' describe('W6 — интеграция D→W2: дисциплина чтения на реальном decideReadEvent', () => { const base = (toolUse) => ({ toolUse, frozenPlan: { artifact_id: null, steps: STEPS }, frozenArtifact: null, stepPtr: 0, key: 'k', verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: (p) => String(p).toLowerCase() }); - it('сырьё вне плана в impl → блок; файл шага → allow; граф-карта → allow', () => { - expect(decide(base({ name: 'Read', input: { file_path: 'app/Services/LeadRouter.php' } })).decision).toBe('block'); + it('A: сырьё под планом в impl → allow (ДР-1 снят); файл шага → allow; граф-карта → allow', () => { + expect(decide(base({ name: 'Read', input: { file_path: 'app/Services/LeadRouter.php' } })).decision).toBe('allow'); expect(decide(base({ name: 'Read', input: { file_path: 'tools/x.mjs' } })).decision).toBe('allow'); expect(decide(base({ name: 'Read', input: { file_path: '.claude/worktrees/graphify-spike/graphify-out/graph.json' } })).decision).toBe('allow'); }); - it('decideReadEvent (реальный D) различает виды: граф-карта свободна, сырьё в impl блокируется', () => { + it('A: decideReadEvent (реальный D) — и граф-карта, и сырьё в impl свободны (ДР-1 снят)', () => { const raw = decideReadEvent({ ext: '.php', path: 'app/Services/LeadRouter.php', frozenPlan: true }); - expect(raw.gate.block).toBe(true); + expect(raw.gate.block).toBe(false); const map = decideReadEvent({ ext: '.json', path: '.claude/worktrees/graphify-spike/graphify-out/graph.json', frozenPlan: true }); expect(map.gate.block).toBe(false); });