docs(seal): sealed-plan production design spec + TDD implementation plan

Спека (ОДОБРЕНА владельцем, 2 адверсариальных разбора, 18 находок закрыты)
+ bite-sized TDD-план (7 задач) для код-предусловия флипа Фазы 8
(производство двух печатей: артефакт-решение + план-шаги).

design-only — прод-код и стена не затронуты. Эпик «роутер-наставник» М7.
This commit is contained in:
Дмитрий
2026-06-09 16:50:02 +03:00
parent 3811d24528
commit de39beb813
4 changed files with 912 additions and 60 deletions
+36
View File
@@ -1974,3 +1974,39 @@ monitorится
guillemets
mirror'ящий
plan'овский
# 2026-06-09 sealed-plan production — spec + plan terms
sealable
backstop
энфорсмент
энфорсмента
энфорсментом
энфорсятся
энфорсит
энфорсить
энфорсменту
неперсистнутый
сеаленный
делимитеры
делимитер
слаг
плейсхолдеров
резолвиться
резолюцию
когерентность
когерентен
распарсить
провалидировать
детерминированный
детерминированную
канонизирует
канонизируется
канонизировать
хеш
хеша
хеширует
хешировала
хеширования
хешируется
штампует
штампуется
+67 -60
View File
@@ -1,22 +1,46 @@
# Brain Status (auto-generated)
Last updated: 2026-05-30T13:11:39.164Z
Last updated: 2026-06-09T11:24:48.158Z
| Контролёр | Состояние | Детали |
|---|---|---|
| C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift |
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 1 week(s) ago |
| C4 Сигнальный статус | ✅ | This file (self-reference) |
| C5 Observer-coverage | ⚠️ | 752 episode(s) this month · Stop-hook + post-commit OK · 20 missed activation(s) — see /brain-retro |
| C5 Observer-coverage | | 765 episode(s) this month · Stop-hook + post-commit OK |
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
## Кто на посту (оборона М1–М6)
⚠️ **ПОСТ ПУСТОЙ** — не зарегистрированы: enforce-floor.mjs, enforce-supreme-gate.mjs, enforce-judge-gate.mjs, enforce-snapshot.mjs, enforce-floor-escape-consume.mjs, enforce-skill-journaler.mjs, enforce-verify-gate.mjs, enforce-criterion-gate.mjs (оборона НЕ подтверждена; SE-B/Δ8)
Судья М4: **inert** (inert $0 / shadow / floor-only / live-block)
| Машина / страж | Хук | Зарегистрирован |
|---|---|---|
| М1/М5 Нормативный страж (КАРТА/ЗАКОН) | `enforce-normative-content-rules.mjs` | ✅ |
| М5 Read-exfil страж | `enforce-read-path-deny.mjs` | ✅ |
| М5 Egress-exfil страж | `enforce-mcp-classification.mjs` | ✅ |
| enforce-coverage-verify.mjs | `enforce-coverage-verify.mjs` | ✅ |
| enforce-todowrite-skill-verifier.mjs | `enforce-todowrite-skill-verifier.mjs` | ✅ |
| М5 Пол (вето-до-плана / content-floor) | `enforce-floor.mjs` | 🔴 |
| М2 Стена (действие = шаг плана) | `enforce-supreme-gate.mjs` | 🔴 |
| М4 Судья (приёмка + надзор) | `enforce-judge-gate.mjs` | 🔴 |
| М6 Снимок (точка отката) | `enforce-snapshot.mjs` | 🔴 |
| М6 Escape владельца (законная дверь) | `enforce-floor-escape-consume.mjs` | 🔴 |
| М1 Журналер навыков | `enforce-skill-journaler.mjs` | 🔴 |
| enforce-verify-gate.mjs | `enforce-verify-gate.mjs` | 🔴 |
| enforce-criterion-gate.mjs | `enforce-criterion-gate.mjs` | 🔴 |
Недавние escape владельца: 0 · Недавние блоки: 0
## Метрики (информационные, не алерты)
- Observer evidence: 752 episodes this month, 0 observer_error markers, 186 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 613
- Last /brain-retro: 0 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 20. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
- Observer evidence: 765 episodes this month, 0 observer_error markers, 0 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 765
- Last /brain-retro: 13 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
## Метрики дисциплины
@@ -24,16 +48,14 @@ Baseline дисциплины роутера (этап 2 router discipline overh
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|---|---|---|---|
| analysis | 34 | 23.5% | 14.7% |
| planning | 25 | 12.0% | 16.0% |
| bugfix | 25 | 24.0% | 20.0% |
| feature | 19 | 10.5% | 0.0% |
| cleanup | 6 | 0.0% | 0.0% |
| refactor | 1 | 0.0% | 0.0% |
| planning | 108 | 9.3% | 18.5% |
| analysis | 35 | 5.7% | 0.0% |
| feature | 27 | 11.1% | 3.7% |
| bugfix | 26 | 15.4% | 19.2% |
Router step distribution: 1: 330, 2: 279, 3: 67, 5: 67
Router step distribution: 1: 363, 2: 287, 3: 18, 5: 81
Boundaries applied (ADR / границы): 76 of 743 эпизодов (10.2%).
Boundaries applied (ADR / границы): 8 of 749 эпизодов (1.1%).
## Активные многоэтапные проекты
@@ -45,22 +67,16 @@ Boundaries applied (ADR / границы): 76 of 743 эпизодов (10.2%).
## Длинные сессии
⚠️ Сегодня (2026-05-30 UTC) есть сессии с 50 ходов — корреляция с падением дисциплины роутинга (retro #5 candidate B).
| session_id | макс. ход | % regulated | последний эпизод |
|---|---|---|---|
| `52b2b52d` | 75 | 3% | 2026-05-30T11:45:39.213Z |
Long sessions correlate with discipline drift. Если % regulated просел в текущей сессии — рассмотри перезапуск.
Ни одной сессии с >50 ходов сегодня (UTC). ✅
## Стоимость месяца
| Компонент | Токены (in/out) | USD |
|---|---|---|
| Classifier (Sonnet 4.6) | 12550/86494 | $1.34 |
| Classifier (Sonnet 4.6) | 47009/200345 | $3.15 |
| Self-assessment (Sonnet 4.6) | 0/0 | $0.00 |
| Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 |
| **Итого** | | **$1.34** |
| **Итого** | | **$3.15** |
## Аномалии классификатора
@@ -73,50 +89,20 @@ Episodes since last run: 542 / threshold: 10
## Reviewer: субагент vs fallback
0 эпизодов проверено из 752.
0 эпизодов проверено из 765.
## Reviewer findings
Проверено: 372 эпизодов. **69 actionable** (wrong_skill + wrong_chain_order).
### error_root_cause
| cause | count |
|---|---:|
| n/a | 271 |
| wrong_skill | 55 |
| external_failure | 28 |
| wrong_chain_order | 14 |
| wrong_tool | 4 |
### Топ alternative_better
| recommended | count |
|---|---:|
| #19 | 18 |
| #25 | 15 |
| #34 | 8 |
| #18 | 8 |
| #33 | 3 |
### node_quality
| judgment | count |
|---|---:|
| disputable | 207 |
| correct | 120 |
| wrong_node | 40 |
| underkill | 3 |
| overkill | 2 |
(нет проверенных эпизодов в текущем периоде)
## Использование override-фраз
⚠️ Превышен порог override-использования сегодня (≥5/день)
| Фраза | За всё время | За сегодня |
|---|---|---|
| `recovery` | 2302 | 23 ⚠️ |
| `без скилов` | 507 | 40 ⚠️ |
| `recovery` | 2302 | 0 |
| `без скилов` | 507 | 0 |
| `ремонт инфраструктуры` | 331 | 0 |
| `срочно` | 225 | 0 |
| `memory dump` | 46 | 0 |
@@ -125,7 +111,28 @@ Episodes since last run: 542 / threshold: 10
## System Health
Долго работающих процессов нет (порог CPU > 1ч).
Топ-3 процессов с CPU > 1ч:
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3916 | MsMpEng | 3.13ч | NaNч |
| 1208 | svchost | 1.19ч | 0.0ч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
## Очередь обучения роутера
Очередь пуста — нет кандидатов на одобрение.
## Покрытие дверей
⚠️ Есть забытые двери (matcher: (хук НЕ зарегистрирован)).
Непокрытые мутирующие инструменты: Edit, Write, MultiEdit, NotebookEdit, Bash, Task, Skill
## Целостность журналов действий
Журналов сессий не найдено (`~/.claude/runtime/action-journal-*.jsonl`).
## Алерт-индикаторы
@@ -0,0 +1,574 @@
# Sealed-Plan Production Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (inline — субагенты ЗАПРЕЩЕНЫ владельцем, handoff §5). Steps use checkbox (`- [ ]`) syntax.
**Goal:** Построить рантайм-производство двух печатей (артефакт-решение + план-шаги), чтобы стена М2 имела что матчить — код-предусловие флипа Фазы 8.
**Architecture:** Два чистых парсера (спека→`{sections}`, план→`[{op,object,ref}]`) + seal-оркестрация, которую хук судьи зовёт на РЕАЛЬНОМ GO (spec-Write → печать артефакта; plan-Write → печать плана со штампом `artifact_id`). Печать привязана к `judged_hash` судимого объекта (одно представление течёт судья→печать). Персист атомарен, артефакт ДО плана. Закрыты 18 находок двух адверсариальных разборов.
**Tech Stack:** Node ESM, vitest (tools-only config), переиспользование `plan-lock.mjs` / `judge-seal-channel.mjs` / `receipt-sign.mjs` / `path-normalization.mjs` / `receipt-key-config.mjs`.
**Источник истины:** [docs/superpowers/specs/2026-06-09-sealed-plan-production-design.md](../specs/2026-06-09-sealed-plan-production-design.md) (ОДОБРЕНА). При расхождении — приоритет у спеки.
**Квирки (handoff §5/§7):** vitest через Bash одной абс.командой `node "<repo>\app\node_modules\vitest\vitest.mjs" run --root "<repo>\app" --config "<repo>\app\vitest.config.tools.mjs" <фильтр> --reporter dot`; тест-файлы **for-of + it()**, НЕ `it.each`; enforce-tdd-gate требует правки `*.test.mjs` ПЕРЕД правкой prod `*.mjs` в том же ходе (RED первым); коммит-не-пуш, git через PowerShell.
---
## File Structure
| Файл | Ответственность | Действие |
|---|---|---|
| `tools/artifact-from-spec.mjs` | спека markdown → `{sections, source_sha}` по якорям `{#id}` (P2-2) | Create |
| `tools/plan-steps-parse.mjs` | план markdown → `[{op,object,ref}]`; валидация fail-CLOSE; reject `op:'Task'` (VA-4); канон `object` (SE-5) | Create |
| `tools/seal-orchestration.mjs` | sealable-объекты + `judgedHashOf` (SD-1) + `sealArtifact`/`sealPlan` (real-GO, TOCTOU, carve-out, штамп `artifact_id`, fail-CLOSE) | Create |
| `tools/plan-lock.mjs` | +`judgeMode` в подписанную базу `freezePlan`; атомарный персист `saveFrozen*` (SE-4/VA-2/VA-3) | Modify |
| `tools/enforce-judge-gate.mjs` | в `runJudgeTurn`: spec-Write GO → `sealArtifact`; plan-Write GO → `sealPlan`; анти-инъекция продукта (P2-3) | Modify |
| `tools/*.test.mjs` (зеркальные) | тесты каждого модуля | Create |
`<repo>` = `c:\моя\проекты\портал crm\Документация`.
---
## Task 1: C1 — парсер артефакта из спеки (`artifact-from-spec.mjs`)
**Files:**
- Create: `tools/artifact-from-spec.mjs`
- Test: `tools/artifact-from-spec.test.mjs`
- [ ] **Step 1: Write the failing test**
```javascript
import { describe, it, expect } from 'vitest';
import { parseAnchoredSections, buildArtifact } from './artifact-from-spec.mjs';
describe('artifact-from-spec', () => {
const md = [
'# Spec',
'## Решение А {#dec-a}',
'Текст А.',
'## Решение Б {#dec-b}',
'Текст Б.',
].join('\n');
for (const [name, fn] of [['parses anchored sections', () => {
const { sections } = parseAnchoredSections(md);
expect(Object.keys(sections).sort()).toEqual(['dec-a', 'dec-b']);
expect(sections['dec-a']).toContain('Текст А.');
}]]) it(name, fn);
it('heading WITHOUT anchor is not a section key', () => {
const { sections } = parseAnchoredSections('## Без якоря\nx');
expect(Object.keys(sections)).toEqual([]);
});
it('empty-content section is dropped (fail-CLOSE)', () => {
const { sections } = parseAnchoredSections('## Пусто {#e}\n\n## Полно {#f}\ny');
expect(Object.keys(sections)).toEqual(['f']);
});
it('buildArtifact returns {sections, source_sha} deterministically', () => {
const a1 = buildArtifact(md); const a2 = buildArtifact(md);
expect(a1.source_sha).toEqual(a2.source_sha);
expect(typeof a1.source_sha).toBe('string');
expect(a1.sections['dec-a']).toBeTruthy();
});
});
```
- [ ] **Step 2: Run test → FAIL**
Run: `node "<repo>\app\node_modules\vitest\vitest.mjs" run --root "<repo>\app" --config "<repo>\app\vitest.config.tools.mjs" artifact-from-spec --reporter dot`
Expected: FAIL (module not found / not a function).
- [ ] **Step 3: Implement**
```javascript
#!/usr/bin/env node
/**
* artifact-from-spec (C1, §5) — спека markdown → {sections, source_sha}.
* ref = ЯВНЫЙ якорь `## ... {#id}` (P2-2, НЕ слаг заголовка). Пустой раздел отброшен
* (fail-CLOSE). source_sha — sha256 исходного текста (входит в sealable-объект, SD-1).
*/
import { createHash } from 'node:crypto';
const ANCHOR_RE = /^#{1,6}\s+.*\{#([a-zA-Z0-9._-]+)\}\s*$/;
/** markdown → { sections: { <anchor>: <содержимое раздела> } }. */
export function parseAnchoredSections(md) {
const lines = String(md == null ? '' : md).split(/\r?\n/);
const sections = {};
let curKey = null; let buf = [];
const flush = () => {
if (curKey) { const body = buf.join('\n').trim(); if (body) sections[curKey] = body; }
buf = [];
};
for (const line of lines) {
const m = line.match(ANCHOR_RE);
const isHeading = /^#{1,6}\s+/.test(line);
if (m) { flush(); curKey = m[1]; }
else if (isHeading) { flush(); curKey = null; } // заголовок без якоря закрывает раздел
else if (curKey) buf.push(line);
}
flush();
return { sections };
}
/** sealable-объект артефакта: { sections, source_sha }. */
export function buildArtifact(md) {
const { sections } = parseAnchoredSections(md);
const source_sha = createHash('sha256').update(String(md == null ? '' : md)).digest('hex');
return { sections, source_sha };
}
```
- [ ] **Step 4: Run test → PASS**
Run: тот же vitest-фильтр `artifact-from-spec`. Expected: PASS.
- [ ] **Step 5: Commit**
```
# PowerShell, из <repo>
git add tools/artifact-from-spec.mjs tools/artifact-from-spec.test.mjs
git commit -F .scratch/c1.txt # msg: "feat(seal): C1 artifact-from-spec parser (anchored sections, P2-2)"
```
---
## Task 2: C2 — парсер шагов плана (`plan-steps-parse.mjs`)
**Files:**
- Create: `tools/plan-steps-parse.mjs`
- Test: `tools/plan-steps-parse.test.mjs`
Контракт блока в плане: огороженный ` ```steps-json ` блок с JSON-массивом `[{op,object,ref}]`.
- [ ] **Step 1: Write the failing test**
```javascript
import { describe, it, expect } from 'vitest';
import { extractStepsBlock, parsePlanSteps } from './plan-steps-parse.mjs';
const good = [
'# Plan', '```steps-json',
'[{"op":"Edit","object":"app/Foo.php","ref":"dec-a"},',
' {"op":"Bash","object":"npm run build","ref":"dec-b"}]',
'```',
].join('\n');
describe('plan-steps-parse', () => {
it('extracts and parses valid steps', () => {
const steps = parsePlanSteps(good);
expect(steps).toHaveLength(2);
expect(steps[0]).toMatchObject({ op: 'Edit', ref: 'dec-a' });
});
it('canonicalizes file object to repo-relative POSIX (SE-5)', () => {
const md = '```steps-json\n[{"op":"Write","object":"app\\\\Bar.php","ref":"r"}]\n```';
expect(parsePlanSteps(md)[0].object).toBe('app/Bar.php');
});
it('rejects step without ref (SE-1) → throws', () => {
const md = '```steps-json\n[{"op":"Edit","object":"x"}]\n```';
expect(() => parsePlanSteps(md)).toThrow(/ref/);
});
it('rejects op:Task (VA-4, субагенты запрещены) → throws', () => {
const md = '```steps-json\n[{"op":"Task","object":"coder","ref":"r"}]\n```';
expect(() => parsePlanSteps(md)).toThrow(/Task/);
});
it('missing block → throws (fail-CLOSE)', () => {
expect(() => parsePlanSteps('# Plan no block')).toThrow();
});
it('non-JSON block → throws', () => {
expect(() => parsePlanSteps('```steps-json\nnot json\n```')).toThrow();
});
});
```
- [ ] **Step 2: Run test → FAIL**
Run: vitest-фильтр `plan-steps-parse`. Expected: FAIL.
- [ ] **Step 3: Implement**
```javascript
#!/usr/bin/env node
/**
* plan-steps-parse (C2, §5) — план markdown → [{op,object,ref}]. fail-CLOSE на любой брак.
* Reject op:'Task' (VA-4). Канон object: файлы → repo-relative POSIX (SE-5). ref обязателен (SE-1).
*/
import { pathNormalize } from './path-normalization.mjs';
const BLOCK_RE = /```steps-json\s*\n([\s\S]*?)\n```/;
const FILE_OPS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
/** Достать содержимое блока ```steps-json``` или null. */
export function extractStepsBlock(md) {
const m = String(md == null ? '' : md).match(BLOCK_RE);
return m ? m[1] : null;
}
function canonObject(op, object) {
const s = String(object ?? '');
if (FILE_OPS.has(op)) { try { return pathNormalize(s).replace(/\\/g, '/'); } catch { return s.replace(/\\/g, '/'); } }
return s;
}
/** Распарсить + провалидировать шаги. Бросает на брак (fail-CLOSE). */
export function parsePlanSteps(md) {
const raw = extractStepsBlock(md);
if (raw == null) throw new Error('plan-steps-parse: нет блока ```steps-json``` (fail-CLOSE)');
let arr;
try { arr = JSON.parse(raw); } catch { throw new Error('plan-steps-parse: блок не валидный JSON (fail-CLOSE)'); }
if (!Array.isArray(arr) || arr.length === 0) throw new Error('plan-steps-parse: ожидался непустой массив');
return arr.map((s, i) => {
if (!s || typeof s !== 'object') throw new Error(`plan-steps-parse: шаг ${i} не объект`);
const op = String(s.op || '');
const object = String(s.object ?? '');
const ref = String(s.ref ?? '');
if (!op) throw new Error(`plan-steps-parse: шаг ${i} без op`);
if (op === 'Task') throw new Error('plan-steps-parse: op:Task запрещён (субагенты, VA-4)');
if (!object.trim()) throw new Error(`plan-steps-parse: шаг ${i} без object`);
if (!ref.trim()) throw new Error(`plan-steps-parse: шаг ${i} без ref (closed-door, SE-1)`);
return { op, object: canonObject(op, object), ref };
});
}
```
- [ ] **Step 4: Run test → PASS** (vitest-фильтр `plan-steps-parse`).
- [ ] **Step 5: Commit**
```
git add tools/plan-steps-parse.mjs tools/plan-steps-parse.test.mjs
git commit -F .scratch/c2.txt # "feat(seal): C2 plan-steps parser (fail-CLOSE, no-Task VA-4, canon SE-5)"
```
---
## Task 3: plan-lock — judge_mode в подписи плана + атомарный персист
**Files:**
- Modify: `tools/plan-lock.mjs` (`freezePlan`, `saveFrozenArtifact`, `saveFrozenPlan`)
- Test: `tools/plan-lock.test.mjs` (добавить кейсы — Read файла перед правкой существующего теста, harness)
- [ ] **Step 1: Write the failing test (добавить в plan-lock.test.mjs)**
```javascript
it('freezePlan carries judge_mode in signed base (VA-2)', () => {
const key = 'k';
const p = freezePlan({ steps: [{ op: 'Edit', object: 'a', ref: 'r' }], artifactId: 'aid', judgeMode: 'shadow', key });
expect(p.judge_mode).toBe('shadow');
expect(verifyFrozenPlan(p, key)).toBe(true);
const tampered = { ...p, judge_mode: 'live-block' };
expect(verifyFrozenPlan(tampered, key)).toBe(false); // judge_mode в подписи
});
it('saveFrozenPlan writes atomically via temp+rename (SE-4)', () => {
const calls = [];
const fsImpl = { writeFileSync: (p, d) => calls.push(['write', p]), renameSync: (a, b) => calls.push(['rename', a, b]) };
saveFrozenPlan({ plan: { plan_id: 'x' }, sessionId: 's', runtimeDir: '/r', fsImpl });
expect(calls.some(c => c[0] === 'rename')).toBe(true); // прошёл через rename
});
```
- [ ] **Step 2: Run → FAIL** (vitest-фильтр `plan-lock`). Expected: FAIL (judge_mode/rename отсутствуют).
- [ ] **Step 3: Implement (правки в plan-lock.mjs)**
`freezePlan` — добавить `judgeMode`:
```javascript
export function freezePlan({ steps, artifactId = null, judgeMode = null, key, nowMs }) {
const sealedSteps = withCriterionIds(steps);
const id = planId(sealedSteps);
const base = { plan_id: id, artifact_id: artifactId, judge_mode: judgeMode,
frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps: sealedSteps };
return { ...base, sig: signPayload(base, key, RECEIPT_DOMAINS.FROZEN_PLAN) };
}
```
Атомарный персист — общий помощник + замена в обоих `save*`:
```javascript
function writeAtomic(path, data, fsImpl) {
const tmp = `${path}.tmp-${planId([path, data.length])}`;
fsImpl.writeFileSync(tmp, data);
fsImpl.renameSync(tmp, path);
}
export function saveFrozenPlan({ plan, sessionId, runtimeDir, fsImpl = fsDefault }) {
writeAtomic(planPath(runtimeDir, sessionId), JSON.stringify(plan), fsImpl);
}
export function saveFrozenArtifact({ artifact, sessionId, runtimeDir, fsImpl = fsDefault }) {
writeAtomic(artifactPath(runtimeDir, sessionId), JSON.stringify(artifact), fsImpl);
}
```
(NB: `verifyFrozenPlan` не трогаем — `verifyReceipt` пересчитывает base из всех полей кроме sig, judge_mode попадёт автоматически. Существующие печати без judge_mode: base без поля → verify по-прежнему ок.)
- [ ] **Step 4: Run → PASS** (vitest-фильтр `plan-lock`, регрессия существующих кейсов зелёная).
- [ ] **Step 5: Commit**
```
git add tools/plan-lock.mjs tools/plan-lock.test.mjs
git commit -F .scratch/t3.txt # "feat(seal): plan-lock judge_mode-in-sig (VA-2) + atomic persist (SE-4)"
```
---
## Task 4: seal-orchestration — sealable + judged_hash + sealArtifact/sealPlan
**Files:**
- Create: `tools/seal-orchestration.mjs`
- Test: `tools/seal-orchestration.test.mjs`
- [ ] **Step 1: Write the failing test**
```javascript
import { describe, it, expect } from 'vitest';
import { sealableArtifact, sealablePlan, judgedHashOf, sealArtifact, sealPlan } from './seal-orchestration.mjs';
import { contentHash } from './judge-seal-channel.mjs';
const specMd = '## Реш {#dec-a}\nтекст';
const planMd = '```steps-json\n[{"op":"Edit","object":"app/Foo.php","ref":"dec-a"}]\n```';
const KEY = 'k';
const goVerdict = (obj) => ({ wired: true, decision: 'GO', judged_hash: judgedHashOf(obj), verdict_id: 'v1' });
describe('seal-orchestration', () => {
it('judgedHashOf == contentHash of same sealable object (SD-1)', () => {
const a = sealableArtifact(specMd);
expect(judgedHashOf(a)).toBe(contentHash(a));
});
it('sealArtifact on real GO → sealed, verifiable', () => {
const a = sealableArtifact(specMd);
const r = sealArtifact({ md: specMd, verdict: goVerdict(a), key: KEY, judgeMode: 'live-block' });
expect(r.sealed).toBe(true);
expect(r.seal.sections['dec-a']).toBeTruthy();
expect(r.seal.judge_mode).toBe('live-block');
});
it('sealArtifact NOT sealed on degraded GO (wired:false, SE-3)', () => {
const a = sealableArtifact(specMd);
const r = sealArtifact({ md: specMd, verdict: { wired: false, decision: 'GO' }, key: KEY, judgeMode: 'shadow' });
expect(r.sealed).toBe(false);
});
it('sealArtifact NOT sealed on judged_hash mismatch (SD-1/TOCTOU)', () => {
const r = sealArtifact({ md: specMd, verdict: { wired: true, decision: 'GO', judged_hash: 'WRONG' }, key: KEY, judgeMode: 'live-block' });
expect(r.sealed).toBe(false);
});
it('sealPlan stamps artifact_id from current artifact (SD-3)', () => {
const planObj = sealablePlan(planMd);
const r = sealPlan({ md: planMd, currentArtifact: { artifact_id: 'AID' }, verdict: goVerdict(planObj), key: KEY, judgeMode: 'live-block' });
expect(r.sealed).toBe(true);
expect(r.seal.artifact_id).toBe('AID');
expect(r.seal.judge_mode).toBe('live-block');
});
it('sealPlan fail-CLOSE without current artifact (VA-1/SD-3)', () => {
const planObj = sealablePlan(planMd);
const r = sealPlan({ md: planMd, currentArtifact: null, verdict: goVerdict(planObj), key: KEY, judgeMode: 'live-block' });
expect(r.sealed).toBe(false);
});
});
```
- [ ] **Step 2: Run → FAIL** (vitest-фильтр `seal-orchestration`).
- [ ] **Step 3: Implement**
```javascript
#!/usr/bin/env node
/**
* seal-orchestration (C3-C6, §5) — собрать sealable-объект, сверить judged_hash (SD-1),
* поставить печать ТОЛЬКО на реальном GO (SE-3), со штампом artifact_id (SD-3) и judge_mode.
* Печать-движок инъектируется (в проде freezeArtifact/freezePlan Машины 2).
*/
import { buildArtifact } from './artifact-from-spec.mjs';
import { parsePlanSteps } from './plan-steps-parse.mjs';
import { contentHash, sealOnApproval } from './judge-seal-channel.mjs';
import { freezeArtifact, freezePlan } from './plan-lock.mjs';
export function sealableArtifact(md) { return buildArtifact(md); } // {sections, source_sha}
export function sealablePlan(md) { return { steps: parsePlanSteps(md) }; } // {steps:[{op,object,ref}]}
export function judgedHashOf(obj) { return contentHash(obj); }
function isRealGo(v) { return !!(v && v.wired === true && v.decision === 'GO'); }
/** Печать артефакта на реальном GO (carve-out деньги/тупик — через sealOnApproval). */
export function sealArtifact({ md, verdict, key, judgeMode, nowMs, freezeImpl = freezeArtifact }) {
if (!isRealGo(verdict)) return { sealed: false, reason: 'нет реального GO (SE-3)' };
const artifact = sealableArtifact(md);
if (verdict.judged_hash !== judgedHashOf(artifact)) return { sealed: false, reason: 'judged_hash mismatch (SD-1/TOCTOU)' };
return sealOnApproval({ artifact: { ...artifact, judge_mode: judgeMode }, verdict, key, nowMs, freezeImpl });
}
/** Печать плана: штамп artifact_id текущего артефакта (SD-3); нет артефакта → fail-CLOSE (VA-1). */
export function sealPlan({ md, currentArtifact, verdict, key, judgeMode, nowMs, freezeImpl = freezePlan }) {
if (!isRealGo(verdict)) return { sealed: false, reason: 'нет реального GO (SE-3)' };
if (!currentArtifact || !currentArtifact.artifact_id) return { sealed: false, reason: 'нет текущего артефакта (VA-1/SD-3)' };
let planObj;
try { planObj = sealablePlan(md); } catch (e) { return { sealed: false, reason: `парс плана fail-CLOSE: ${e.message}` }; }
if (verdict.judged_hash !== judgedHashOf(planObj)) return { sealed: false, reason: 'judged_hash mismatch (SD-1/TOCTOU)' };
const seal = freezeImpl({ steps: planObj.steps, artifactId: currentArtifact.artifact_id, judgeMode, key, nowMs });
return { sealed: true, seal };
}
```
- [ ] **Step 4: Run → PASS** (vitest-фильтр `seal-orchestration`).
- [ ] **Step 5: Commit**
```
git add tools/seal-orchestration.mjs tools/seal-orchestration.test.mjs
git commit -F .scratch/t4.txt # "feat(seal): orchestration sealArtifact/sealPlan (SD-1/SD-3/SE-3/VA-1)"
```
---
## Task 5: Wire в enforce-judge-gate — печать на реальном GO + анти-инъекция (P2-3)
**Files:**
- Modify: `tools/enforce-judge-gate.mjs` (новый `SPEC_PATH_RE`, ветвление продукта; в `runJudgeTurn` — печать на `wired` GO)
- Test: `tools/enforce-judge-gate.test.mjs` (Read перед правкой существующего теста, harness)
**Принцип P2-3:** продукт на суд (`extractGate2Product` / новый `extractSpecProduct`) подаётся через существующую анти-инъекцию судьи (делимитеры/pre-filter из `judge-engine`/`buildJudgePrompt`); `parseJudgeResponse` уже fail-closed. Печать ставится из чистой seal-оркестрации (Task 4), НЕ из текста контроллера.
- [ ] **Step 1: Write the failing test (добавить)**
```javascript
import { sealOnWiredGo, SPEC_PATH_RE } from './enforce-judge-gate.mjs';
it('SPEC_PATH_RE matches docs/superpowers/specs/*.md', () => {
expect(SPEC_PATH_RE.test('docs/superpowers/specs/2026-06-09-x-design.md')).toBe(true);
expect(SPEC_PATH_RE.test('docs/superpowers/plans/x.md')).toBe(false);
});
it('sealOnWiredGo: spec-Write + real GO → sealArtifact invoked', () => {
const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x-design.md', content: '## R {#r}\nt' } };
const calls = [];
const r = sealOnWiredGo({ event, verdict: { wired: true, decision: 'GO' }, judgeMode: 'shadow',
deps: { sealArtifact: (a) => { calls.push(['artifact', a.judgeMode]); return { sealed: true }; }, persistArtifact: () => {} } });
expect(calls[0]).toEqual(['artifact', 'shadow']);
});
it('sealOnWiredGo: degraded GO (wired:false) → no seal call', () => {
const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x-design.md', content: 'x' } };
const calls = [];
sealOnWiredGo({ event, verdict: { wired: false, decision: 'GO' }, judgeMode: 'shadow',
deps: { sealArtifact: () => { calls.push('a'); return {}; } } });
expect(calls).toEqual([]);
});
```
- [ ] **Step 2: Run → FAIL** (vitest-фильтр `enforce-judge-gate`).
- [ ] **Step 3: Implement (в enforce-judge-gate.mjs)**
```javascript
export const SPEC_PATH_RE = /(^|[/\\])docs[/\\]superpowers[/\\]specs[/\\][^/\\]+\.md$/i;
/**
* Печать на ВЫЧИСЛЕННОМ реальном GO (wired===true). spec-Write → sealArtifact;
* plan-Write → sealPlan (currentArtifact из загрузки). Persist инъектируется.
* Чистая (без stdin/exit) — для теста. Возвращает {sealed?, kind?}.
*/
export function sealOnWiredGo({ event, verdict, judgeMode, deps = {} }) {
if (!(verdict && verdict.wired === true && verdict.decision === 'GO')) return { sealed: false };
const fp = String(event?.tool_input?.file_path || '');
const content = String(event?.tool_input?.content ?? '');
const key = deps.key !== undefined ? deps.key : (deps.resolveReceiptKey ? deps.resolveReceiptKey() : null);
if (SPEC_PATH_RE.test(fp)) {
const r = (deps.sealArtifact)({ md: content, verdict, key, judgeMode });
if (r.sealed && deps.persistArtifact) deps.persistArtifact(r.seal);
return { sealed: r.sealed, kind: 'artifact' };
}
if (PLAN_PATH_RE.test(fp)) {
const cur = deps.loadCurrentArtifact ? deps.loadCurrentArtifact() : null;
const r = (deps.sealPlan)({ md: content, currentArtifact: cur, verdict, key, judgeMode });
if (r.sealed && deps.persistPlan) deps.persistPlan(r.seal);
return { sealed: r.sealed, kind: 'plan' };
}
return { sealed: false };
}
```
В `runJudgeTurn` после `verdict = await runJudgeGate(...)`, если `verdict.wired` — вызвать `sealOnWiredGo({ event, verdict, judgeMode: mode, deps: <прод-deps> })` (прод-deps: `sealArtifact`/`sealPlan` из seal-orchestration; `resolveReceiptKey`; `loadFrozenArtifact`; `saveFrozenArtifact`/`saveFrozenPlan` атомарные — артефакт ДО плана по порядку событий, VA-3). Печать best-effort, НЕ влияет на block-решение (печать = одобрение, не энфорсмент).
**judged_hash в вердикте (C5):** в `runJudgeGate`/`callJudgeModel` продукт, отданный судье, = sealable-объект (`sealableArtifact`/`sealablePlan`), и `verdict.judged_hash = judgedHashOf(тот же объект)` (одно представление, SD-1). Добавить в `runJudgeGate` присвоение `judged_hash` перед возвратом.
- [ ] **Step 4: Run → PASS** (vitest-фильтр `enforce-judge-gate`, регрессия зелёная).
- [ ] **Step 5: Commit**
```
git add tools/enforce-judge-gate.mjs tools/enforce-judge-gate.test.mjs
git commit -F .scratch/t5.txt # "feat(seal): wire seal-on-wired-GO into judge-gate (P2-3 anti-inject, SD-1 judged_hash)"
```
---
## Task 6: Интеграционный тест — печать → стена пускает/блокирует/closed-door
**Files:**
- Test: `tools/seal-to-wall.integration.test.mjs` (Create)
- [ ] **Step 1: Write the failing test**
```javascript
import { describe, it, expect } from 'vitest';
import { sealArtifact, sealPlan, judgedHashOf, sealableArtifact, sealablePlan } from './seal-orchestration.mjs';
import { decideMode } from './enforce-supreme-gate.mjs';
const KEY = 'k';
const specMd = '## Реш {#dec-a}\nтекст';
const planMd = '```steps-json\n[{"op":"Edit","object":"app/Foo.php","ref":"dec-a"}]\n```';
describe('seal → wall integration', () => {
it('matching edit allowed; non-matching blocked; bad ref blocked (closed-door)', () => {
const aObj = sealableArtifact(specMd);
const aRes = sealArtifact({ md: specMd, verdict: { wired: true, decision: 'GO', judged_hash: judgedHashOf(aObj), verdict_id: 'v' }, key: KEY, judgeMode: 'live-block' });
const pObj = sealablePlan(planMd);
const pRes = sealPlan({ md: planMd, currentArtifact: aRes.seal, verdict: { wired: true, decision: 'GO', judged_hash: judgedHashOf(pObj), verdict_id: 'v' }, key: KEY, judgeMode: 'live-block' });
const base = { frozenPlan: pRes.seal, frozenArtifact: aRes.seal, key: KEY, stepPtr: 0 };
// совпавшая правка
expect(decideMode({ ...base, toolUse: { name: 'Edit', input: { file_path: 'app/Foo.php' } } }).decision).toBe('allow');
// несовпавшая
expect(decideMode({ ...base, toolUse: { name: 'Edit', input: { file_path: 'app/Other.php' } } }).decision).toBe('block');
});
});
```
- [ ] **Step 2: Run → FAIL/наблюдать** — убедиться, что тест краснеет до согласования форм (если стена ждёт `step.ref` резолюцию, шаг несёт `ref:'dec-a'`, артефакт имеет `sections['dec-a']` → closed-door проходит). Подогнать формы при необходимости.
- [ ] **Step 3: Зафиксировать форму** — если decideMode требует `frozenArtifact.sections`/`artifact_id` совпадений, проверить, что `aRes.seal.artifact_id` === `pRes.seal.artifact_id`. Доработок prod-кода быть не должно (Task 1-5 уже дают формы); если тест вскрыл рассинхрон — это находка, чинить по systematic-debugging.
- [ ] **Step 4: Run → PASS**.
- [ ] **Step 5: Commit**
```
git add tools/seal-to-wall.integration.test.mjs
git commit -F .scratch/t6.txt # "test(seal): integration seal → supreme-gate allow/block/closed-door"
```
---
## Task 7: Гейт закрытия — регрессия + финал
- [ ] **Step 1:** Полная регрессия tools-only:
Run: `node "<repo>\app\node_modules\vitest\vitest.mjs" run --root "<repo>\app" --config "<repo>\app\vitest.config.tools.mjs" --reporter dot`
Expected: ≥ baseline (текущий main baseline ~3398) + новые кейсы Task 1-6, **0 регрессий**.
- [ ] **Step 2:** `verification-before-completion` — сверить, что каждый компонент C1-C6 спеки реализован и каждый инвариант §11 покрыт тестом.
- [ ] **Step 3:** Финальный коммит-сводка (если нужен) + обновить §13 спеки (реализация done). **Push — НЕ делать** (только по слову «пуш»).
---
## Self-Review (план против спеки)
- **Покрытие спеки:** C1→Task1, C2→Task2, C3/C4/C5/C6→Task3-5, closed-door→Task2(ref)+Task6, инварианты §11 (SE-1..SD-3, P2-1..3)→Task2-6. P2-1 (несущая зависимость runtime-страж) — проверяется на Фазе 8 (порядок регистрации), не код-задача здесь; P2-3 анти-инъекция — Task5 (переиспользование судьи).
- **Плейсхолдеры:** нет — реальный код в каждом шаге.
- **Согласованность типов:** `judgedHashOf`/`sealable*` (Task4) ↔ `judged_hash` в вердикте (Task5); `freezePlan({steps,artifactId,judgeMode,key})` (Task3) ↔ вызов в `sealPlan` (Task4) ↔ интеграция (Task6) — совпадают.
- **NB scope:** анти-инъекция (P2-3) опирается на существующий слой судьи — Task5 переиспользует, не строит заново; если слоя нет в коде судьи — отдельная под-задача (вскроется на Task5 audit-context-building).
@@ -0,0 +1,235 @@
# Дизайн: связка печати (sealed-plan production) — М7 Фаза 8, код-предусловие
**Дата:** 2026-06-09 · **Кодовая фраза эпика:** «роутер-наставник» · **Статус:** ЧЕРНОВИК (ждёт двух адверсариальных проходов + ревью владельца).
**Статус:** ✅ ОДОБРЕНА владельцем 2026-06-09 (два адверсариальных разбора пройдены, 18 находок закрыты). Готова к `writing-plans` по слову «собирай».
**Тип:** design-only. Кода нет. Реализация — отдельный заход по слову владельца «собирай».
**Цепочка дизайна (одобрена владельцем):** `brainstorming``audit-context-building` → черновик → **разбор #1 + разбор #2** (`sharp-edges``variant-analysis``systematic-debugging`, оба по написанной спеке) → spec self-review → ревью владельца. `writing-plans` — следующая фаза.
---
## 1. Цель и почему это предусловие флипа
Сухой прогон по коду (09.06.2026) вскрыл: **печать плана в рантайме не производится ни одним запускаемым хуком.** Канал печати [judge-seal-channel.mjs](../../../tools/judge-seal-channel.mjs) построен, но хук судьи [enforce-judge-gate.mjs:173](../../../tools/enforce-judge-gate.mjs#L173) (`runJudgeTurn`) на GO только **логирует/блокирует — печать не ставит**. Структурные шаги `{op, object}` из markdown-плана в рантайме никто не извлекает. Стена М2 ([enforce-supreme-gate.mjs:134](../../../tools/enforce-supreme-gate.mjs#L134)) читает `frozen-plan-<session>.json` / `frozen-artifact-<session>.json`, но **их некому записать**.
**Следствие:** регистрация стены М2 без этой связки = блок **любой** правки (опечатанного плана не появится → default-deny, пройдут только seed/observe-only). Поэтому связка печати — **код-предусловие** Фазы 8 (флипа): без неё стену включать нельзя.
Эта спека закрывает производящий путь: **как одобренное решение и план становятся опечатанными артефактом+планом, которые стена матчит.**
---
## 2. Решения владельца (зафиксированы в брейнсторме 09.06.2026)
1. **Отклонение = полный пересмотр плана.** Правка вне опечатанного плана → стена блок → план **переписывается целиком** → судья судит весь план заново → новая печать плана. Роутер задачи (М3) **не** пере-запускается (задача та же). Изменилось само решение → пере-печатывается и артефакт.
2. **Две раздельные печати.** Печать №1 = РЕШЕНИЕ (артефакт, спека). Печать №2 = ПЛАН (шаги), привязан к версии артефакта (`artifact_id`). «Договорились ЧТО» отдельно от «договорились О ШАГАХ».
3. **Closed-door в v1.** Шаги плана ссылаются (`ref`) на разделы решения; стена требует резолюцию ссылки в опечатанном артефакте ([enforce-supreme-gate.mjs:159](../../../tools/enforce-supreme-gate.mjs#L159)).
4. **Per-tool надзор судьи А2 — НЕ в v1.** Судья судит изменение решения/плана, не каждую правку.
---
## 3. Модель: две печати
```
Разговор/брейнсторм
│ (judge GO на записи spec)
Печать №1: frozen-artifact ← { sections: {<ref>: ...}, artifact_id, frozen_at, sig }
План со встроенными шагами (каждый шаг → {op, object, ref}); печать ШТАМПУЕТ artifact_id текущего артефакта (Claude его не пишет — SD-3)
│ (judge GO на записи плана)
Печать №2: frozen-plan ← { plan_id, artifact_id, steps:[{op,object,ref,criterion_id}], sig }
Реализация: стена М2 на КАЖДУЮ правку:
обе печати есть? версии совпадают? шаг матчит? ref резолвится в раздел артефакта? → allow, иначе block
Отклонение → блок → план целиком переписан → judge заново → новая печать №2
Решение изменилось → judge заново на spec → новая печать №1 → план обязан пере-печататься под новую версию
```
---
## 4. Форматы данных
### 4.1 Артефакт (решение)
Выводится из опечатываемой спеки/решения. Форма (вход `freezeArtifact`, [plan-lock.mjs:171](../../../tools/plan-lock.mjs#L171)):
```
{ sections: { "<ref-1>": <непустое>, "<ref-2>": <непустое>, ... } }
```
`sections` — карта `ref → содержимое` разделов решения. `ref` — стабильный якорь раздела (`{#id}`, см. §4.2 + P2-2; НЕ слаг заголовка). После `freezeArtifact`: +`artifact_id` (sha256 канон-содержания), +`frozen_at`, +`sig` (домен `FROZEN_ARTIFACT`). `refResolves` ([plan-lock.mjs:201](../../../tools/plan-lock.mjs#L201)) проверяет наличие ключа в `sections`.
### 4.2 Структурные шаги плана
Встроенный машиночитаемый блок в `docs/superpowers/plans/*.md` (детерминированный парсер, без LLM):
```
{ op: "Edit"|"Write"|"Bash"|"Skill"|..., object: "<путь|команда|skill>", ref: "<ref раздела артефакта>" }
```
`op`/`object` матчатся стеной через `actionMatchesStep` ([plan-lock.mjs:72](../../../tools/plan-lock.mjs#L72)) (op И object точно; файлы через `pathNormalize`, Bash через `normCommand`). `ref` обязателен в v1 (closed-door); `ref` = **явный стабильный якорь** раздела решения (конвенция `{#id}` в спеке, P2-2 — НЕ слаг заголовка: правка прозы заголовка не должна ломать ключ). Парсер C1 строит `sections` по якорям, парсер C2 требует, чтобы `ref` шага совпал с ключом-якорем из `sections`. `criterion_id` проставляется автоматически при печати (`withCriterionIds`, [plan-lock.mjs:34](../../../tools/plan-lock.mjs#L34)).
**Источник блока (решение дизайна):** Claude перечисляет шаги во встроенном блоке плана. Детерминированно (Claude обязуется ровно к тому, что тронет); прозу не парсим (принцип «не угадывать», как `actionMatchesStep`).
### 4.3 Печати (seals)
- `frozen-artifact-<session>.json`: `{ sections, artifact_id, frozen_at, sig }`. Персист `saveFrozenArtifact` ([plan-lock.mjs:188](../../../tools/plan-lock.mjs#L188)).
- `frozen-plan-<session>.json`: `{ plan_id, artifact_id, frozen_at, steps, sig }`. Персист `saveFrozenPlan` ([plan-lock.mjs:147](../../../tools/plan-lock.mjs#L147)).
- Оба ключом `router-mentor-receipts` (keychain, Claude'у недоступен; `resolveReceiptKey`).
---
## 5. Компоненты и где они живут
| # | Компонент | Где | Что делает |
|---|---|---|---|
| C1 | **spec-парсер артефакта** | новый `tools/artifact-from-spec.mjs` | markdown спеки `## ...``{ sections: {ref: content} }` детерминированно |
| C2 | **plan-парсер шагов** | новый `tools/plan-steps-parse.mjs` | встроенный блок плана → `[{op,object,ref}]`; валидация формы (fail-CLOSE на брак) |
| C3 | **триггер артефакта** | правка `enforce-judge-gate.mjs` | детект записи `specs/*.md` (новый `SPEC_PATH_RE`) → продукт на суд; на **реальном GO** → seal-канал → `freezeArtifact``saveFrozenArtifact` |
| C4 | **триггер плана** | правка `enforce-judge-gate.mjs` | существующий Гейт-2 (`PLAN_PATH_RE`) + на **реальном GO** → парс шагов (C2) + `artifact_id` из текущего артефакта → `freezePlan``saveFrozenPlan` |
| C5 | **judged_hash в вердикте** | правка `runJudgeGate`/seal-оркестрация | при суде считать `judged_hash = contentHash(продукт)` → seal-канал сверяет TOCTOU ([judge-seal-channel.mjs:59](../../../tools/judge-seal-channel.mjs#L59)) |
| C6 | **seal-оркестрация** | новый `tools/seal-orchestration.mjs` (или в judge-gate) | `sealOnApproval` ([judge-seal-channel.mjs:48](../../../tools/judge-seal-channel.mjs#L48)) с injected `freezeImpl` = `freezeArtifact`/`freezePlan`; персист Node-fs |
**Печать ставит только хук судьи** на реальном GO. Путь «контроллер сам зовёт freeze» не строится (ключ недоступен). Персист — Node-fs (не Write-инструментом → `runtime-write-deny` не мешает, как `journalAppend`/`logVerdictLine`).
---
## 6. Поток печати (когда именно ставится)
Печать привязана к **реальному** вердикту судьи, отдельно от решения блокировать:
- **inert** (судья выключен): суда нет → GO нет → печати нет.
- **shadow** (судит, логирует, не блокирует): на GO **печать ставится** (видно на доске, стена при этом может быть не зарегистрирована → печать наблюдаема, но не энфорсится — идеально для обкатки).
- **live-block**: GO → печать + allow; NO-GO → блок, печати нет.
- **degraded/neutral GO** (`wired:false` — нет ключа/транспорт упал): **печать НЕ ставится** (это не настоящее одобрение).
Carve-out §6 ([judge-seal-channel.mjs:33](../../../tools/judge-seal-channel.mjs#L33)): деньги/тупик → печать ставит **владелец**, не судья.
---
## 7. Closed-door v1
- Артефакт несёт `sections` (карта `ref → раздел`). `ref` шага плана ОБЯЗАН резолвиться в `sections` опечатанного артефакта ([enforce-supreme-gate.mjs:159](../../../tools/enforce-supreme-gate.mjs#L159)).
- Шаг с `ref`, но без опечатанного артефакта / без раздела → стена блок («вернись в разговор, пере-печатай»).
- Парсер C1 строит `sections`; парсер C2 требует `ref` в каждом шаге (fail-CLOSE на шаг без `ref` в v1).
---
## 8. Поток отклонения (решение №1 владельца)
1. Правка не матчит шаг → стена блок с диагностикой («ожидался шаг N: …»).
2. Claude **переписывает план как ОСТАТОК работы** от точки отклонения (новый встроенный блок шагов; уже сделанное не перечисляется — SD-2, иначе стена ждёт повтора при сбросе указателя в 0).
3. Запись плана → судья судит **весь** план заново (Гейт-2) → на GO **новая печать №2** (новый `plan_id`, тот же `artifact_id`, если решение не менялось).
4. Указатель шага сбрасывается на новый `plan_id` (`resolveStepPtr` R-27, [enforce-supreme-gate.mjs:83](../../../tools/enforce-supreme-gate.mjs#L83)).
5. Если менялось **решение** → сперва пере-печать артефакта (новый `artifact_id`) → план обязан пере-печататься под новую версию (стена требует совпадения версий).
Роутер задачи (М3) не пере-запускается (задача та же — подтверждено владельцем).
---
## 9. Вне scope v1 (YAGNI)
- **Per-tool надзор судьи А2** на каждую правку — НЕ строим (судья на изменении решения/плана).
- **Авто-извлечение шагов из прозы** — НЕ строим (только встроенный блок).
- **Мульти-артефактные планы** (план, опирающийся на >1 решение) — один `artifact_id` на план.
- **Owner-seal UI** сверх существующего carve-out — используем существующий `requiresOwnerSeal`.
---
## 10. Интеграция с существующим кодом (anchors)
- Не трогаем: движок судьи (`judge-engine`/`judge-orchestrator`), пол М5, escape М6, стена decide/decideMode (только **читают** печати — они уже готовы их принять).
- Правим: `enforce-judge-gate.mjs` (триггеры C3/C4/C5/C6 в `runJudgeTurn` на реальном GO).
- Добавляем: `artifact-from-spec.mjs` (C1), `plan-steps-parse.mjs` (C2), seal-оркестрация (C6).
- Переиспользуем: `freezeArtifact`/`freezePlan`/`saveFrozen*` (plan-lock), `sealOnApproval`/`contentHash` (seal-channel), `resolveReceiptKey` (keychain).
---
## 11. Стратегия тестирования (TDD-инварианты — для writing-plans)
- C1: спека `## A`/`## B``sections` с ключами `A`/`B`; пустой раздел отброшен (fail-CLOSE).
- C2: валидный блок → шаги; шаг без `op`/`object`/`ref` → парсер fail-CLOSE (не «пустой джокер», ср. F5 [plan-lock.mjs:77](../../../tools/plan-lock.mjs#L77)).
- C3: запись `specs/*.md` + реальный GO → `frozen-artifact` записан, печать валидна (`verifyFrozenArtifact`).
- C4: запись `plans/*.md` + реальный GO → `frozen-plan` записан, `artifact_id` = текущего артефакта.
- C5: подмена продукта после суда (TOCTOU) → `judged_hash` не сходится → печать НЕ ставится.
- Поток: артефакт+план опечатаны → стена пускает совпавшую правку, блокирует несовпавшую, блокирует шаг с неразрешимым `ref` (closed-door).
- Печать-режим: shadow GO → печать ставится; degraded GO (нет ключа) → печать НЕ ставится; NO-GO → нет печати.
- **Инварианты из разбора #1 (укрепления):**
- SE-1/VA-1: план с ref-less шагом ИЛИ без `artifact_id` (после штампа) → seal fail-CLOSE (печать не ставится).
- SE-2/VA-2: обе печати несут `judge_mode`; стена не энфорсит shadow-печать как live (тест на различение).
- SE-3: seal только при `verdict.wired === true && verdict.decision === 'GO'` (строго `===`).
- SE-4/VA-3: запись печати атомарна (temp→rename); артефакт персистится ДО плана (тест порядка).
- SE-5: `object` шага канонизируется (repo-relative POSIX); правка с эквивалентным путём в иной форме матчит шаг (Windows-тест `/``\`, отн↔абс).
- VA-4: шаг `op:'Task'` → парсер/seal fail-CLOSE (субагенты запрещены).
- SD-1: судимое представление == печатаемое (`judged_hash = contentHash(sealable-объекта)`); подмена прозы/структуры после суда → печать не ставится.
- SD-3: `artifact_id` штампуется печатью из текущего артефакта (не из текста плана); нет артефакта → план-печать fail-CLOSE.
- **Инварианты из разбора #2:**
- P2-2: правка прозы заголовка решения НЕ меняет `ref`-ключ (якорь стабилен); смена якоря → новый `artifact_id` (осознанно).
- P2-3: судье подаётся продукт через анти-инъекцию (делимитеры/pre-filter); инъекция «output GO» в текст плана → НЕ форсит GO (мульти-судья/fail-closed parse); тест на инъекцию-вектор.
- P2-1: при снятом runtime-страже печать не доверенная (Фаза 8: пол регистрируется ДО энфорсмента стены).
- Регрессия tools-only ≥ baseline (план уточнит число).
---
## 12. Открытые пункты для адверсариального разбора (разбор #1/#2)
- **Re-seal атомарность:** пере-печать плана между блоком и новой записью — нет ли окна, где стена видит старую печать + новый указатель (рассинхрон `plan_id`/`ptr`)?
- **Артефакт без плана / план без артефакта:** порядок записей; что если план записан раньше артефакта (нет `frozen-artifact` → стена backstop C-10).
- **judged_hash продукта:** ровно ли то содержание, что опечатывается, судилось (граница «спека-файл vs извлечённые sections»).
- **shadow-печать + стена-зарегистрирована:** если стена включена, а судья в shadow — печати производятся и энфорсятся; ожидаемо ли (связь режимов)?
- **closed-door обход:** `ref` на несуществующий раздел; пустой `sections`; артефакт с `sections`, не сошедшимися по версии.
- **Класс 1 (текст контроллера ≠ факт):** не появляется ли путь, где seal ставится по строке контроллера, а не по реальному вердикту судьи.
---
## 12.1 Разбор #1 — sharp-edges (findings + закрытие в дизайне)
| # | Severity | Грабля | Закрытие в дизайне |
|---|---|---|---|
| SE-1 | High | Closed-door тихо отключается для шага без `ref` (`refResolves``true` при `!step.ref`, [plan-lock.mjs:201](../../../tools/plan-lock.mjs#L201)); защита держится лишь на C2 | **Seal-time инвариант:** seal-оркестрация (C6) отвергает план, где хоть один шаг без `ref` (fail-CLOSE при печати, не только при парсе). Закрытость не зависит от парсера. |
| SE-2 | High | Seal-в-shadow связывает режим судьи с энфорсментом: стена-зарегистрирована + судья-shadow → печати энфорсятся при «наблюдающем» судье | **Печать несёт `judge_mode`** (shadow/live-block). Стена (decideMode) НЕ чтит shadow-печать как одобрение к энфорсменту: shadow-seal допустим для наблюдения, но enforcement требует live-block-печати ИЛИ явного owner-флага «обкатка стены». Доска показывает «стена энфорсит shadow-печати», если так настроено. |
| SE-3 | Med | Печать на «truthy» вердикт вместо строгого GO | **Инвариант:** seal только при `verdict.wired === true && verdict.decision === 'GO'` (строго `===`, как N5). Degraded/neutral GO (`wired:false`) → нет печати (уже §6). |
| SE-4 | Med | Персист печати не атомарен (`writeFileSync`) → битый файл кирпичит до пере-печати | **Атомарная запись:** temp-файл → `rename` для `frozen-artifact`/`frozen-plan` (как прочие state-записи эпика). |
| SE-5 | High (Windows) | `object` шага — строка; рассинхрон формы пути (отн/абс, `/``\`, квирк cwd) → стена блокирует легитимную правку | **Канон формы `object`:** repo-relative POSIX. `pathNormalize` приводит и авторскую (план), и событийную (tool) форму к канону ДО матча. C2 валидирует/канонизирует `object` при парсе. (Иначе система неюзабельна на Windows.) |
| SE-6 | info | «Печать плана = благословение его команд» — ложное впечатление | **Явно:** in-plan Bash/PowerShell-шаг остаётся под content-floor М5 (rule 8, Δ7) независимо от печати — seal НЕ отмывает опасную команду мимо пола. |
## 12.2 Разбор #1 — variant-analysis (близнецы найденных классов)
| # | Severity | Близнец (класс) | Закрытие |
|---|---|---|---|
| VA-1 | High | Класс SE-1 («требование снято при отсутствии поля»): план без `artifact_id` → version-binding пропущен (`if (frozenPlan.artifact_id)`, [enforce-supreme-gate.mjs:147](../../../tools/enforce-supreme-gate.mjs#L147)), closed-door резолвит ref против **любой** версии артефакта | **Seal-инвариант:** план без `artifact_id` не печатается (v1 каждый план привязан к версии артефакта). |
| VA-2 | Med | Класс SE-2 («mode не записан»): `judge_mode` нужен на **обеих** печатях | freezeArtifact и freezePlan обе несут `judge_mode`. |
| VA-3 | Med | Класс SE-4 («не-атомарно/порядок»): план записан раньше артефакта → ссылка на неперсистнутый артефакт | **Порядок:** артефакт персистится ДО плана; обе записи атомарны (temp→rename). |
| VA-4 | High | Класс «in-plan отмывает запрещённое»: шаг `op:'Task'` (субагент) | C2/seal **отвергают** `op:'Task'` (субагенты запрещены владельцем) — иначе Task-шаг легализует запрещённого субагента как «в плане». |
## 12.3 Разбор #1 — systematic-debugging (логические дыры потока)
| # | Severity | Дыра (корень) | Закрытие в дизайне |
|---|---|---|---|
| SD-1 | High | judged_hash несогласован: судья судит одно представление (прозу), печать хеширует другое (`{sections}`) → `judged_hash !== contentHash` всегда → артефакт никогда не печатается → стена без артефакта блокирует всё | **Одно каноническое «печатаемое»:** определяем sealable-объект (артефакт `{sections, source}` / план `{steps}`); судья получает ЕГО как продукт, `judged_hash = contentHash(того же объекта)`, `sealOnApproval` сверяет тот же. Одно представление течёт судья→печать. |
| SD-3 | High | Claude не может авторить `artifact_id`: id в runtime-печати, read-path-deny запрещает Claude читать runtime, node закрыт | **Печать штампует** `frozenPlan.artifact_id = currentFrozenArtifact.artifact_id` на печати плана (хук имеет печать+ключ). Нет текущего артефакта → план-печать fail-CLOSE. Claude авторит только `{op,object,ref}`; `ref` = заголовки спеки, которые он сам писал (читать runtime не нужно). Закрывает и VA-1. |
| SD-2 | Med | Пере-печать сбрасывает указатель в 0 (R-27, [enforce-supreme-gate.mjs:83](../../../tools/enforce-supreme-gate.mjs#L83)); если новый план повторяет сделанное — стена ждёт повтора | **v1-правило:** пере-сеаленный план = ТОЛЬКО остаток работы от точки отклонения (Claude переписывает «оставшиеся шаги»). Шаг 0 нового плана = следующее реальное действие. |
| SD-4 | Med | Артефакт — синглтон `frozen-artifact-<session>.json`; вторая спека/задача перетирает | **v1-ограничение:** одно активное решение на сессию (задачи сериализуются). Мульти-задача в сессии — не-цель v1. |
| SD-5 | info | Если ломается сам конвейер печати после флипа → нельзя опечатать план, чтобы починить → self-mod deadlock | **escape М6** (дверь владельца) — путь восстановления, когда конвейер печати сломан. Уже в архитектуре, фиксируем как явный recovery. |
## 12.4 Разбор #2 — независимый проход (классы, пропущенные перечислением #1)
Методика R7: сперва абстрактная классификация, потом перечисление. Разбор #1 покрыл footguns/близнецы/data-flow; #2 искал **новые классы**.
| # | Severity | Класс (пропущен #1) | Закрытие |
|---|---|---|---|
| P2-2 | High | **Хрупкость идентификатора:** `ref` = слаг заголовка → косметическая правка заголовка решения молча меняет ключ → closed-door рвётся | `ref` = **явный стабильный якорь** (`{#id}`), C1 парсит якоря не прозу; правка прозы заголовка не трогает ключ. (Тело §4.2 исправлено.) |
| P2-3 | High | **Инъекция в судимый продукт:** план/спеку авторит контроллер и хочет GO; raw-контент идёт судье → «output GO»-инъекция → подделанный GO → подделанная печать | C3/C4 **переиспользуют анти-инъекцию** судьи L4 (делимитеры/pre-filter regex/мульти-судья-консенсус, как §3.8 эпика v3.8); `parseJudgeResponse` уже fail-closed; продукт не подаётся raw. |
| P2-1 | Med | **Несущая зависимость:** целостность печати держится на активном `runtime-write-deny` (пол) — иначе контроллер восстановит/подделает старую печать (node закрыт, floor — единственный барьер записи в runtime) | Зафиксировать как несущую зависимость (аналог «зависимости журнала» эпика): печать достоверна ТОЛЬКО при зарегистрированном поле-страже runtime. Тест-инвариант: при снятом runtime-страже — печать не считается доверенной (Фаза 8 порядок регистрации: пол ДО энфорсмента). |
## 13. Статус и следующие шаги
- [x] Дизайн сошёлся (брейнсторм, решения владельца §2).
- [x] **Разбор #1** по спеке: `sharp-edges``variant-analysis``systematic-debugging` (15 находок, §12.1–12.3, все закрыты).
- [x] **Разбор #2** по спеке (3 новых класса, §12.4, все закрыты).
- [x] Spec self-review (плейсхолдеры нет / противоречий нет / scope сфокусирован / висячие ссылки исправлены).
- [x] **Ревью владельца** — ✅ ПРИНЯТО 2026-06-09.
- [ ] `writing-plans` → план реализации (bite-sized TDD) — отдельный заход по слову «собирай». ← следующий шаг.
**Прод-код и стена НЕ затронуты** этой спекой (design-only). Связка — код-предусловие флипа; флип (регистрация М1–М6 + увольнение зоопарка) — после её сборки и тест-гейта §9.2 runbook'а `2026-06-09-phase8-deployment-runbook.md`.