From de39beb813da0e8942f2bc7ea24e79d4080042da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 9 Jun 2026 16:50:02 +0300 Subject: [PATCH] docs(seal): sealed-plan production design spec + TDD implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спека (ОДОБРЕНА владельцем, 2 адверсариальных разбора, 18 находок закрыты) + bite-sized TDD-план (7 задач) для код-предусловия флипа Фазы 8 (производство двух печатей: артефакт-решение + план-шаги). design-only — прод-код и стена не затронуты. Эпик «роутер-наставник» М7. --- cspell-words.txt | 36 ++ docs/observer/STATUS.md | 127 ++-- .../2026-06-09-sealed-plan-production.md | 574 ++++++++++++++++++ ...026-06-09-sealed-plan-production-design.md | 235 +++++++ 4 files changed, 912 insertions(+), 60 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-09-sealed-plan-production.md create mode 100644 docs/superpowers/specs/2026-06-09-sealed-plan-production-design.md diff --git a/cspell-words.txt b/cspell-words.txt index 572aa886..d046fadf 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1974,3 +1974,39 @@ monitorится guillemets mirror'ящий plan'овский + +# 2026-06-09 sealed-plan production — spec + plan terms +sealable +backstop +энфорсмент +энфорсмента +энфорсментом +энфорсятся +энфорсит +энфорсить +энфорсменту +неперсистнутый +сеаленный +делимитеры +делимитер +слаг +плейсхолдеров +резолвиться +резолюцию +когерентность +когерентен +распарсить +провалидировать +детерминированный +детерминированную +канонизирует +канонизируется +канонизировать +хеш +хеша +хеширует +хешировала +хеширования +хешируется +штампует +штампуется diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 0dd27946..ce9c42ac 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -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`). ## Алерт-индикаторы diff --git a/docs/superpowers/plans/2026-06-09-sealed-plan-production.md b/docs/superpowers/plans/2026-06-09-sealed-plan-production.md new file mode 100644 index 00000000..b4ff1052 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-sealed-plan-production.md @@ -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 "\app\node_modules\vitest\vitest.mjs" run --root "\app" --config "\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 | + +`` = `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 "\app\node_modules\vitest\vitest.mjs" run --root "\app" --config "\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: { : <содержимое раздела> } }. */ +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, из +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 "\app\node_modules\vitest\vitest.mjs" run --root "\app" --config "\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). diff --git a/docs/superpowers/specs/2026-06-09-sealed-plan-production-design.md b/docs/superpowers/specs/2026-06-09-sealed-plan-production-design.md new file mode 100644 index 00000000..db469da3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-sealed-plan-production-design.md @@ -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-.json` / `frozen-artifact-.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: {: ...}, 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: { "": <непустое>, "": <непустое>, ... } } +``` + +`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: "" } +``` + +`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-.json`: `{ sections, artifact_id, frozen_at, sig }`. Персист `saveFrozenArtifact` ([plan-lock.mjs:188](../../../tools/plan-lock.mjs#L188)). +- `frozen-plan-.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-.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`.