feat: A - чтение под опечатанным планом свободно (ДР-1 снят в impl)

Под планом авторское чтение больше не блок: свой вывод, лог упавшего шага,
новый файл доступны. Чтение не двигает очередь шагов; impl-чтения логируются
с пометкой impl:true для ретро и не считаются во фронт-лоад порог. Секреты
держит отдельный read-path-deny. Свод зелёный: 4221 passed, 2 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-18 10:40:23 +03:00
parent abf2060328
commit e91aa021f0
5 changed files with 140 additions and 26 deletions
@@ -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.
+4 -5
View File
@@ -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 не зовёт (разговорный — не его зона)', () => {
+16 -5
View File
@@ -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)' };
}
+21 -12
View File
@@ -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 });
+4 -4
View File
@@ -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);
});