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:
@@ -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.
|
||||
@@ -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 не зовёт (разговорный — не его зона)', () => {
|
||||
|
||||
@@ -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)' };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user