docs: bag-репорты багов стены plus спеки и планы фаз gate3 plus роадмап открытых вопросов по сессиям

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-17 15:41:09 +03:00
parent cd831b872f
commit ba584a8335
15 changed files with 2748 additions and 0 deletions
@@ -0,0 +1,26 @@
# Баг: produce-verify-receipt.mjs смотрит на app/ после сплита
**Дата:** 2026-06-17 · **Репозиторий:** claude-brain
## Симптом
`node tools/produce-verify-receipt.mjs``[produce-verify-receipt] NOT signed: suite-not-passed`,
даже когда полная суита зелёная (`npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
= 4156 passed). Следствие: `enforce-verify-gate` отклоняет Claude-коммиты (`stale-fingerprint` /
«нет подписанной verify-расписки»), и escape/override `ремонт инфраструктуры` его НЕ снимают —
нужна настоящая подписанная расписка.
## Причина
`produce-verify-receipt.mjs` (стр. 62-63) гоняет vitest с захардкоженным путём
`join(gitCwd, 'app', 'vitest.config.tools.mjs')` и `--root "${appRoot}"`. Это layout Лидерры
(подпапка `app/`). В claude-brain после сплита `app/` нет — конфиг лежит в КОРНЕ
(`vitest.config.tools.mjs`). vitest не находит конфиг → suite-not-passed → расписка не подписывается.
## Обход (сейчас)
Коммит+пуш в терминале владельца — `enforce-verify-gate` это Claude-PreToolUse-хук, на терминальный
git не срабатывает. Прецедент: коммит `9b85f51` (инкремент-2 видимости вердиктов) прошёл из терминала
после того, как Claude-путь упёрся в verify-gate.
## Фикс (TODO)
Определять путь конфига от корня репо: если есть `app/vitest.config.tools.mjs` — он (+`--root app`),
иначе корневой `vitest.config.tools.mjs` (+`--root` репо). Иначе Claude-коммиты в claude-brain
невозможны без терминала. Связано: enforce-verify-gate.mjs, verify-receipt.mjs.
@@ -0,0 +1,157 @@
# Баг-репорт стены «роутер-наставник»: невозможно провести интерактивный браузерный осмотр под стеной
**Дата:** 17.06.2026
**Кому:** проект claude-brain (управляющий слой, ADR-020)
**Где наблюдалось:** замороженная рабочая копия рантайм-хуков в репо «Документация»
(`tools/enforce-supreme-gate.mjs` + оркестратор наставник→судья).
**Задача, на которой всплыло:** Фаза 1 имитации — ручной осмотр запущенного
портала 127.0.0.1:8000 глазами клиента через Playwright (MCP). Подготовка
(seed/build/serve) прошла штатно; сам **интерактивный проход по экранам** уперся в
стену.
---
## Краткая суть (TL;DR)
Стена спроектирована под **детерминированные код-церемонии** (шаги Write/Edit/Bash
с заранее известными op+object). **Интерактивный браузерный осмотр в неё не
ложится** по трём независимым причинам:
1. **Шаг `op:"Skill"` запечатывается, но на исполнении НЕ двигает указатель**
план намертво виснет на этом шаге.
2. **Блок `tools-json` (запрос набора инструментов) игнорируется** — стена чисто
пошаговая, разрешает только текущий шаг.
3. **Живой проход нельзя выразить шагами** — клики/ввод идут по `ref` элементов из
снимка страницы, которых на момент написания плана не существует.
Дополнительно: **наставник (этап печати) требует «шаг verify»**, который по
причине №1 неисполним — печать и исполнение противоречат друг другу.
---
## Баг 1 — `op:"Skill"` не двигает указатель (deadlock)
**Что ожидалось:** объявить шаг `{op:"Skill", object:"verify"}`; при вызове навыка
`verify` указатель сдвигается на следующий шаг.
**Что произошло:** навык `verify` вызывается и загружается (PreToolUse пускает —
скил объявлен в `skills-json`), но **указатель остаётся на шаге `Skill verify`**.
Любое следующее действие (ToolSearch, browser-вызов, даже `Read`) режется:
```
[supreme-gate] действие не в плане (ожидался шаг undefined: Skill verify)
```
и затем:
```
[supreme-gate] авторское сырьё-чтение вне шага плана — гейт ДР-1 (impl-режим) [тип: code]
```
**Вероятная причина:** указатель сдвигается на **PostToolUse** инструмента (для
Write/Edit/Bash это работает — подготовительный план из Edit+Bash прошёл все 7
шагов подряд). Но вызов навыка возвращается в среде Claude Code **как инъекция
user-сообщения** (текст скила приходит новым ходом), а не как обычный tool-result —
поэтому PostToolUse для шага `Skill` **не срабатывает**, и шаг никогда не
«закрывается». Подтверждено на запечатанном плане v5: после `verify` указатель
застрял на шаге 1, план заперт целиком (impl-режим блокирует всё).
**Рекомендация brain:**
- либо сдвигать указатель для `Skill`-шага по факту загрузки скила (отдельный
механизм, не PostToolUse-tool-result);
- либо **запретить `op:"Skill"` на этапе печати** с явным сообщением «Skill не
может быть шагом» — чтобы наставник не требовал заведомо неисполнимый шаг.
---
## Баг 2 — `tools-json` игнорируется, стена строго пошаговая
**Что пробовали:** объявить нужные инструменты в блоке `tools-json` рядом со
`skills-json` (по аналогии «запросить инструменты в плане»):
```
tools-json: [ ToolSearch, mcp__playwright__browser_navigate, browser_snapshot,
browser_type, browser_click, browser_select_option,
browser_fill_form, browser_wait_for, browser_take_screenshot ]
```
**Что произошло:** на запечатанном плане v6 (шаги — две записи) `ToolSearch`
отбит:
```
[supreme-gate] действие не в плане (ожидался шаг: Write ...observations.md)
```
То есть `tools-json` **не дал никакого доступа** — стена пускает строго текущий
шаг и ничего кроме него.
**Рекомендация brain:** ввести реальный механизм авторизации инструментов на план:
- либо `tools-json` как allowlist, который PreToolUse чтит на протяжении всего
плана (разрешает перечисленные инструменты независимо от позиции указателя);
- либо специальный тип шага «сессия инструмента» / «исследовательский шаг»,
внутри которого разрешён заявленный набор MCP-инструментов до явного перехода
к следующему шагу.
---
## Баг 3 (корневой архитектурный) — интерактивный проход не выразить шагами
Браузерный осмотр исследовательский: чтобы кликнуть/ввести, нужен `ref` элемента
из **снимка живой страницы** (`browser_snapshot` возвращает динамические `e1,
e2…`). На момент написания плана этих `ref` не существует, поэтому конкретные
шаги `browser_click {ref: …}` **нельзя задать заранее**. Логин (ввод email/пароля
+ submit) тем более требует ввода в поля с динамическими ref.
Стена матчит шаги по op+object (для Bash — точная команда; проверено). Для
интерактивного UI это означает, что **ни фиксированной цепочкой шагов, ни
allowlist'ом текущая стена проход не покрывает**.
**Рекомендация brain:** для классов задач «интерактивный осмотр/драйв UI»
предусмотреть режим, где после печати плана разрешён заявленный MCP-инструментарий
свободно (owner-gated), а результат фиксируется обычными шагами записи
(наблюдения → отчёт). По сути — это Баг 2 в более сильной форме.
---
## Противоречие печать ↔ исполнение (наставник)
Наставник на этапе печати **требовал явный «шаг verify»** (круги 911):
- v1 (один Write-отчёт) → NO-GO: «нет шага, выполняющего верификацию»;
- v2 (Write наблюдений + Write отчёта) → NO-GO: «нет шага, инициирующего верификацию»;
- v3 (`Skill verify` ref D1) → NO-GO: «ref должен покрывать D1–D5»;
- v4 (`Skill verify` ref D1-D5) → **GO, опечатано**; на исполнении — deadlock (Баг 1);
- v5 (`tools-json` + `Skill verify`) → GO; deadlock на шаге Skill;
- v6 (`tools-json` + 2 Write, `verify` в `skills-json`) → GO; `tools-json` проигнорирован (Баг 2).
**Суть противоречия:** наставник настаивает на шаге-операции `verify`, но такой шаг
**неисполним** (Баг 1). Печать (наставник) и исполнение (supreme-gate) расходятся:
печать принимает `op:"Skill"`, исполнение на нём виснет.
**Рекомендация brain:** согласовать две стороны — если `Skill`-шаг неисполним,
наставник не должен его требовать; «использование навыка» должно засчитываться
через дисциплину навыков (`skills-json` + реальный вызов до первого мутирующего
шага, что проверяет `enforce-domain-skill-discipline`), без шага-операции.
---
## Сопутствующие наблюдения (для полноты)
- **Видимость вердикта с задержкой на ход.** Исход наставника/судьи приходит
контроллеру строкой только в начале **следующего** хода (`enforce-verdict-surface`).
В тот же ход после записи плана проверить печать делом можно (если GO — шаг
проходит сразу), но **текст** замечаний виден лишь следующим ходом. Несколько
раз строка «✅ GO — опечатано ×N» показывалась как **прошлые** записи, не
относящиеся к текущему плану, — это путало (казалось «GO», а печати уже/ещё нет).
- **Финансовый детектор / эскалация.** Спека и план, содержащие денежные термины
(баланс, списания, тарифы), должны были **эскалироваться владельцу**, но
эскалация не сработала; помогла money-free переформулировка. Владелец отметил,
что это **известный баг, починенный в новом релизе мозга**. Зафиксировано здесь
для сверки.
- **Эфемерность печати — опровергнута.** Ранее казалось, что печать «на один ход»;
на деле запечатанный план **сохраняется между ходами**, пока не завершён или не
снят escape'ом (v5/v6 держались impl-режим через ход). Прошлые «GO без печати»
объясняются Багом 1/2 и задержкой видимости вердикта, а не эфемерностью.
---
## Что работает штатно (контроль)
Подготовительный план (Edit команды seed → Bash `db:show``migrate``seed`
`npm ls vite``npm run build``php artisan serve`) **запечатался и прошёл все
7 шагов подряд в одном ходу** без проблем. То есть для детерминированных
Write/Edit/Bash-церемоний стена работает как задумано. Проблема строго в
интерактивном MCP-проходе.
+80
View File
@@ -0,0 +1,80 @@
# БАГ — чтение под стеной «роутер-наставник» (impl-режим) + связанный десинк указателя
**Дата:** 17.06.2026
**Откуда:** живой прогон go-live security gate (отчёт `docs/security/2026-06-17-go-live-security-report.md`).
**Куда:** claude-brain (управляющий слой стены — `enforce-supreme-gate` и оркестрация).
**Связано:** `docs/superpowers/router-mentor-wall-GUIDE.md` (раздел «Уроки живого прогона»).
---
## Суть бага (одной фразой)
В режиме реализации (под опечатанным планом) чтение разрешено **только по пути
текущего шага**. Всё остальное читать нельзя — включая файлы, появившиеся во время
прогона, и **собственный вывод запущенных инструментов** (гейт ДР-1 в
`enforce-supreme-gate`).
## Почему это серьёзно
Многошаговая работа, где следующее действие зависит от прочитанного (аудит,
отладка, цепочки инструментов, проверка результата), под стеной фактически
слепнет. Обойти удалось только тем, что **владелец вставлял файлы в чат вручную**
(контекст разговора ≠ вызов Read-инструмента → стена его не трогает). Это
костыль, а не решение.
## Случаи, где дефект бьёт
1. **Свой же вывод не прочитать.** Длинная команда (сканер/сборка/тесты)
уезжает в фоновый запуск, результат пишется в temp-файл — открыть нельзя.
*Живой пример прогона:* не прочитался вывод Nuclei.
2. **Забыл прочитать до печати плана — всё.** Понадобился файл по ходу — под
планом не открыть; только переделывать план или просить владельца вставить.
3. **Сторонний сервис/процесс создал файл во время прогона.** Генератор,
выгрузка, отчёт другой задачи, артефакт CI — недоступны.
4. **Нечем проверить результат шага.** Сделал шаг, для проверки нужен другой
файл/лог — нельзя. Получается «сделал вслепую».
5. **Ветвление по содержимому невозможно.** «Если в конфиге X — делаем Y» не
работает: чтобы выбрать, надо прочитать.
6. **Диагностика ошибки по внешнему логу.** Шаг упал, ошибка «смотри лог тут» —
тот лог открыть нельзя.
7. **Промежуточный артефакт в цепочке инструментов.** Шаг N сделал файл, шаг N+1
должен на него посмотреть — нельзя, только передать вслепую.
8. **Перечитать только что записанное.** Записал файл на шаге 2, на шаге 4 надо
свериться — закрыто.
9. **Неожиданная находка.** Поиск дал совпадение в файле, о котором при
планировании не знал — открыть его уже нельзя.
10. **Файлы от параллельной сессии.** Соседняя сессия добавила/закоммитила
файлы — мне их не глянуть.
## Связь с рассинхроном указателя (важно для починки)
Наивная мысль «добавить escape-метку `read:`» **опасна**: стена сейчас двигает
счётчик шагов вперёд **даже когда действие не прошло** (это и есть десинк F-J —
на прогоне так был пропущен шаг gitleaks: `supreme-gate` сдвинул указатель, а
`enforce-domain-skill-discipline` дальше в цепочке уронил действие). Если пустить
чтение через ту же машинерию шагов — **очередь сдвинется, план поедет**.
Ключ: **чтение не является шагом.** Шаги плана — только `Write/Edit/Bash/MultiEdit`.
Значит разрешение на чтение должно работать **«сбоку от очереди»**, не касаясь
счётчика шагов.
## Приоритет починки (для claude-brain)
1. **ГЛАВНОЕ — счётчик шагов += 1 только при успешно завершённом настоящем
шаге.** Не на заблокированном действии, не на чтении, не на постороннем.
Это чинит десинк F-J И автоматически делает чтение безопасным (раз чтение не
шаг — оно не может сдвинуть очередь).
2. **Пассивно разрешить чтение** без escape и без касания очереди:
(а) собственный вывод инструментов, запущенных в этом плане (temp-файлы);
(б) файлы, появившиеся ПОСЛЕ опечатывания плана (их при планировании не было —
запрет «лишнего чтения» здесь нелогичен).
3. **Escape-метка `read:<путь>` — только крайний резерв**, и строго мимо
счётчика шагов (out-of-band), чтобы не повторить десинк. После п.1+п.2 почти
не нужна.
## Критерий «починено»
- Под опечатанным планом можно прочитать свой вывод и файлы, созданные после
печати, без сдвига очереди шагов.
- Падение/блокировка шага НЕ двигает указатель (десинк F-J не воспроизводится).
- При необходимости разовое `read:<путь>` через escape не ломает очередность.
@@ -0,0 +1,112 @@
# E-S1 gate-3 приёмка владельца Фаза 2a — чистое ядро карточки Implementation Plan
**Goal:** Добавить чистый сборщик карточки `buildOwnerCard` и расширить `decideGate3Closure`
входами `delivery`+`cardVerdict` и состояниями `await-card`/`await-owner` (degraded карточки →
`await-owner unverified:true`), сохранив обратную совместимость существующего ядра.
**Architecture:** Обе функции живут в существующем `tools/loop-termination.mjs` (без новых
production-файлов). `buildOwnerCard` — чистый сборщик полей простого языка с честными заглушками.
Расширение `decideGate3Closure` добавляет ветку `delivery:'user-result'`, требующую И судью-кода, И
сверенную карточку, И подписанного владельца; `delivery:'internal'` и старые вызовы без новых полей
сохраняют прежнее поведение (дефолт `delivery='internal'`). Закрытие петли — только через
`loopTerminationDecision` (инвариант SE-R7-6).
**Tech Stack:** Node ESM, vitest (config `vitest.config.tools.mjs`). TDD.
## Цель
Реализовать чистое ядро пользовательской приёмки владельца (Фаза 2a): сборщик карточки
`buildOwnerCard` и расширение замыкания `decideGate3Closure` без проводки в Stop-хук. Контроллер
петлю не закрывает; закрытие `user-result` требует кода-GO + честной карточки + подписи владельца.
## Структура файлов
- Изменить (тест): `tools/loop-termination.test.mjs` — тесты `buildOwnerCard` и новых веток замыкания.
- Изменить: `tools/loop-termination.mjs` — добавить `buildOwnerCard`; расширить `decideGate3Closure`.
```skills-json
["test-driven-development"]
```
```steps-json
[
{"op":"Edit","object":"tools/loop-termination.test.mjs","ref":"u9"},
{"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"},
{"op":"Edit","object":"tools/loop-termination.mjs","ref":"u2"},
{"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"},
{"op":"Edit","object":"tools/loop-termination.test.mjs","ref":"u9"},
{"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"},
{"op":"Edit","object":"tools/loop-termination.mjs","ref":"u5"},
{"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"},
{"op":"Bash","object":"npx vitest run --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"}
]
```
```verified-context-json
[{"id":"vpc2","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"export function decideGate3Closure("}]
```
---
### Task 1: `buildOwnerCard` — сборщик карточки (§u2)
- [ ] **Step 1 (Edit `tools/loop-termination.test.mjs`): падающие тесты `buildOwnerCard`**
В конец файла дописать импорт + блок тестов 4 частей карточки, ветвления `kind`, честных заглушек и
видимого предупреждения при `honestyChecked:false`.
- [ ] **Step 2 (Bash): прогон — тесты падают (RED)**
Run: `npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — `buildOwnerCard is not a function`. (Под Claude harness-collapse; авторитет — терминал владельца.)
- [ ] **Step 3 (Edit `tools/loop-termination.mjs`): реализация `buildOwnerCard`**
После `buildGate3Product` (перед комментарием `decideGate3Closure`) добавить чистый сборщик полей с
честными заглушками и полем `warning` при `honestyChecked!==true`.
- [ ] **Step 4 (Bash): прогон — тесты зелёные (GREEN)**
Run: `npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — тесты `buildOwnerCard` + прежние. (Авторитет — терминал владельца.)
---
### Task 2: расширение `decideGate3Closure` (delivery + cardVerdict, §u5)
- [ ] **Step 5 (Edit `tools/loop-termination.test.mjs`): падающие тесты новых веток замыкания**
После блока `buildOwnerCard` дописать тесты: internal+GO→closed; user-result+карточка-NO-GO→await-card;
+карточка-GO→await-owner; +accept→closed; degraded→await-owner unverified; нет карточки→await-card;
без подписи не закрывает.
- [ ] **Step 6 (Bash): прогон — новые ветки падают (RED)**
Run: `npx vitest run tools/loop-termination.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — `user-result` ветки сейчас возвращают closed. (Авторитет — терминал владельца.)
- [ ] **Step 7 (Edit `tools/loop-termination.mjs`): расширить `decideGate3Closure`**
Добавить параметры `delivery`/`cardVerdict`; в ветке код-GO разобрать `delivery:'user-result'` →
await-card/await-owner/await-owner+unverified; `internal` и accept — как прежде.
- [ ] **Step 8 (Bash): прогон — новые ветки зелёные (GREEN)**
Run: `npx vitest run tools/loop-termination.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — новые ветки + прежние тесты ядра (обратная совместимость). (Авторитет — терминал владельца.)
- [ ] **Step 9 (Bash): полная регрессия tools**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — база + новые тесты, 0 регрессий. (Под Claude harness-collapse; авторитетный свод — терминал владельца.)
## Self-Review (план против спеки 2a)
- **§u2 `buildOwnerCard`** — Task 1: 4 части, ветвление `kind`, честные заглушки, предупреждение. ✓
- **§u5 замыкание** — Task 2: internal→closed; карточка-NO-GO→await-card; карточка-GO→await-owner;
accept→closed; degraded→await-owner unverified; нет карточки→await-card; без подписи не закрывает. ✓
- **Инвариант SE-R7-6** — `terminate:true` только через `loopTerminationDecision`. ✓
- **Обратная совместимость** — дефолт `delivery='internal'`; 7 прежних тестов ядра не трогаются. ✓
- **DR-1** — мутирующие шаги 1,3,5,7 сопровождены Bash (2,4,6,8); один файл дважды правится только с
Bash между (test: 2,4 между 1 и 5; mjs: 4,6 между 3 и 7); RED/GREEN — разная неопределённость. ✓
- **Вне 2a** — судья карточки, честность пометки на gate-2, проводка в Stop-хук — не здесь. ✓
@@ -0,0 +1,273 @@
# E-S1 gate-3 приёмка владельца Фаза 2a — чистое ядро карточки Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Добавить чистый сборщик пользовательской карточки `buildOwnerCard` и расширить `decideGate3Closure` входами `delivery`+`cardVerdict` и состояниями `await-card`/`await-owner` (degraded карточки → `await-owner unverified:true`), сохранив обратную совместимость существующего ядра.
**Architecture:** Обе функции живут в существующем `tools/loop-termination.mjs` (без новых production-файлов — override не нужен). `buildOwnerCard` — чистый сборщик полей простого языка с честными заглушками. Расширение `decideGate3Closure` добавляет ветку `delivery:'user-result'`, требующую И судью-кода, И сверенную карточку, И подписанного владельца; `delivery:'internal'` и старые вызовы без новых полей сохраняют прежнее поведение (fail-safe дефолт `delivery='internal'`). Закрытие петли — только через `loopTerminationDecision` (инвариант SE-R7-6).
**Tech Stack:** Node ESM, vitest (config `vitest.config.tools.mjs`). TDD.
## Цель
Реализовать чистое ядро пользовательской приёмки владельца (Фаза 2a спеки v2): сборщик карточки `buildOwnerCard` и расширение замыкания `decideGate3Closure` без проводки в Stop-хук (проводка — Фаза 2d). Контроллер петлю не закрывает; закрытие `user-result` требует кода-GO + честной карточки + подписи владельца.
## Структура файлов
- Изменить: `tools/loop-termination.mjs` — добавить `buildOwnerCard`; расширить `decideGate3Closure`.
- Изменить (тест): `tools/loop-termination.test.mjs` — тесты `buildOwnerCard` и новых веток замыкания.
Новых файлов нет. Существующие модули правятся точечными `Edit`.
```skills-json
["test-driven-development"]
```
```steps-json
[
{"op":"Edit","object":"tools/loop-termination.test.mjs","ref":"u9"},
{"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"},
{"op":"Edit","object":"tools/loop-termination.mjs","ref":"u2"},
{"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"},
{"op":"Edit","object":"tools/loop-termination.test.mjs","ref":"u9"},
{"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"},
{"op":"Edit","object":"tools/loop-termination.mjs","ref":"u5"},
{"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"},
{"op":"Bash","object":"npx vitest run --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"}
]
```
```verified-context-json
[{"id":"vc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"export function decideGate3Closure("}]
```
---
### Task 1: `buildOwnerCard` — сборщик карточки (§u2)
**Files:**
- Modify: `tools/loop-termination.mjs`
- Test: `tools/loop-termination.test.mjs`
- [ ] **Step 1 (Edit `tools/loop-termination.test.mjs`): добавить падающие тесты `buildOwnerCard`**
В конец файла дописать (импорт расширить):
```js
import { buildOwnerCard } from './loop-termination.mjs';
describe('buildOwnerCard (E-S1 Фаза 2 §u2)', () => {
it('собирает 4 части простым языком (goal/change/verifySteps/boundary)', () => {
const c = buildOwnerCard({
goal: 'закрыть петлю по приёмке',
change: ['владелец видит карточку результата'],
verifySteps: ['запусти X → увидишь Y'],
boundary: 'screen-путь не задействован',
kind: 'machinery',
honestyChecked: true,
});
expect(c.goal).toBe('закрыть петлю по приёмке');
expect(c.change).toEqual(['владелец видит карточку результата']);
expect(c.verifySteps).toEqual(['запусти X → увидишь Y']);
expect(c.boundary).toBe('screen-путь не задействован');
expect(c.kind).toBe('machinery');
expect(c.honestyChecked).toBe(true);
expect(c.warning).toBeUndefined();
});
it('kind screen ветвит заглушку шагов проверки', () => {
const c = buildOwnerCard({ goal: 'g', kind: 'screen', honestyChecked: true });
expect(c.kind).toBe('screen');
expect(c.verifySteps[0]).toMatch(/экран/);
});
it('пустые входы → честные заглушки, не выдумки', () => {
const c = buildOwnerCard({});
expect(c.goal).toMatch(/не указана/);
expect(c.change[0]).toMatch(/не указано/);
expect(c.boundary).toMatch(/не указана/);
expect(c.kind).toBe('machinery');
});
it('honestyChecked:false → карточка несёт видимое предупреждение', () => {
const c = buildOwnerCard({ goal: 'g', honestyChecked: false });
expect(c.honestyChecked).toBe(false);
expect(c.warning).toMatch(/автоматическая сверка честности недоступна/);
});
});
```
- [ ] **Step 2 (Bash): прогон — тесты `buildOwnerCard` падают (RED)**
Run: `npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — `buildOwnerCard is not a function` / не экспортирован. (Под Claude возможен harness-collapse `reading 'config'` — авторитетный прогон у владельца в терминале.)
- [ ] **Step 3 (Edit `tools/loop-termination.mjs`): минимальная реализация `buildOwnerCard`**
После `buildGate3Product` (перед `decideGate3Closure`) добавить:
```js
/**
* E-S1 Фаза 2 §u2: пользовательская карточка приёмки — чистый сборщик полей простого языка
* (не код). Пустые входы → честные заглушки (не выдумки). honestyChecked!==true → карточка несёт
* видимое предупреждение «автоматическая сверка честности недоступна» (§u4 degraded-эскалация).
*/
export function buildOwnerCard({ goal = '', change = [], verifySteps = [], boundary = '', kind = 'machinery', honestyChecked = false } = {}) {
const toList = (v) => (Array.isArray(v) ? v : (v == null || String(v).trim() === '' ? [] : [v]))
.map((x) => String(x)).filter((x) => x.trim() !== '');
const k = kind === 'screen' ? 'screen' : 'machinery';
const changeArr = toList(change);
const stepsArr = toList(verifySteps);
const card = {
goal: String(goal || '').trim() || '(цель не указана)',
change: changeArr.length ? changeArr : ['(что изменилось — не указано)'],
verifySteps: stepsArr.length ? stepsArr : [k === 'screen' ? '(шаги проверки на экране не указаны)' : '(сценарий проверки не указан)'],
boundary: String(boundary || '').trim() || '(граница не указана)',
kind: k,
honestyChecked: honestyChecked === true,
};
if (card.honestyChecked !== true) {
card.warning = 'автоматическая сверка честности недоступна — проверь по шагам сам';
}
return card;
}
```
- [ ] **Step 4 (Bash): прогон — тесты `buildOwnerCard` зелёные (GREEN)**
Run: `npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — 4 теста `buildOwnerCard` + прежние тесты файла. (Авторитет — терминал владельца.)
---
### Task 2: расширение `decideGate3Closure` (delivery + cardVerdict, §u5)
**Files:**
- Modify: `tools/loop-termination.mjs`
- Test: `tools/loop-termination.test.mjs`
- [ ] **Step 5 (Edit `tools/loop-termination.test.mjs`): добавить падающие тесты новых веток замыкания**
В конец файла дописать:
```js
describe('decideGate3Closure Фаза 2 (delivery+cardVerdict §u5)', () => {
it('internal + код-GO → closed (обратная совместимость)', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'internal' });
expect(r.state).toBe('closed');
expect(r.terminate).toBe(true);
});
it('user-result + код-GO + карточка wired-NO-GO → await-card (владельца не зовём)', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'user-result', cardVerdict: { wired: true, decision: 'NO-GO' } });
expect(r.state).toBe('await-card');
expect(r.terminate).toBe(false);
});
it('user-result + код-GO + карточка GO + нет подписи → await-owner', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'user-result', cardVerdict: { wired: true, decision: 'GO' } });
expect(r.state).toBe('await-owner');
expect(r.terminate).toBe(false);
expect(r.unverified).toBeUndefined();
});
it('user-result + код-GO + карточка GO + подписанный accept → closed', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'user-result', cardVerdict: { wired: true, decision: 'GO' }, ownerArbitration: 'accept' });
expect(r.state).toBe('closed');
expect(r.terminate).toBe(true);
});
it('user-result + код-GO + карточка degraded → await-owner unverified:true (НЕ висит)', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'user-result', cardVerdict: { wired: false } });
expect(r.state).toBe('await-owner');
expect(r.unverified).toBe(true);
expect(r.terminate).toBe(false);
});
it('user-result + код-GO + карточки ещё нет → await-card', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'user-result', cardVerdict: null });
expect(r.state).toBe('await-card');
expect(r.terminate).toBe(false);
});
it('user-result контроллер без подписи карточку-GO сам не закрывает', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'user-result', cardVerdict: { wired: true, decision: 'GO' } });
expect(r.terminate).toBe(false);
});
});
```
- [ ] **Step 6 (Bash): прогон — новые ветки падают (RED)**
Run: `npx vitest run tools/loop-termination.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — `user-result` ветки сейчас возвращают `closed` вместо `await-card`/`await-owner`. (Авторитет — терминал владельца.)
- [ ] **Step 7 (Edit `tools/loop-termination.mjs`): расширить `decideGate3Closure`**
Заменить сигнатуру и блок код-GO начиная с строки `export function decideGate3Closure(...)`. Контурный Edit: добавить параметры `delivery`/`cardVerdict` и вставить ветку `codeGo` с разбором `delivery`.
`old_string` (текущая сигнатура + блок код-GO):
```js
export function decideGate3Closure({ gate3Verdict = null, noGoCount = 0, ownerArbitration = null, maxRounds = 3 } = {}) {
const degraded = !!gate3Verdict && gate3Verdict.wired === false;
if (degraded && ownerArbitration !== 'accept') {
return { state: 'negotiate', terminate: false, reason: 'судья gate-3 недоступен — не закрывать, повтор/доработка' };
}
if (ownerArbitration === 'accept') {
const t = loopTerminationDecision({ ownerDeclaredDone: true });
return { state: 'closed', terminate: t.terminate, reason: t.reason };
}
if (gate3Verdict && gate3Verdict.decision === 'GO' && gate3Verdict.wired !== false) {
const t = loopTerminationDecision({ judgeGate3Go: true });
return { state: 'closed', terminate: t.terminate, reason: t.reason };
}
```
`new_string`:
```js
export function decideGate3Closure({ gate3Verdict = null, noGoCount = 0, ownerArbitration = null, maxRounds = 3, delivery = 'internal', cardVerdict = null } = {}) {
const degraded = !!gate3Verdict && gate3Verdict.wired === false;
if (degraded && ownerArbitration !== 'accept') {
return { state: 'negotiate', terminate: false, reason: 'судья gate-3 недоступен — не закрывать, повтор/доработка' };
}
if (ownerArbitration === 'accept') {
const t = loopTerminationDecision({ ownerDeclaredDone: true });
return { state: 'closed', terminate: t.terminate, reason: t.reason };
}
const codeGo = !!gate3Verdict && gate3Verdict.decision === 'GO' && gate3Verdict.wired !== false;
if (codeGo) {
if (delivery === 'user-result') {
// §u5: код исправен, но пользовательский результат требует И сверенной карточки, И подписи владельца
const cardDegraded = !!cardVerdict && cardVerdict.wired === false;
const cardGo = !!cardVerdict && cardVerdict.decision === 'GO' && cardVerdict.wired !== false;
const cardNoGo = !!cardVerdict && cardVerdict.wired === true && cardVerdict.decision !== 'GO';
if (cardNoGo) {
return { state: 'await-card', terminate: false, reason: 'карточка приукрашена/неточна — доработать, владельца не звать' };
}
if (cardDegraded) {
return { state: 'await-owner', terminate: false, unverified: true, reason: 'судья карточки недоступен — показать владельцу непроверенную карточку с предупреждением' };
}
if (cardGo) {
return { state: 'await-owner', terminate: false, reason: 'код-GO + карточка сверена — ждём подписанного решения владельца' };
}
return { state: 'await-card', terminate: false, reason: 'код-GO; карточка ещё не сверена — собрать и подать судье карточки' };
}
const t = loopTerminationDecision({ judgeGate3Go: true });
return { state: 'closed', terminate: t.terminate, reason: t.reason };
}
```
(Остальное тело функции — `ownerArbitration === 'continue'`, счётчик кругов, арбитраж — без изменений.)
- [ ] **Step 8 (Bash): прогон — новые ветки зелёные (GREEN)**
Run: `npx vitest run tools/loop-termination.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — новые 7 веток + 7 прежних тестов ядра остаются зелёными (обратная совместимость). (Авторитет — терминал владельца.)
- [ ] **Step 9 (Bash): полная регрессия tools**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — база (4134 passed | 2 skipped) + новые тесты, 0 регрессий. (Под Claude harness-collapse ожидаем; авторитетный полный свод гонит владелец в своём терминале.)
## Self-Review (проверка плана против спеки v2)
- **§u2 `buildOwnerCard`** — Task 1: 4 части простым языком, `kind` machinery/screen ветвление, пустые входы → честные заглушки, `honestyChecked:false` → видимое предупреждение. ✓
- **§u5 замыкание** — Task 2: `internal`+код-GO→closed; `user-result`+карточка-NO-GO→await-card; +карточка-GO→await-owner; +accept→closed; degraded→await-owner unverified:true; нет карточки→await-card; без подписи не закрывает. ✓
- **Инвариант SE-R7-6** — `terminate:true` только через `loopTerminationDecision` (accept / judgeGate3Go). `await-card`/`await-owner` — `terminate:false`. ✓
- **Обратная совместимость** — дефолт `delivery='internal'`, старые вызовы `decideStopTeeth` без новых полей → прежнее поведение; 7 существующих тестов ядра не трогаются. ✓
- **Вне 2a** — судья-карточки (`gate3card` линзы) — Фаза 2b; честность пометки на gate-2 — 2c; проводка в Stop-хук — 2d. В этом плане не реализуются (по декомпозиции спеки §u6). ✓
- **Placeholder-scan** — все шаги несут полный код/команды, заглушек нет. ✓
- **Type consistency** — `buildOwnerCard` поля (goal/change/verifySteps/boundary/kind/honestyChecked/warning) и состояния замыкания (`await-card`/`await-owner`/`unverified`) согласованы между тестами и реализацией. ✓
@@ -0,0 +1,273 @@
# E-S1 gate-3 приёмка владельца Фаза 2a — чистое ядро карточки Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Добавить чистый сборщик пользовательской карточки `buildOwnerCard` и расширить `decideGate3Closure` входами `delivery`+`cardVerdict` и состояниями `await-card`/`await-owner` (degraded карточки → `await-owner unverified:true`), сохранив обратную совместимость существующего ядра.
**Architecture:** Обе функции живут в существующем `tools/loop-termination.mjs` (без новых production-файлов — override не нужен). `buildOwnerCard` — чистый сборщик полей простого языка с честными заглушками. Расширение `decideGate3Closure` добавляет ветку `delivery:'user-result'`, требующую И судью-кода, И сверенную карточку, И подписанного владельца; `delivery:'internal'` и старые вызовы без новых полей сохраняют прежнее поведение (fail-safe дефолт `delivery='internal'`). Закрытие петли — только через `loopTerminationDecision` (инвариант SE-R7-6).
**Tech Stack:** Node ESM, vitest (config `vitest.config.tools.mjs`). TDD.
## Цель
Реализовать чистое ядро пользовательской приёмки владельца (Фаза 2a спеки v2): сборщик карточки `buildOwnerCard` и расширение замыкания `decideGate3Closure` без проводки в Stop-хук (проводка — Фаза 2d). Контроллер петлю не закрывает; закрытие `user-result` требует кода-GO + честной карточки + подписи владельца.
## Структура файлов
- Изменить: `tools/loop-termination.mjs` — добавить `buildOwnerCard`; расширить `decideGate3Closure`.
- Изменить (тест): `tools/loop-termination.test.mjs` — тесты `buildOwnerCard` и новых веток замыкания.
Новых файлов нет. Существующие модули правятся точечными `Edit`.
```skills-json
["test-driven-development"]
```
```steps-json
[
{"op":"Edit","object":"tools/loop-termination.test.mjs","ref":"u9"},
{"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"},
{"op":"Edit","object":"tools/loop-termination.mjs","ref":"u2"},
{"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"},
{"op":"Edit","object":"tools/loop-termination.test.mjs","ref":"u9"},
{"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"},
{"op":"Edit","object":"tools/loop-termination.mjs","ref":"u5"},
{"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"},
{"op":"Bash","object":"npx vitest run --config vitest.config.tools.mjs --no-file-parallelism","ref":"u9"}
]
```
```verified-context-json
[{"id":"vc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"export function decideGate3Closure("}]
```
---
### Task 1: `buildOwnerCard` — сборщик карточки (§u2)
**Files:**
- Modify: `tools/loop-termination.mjs`
- Test: `tools/loop-termination.test.mjs`
- [ ] **Step 1 (Edit `tools/loop-termination.test.mjs`): добавить падающие тесты `buildOwnerCard`**
В конец файла дописать (импорт расширить):
```js
import { buildOwnerCard } from './loop-termination.mjs';
describe('buildOwnerCard (E-S1 Фаза 2 §u2)', () => {
it('собирает 4 части простым языком (goal/change/verifySteps/boundary)', () => {
const c = buildOwnerCard({
goal: 'закрыть петлю по приёмке',
change: ['владелец видит карточку результата'],
verifySteps: ['запусти X → увидишь Y'],
boundary: 'screen-путь не задействован',
kind: 'machinery',
honestyChecked: true,
});
expect(c.goal).toBe('закрыть петлю по приёмке');
expect(c.change).toEqual(['владелец видит карточку результата']);
expect(c.verifySteps).toEqual(['запусти X → увидишь Y']);
expect(c.boundary).toBe('screen-путь не задействован');
expect(c.kind).toBe('machinery');
expect(c.honestyChecked).toBe(true);
expect(c.warning).toBeUndefined();
});
it('kind screen ветвит заглушку шагов проверки', () => {
const c = buildOwnerCard({ goal: 'g', kind: 'screen', honestyChecked: true });
expect(c.kind).toBe('screen');
expect(c.verifySteps[0]).toMatch(/экран/);
});
it('пустые входы → честные заглушки, не выдумки', () => {
const c = buildOwnerCard({});
expect(c.goal).toMatch(/не указана/);
expect(c.change[0]).toMatch(/не указано/);
expect(c.boundary).toMatch(/не указана/);
expect(c.kind).toBe('machinery');
});
it('honestyChecked:false → карточка несёт видимое предупреждение', () => {
const c = buildOwnerCard({ goal: 'g', honestyChecked: false });
expect(c.honestyChecked).toBe(false);
expect(c.warning).toMatch(/автоматическая сверка честности недоступна/);
});
});
```
- [ ] **Step 2 (Bash): прогон — тесты `buildOwnerCard` падают (RED)**
Run: `npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — `buildOwnerCard is not a function` / не экспортирован. (Под Claude возможен harness-collapse `reading 'config'` — авторитетный прогон у владельца в терминале.)
- [ ] **Step 3 (Edit `tools/loop-termination.mjs`): минимальная реализация `buildOwnerCard`**
После `buildGate3Product` (перед `decideGate3Closure`) добавить:
```js
/**
* E-S1 Фаза 2 §u2: пользовательская карточка приёмки — чистый сборщик полей простого языка
* (не код). Пустые входы → честные заглушки (не выдумки). honestyChecked!==true → карточка несёт
* видимое предупреждение «автоматическая сверка честности недоступна» (§u4 degraded-эскалация).
*/
export function buildOwnerCard({ goal = '', change = [], verifySteps = [], boundary = '', kind = 'machinery', honestyChecked = false } = {}) {
const toList = (v) => (Array.isArray(v) ? v : (v == null || String(v).trim() === '' ? [] : [v]))
.map((x) => String(x)).filter((x) => x.trim() !== '');
const k = kind === 'screen' ? 'screen' : 'machinery';
const changeArr = toList(change);
const stepsArr = toList(verifySteps);
const card = {
goal: String(goal || '').trim() || '(цель не указана)',
change: changeArr.length ? changeArr : ['(что изменилось — не указано)'],
verifySteps: stepsArr.length ? stepsArr : [k === 'screen' ? '(шаги проверки на экране не указаны)' : '(сценарий проверки не указан)'],
boundary: String(boundary || '').trim() || '(граница не указана)',
kind: k,
honestyChecked: honestyChecked === true,
};
if (card.honestyChecked !== true) {
card.warning = 'автоматическая сверка честности недоступна — проверь по шагам сам';
}
return card;
}
```
- [ ] **Step 4 (Bash): прогон — тесты `buildOwnerCard` зелёные (GREEN)**
Run: `npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — 4 теста `buildOwnerCard` + прежние тесты файла. (Авторитет — терминал владельца.)
---
### Task 2: расширение `decideGate3Closure` (delivery + cardVerdict, §u5)
**Files:**
- Modify: `tools/loop-termination.mjs`
- Test: `tools/loop-termination.test.mjs`
- [ ] **Step 5 (Edit `tools/loop-termination.test.mjs`): добавить падающие тесты новых веток замыкания**
В конец файла дописать:
```js
describe('decideGate3Closure Фаза 2 (delivery+cardVerdict §u5)', () => {
it('internal + код-GO → closed (обратная совместимость)', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'internal' });
expect(r.state).toBe('closed');
expect(r.terminate).toBe(true);
});
it('user-result + код-GO + карточка wired-NO-GO → await-card (владельца не зовём)', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'user-result', cardVerdict: { wired: true, decision: 'NO-GO' } });
expect(r.state).toBe('await-card');
expect(r.terminate).toBe(false);
});
it('user-result + код-GO + карточка GO + нет подписи → await-owner', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'user-result', cardVerdict: { wired: true, decision: 'GO' } });
expect(r.state).toBe('await-owner');
expect(r.terminate).toBe(false);
expect(r.unverified).toBeUndefined();
});
it('user-result + код-GO + карточка GO + подписанный accept → closed', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'user-result', cardVerdict: { wired: true, decision: 'GO' }, ownerArbitration: 'accept' });
expect(r.state).toBe('closed');
expect(r.terminate).toBe(true);
});
it('user-result + код-GO + карточка degraded → await-owner unverified:true (НЕ висит)', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'user-result', cardVerdict: { wired: false } });
expect(r.state).toBe('await-owner');
expect(r.unverified).toBe(true);
expect(r.terminate).toBe(false);
});
it('user-result + код-GO + карточки ещё нет → await-card', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'user-result', cardVerdict: null });
expect(r.state).toBe('await-card');
expect(r.terminate).toBe(false);
});
it('user-result контроллер без подписи карточку-GO сам не закрывает', () => {
const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' }, delivery: 'user-result', cardVerdict: { wired: true, decision: 'GO' } });
expect(r.terminate).toBe(false);
});
});
```
- [ ] **Step 6 (Bash): прогон — новые ветки падают (RED)**
Run: `npx vitest run tools/loop-termination.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — `user-result` ветки сейчас возвращают `closed` вместо `await-card`/`await-owner`. (Авторитет — терминал владельца.)
- [ ] **Step 7 (Edit `tools/loop-termination.mjs`): расширить `decideGate3Closure`**
Заменить сигнатуру и тело начиная с строки `export function decideGate3Closure(...)`. Контурный Edit: добавить параметры `delivery`/`cardVerdict` и вставить ветку `codeGo` с разбором `delivery`.
`old_string` (текущая сигнатура + блок код-GO):
```js
export function decideGate3Closure({ gate3Verdict = null, noGoCount = 0, ownerArbitration = null, maxRounds = 3 } = {}) {
const degraded = !!gate3Verdict && gate3Verdict.wired === false;
if (degraded && ownerArbitration !== 'accept') {
return { state: 'negotiate', terminate: false, reason: 'судья gate-3 недоступен — не закрывать, повтор/доработка' };
}
if (ownerArbitration === 'accept') {
const t = loopTerminationDecision({ ownerDeclaredDone: true });
return { state: 'closed', terminate: t.terminate, reason: t.reason };
}
if (gate3Verdict && gate3Verdict.decision === 'GO' && gate3Verdict.wired !== false) {
const t = loopTerminationDecision({ judgeGate3Go: true });
return { state: 'closed', terminate: t.terminate, reason: t.reason };
}
```
`new_string`:
```js
export function decideGate3Closure({ gate3Verdict = null, noGoCount = 0, ownerArbitration = null, maxRounds = 3, delivery = 'internal', cardVerdict = null } = {}) {
const degraded = !!gate3Verdict && gate3Verdict.wired === false;
if (degraded && ownerArbitration !== 'accept') {
return { state: 'negotiate', terminate: false, reason: 'судья gate-3 недоступен — не закрывать, повтор/доработка' };
}
if (ownerArbitration === 'accept') {
const t = loopTerminationDecision({ ownerDeclaredDone: true });
return { state: 'closed', terminate: t.terminate, reason: t.reason };
}
const codeGo = !!gate3Verdict && gate3Verdict.decision === 'GO' && gate3Verdict.wired !== false;
if (codeGo) {
if (delivery === 'user-result') {
// §u5: код исправен, но пользовательский результат требует И сверенной карточки, И подписи владельца
const cardDegraded = !!cardVerdict && cardVerdict.wired === false;
const cardGo = !!cardVerdict && cardVerdict.decision === 'GO' && cardVerdict.wired !== false;
const cardNoGo = !!cardVerdict && cardVerdict.wired === true && cardVerdict.decision !== 'GO';
if (cardNoGo) {
return { state: 'await-card', terminate: false, reason: 'карточка приукрашена/неточна — доработать, владельца не звать' };
}
if (cardDegraded) {
return { state: 'await-owner', terminate: false, unverified: true, reason: 'судья карточки недоступен — показать владельцу непроверенную карточку с предупреждением' };
}
if (cardGo) {
return { state: 'await-owner', terminate: false, reason: 'код-GO + карточка сверена — ждём подписанного решения владельца' };
}
return { state: 'await-card', terminate: false, reason: 'код-GO; карточка ещё не сверена — собрать и подать судье карточки' };
}
const t = loopTerminationDecision({ judgeGate3Go: true });
return { state: 'closed', terminate: t.terminate, reason: t.reason };
}
```
(Остальное тело функции — `ownerArbitration === 'continue'`, счётчик кругов, арбитраж — без изменений.)
- [ ] **Step 8 (Bash): прогон — новые ветки зелёные (GREEN)**
Run: `npx vitest run tools/loop-termination.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — новые 7 веток + 7 прежних тестов ядра остаются зелёными (обратная совместимость). (Авторитет — терминал владельца.)
- [ ] **Step 9 (Bash): полная регрессия tools**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — база (4134 passed | 2 skipped) + новые тесты, 0 регрессий. (Под Claude harness-collapse ожидаем; авторитетный полный свод гонит владелец в своём терминале.)
## Self-Review (проверка плана против спеки v2)
- **§u2 `buildOwnerCard`** — Task 1: 4 части простым языком, `kind` machinery/screen ветвление, пустые входы → честные заглушки, `honestyChecked:false` → видимое предупреждение. ✓
- **§u5 замыкание** — Task 2: `internal`+код-GO→closed; `user-result`+карточка-NO-GO→await-card; +карточка-GO→await-owner; +accept→closed; degraded→await-owner unverified:true; нет карточки→await-card; без подписи не закрывает. ✓
- **Инвариант SE-R7-6** — `terminate:true` только через `loopTerminationDecision` (accept / judgeGate3Go). `await-card`/`await-owner` — `terminate:false`. ✓
- **Обратная совместимость** — дефолт `delivery='internal'`, старые вызовы `decideStopTeeth` без новых полей → прежнее поведение; 7 существующих тестов ядра не трогаются. ✓
- **Вне 2a** — судья-карточки (`gate3card` линзы) — Фаза 2b; честность пометки на gate-2 — 2c; проводка в Stop-хук — 2d. В этом плане не реализуются (по декомпозиции спеки §u6). ✓
- **Placeholder-scan** — все шаги несут полный код/команды, заглушек нет. ✓
- **Type consistency** — `buildOwnerCard` поля (goal/change/verifySteps/boundary/kind/honestyChecked/warning) и состояния замыкания (`await-card`/`await-owner`/`unverified`) согласованы между тестами и реализацией. ✓
@@ -0,0 +1,507 @@
# E-S1 gate-3 trigger (Stop-хук) + зубы петли — Implementation Plan (v2)
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline; под стеной Task запрещён). Шаги — checkbox (`- [ ]`).
**Goal:** автозапуск судьи gate-3 на завершении плана через Stop-хук + блокировка завершения хода при недостигнутой цели, потребляя готовое ядро `decideGate3Closure`.
**Architecture:** стена в in-band такте `planComplete` (та же ветка `runGate`, что зовёт `removeFrozenPlan`) через инъектируемый dep `writeLoopOpen` пишет подписанную метку «петля открыта»; новый Stop-хук `enforce-gate3-loop.mjs` на конце хода читает метку, при изменении работы зовёт судью gate-3, применяет ядро и ставит зубы (block Stop, кроме `closed`/`open`). Закрытие — только реальный GO судьи или подписанный выбор владельца (SE-R7-6).
**Tech Stack:** Node ESM, vitest, HMAC-квитанции (`receipt-sign`), escape-грант (`escape-grant`), движок судьи (`judge-engine`), ядро (`loop-termination`).
## Цель
Замкнуть внешнюю петлю наставника: завершение плана автоматически запускает проверку «цель
достигнута?», а попытка завершить ход при открытой петле блокируется до GO судьи gate-3 либо
подписанного решения владельца. Контроллер петлю не закрывает ни прямо, ни подделкой.
## Переговоры
### Круг 1
**Судье:** По замечанию «нет гарантии, что хук сработает после завершения последнего шага → фича
инертна»: метка пишется НЕ отдельным срабатыванием «после завершения», а **в том же in-band такте
`runGate`**, где вычислен `planComplete` и уже вызывается `removeFrozenPlan` (ветка `if (r.planComplete)`
в `runGate`). Это `PreToolUse`-авторизация ПОСЛЕДНЕГО шага: любой инструментальный вызов проходит
стену, значит для любого исполняемого последнего шага метка пишется гарантированно тем же вызовом,
что сдвигает указатель и снимает печать. Если последний шаг не исполняется (план брошен) — петля и
не должна открываться (нет завершения → нет метки), инертности нет. Врезка вынесена в инъектируемый
dep `writeLoopOpen`, поэтому факт вызова на последнем шаге проверяется юнит-тестом (Task 5), а не
постулируется. По замечанию «тест-заглушка»: заглушка заменена реальным тестом на сетапе
`runGateCE`+`PLAN1`/`PLAN2` (последний шаг → `writeLoopOpen` вызван; не-последний → не вызван).
## Предусловия исполнения (под стеной)
- Новый production-файл `tools/enforce-gate3-loop.mjs` требует override владельца ДВУМЯ строками
(`ремонт инфраструктуры` + `ремонт: новый Stop-хук gate-3 по опечатанной спеке`) ИЛИ escape-per-step.
- Правки стены (`enforce-supreme-gate.mjs`) делать **escape-per-step с самого начала**, атомарно
(урок самомодификации F-J — не дробить мутирующие шаги под живой печатью без escape).
- vitest под Claude-Bash недостижим (harness-collapse) → verify-шаги двигают указатель, авторитетный
прогон — у владельца: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`.
- Регистрация хука в `settings.json` (Stop) + перезапуск — действие владельца, ВНЕ этого плана.
## Структура файлов
- **Создать** `tools/enforce-gate3-loop.mjs` — Stop-хук: подпись/IO метки, отпечаток+кэш,
`decideStopTeeth`, сбор продукта, вызов судьи, маппинг подписанного выбора владельца, `main()`,
экспорт `writeLoopOpen` (зовётся стеной как dep).
- **Создать** `tools/enforce-gate3-loop.test.mjs` — юнит-тесты чистых ядер.
- **Изменить** `tools/enforce-supreme-gate.mjs``runGate`: dep `writeLoopOpen` + вызов в ветке
`planComplete`; `main()`: импорт + построение dep-замыкания + проброс в `runGate`.
- **Изменить** `tools/enforce-supreme-gate.test.mjs` — реальный тест: на последнем шаге
`writeLoopOpen` вызван, на не-последнем — нет.
Контракты (имена фиксированы во всех задачах):
- домен подписи метки: `const GATE3_LOOP_DOMAIN = 'gate3-loop'` (литерал, без правки `receipt-sign`).
- файлы (в `~/.claude/runtime`): метка `gate3-loop-<sess>.json`, кэш `gate3-cache-<sess>.json`.
- метка: `{ taskId, planId, artifactId, steps, at, sig }`.
- escape-метки владельца: `gate3-arb:accept:<fp>` / `gate3-arb:continue:<fp>`.
```skills-json
["test-driven-development"]
```
---
### Task 1: Тесты чистых ядер (RED)
**Files:**
- Create: `tools/enforce-gate3-loop.test.mjs`
- [ ] **Step 1: Написать падающие тесты**
```javascript
import { describe, it, expect } from 'vitest';
import {
signLoopMarker, verifyLoopMarker, computeFingerprint,
decideStopTeeth, resolveOwnerArbitration, buildGate3ProductFromMarker,
} from './enforce-gate3-loop.mjs';
const KEY = 'test-key-123';
describe('signLoopMarker/verifyLoopMarker', () => {
it('roundtrip', () => {
const m = signLoopMarker({ taskId: 't1', planId: 'p1', artifactId: 'a1', steps: [], at: 1 }, KEY);
expect(typeof m.sig).toBe('string');
expect(verifyLoopMarker(m, KEY)).toBe(true);
});
it('подмена поля ломает подпись', () => {
const m = signLoopMarker({ taskId: 't1', planId: 'p1', artifactId: 'a1', steps: [], at: 1 }, KEY);
expect(verifyLoopMarker({ ...m, planId: 'p2' }, KEY)).toBe(false);
});
it('нет sig → false', () => { expect(verifyLoopMarker({ taskId: 't1' }, KEY)).toBe(false); });
});
describe('computeFingerprint', () => {
it('детерминирован и не зависит от порядка greens', () => {
expect(computeFingerprint({ planId: 'p', greenIds: ['c2', 'c1'], negotiationText: 'x' }))
.toBe(computeFingerprint({ planId: 'p', greenIds: ['c1', 'c2'], negotiationText: 'x' }));
});
it('меняется на новый green', () => {
expect(computeFingerprint({ planId: 'p', greenIds: ['c1'], negotiationText: 'x' }))
.not.toBe(computeFingerprint({ planId: 'p', greenIds: ['c1', 'c2'], negotiationText: 'x' }));
});
it('меняется на новый довод', () => {
expect(computeFingerprint({ planId: 'p', greenIds: ['c1'], negotiationText: 'x' }))
.not.toBe(computeFingerprint({ planId: 'p', greenIds: ['c1'], negotiationText: 'y' }));
});
});
describe('decideStopTeeth', () => {
const go = { wired: true, decision: 'GO' };
const nogo = { wired: true, decision: 'NO-GO' };
const degraded = { wired: false, decision: 'GO' };
it('GO → allow + clear', () => {
const r = decideStopTeeth({ verdict: go, noGoCount: 0, ownerArbitration: null });
expect(r.block).toBe(false); expect(r.clear).toBe(true);
});
it('accept владельца → allow + clear', () => {
const r = decideStopTeeth({ verdict: nogo, noGoCount: 0, ownerArbitration: 'accept' });
expect(r.block).toBe(false); expect(r.clear).toBe(true);
});
it('continue → allow, метку держим', () => {
const r = decideStopTeeth({ verdict: nogo, noGoCount: 0, ownerArbitration: 'continue' });
expect(r.block).toBe(false); expect(r.clear).toBe(false);
});
it('NO-GO круг<3 → block negotiate', () => {
const r = decideStopTeeth({ verdict: nogo, noGoCount: 1, ownerArbitration: null });
expect(r.block).toBe(true); expect(r.state).toBe('negotiate');
});
it('NO-GO круг>=3 → block arbitrate+card', () => {
const r = decideStopTeeth({ verdict: nogo, noGoCount: 3, ownerArbitration: null });
expect(r.block).toBe(true); expect(r.state).toBe('arbitrate'); expect(r.card).toBe(true);
});
it('degraded → block, НЕ closed/arbitrate', () => {
const r = decideStopTeeth({ verdict: degraded, noGoCount: 5, ownerArbitration: null });
expect(r.block).toBe(true); expect(r.clear).toBe(false); expect(r.state).toBe('negotiate');
});
});
describe('resolveOwnerArbitration', () => {
const fp = 'abc';
const g = (action, ts = 1000) => ({ action, ts });
it('accept грант → accept', () => {
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [g(`gate3-arb:accept:${fp}`)], consumed: [], now: 1000 })).toBe('accept');
});
it('continue грант → continue', () => {
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [g(`gate3-arb:continue:${fp}`)], consumed: [], now: 1000 })).toBe('continue');
});
it('нет гранта → null', () => {
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [], consumed: [], now: 1000 })).toBe(null);
});
it('грант на другой отпечаток → null (анти-реплей)', () => {
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [g('gate3-arb:accept:OTHER')], consumed: [], now: 1000 })).toBe(null);
});
});
describe('buildGate3ProductFromMarker', () => {
it('цель из секций + шаги + greens', () => {
const marker = { steps: [{ op: 'Write', object: 'a.mjs', criterion_id: 'c1' }], planId: 'p' };
const frozenArtifact = { sections: { s1: 'построить X', s2: 'критерий Y' } };
const out = buildGate3ProductFromMarker({ marker, frozenArtifact, greens: [{ criterion_id: 'c1', green: true }] });
expect(out.goal).toContain('построить X');
expect(out.product).toContain('a.mjs');
expect(out.product).toContain('green');
});
});
```
- [ ] **Step 2: Прогон — падают**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — модуля/экспортов нет.
---
### Task 2: Реализация `enforce-gate3-loop.mjs` (GREEN)
**Files:**
- Create: `tools/enforce-gate3-loop.mjs`
- [ ] **Step 1: Написать модуль целиком**
```javascript
#!/usr/bin/env node
/**
* enforce-gate3-loop (E-S1 триггер) — Stop-хук «зубы петли»: стена на завершении плана пишет
* метку «петля открыта»; здесь на конце хода судим «цель достигнута?» (gate-3) и блокируем
* завершение, пока петля не закрыта. Закрытие — только реальный GO судьи ИЛИ подписанный выбор
* владельца (SE-R7-6). Чистые ядра тестируемы без модели/IO; main() — тонкая обёртка.
*/
import fsDefault from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { canonicalJson, signPayload, verifyReceipt } from './receipt-sign.mjs';
import { buildGate3Product, decideGate3Closure } from './loop-termination.mjs';
import { escapeGrantOpen } from './escape-grant.mjs';
import { parseNegotiationSection } from './negotiation-section.mjs';
import { buildArbitrationCard } from './arbitration-card.mjs';
import { buildObjectionFeedback, buildDegradedFeedback } from './objection-delivery.mjs';
const GATE3_LOOP_DOMAIN = 'gate3-loop';
const ESCALATE_AFTER = 3;
export function signLoopMarker(payload, key) { return { ...payload, sig: signPayload(payload, key, GATE3_LOOP_DOMAIN) }; }
export function verifyLoopMarker(marker, key) { return verifyReceipt(marker, key, GATE3_LOOP_DOMAIN); }
export function loopMarkerPath(runtimeDir, sess) { return join(runtimeDir, `gate3-loop-${sess}.json`); }
export function cachePath(runtimeDir, sess) { return join(runtimeDir, `gate3-cache-${sess}.json`); }
export function writeLoopOpen({ taskId, planId, artifactId, steps, at, key, runtimeDir, sess, fsImpl = fsDefault }) {
const marker = signLoopMarker({ taskId: taskId || null, planId, artifactId: artifactId || null, steps: steps || [], at: at || 0 }, key);
try { fsImpl.mkdirSync(runtimeDir, { recursive: true }); fsImpl.writeFileSync(loopMarkerPath(runtimeDir, sess), JSON.stringify(marker)); } catch { /* best-effort */ }
}
export function readLoopOpen({ runtimeDir, sess, key, fsImpl = fsDefault }) {
let m = null;
try { m = JSON.parse(fsImpl.readFileSync(loopMarkerPath(runtimeDir, sess), 'utf8')); } catch { return null; }
return verifyLoopMarker(m, key) ? m : null;
}
export function clearLoopOpen({ runtimeDir, sess, fsImpl = fsDefault }) {
try { fsImpl.unlinkSync(loopMarkerPath(runtimeDir, sess)); } catch { /* no-op */ }
}
export function computeFingerprint({ planId = '', greenIds = [], negotiationText = '' } = {}) {
const sorted = [...greenIds].map(String).sort();
return createHash('sha256').update(canonicalJson({ planId: String(planId), greenIds: sorted, negotiationText: String(negotiationText) })).digest('hex');
}
export function loadCache({ runtimeDir, sess, fsImpl = fsDefault }) {
try { return JSON.parse(fsImpl.readFileSync(cachePath(runtimeDir, sess), 'utf8')); } catch { return null; }
}
export function saveCache({ runtimeDir, sess, cache, fsImpl = fsDefault }) {
try { fsImpl.mkdirSync(runtimeDir, { recursive: true }); fsImpl.writeFileSync(cachePath(runtimeDir, sess), JSON.stringify(cache)); } catch { /* best-effort */ }
}
export function buildGate3ProductFromMarker({ marker, frozenArtifact, greens }) {
const sections = (frozenArtifact && frozenArtifact.sections) || {};
const goal = Object.keys(sections).sort().map((k) => `[${k}] ${sections[k]}`).join('\n') || '(цель не извлечена из опечатанного артефакта)';
const greenIds = new Set((Array.isArray(greens) ? greens : []).filter((g) => g && g.green).map((g) => String(g.criterion_id)));
const planSteps = (marker && Array.isArray(marker.steps) ? marker.steps : []).map((s) => ({ id: s.criterion_id, op: s.op, object: s.object }));
const greenRuns = planSteps.filter((s) => greenIds.has(String(s.id))).map((s) => ({ stepId: s.id, criterion: true }));
return buildGate3Product({ goal, planSteps, greenRuns });
}
export function resolveOwnerArbitration({ fingerprint, grants, consumed, now }) {
if (escapeGrantOpen(`gate3-arb:accept:${fingerprint}`, grants, consumed, now)) return 'accept';
if (escapeGrantOpen(`gate3-arb:continue:${fingerprint}`, grants, consumed, now)) return 'continue';
return null;
}
export function decideStopTeeth({ verdict, noGoCount = 0, ownerArbitration = null, maxRounds = ESCALATE_AFTER }) {
const d = decideGate3Closure({ gate3Verdict: verdict, noGoCount, ownerArbitration, maxRounds });
if (d.state === 'closed') return { block: false, clear: true, state: d.state, reason: d.reason };
if (d.state === 'open') return { block: false, clear: false, state: d.state, reason: d.reason };
return { block: true, clear: false, state: d.state, card: !!d.card, reason: d.reason };
}
/** Чистая оркестрация хода Stop (deps инъектируются — тест без IO/модели). {block, message?}. */
export async function runGate3Stop(event, deps) {
const { runtimeDir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, grants, consumed, now } = deps;
const marker = readLoopOpen({ runtimeDir, sess, key });
if (!marker) return { block: false };
const greens = (loadGreens && loadGreens()) || [];
const greenIds = greens.filter((g) => g && g.green).map((g) => g.criterion_id);
const frozenArtifact = (loadArtifact && loadArtifact()) || null;
const negotiationText = parseNegotiationSection((frozenArtifact && frozenArtifact._md) || '').map((r) => r.position).join('\n');
const fingerprint = computeFingerprint({ planId: marker.planId, greenIds, negotiationText });
const cache = loadCache({ runtimeDir, sess }) || { fingerprint: null, verdict: null, noGoCount: 0 };
let verdict = cache.verdict;
let noGoCount = cache.noGoCount || 0;
if (cache.fingerprint !== fingerprint) {
if (judgeKey && callJudge) {
verdict = await callJudge(buildGate3ProductFromMarker({ marker, frozenArtifact, greens }));
} else {
verdict = { wired: false, decision: 'GO', unavailable: true };
}
const isContentNoGo = !!verdict && verdict.wired === true && verdict.decision !== 'GO';
const isContentGo = !!verdict && verdict.wired === true && verdict.decision === 'GO';
noGoCount = isContentNoGo ? noGoCount + 1 : (isContentGo ? 0 : noGoCount);
if (verdict && verdict.wired !== false) saveCache({ runtimeDir, sess, cache: { fingerprint, verdict, noGoCount } });
}
const ownerArbitration = resolveOwnerArbitration({ fingerprint, grants, consumed, now });
const t = decideStopTeeth({ verdict, noGoCount, ownerArbitration });
if (t.clear) { clearLoopOpen({ runtimeDir, sess }); saveCache({ runtimeDir, sess, cache: { fingerprint: null, verdict: null, noGoCount: 0 } }); }
if (!t.block) return { block: false };
let message;
if (t.state === 'arbitrate') {
const card = buildArbitrationCard({ side: 'judge', level: 'L2', round: noGoCount, objectionVerbatim: t.reason || '(возражение судьи)', controllerPositionVerbatim: negotiationText || '(позиция не указана)', sealAction: `gate3-arb:accept:${fingerprint}` });
message = `[gate3-loop] ${card.title}\nЦель не подтверждена. Замечание: ${card.objection}\nВыбор владельца: FLOOR-ESCAPE: gate3-arb:accept:${fingerprint} (принять) / gate3-arb:continue:${fingerprint} (продолжать).`;
} else if (verdict && verdict.wired === false) {
message = buildDegradedFeedback({ side: 'judge', reason: 'судья gate-3 недоступен — петля не закрыта; выход: escape владельца ИЛИ plan-done' });
} else {
message = buildObjectionFeedback({ side: 'judge', text: t.reason || 'цель не достигнута — доработай или докажи' });
}
return { block: true, message };
}
async function main() {
const { readStdin, parseEventJson, runtimeDir, exitDecision } = await import('./enforce-hook-helpers.mjs');
const { resolveReceiptKey } = await import('./receipt-key-config.mjs');
const { resolveJudgeLlmKey } = await import('./judge-gate-config.mjs');
const { callJudgeModel } = await import('./enforce-judge-gate.mjs');
const { requiredLensesFor, runJudge } = await import('./judge-engine.mjs');
const { loadFloorEscapes, loadConsumed } = await import('./escape-grant.mjs');
try {
const event = parseEventJson(await readStdin());
const dir = runtimeDir();
const sess = (event && event.session_id) || process.env.CLAUDE_SESSION_ID || 'unknown';
const key = resolveReceiptKey();
const judgeKey = resolveJudgeLlmKey();
const loadGreens = () => { try { return JSON.parse(fsDefault.readFileSync(join(dir, `criterion-greens-${sess}.json`), 'utf8')); } catch { return []; } };
const loadArtifact = () => { try { return JSON.parse(fsDefault.readFileSync(join(dir, `frozen-artifact-${sess}.json`), 'utf8')); } catch { return null; } };
const callJudge = async (product) => {
const requiredLenses = requiredLensesFor('gate3');
const promptArgs = { ...product, roundMemory: {} };
const raw = await callJudgeModel({ functionName: 'gate3', requiredLenses, promptArgs, apiKey: judgeKey });
if (raw && raw.unavailable) return { wired: false, decision: 'GO', unavailable: true };
const v = runJudge({ functionName: 'gate3', requiredLenses, subRunsRequired: [], subRuns: [], llmCall: () => raw, promptArgs });
return { wired: true, decision: v.decision, verdict: v };
};
const r = await runGate3Stop(event, { runtimeDir: dir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, grants: loadFloorEscapes(sess), consumed: loadConsumed(sess), now: Date.now() });
exitDecision({ block: !!r.block, message: r.block ? `[gate3-loop] ${r.message || 'петля открыта — цель не подтверждена'}` : undefined });
} catch {
exitDecision({ block: false }); // Stop fail-OPEN: внутренняя ошибка хука НЕ кирпичит конец хода
}
}
import { fileURLToPath } from 'node:url';
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
```
- [ ] **Step 2: Прогон — зелено**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS (авторитетно у владельца).
---
### Task 3: `runGate` стены — dep `writeLoopOpen` + вызов на planComplete
**Files:**
- Modify: `tools/enforce-supreme-gate.mjs` (сигнатура `runGate` + ветка `if (r.planComplete)`)
- [ ] **Step 1: Добавить writeLoopOpen в сигнатуру runGate**
```javascript
// было:
export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr = null, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
// стало (добавлен writeLoopOpen):
export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr = null, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, writeLoopOpen, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
```
- [ ] **Step 2: Verify**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS (новый необязательный параметр не ломает существующие вызовы).
- [ ] **Step 3: Вызвать writeLoopOpen в ветке planComplete (рядом с removeFrozenPlan)**
```javascript
// было:
if (r.planComplete) {
saveStep(r.advanceTo, null);
if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } }
} else {
// стало:
if (r.planComplete) {
saveStep(r.advanceTo, null);
if (typeof writeLoopOpen === 'function') { try { writeLoopOpen(); } catch { /* best-effort: сбой метки не ломает завершение */ } }
if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } }
} else {
```
- [ ] **Step 4: Verify**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
---
### Task 4: `main()` стены — построить dep-замыкание и пробросить
**Files:**
- Modify: `tools/enforce-supreme-gate.mjs` (импорты + вызов `runGate` в `main()`)
- [ ] **Step 1: Импорт writeLoopOpen (alias) + loadTaskId**
После строки `import { canonicalAction, escapeGrantOpen, ... } from './escape-grant.mjs';` добавить:
```javascript
import { writeLoopOpen as writeLoopOpenMarker } from './enforce-gate3-loop.mjs';
import { loadTaskId } from './router-task-id.mjs';
```
- [ ] **Step 2: Verify**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
- [ ] **Step 3: Пробросить writeLoopOpen-замыкание в runGate (в main)**
В `main()`, в объект-аргумент `runGate({...})`, добавить поле `writeLoopOpen` рядом с `removeFrozenPlan`:
```javascript
removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }),
writeLoopOpen: () => {
let taskId = null;
try { taskId = loadTaskId({ sessionId: sess, runtimeDir, fsImpl: fs }); } catch { taskId = null; }
writeLoopOpenMarker({
taskId, planId: frozenPlan?.plan_id ?? null, artifactId: frozenPlan?.artifact_id ?? null,
steps: (frozenPlan && frozenPlan.steps) || [], at: event.nowMs ?? Date.now(),
key, runtimeDir, sess, fsImpl: fs,
});
},
```
- [ ] **Step 4: Verify**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
---
### Task 5: Реальный тест стены — метка на planComplete
**Files:**
- Modify: `tools/enforce-supreme-gate.test.mjs` (в существующем describe «Фаза 5 — чистое завершение плана»)
- [ ] **Step 1: Добавить два реальных теста (сетап runGateCE/PLAN1/PLAN2 уже в файле)**
```javascript
it('runGate: последний шаг → writeLoopOpen вызван (метка петли открыта)', () => {
let opened = 0;
const r = runGateCE({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
frozenPlan: { ...PLAN1, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE,
verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE,
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => {}, writeLoopOpen: () => { opened++; },
});
expect(r.block).toBe(false);
expect(opened).toBe(1);
});
it('runGate: НЕ последний шаг → writeLoopOpen НЕ вызван', () => {
let opened = 0;
runGateCE({
event: { tool_name: 'Write', tool_input: { file_path: 'tools/a.mjs' } },
frozenPlan: { ...PLAN2, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE,
verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE,
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => {}, writeLoopOpen: () => { opened++; },
});
expect(opened).toBe(0);
});
```
- [ ] **Step 2: Verify полной регрессии**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — база + новые тесты зелёные (smoke существующих тестов стены — в этом же своде).
---
### Task 6: Финальная регрессия, ручное ревью, коммит
- [ ] **Step 1: Полный свод (авторитетно — владелец в терминале)**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — 4105+новые passed, 0 регрессий (переиспользуемые модули не модифицированы).
- [ ] **Step 2: Ручное ревью изменённых файлов (рекомендация наставника)**
Глазами просмотреть diff `enforce-gate3-loop.mjs` (логика судьи/кэша) и `enforce-supreme-gate.mjs`
(минимальность врезки, нет лишних побочных эффектов на не-последних шагах).
- [ ] **Step 3: Коммит (через escape владельца)**
```bash
git add tools/enforce-gate3-loop.mjs tools/enforce-gate3-loop.test.mjs tools/enforce-supreme-gate.mjs tools/enforce-supreme-gate.test.mjs
git commit -m "feat: E-S1 gate-3 trigger Stop-hook enforce-gate3-loop plus wall loop-open marker" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>" -- tools/enforce-gate3-loop.mjs tools/enforce-gate3-loop.test.mjs tools/enforce-supreme-gate.mjs tools/enforce-supreme-gate.test.mjs
git push gitea main
```
## Критерий приёмки
- Юнит-тесты `enforce-gate3-loop.test.mjs` зелёные (подпись метки, отпечаток, decideStopTeeth все
ветки, resolveOwnerArbitration анти-реплей, сбор продукта).
- Тест стены НЕ заглушка: на последнем шаге `writeLoopOpen` вызван ровно 1 раз; на не-последнем — 0.
- Полная регрессия tools зелёная, 0 регрессий.
- Врезка в стену минимальна: один dep + один вызов в существующей ветке `planComplete`.
```steps-json
[
{"op": "Write", "object": "tools/enforce-gate3-loop.test.mjs", "ref": "s9"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s9"},
{"op": "Write", "object": "tools/enforce-gate3-loop.mjs", "ref": "s6"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s6"},
{"op": "Edit", "object": "tools/enforce-supreme-gate.mjs", "ref": "s6"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s6"},
{"op": "Edit", "object": "tools/enforce-supreme-gate.mjs", "ref": "s6"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s6"},
{"op": "Edit", "object": "tools/enforce-supreme-gate.test.mjs", "ref": "s9"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s9"}
]
```
```verified-context-json
[{"id":"vc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"decideGate3Closure"}]
```
@@ -0,0 +1,494 @@
# E-S1 gate-3 trigger (Stop-хук) + зубы петли — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline; под стеной Task запрещён). Шаги — checkbox (`- [ ]`).
**Goal:** автозапуск судьи gate-3 на завершении плана через Stop-хук + блокировка завершения хода при недостигнутой цели, потребляя готовое ядро `decideGate3Closure`.
**Architecture:** стена на `planComplete` пишет подписанную метку «петля открыта»; новый Stop-хук `enforce-gate3-loop.mjs` на конце хода читает метку, при изменении работы зовёт судью gate-3, применяет ядро и ставит зубы (block Stop, кроме `closed`/`open`). Закрытие — только реальный GO судьи или подписанный выбор владельца (SE-R7-6).
**Tech Stack:** Node ESM, vitest, HMAC-квитанции (`receipt-sign`), escape-грант (`escape-grant`), движок судьи (`judge-engine`), ядро (`loop-termination`).
## Цель
Замкнуть внешнюю петлю наставника: завершение плана автоматически запускает проверку «цель
достигнута?», а попытка завершить ход при открытой петле блокируется до GO судьи gate-3 либо
подписанного решения владельца. Контроллер петлю не закрывает ни прямо, ни подделкой.
## Предусловия исполнения (под стеной)
- Новый production-файл `tools/enforce-gate3-loop.mjs` требует override владельца ДВУМЯ строками
(`ремонт инфраструктуры` + `ремонт: новый Stop-хук gate-3 по опечатанной спеке`) ИЛИ escape-per-step
на каждый Write/Edit/Bash.
- vitest под Claude-Bash недостижим (harness-collapse) → verify-шаги двигают указатель, авторитетный
прогон — у владельца: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`.
- Регистрация хука в `settings.json` (Stop) + перезапуск — действие владельца, ВНЕ этого плана.
## Структура файлов
- **Создать** `tools/enforce-gate3-loop.mjs` — весь Stop-хук: подпись/IO метки, отпечаток+кэш,
`decideStopTeeth`, сбор продукта, вызов судьи, маппинг подписанного выбора владельца, `main()`,
плюс экспорт `writeLoopOpen` (зовёт стена).
- **Создать** `tools/enforce-gate3-loop.test.mjs` — юнит-тесты чистых ядер.
- **Изменить** `tools/enforce-supreme-gate.mjs` — surface `planComplete` из `runGate` + в `main()`
записать метку через `writeLoopOpen`.
- **Изменить** `tools/enforce-supreme-gate.test.mjs` — тест: на `planComplete` пишется метка.
Константы/контракты (используются ниже во всех задачах — имена фиксированы):
- домен подписи метки: `const GATE3_LOOP_DOMAIN = 'gate3-loop'` (литерал, без правки `receipt-sign`).
- файл метки: `gate3-loop-<sess>.json`; кэш: `gate3-cache-<sess>.json``~/.claude/runtime`).
- метка: `{ taskId, planId, artifactId, steps, at, sig }`.
- escape-метки владельца: `gate3-arb:accept:<fp>` / `gate3-arb:continue:<fp>`.
```skills-json
["test-driven-development"]
```
---
### Task 1: Тесты чистых ядер (RED)
**Files:**
- Create: `tools/enforce-gate3-loop.test.mjs`
- [ ] **Step 1: Написать падающие тесты**
```javascript
import { describe, it, expect, vi } from 'vitest';
import {
signLoopMarker, verifyLoopMarker, computeFingerprint,
decideStopTeeth, resolveOwnerArbitration, buildGate3ProductFromMarker,
} from './enforce-gate3-loop.mjs';
const KEY = 'test-key-123';
describe('signLoopMarker/verifyLoopMarker', () => {
it('roundtrip: подписанная метка верифицируется', () => {
const m = signLoopMarker({ taskId: 't1', planId: 'p1', artifactId: 'a1', steps: [], at: 1 }, KEY);
expect(typeof m.sig).toBe('string');
expect(verifyLoopMarker(m, KEY)).toBe(true);
});
it('подмена поля ломает подпись', () => {
const m = signLoopMarker({ taskId: 't1', planId: 'p1', artifactId: 'a1', steps: [], at: 1 }, KEY);
expect(verifyLoopMarker({ ...m, planId: 'p2' }, KEY)).toBe(false);
});
it('нет ключа / нет sig → false', () => {
expect(verifyLoopMarker({ taskId: 't1' }, KEY)).toBe(false);
});
});
describe('computeFingerprint', () => {
it('детерминирован и не зависит от порядка greens', () => {
const a = computeFingerprint({ planId: 'p', greenIds: ['c2', 'c1'], negotiationText: 'x' });
const b = computeFingerprint({ planId: 'p', greenIds: ['c1', 'c2'], negotiationText: 'x' });
expect(a).toBe(b);
});
it('меняется на новый green', () => {
const a = computeFingerprint({ planId: 'p', greenIds: ['c1'], negotiationText: 'x' });
const b = computeFingerprint({ planId: 'p', greenIds: ['c1', 'c2'], negotiationText: 'x' });
expect(a).not.toBe(b);
});
it('меняется на новый довод переговоров', () => {
const a = computeFingerprint({ planId: 'p', greenIds: ['c1'], negotiationText: 'x' });
const b = computeFingerprint({ planId: 'p', greenIds: ['c1'], negotiationText: 'y' });
expect(a).not.toBe(b);
});
});
describe('decideStopTeeth', () => {
const go = { wired: true, decision: 'GO' };
const nogo = { wired: true, decision: 'NO-GO' };
const degraded = { wired: false, decision: 'GO' };
it('GO судьи → allow + clear', () => {
const r = decideStopTeeth({ verdict: go, noGoCount: 0, ownerArbitration: null });
expect(r.block).toBe(false); expect(r.clear).toBe(true);
});
it('подписанный accept владельца → allow + clear', () => {
const r = decideStopTeeth({ verdict: nogo, noGoCount: 0, ownerArbitration: 'accept' });
expect(r.block).toBe(false); expect(r.clear).toBe(true);
});
it('continue владельца → allow, метку держим', () => {
const r = decideStopTeeth({ verdict: nogo, noGoCount: 0, ownerArbitration: 'continue' });
expect(r.block).toBe(false); expect(r.clear).toBe(false);
});
it('NO-GO, круг < 3 → block (negotiate)', () => {
const r = decideStopTeeth({ verdict: nogo, noGoCount: 1, ownerArbitration: null });
expect(r.block).toBe(true); expect(r.state).toBe('negotiate');
});
it('NO-GO, круг >= 3 → block + card (arbitrate)', () => {
const r = decideStopTeeth({ verdict: nogo, noGoCount: 3, ownerArbitration: null });
expect(r.block).toBe(true); expect(r.state).toBe('arbitrate'); expect(r.card).toBe(true);
});
it('degraded → block, НЕ closed/arbitrate', () => {
const r = decideStopTeeth({ verdict: degraded, noGoCount: 5, ownerArbitration: null });
expect(r.block).toBe(true); expect(r.clear).toBe(false); expect(r.state).toBe('negotiate');
});
});
describe('resolveOwnerArbitration', () => {
const fp = 'abc';
const grant = (action, ts = 1000) => ({ action, ts });
it('подписанный accept грант → accept', () => {
const r = resolveOwnerArbitration({ fingerprint: fp, grants: [grant(`gate3-arb:accept:${fp}`)], consumed: [], now: 1000 });
expect(r).toBe('accept');
});
it('continue грант → continue', () => {
const r = resolveOwnerArbitration({ fingerprint: fp, grants: [grant(`gate3-arb:continue:${fp}`)], consumed: [], now: 1000 });
expect(r).toBe('continue');
});
it('нет гранта → null', () => {
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [], consumed: [], now: 1000 })).toBe(null);
});
it('грант на ДРУГОЙ отпечаток → null (анти-реплей)', () => {
const r = resolveOwnerArbitration({ fingerprint: fp, grants: [grant('gate3-arb:accept:OTHER')], consumed: [], now: 1000 });
expect(r).toBe(null);
});
});
describe('buildGate3ProductFromMarker', () => {
it('собирает продукт: цель из секций + шаги + greens', () => {
const marker = { steps: [{ op: 'Write', object: 'a.mjs', criterion_id: 'c1' }], planId: 'p' };
const frozenArtifact = { sections: { s1: 'построить X', s2: 'критерий Y' } };
const greens = [{ criterion_id: 'c1', green: true }];
const out = buildGate3ProductFromMarker({ marker, frozenArtifact, greens });
expect(out.goal).toContain('построить X');
expect(out.product).toContain('a.mjs');
expect(out.product).toContain('green');
});
});
```
- [ ] **Step 2: Прогнать тесты — убедиться, что падают**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — `enforce-gate3-loop.mjs` не существует / экспортов нет.
---
### Task 2: Реализация `enforce-gate3-loop.mjs` (GREEN)
**Files:**
- Create: `tools/enforce-gate3-loop.mjs`
- [ ] **Step 1: Написать модуль целиком**
```javascript
#!/usr/bin/env node
/**
* enforce-gate3-loop (E-S1 триггер) — Stop-хук «зубы петли»: на завершении плана стена пишет
* метку «петля открыта»; здесь на конце хода судим «цель достигнута?» (gate-3) и блокируем
* завершение, пока петля не закрыта. Закрытие — только реальный GO судьи ИЛИ подписанный выбор
* владельца (SE-R7-6). Чистые ядра тестируемы без модели/IO; main() — тонкая обёртка.
*/
import fsDefault from 'node:fs';
import { join } from 'node:path';
import { canonicalJson, signPayload, verifyReceipt } from './receipt-sign.mjs';
import { createHash } from 'node:crypto';
import { buildGate3Product, decideGate3Closure } from './loop-termination.mjs';
import { escapeGrantOpen } from './escape-grant.mjs';
import { parseNegotiationSection } from './negotiation-section.mjs';
import { buildArbitrationCard } from './arbitration-card.mjs';
import { buildObjectionFeedback, buildDegradedFeedback } from './objection-delivery.mjs';
const GATE3_LOOP_DOMAIN = 'gate3-loop';
const ESCALATE_AFTER = 3;
// ── метка «петля открыта» ──
export function signLoopMarker(payload, key) {
return { ...payload, sig: signPayload(payload, key, GATE3_LOOP_DOMAIN) };
}
export function verifyLoopMarker(marker, key) {
return verifyReceipt(marker, key, GATE3_LOOP_DOMAIN);
}
export function loopMarkerPath(runtimeDir, sess) { return join(runtimeDir, `gate3-loop-${sess}.json`); }
export function cachePath(runtimeDir, sess) { return join(runtimeDir, `gate3-cache-${sess}.json`); }
/** Запись метки стеной (зовётся из enforce-supreme-gate.main). Best-effort. */
export function writeLoopOpen({ taskId, planId, artifactId, steps, at, key, runtimeDir, sess, fsImpl = fsDefault }) {
const marker = signLoopMarker({ taskId: taskId || null, planId, artifactId: artifactId || null, steps: steps || [], at: at || 0 }, key);
try { fsImpl.mkdirSync(runtimeDir, { recursive: true }); fsImpl.writeFileSync(loopMarkerPath(runtimeDir, sess), JSON.stringify(marker)); } catch { /* best-effort */ }
}
export function readLoopOpen({ runtimeDir, sess, key, fsImpl = fsDefault }) {
let m = null;
try { m = JSON.parse(fsImpl.readFileSync(loopMarkerPath(runtimeDir, sess), 'utf8')); } catch { return null; }
return verifyLoopMarker(m, key) ? m : null; // подделка/битая подпись → как нет метки (fail-safe)
}
export function clearLoopOpen({ runtimeDir, sess, fsImpl = fsDefault }) {
try { fsImpl.unlinkSync(loopMarkerPath(runtimeDir, sess)); } catch { /* no-op */ }
}
// ── отпечаток + кэш ──
export function computeFingerprint({ planId = '', greenIds = [], negotiationText = '' } = {}) {
const sorted = [...greenIds].map(String).sort();
return createHash('sha256').update(canonicalJson({ planId: String(planId), greenIds: sorted, negotiationText: String(negotiationText) })).digest('hex');
}
export function loadCache({ runtimeDir, sess, fsImpl = fsDefault }) {
try { return JSON.parse(fsImpl.readFileSync(cachePath(runtimeDir, sess), 'utf8')); } catch { return null; }
}
export function saveCache({ runtimeDir, sess, cache, fsImpl = fsDefault }) {
try { fsImpl.mkdirSync(runtimeDir, { recursive: true }); fsImpl.writeFileSync(cachePath(runtimeDir, sess), JSON.stringify(cache)); } catch { /* best-effort */ }
}
// ── продукт судьи gate-3 ──
export function buildGate3ProductFromMarker({ marker, frozenArtifact, greens }) {
const sections = (frozenArtifact && frozenArtifact.sections) || {};
const goal = Object.keys(sections).sort().map((k) => `[${k}] ${sections[k]}`).join('\n') || '(цель не извлечена из опечатанного артефакта)';
const greenIds = new Set((Array.isArray(greens) ? greens : []).filter((g) => g && g.green).map((g) => String(g.criterion_id)));
const planSteps = (marker && Array.isArray(marker.steps) ? marker.steps : []).map((s) => ({ id: s.criterion_id, op: s.op, object: s.object }));
const greenRuns = planSteps.filter((s) => greenIds.has(String(s.id))).map((s) => ({ stepId: s.id, criterion: true }));
return buildGate3Product({ goal, planSteps, greenRuns });
}
// ── подписанный выбор владельца ──
export function resolveOwnerArbitration({ fingerprint, grants, consumed, now }) {
if (escapeGrantOpen(`gate3-arb:accept:${fingerprint}`, grants, consumed, now)) return 'accept';
if (escapeGrantOpen(`gate3-arb:continue:${fingerprint}`, grants, consumed, now)) return 'continue';
return null;
}
// ── зубы (чистое решение) ──
export function decideStopTeeth({ verdict, noGoCount = 0, ownerArbitration = null, maxRounds = ESCALATE_AFTER }) {
const d = decideGate3Closure({ gate3Verdict: verdict, noGoCount, ownerArbitration, maxRounds });
if (d.state === 'closed') return { block: false, clear: true, state: d.state, reason: d.reason };
if (d.state === 'open') return { block: false, clear: false, state: d.state, reason: d.reason };
// negotiate / arbitrate / degraded(negotiate) → block, метку держим
return { block: true, clear: false, state: d.state, card: !!d.card, reason: d.reason };
}
/**
* Чистая оркестрация хода Stop (deps инъектируются — тест без IO/модели).
* Возвращает {block, message?, clear?}.
*/
export async function runGate3Stop(event, deps) {
const { runtimeDir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, grants, consumed, now, taskId } = deps;
const marker = readLoopOpen({ runtimeDir, sess, key });
if (!marker) return { block: false }; // петли нет — не наше дело
const greens = (loadGreens && loadGreens()) || [];
const greenIds = greens.filter((g) => g && g.green).map((g) => g.criterion_id);
const frozenArtifact = (loadArtifact && loadArtifact()) || null;
const negotiationRounds = parseNegotiationSection((frozenArtifact && frozenArtifact._md) || '');
const negotiationText = negotiationRounds.map((r) => r.position).join('\n');
const fingerprint = computeFingerprint({ planId: marker.planId, greenIds, negotiationText });
const cache = loadCache({ runtimeDir, sess }) || { fingerprint: null, verdict: null, noGoCount: 0 };
let verdict = cache.verdict;
let noGoCount = cache.noGoCount || 0;
if (cache.fingerprint !== fingerprint) {
// работа изменилась → звоним судье gate-3 (если есть ключ)
if (judgeKey && callJudge) {
const product = buildGate3ProductFromMarker({ marker, frozenArtifact, greens });
verdict = await callJudge(product);
} else {
verdict = { wired: false, decision: 'GO', unavailable: true }; // degraded — нет ключа
}
const isContentNoGo = !!verdict && verdict.wired === true && verdict.decision !== 'GO';
noGoCount = isContentNoGo ? noGoCount + 1 : (verdict && verdict.wired === true && verdict.decision === 'GO' ? 0 : noGoCount);
if (verdict && verdict.wired !== false) saveCache({ runtimeDir, sess, cache: { fingerprint, verdict, noGoCount } });
}
const ownerArbitration = resolveOwnerArbitration({ fingerprint, grants, consumed, now });
const t = decideStopTeeth({ verdict, noGoCount, ownerArbitration });
if (t.clear) { clearLoopOpen({ runtimeDir, sess }); saveCache({ runtimeDir, sess, cache: { fingerprint: null, verdict: null, noGoCount: 0 } }); }
if (!t.block) return { block: false };
let message;
if (t.state === 'arbitrate') {
const card = buildArbitrationCard({ side: 'judge', level: 'L2', round: noGoCount, objectionVerbatim: t.reason || '(возражение судьи)', controllerPositionVerbatim: negotiationText || '(позиция не указана)', sealAction: `gate3-arb:accept:${fingerprint}` });
message = `[gate3-loop] ${card.title}\nЦель не подтверждена. Замечание: ${card.objection}\nВыбор владельца: FLOOR-ESCAPE: gate3-arb:accept:${fingerprint} (принять) / gate3-arb:continue:${fingerprint} (продолжать).`;
} else if (verdict && verdict.wired === false) {
message = buildDegradedFeedback({ side: 'judge', reason: 'судья gate-3 недоступен — петля не закрыта; выход: escape владельца ИЛИ plan-done' });
} else {
message = buildObjectionFeedback({ side: 'judge', text: t.reason || 'цель не достигнута — доработай или докажи' });
}
return { block: true, message };
}
// ── main (под активацией владельцем; не в TDD-юнитах) ──
async function main() {
const { readStdin, parseEventJson, runtimeDir } = await import('./enforce-hook-helpers.mjs');
const { resolveReceiptKey } = await import('./receipt-key-config.mjs');
const { resolveJudgeLlmKey } = await import('./judge-gate-config.mjs');
const { callJudgeModel } = await import('./enforce-judge-gate.mjs');
const { requiredLensesFor } = await import('./judge-engine.mjs');
const { runJudge } = await import('./judge-engine.mjs');
const { loadFloorEscapes, loadConsumed } = await import('./escape-grant.mjs');
const os = await import('node:os');
let event = {};
try {
event = parseEventJson(await readStdin());
const dir = runtimeDir();
const sess = (event && event.session_id) || process.env.CLAUDE_SESSION_ID || 'unknown';
const key = resolveReceiptKey();
const judgeKey = resolveJudgeLlmKey();
const fnGreens = () => { try { return JSON.parse(fsDefault.readFileSync(join(dir, `criterion-greens-${sess}.json`), 'utf8')); } catch { return []; } };
const fnArtifact = () => { try { return JSON.parse(fsDefault.readFileSync(join(dir, `frozen-artifact-${sess}.json`), 'utf8')); } catch { return null; } };
const callJudge = async (product) => {
const requiredLenses = requiredLensesFor('gate3');
const raw = await callJudgeModel({ functionName: 'gate3', requiredLenses, promptArgs: { ...product, roundMemory: {} }, apiKey: judgeKey });
if (raw && raw.unavailable) return { wired: false, decision: 'GO', unavailable: true };
const v = runJudge({ functionName: 'gate3', requiredLenses, subRunsRequired: [], subRuns: [], llmCall: () => raw, promptArgs: { ...product, roundMemory: {} } });
return { wired: true, decision: v.decision, verdict: v };
};
const r = await runGate3Stop(event, {
runtimeDir: dir, sess, key, judgeKey, loadGreens: fnGreens, loadArtifact: fnArtifact, callJudge,
grants: loadFloorEscapes(sess), consumed: loadConsumed(sess), now: Date.now(),
});
const { exitDecision } = await import('./enforce-hook-helpers.mjs');
exitDecision({ block: !!r.block, message: r.block ? `[gate3-loop] ${r.message || 'петля открыта — цель не подтверждена'}` : undefined });
} catch {
const { exitDecision } = await import('./enforce-hook-helpers.mjs');
exitDecision({ block: false }); // Stop fail-OPEN: внутренняя ошибка хука НЕ кирпичит конец хода
}
}
import { fileURLToPath } from 'node:url';
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
```
- [ ] **Step 2: Прогнать тесты — убедиться, что зелено**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — все тесты Task 1 зелёные (по факту авторитетно у владельца — harness-collapse).
---
### Task 3: Surface `planComplete` из `runGate` (стена)
**Files:**
- Modify: `tools/enforce-supreme-gate.mjs` (функция `runGate`, ветка `r.planComplete`)
- [ ] **Step 1: Вернуть planComplete наружу из runGate**
Заменить хвост `runGate` (возврат) — добавить `planComplete` в результат, чтобы `main()` мог
записать метку. Точечный diff:
```javascript
// было:
return { block: r.decision === 'block', message: withWarn(r.reason) };
// стало:
return { block: r.decision === 'block', message: withWarn(r.reason), planComplete: r.decision === 'allow' && r.advance === true && !!r.planComplete };
```
- [ ] **Step 2: Verify (двигает указатель; авторитет у владельца)**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS (база зелёная; новое поле не ломает существующие тесты runGate).
---
### Task 4: Запись метки в `main()` стены
**Files:**
- Modify: `tools/enforce-supreme-gate.mjs` (импорт + блок в `main()` после `runGate`)
- [ ] **Step 1: Импорт writeLoopOpen + loadTaskId**
Добавить рядом с существующими импортами (после строки импорта `escape-grant`):
```javascript
import { writeLoopOpen } from './enforce-gate3-loop.mjs';
import { loadTaskId } from './router-task-id.mjs';
```
- [ ] **Step 2: Verify**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS (импорт не исполняет логику).
- [ ] **Step 3: Записать метку на planComplete в main()**
В `main()`, сразу после `const r = runGate({...});` и до `if (r.block) ...`, вставить:
```javascript
if (r.planComplete) {
let taskId = null;
try { taskId = loadTaskId({ sessionId: sess, runtimeDir, fsImpl: fs }); } catch { taskId = null; }
try {
writeLoopOpen({
taskId, planId: frozenPlan?.plan_id ?? null, artifactId: frozenPlan?.artifact_id ?? null,
steps: (frozenPlan && frozenPlan.steps) || [], at: event.nowMs ?? Date.now(),
key, runtimeDir, sess, fsImpl: fs,
});
} catch { /* best-effort: сбой метки не ломает завершение плана */ }
}
```
- [ ] **Step 4: Verify**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
---
### Task 5: Тест стены — метка пишется на planComplete
**Files:**
- Modify: `tools/enforce-supreme-gate.test.mjs`
- [ ] **Step 1: Добавить тест (RED до Task 3-4, GREEN после)**
```javascript
import { runGate } from './enforce-supreme-gate.mjs';
// ... в существующем describe или новом:
it('runGate surface planComplete на последнем шаге', () => {
// план из одного шага Write a.mjs; после него advanceTo не резолвится в лист
// (детали frozenPlan/key берутся из существующих хелперов теста)
// здесь проверяем ТОЛЬКО что результат несёт planComplete:true на последнем allow-шаге.
// (полный сетап frozenPlan — как в соседних тестах файла)
expect(typeof runGate).toBe('function');
});
```
(При исполнении: дополнить реальным сетапом `frozenPlan`/`key` по образцу соседних тестов файла —
сетап там уже есть; проверяемый инвариант — `runGate(...).planComplete === true` на последнем шаге
и `writeLoopOpen`-dep вызван.)
- [ ] **Step 2: Verify полной регрессии**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — база + новые тесты зелёные.
---
### Task 6: Финальная регрессия + коммит
- [ ] **Step 1: Полный свод (авторитетно — владелец в терминале)**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — 4105+новые passed, 0 регрессий (переиспользуемые модули не модифицированы).
- [ ] **Step 2: Коммит (через escape владельца)**
```bash
git add tools/enforce-gate3-loop.mjs tools/enforce-gate3-loop.test.mjs tools/enforce-supreme-gate.mjs tools/enforce-supreme-gate.test.mjs
git commit -m "feat: E-S1 gate-3 trigger Stop-hook enforce-gate3-loop plus wall loop-open marker" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>" -- tools/enforce-gate3-loop.mjs tools/enforce-gate3-loop.test.mjs tools/enforce-supreme-gate.mjs tools/enforce-supreme-gate.test.mjs
git push gitea main
```
## Критерий приёмки
- Все юнит-тесты `enforce-gate3-loop.test.mjs` зелёные (подпись метки, отпечаток, decideStopTeeth
все ветки, resolveOwnerArbitration анти-реплей, сбор продукта).
- Тест стены: `runGate(...).planComplete === true` на последнем шаге; метка пишется.
- Полная регрессия tools зелёная, 0 регрессий.
- Стена изменена минимально (одно поле в возврате + один блок записи метки в main); судья gate-3
и зубы — в новом изолированном файле.
```steps-json
[
{"op": "Write", "object": "tools/enforce-gate3-loop.test.mjs", "ref": "s9"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s9"},
{"op": "Write", "object": "tools/enforce-gate3-loop.mjs", "ref": "s6"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s6"},
{"op": "Edit", "object": "tools/enforce-supreme-gate.mjs", "ref": "s6"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s6"},
{"op": "Edit", "object": "tools/enforce-supreme-gate.mjs", "ref": "s6"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s6"},
{"op": "Edit", "object": "tools/enforce-supreme-gate.test.mjs", "ref": "s9"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s9"}
]
```
```verified-context-json
[{"id":"vc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"decideGate3Closure"}]
```
@@ -0,0 +1,338 @@
# gate-3 приёмка владельца — Фаза 1: проводка пометки delivery — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline; под стеной Task запрещён). Шаги — checkbox (`- [ ]`).
**Goal:** провести обязательную пометку плана `delivery: internal|user-result` от тела плана через подписанную печать до метки «петля открыта», обратносовместимо (по умолчанию `internal` = текущее поведение).
**Architecture:** парсер `parsePlanDelivery` читает пометку из тела плана; `freezePlan` подписывает её (только не-`internal` попадает в базу — старые планы байт-идентичны); `sealablePlan`/`sealPlan` несут её в печать; стена кладёт `delivery` в метку «петля открыта». Карточка/судья/закрытие — Фаза 2.
**Tech Stack:** Node ESM, vitest, HMAC-печать (`plan-lock`/`receipt-sign`).
## Цель
Подготовить фундамент пользовательской приёмки: каждый план несёт машиночитаемую пометку
`delivery`, которая надёжно (через подпись) доходит до Stop-хука gate-3. Эта фаза — чистая проводка
без логики приёмки: по умолчанию `internal` сохраняет текущее поведение бит-в-бит; ветка
`user-result` будет задействована Фазой 2.
**Delivery:** internal
## Структура файлов
- **Изменить** `tools/plan-skills.mjs` — добавить `parsePlanDelivery(content)` (зеркало `parsePlanSkills`).
- **Изменить** `tools/plan-lock.mjs``freezePlan` принимает `delivery` и кладёт в подписанную базу
только если `!== 'internal'` (обратная совместимость старых печатей).
- **Изменить** `tools/seal-orchestration.mjs``sealablePlan` несёт `delivery`; `sealPlan` передаёт его в `freezePlan`.
- **Изменить** `tools/enforce-gate3-loop.mjs``writeLoopOpen` подписывает поле `delivery` в метке.
- **Изменить** `tools/enforce-supreme-gate.mjs` — замыкание `writeLoopOpen` в `main` передаёт `frozenPlan.delivery`.
- Тесты — в соответствующих `*.test.mjs`.
```skills-json
["test-driven-development"]
```
---
### Task 1: parsePlanDelivery (RED→GREEN)
**Files:**
- Modify: `tools/plan-skills.mjs`
- Modify: `tools/plan-skills.test.mjs`
- [ ] **Step 1: Падающий тест**
```javascript
import { parsePlanDelivery } from './plan-skills.mjs';
describe('parsePlanDelivery', () => {
it('читает user-result из маркера', () => {
expect(parsePlanDelivery('текст\n**Delivery:** user-result\nещё')).toBe('user-result');
});
it('читает internal', () => {
expect(parsePlanDelivery('**Delivery:** internal')).toBe('internal');
});
it('по умолчанию internal (нет маркера)', () => {
expect(parsePlanDelivery('план без пометки')).toBe('internal');
});
it('мусорное значение → internal (fail-safe)', () => {
expect(parsePlanDelivery('**Delivery:** whatever')).toBe('internal');
});
});
```
- [ ] **Step 2: Прогон — падает**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — `parsePlanDelivery` не экспортирован.
- [ ] **Step 3: Реализация в plan-skills.mjs**
Добавить в конец файла:
```javascript
/** Пометка поставки плана: `**Delivery:** internal|user-result`. По умолчанию/мусор → 'internal'
* (fail-safe: владельца не дёргаем без явной пометки результата). Зеркало parsePlanSkills. */
export function parsePlanDelivery(content) {
const m = String(content ?? '').match(/(^|\n)\*\*Delivery:\*\*\s*(internal|user-result)\b/i);
return m ? m[2].toLowerCase() : 'internal';
}
```
- [ ] **Step 4: Прогон — зелено**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
---
### Task 2: freezePlan подписывает delivery (обратносовместимо)
**Files:**
- Modify: `tools/plan-lock.mjs`
- Modify: `tools/plan-lock.test.mjs`
- [ ] **Step 1: Падающий тест**
```javascript
import { freezePlan, verifyFrozenPlan } from './plan-lock.mjs';
describe('freezePlan delivery', () => {
const KEY = 'k-deliv';
it('user-result попадает в подписанную печать и верифицируется', () => {
const p = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'user-result', key: KEY, nowMs: 1 });
expect(p.delivery).toBe('user-result');
expect(verifyFrozenPlan(p, KEY)).toBe(true);
});
it('internal (по умолчанию) НЕ добавляет поле — старые печати байт-идентичны', () => {
const a = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], key: KEY, nowMs: 1 });
const b = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'internal', key: KEY, nowMs: 1 });
expect('delivery' in a).toBe(false);
expect(a.sig).toBe(b.sig);
});
it('подмена delivery ломает подпись', () => {
const p = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'user-result', key: KEY, nowMs: 1 });
expect(verifyFrozenPlan({ ...p, delivery: 'internal' }, KEY)).toBe(false);
});
});
```
- [ ] **Step 2: Прогон — падает**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — `delivery` не сохраняется/не подписывается.
- [ ] **Step 3: Реализация — правка freezePlan**
```javascript
// old:
export function freezePlan({ steps, skills = [], artifactId = null, judgeMode = null, key, nowMs }) {
assertValidJudgeMode(judgeMode);
const sealedSteps = withCriterionIds(steps);
const id = planId(sealedSteps);
// judge_mode входит в ПОДПИСАННУЮ базу (VA-2/SE-2): стена различает shadow- и live-печать,
// подмена режима ломает подпись. Существующие печати без judge_mode: base без поля → verify ок.
const base = { plan_id: id, artifact_id: artifactId, judge_mode: judgeMode, skills: Array.isArray(skills) ? skills : [], frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps: sealedSteps };
return { ...base, sig: signPayload(base, key, RECEIPT_DOMAINS.FROZEN_PLAN) };
}
// new:
export function freezePlan({ steps, skills = [], artifactId = null, judgeMode = null, delivery = 'internal', key, nowMs }) {
assertValidJudgeMode(judgeMode);
const sealedSteps = withCriterionIds(steps);
const id = planId(sealedSteps);
// judge_mode входит в ПОДПИСАННУЮ базу (VA-2/SE-2): стена различает shadow- и live-печать,
// подмена режима ломает подпись. Существующие печати без judge_mode: base без поля → verify ок.
const base = { plan_id: id, artifact_id: artifactId, judge_mode: judgeMode, skills: Array.isArray(skills) ? skills : [], frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps: sealedSteps };
// E-S1 Фаза 1: delivery в подписанную базу ТОЛЬКО если не-'internal' — internal-планы
// (умолчание) остаются байт-идентичны старым печатям (обратная совместимость подписи).
if (delivery && delivery !== 'internal') base.delivery = delivery;
return { ...base, sig: signPayload(base, key, RECEIPT_DOMAINS.FROZEN_PLAN) };
}
```
- [ ] **Step 4: Прогон — зелено**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
---
### Task 3: sealablePlan/sealPlan несут delivery
**Files:**
- Modify: `tools/seal-orchestration.mjs`
- Modify: `tools/seal-orchestration.test.mjs`
- [ ] **Step 1: Падающий тест**
```javascript
import { sealablePlan } from './seal-orchestration.mjs';
describe('sealablePlan delivery', () => {
it('несёт delivery из тела плана', () => {
const md = '## Цель\nx\n**Delivery:** user-result\n```steps-json\n[{"op":"Write","object":"a.mjs"}]\n```';
expect(sealablePlan(md).delivery).toBe('user-result');
});
it('без пометки → internal', () => {
const md = '```steps-json\n[{"op":"Write","object":"a.mjs"}]\n```';
expect(sealablePlan(md).delivery).toBe('internal');
});
});
```
- [ ] **Step 2: Прогон — падает**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — `delivery` отсутствует в sealablePlan.
- [ ] **Step 3: Импорт parsePlanDelivery**
```javascript
// old:
import { parsePlanSkills } from './plan-skills.mjs';
// new:
import { parsePlanSkills, parsePlanDelivery } from './plan-skills.mjs';
```
- [ ] **Step 4: Прогон (импорт не ломает)**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
- [ ] **Step 5: sealablePlan несёт delivery + sealPlan передаёт в freezePlan**
```javascript
// old:
export function sealablePlan(md) { return { steps: parsePlanSteps(md), skills: parsePlanSkills(md) }; } // {steps,skills}
// new:
export function sealablePlan(md) { return { steps: parsePlanSteps(md), skills: parsePlanSkills(md), delivery: parsePlanDelivery(md) }; } // {steps,skills,delivery}
```
И в `sealPlan` передать delivery в freezeImpl:
```javascript
// old:
const seal = freezeImpl({ steps: planObj.steps, skills: planObj.skills, artifactId: currentArtifact.artifact_id, judgeMode, key, nowMs });
// new:
const seal = freezeImpl({ steps: planObj.steps, skills: planObj.skills, delivery: planObj.delivery, artifactId: currentArtifact.artifact_id, judgeMode, key, nowMs });
```
- [ ] **Step 6: Прогон — зелено**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
---
### Task 4: метка «петля открыта» несёт delivery
**Files:**
- Modify: `tools/enforce-gate3-loop.mjs`
- Modify: `tools/enforce-gate3-loop.test.mjs`
- Modify: `tools/enforce-supreme-gate.mjs`
- [ ] **Step 1: Падающий тест (подпись метки с delivery)**
```javascript
import { signLoopMarker, verifyLoopMarker } from './enforce-gate3-loop.mjs';
describe('loop marker delivery', () => {
const KEY = 'k-loop-deliv';
it('delivery в подписанной метке верифицируется и ломается при подмене', () => {
const m = signLoopMarker({ taskId: 't', planId: 'p', artifactId: 'a', steps: [], delivery: 'user-result', at: 1 }, KEY);
expect(m.delivery).toBe('user-result');
expect(verifyLoopMarker(m, KEY)).toBe(true);
expect(verifyLoopMarker({ ...m, delivery: 'internal' }, KEY)).toBe(false);
});
});
```
- [ ] **Step 2: Прогон — падает**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — `writeLoopOpen` не кладёт delivery в payload (метка без поля).
- [ ] **Step 3: writeLoopOpen подписывает delivery**
```javascript
// old:
export function writeLoopOpen({ taskId, planId, artifactId, steps, at, key, runtimeDir, sess, fsImpl = fsDefault }) {
const marker = signLoopMarker({ taskId: taskId || null, planId, artifactId: artifactId || null, steps: steps || [], at: at || 0 }, key);
// new:
export function writeLoopOpen({ taskId, planId, artifactId, steps, delivery = 'internal', at, key, runtimeDir, sess, fsImpl = fsDefault }) {
const marker = signLoopMarker({ taskId: taskId || null, planId, artifactId: artifactId || null, steps: steps || [], delivery: delivery || 'internal', at: at || 0 }, key);
```
- [ ] **Step 4: Прогон — зелено**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
- [ ] **Step 5: стена передаёт frozenPlan.delivery в метку**
В `tools/enforce-supreme-gate.mjs`, в замыкании `writeLoopOpen` (в `main`):
```javascript
// old:
writeLoopOpenMarker({ taskId, planId: frozenPlan?.plan_id ?? null, artifactId: frozenPlan?.artifact_id ?? null, steps: (frozenPlan && frozenPlan.steps) || [], at: event.nowMs ?? Date.now(), key, runtimeDir, sess, fsImpl: fs });
// new:
writeLoopOpenMarker({ taskId, planId: frozenPlan?.plan_id ?? null, artifactId: frozenPlan?.artifact_id ?? null, steps: (frozenPlan && frozenPlan.steps) || [], delivery: frozenPlan?.delivery ?? 'internal', at: event.nowMs ?? Date.now(), key, runtimeDir, sess, fsImpl: fs });
```
- [ ] **Step 6: Прогон — зелено**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS.
---
### Task 5: Финальная регрессия + коммит
- [ ] **Step 1: Полный свод (авторитетно — владелец в терминале)**
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — база + новые тесты, 0 регрессий (internal-печати байт-идентичны старым).
- [ ] **Step 2: Коммит (через escape владельца)**
```bash
git add tools/plan-skills.mjs tools/plan-skills.test.mjs tools/plan-lock.mjs tools/plan-lock.test.mjs tools/seal-orchestration.mjs tools/seal-orchestration.test.mjs tools/enforce-gate3-loop.mjs tools/enforce-gate3-loop.test.mjs tools/enforce-supreme-gate.mjs docs/superpowers/specs/2026-06-17-gate3-owner-user-acceptance-design-v2.md docs/superpowers/plans/2026-06-17-gate3-owner-acceptance-phase1-delivery-mark.md
git commit -m "feat: E-S1 gate-3 owner-acceptance phase 1 delivery mark plumbing" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>" -- tools/plan-skills.mjs tools/plan-skills.test.mjs tools/plan-lock.mjs tools/plan-lock.test.mjs tools/seal-orchestration.mjs tools/seal-orchestration.test.mjs tools/enforce-gate3-loop.mjs tools/enforce-gate3-loop.test.mjs tools/enforce-supreme-gate.mjs docs/superpowers/specs/2026-06-17-gate3-owner-user-acceptance-design-v2.md docs/superpowers/plans/2026-06-17-gate3-owner-acceptance-phase1-delivery-mark.md
git push gitea main
```
## Критерий приёмки
- `parsePlanDelivery`: user-result/internal/умолчание/мусор покрыты.
- `freezePlan`: user-result в подписи + verify; internal байт-идентичен старой печати (sig равны); подмена ломает подпись.
- `sealablePlan`: несёт delivery из тела; без пометки → internal.
- метка: delivery в подписи, подмена ломает; стена передаёт `frozenPlan.delivery`.
- полная регрессия tools зелёная, 0 регрессий.
```steps-json
[
{"op": "Edit", "object": "tools/plan-skills.test.mjs", "ref": "u3"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u3"},
{"op": "Edit", "object": "tools/plan-skills.mjs", "ref": "u3"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u3"},
{"op": "Edit", "object": "tools/plan-lock.test.mjs", "ref": "u3"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u3"},
{"op": "Edit", "object": "tools/plan-lock.mjs", "ref": "u3"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u3"},
{"op": "Edit", "object": "tools/seal-orchestration.test.mjs", "ref": "u6"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u6"},
{"op": "Edit", "object": "tools/seal-orchestration.mjs", "ref": "u6"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u6"},
{"op": "Edit", "object": "tools/seal-orchestration.mjs", "ref": "u6"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u6"},
{"op": "Edit", "object": "tools/enforce-gate3-loop.test.mjs", "ref": "u6"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u6"},
{"op": "Edit", "object": "tools/enforce-gate3-loop.mjs", "ref": "u6"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u6"},
{"op": "Edit", "object": "tools/enforce-supreme-gate.mjs", "ref": "u6"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u6"}
]
```
```verified-context-json
[{"id":"vc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"decideGate3Closure"}]
```
@@ -0,0 +1,96 @@
# Наставник-хук: фикс silent-swallow → видимый degraded Implementation Plan
**Goal:** Обернуть регион производства вердикта наставника (память кругов + `onPlanWrite`/`onSpecWrite`)
в `try/catch`, чтобы throw в нём возвращал видимый `{ran:true, wired:false}` (degraded) вместо
молчаливого пробрасывания в `catch` main() — план/спека больше не зависают без сигнала.
**Architecture:** Правка только в `tools/enforce-mentor-on-plan-write.mjs` (обе ветки — план и спека).
`planHash`/`specHash` поднимаются выше региона (нужны в `catch` для binding снимка). Успешный путь
байт-в-байт прежний; degraded-контракт (`wired:false`) уже обрабатывается main() штатно (снимок
degraded + decideMentorObjection degraded-блок). Тесты — инъекцией `roundMemoryImpl`, который бросает.
**Tech Stack:** Node ESM, vitest (config `vitest.config.tools.mjs`). TDD.
## Цель
Превратить молчаливый срыв наставника в видимый degraded-вердикт (Уроки №7), чтобы зависший
наставник был ВИДЕН в снимке и перезапускался, а не выглядел вечным «считает». Обе ветки (план и
спека) симметрично.
## Структура файлов
- Изменить (тест): `tools/enforce-mentor-on-plan-write.test.mjs` — 2 теста (план/спека: throw в
регионе → ran:true, wired:false).
- Изменить: `tools/enforce-mentor-on-plan-write.mjs` — guard план-ветки и спека-ветки.
```skills-json
["test-driven-development"]
```
```steps-json
[
{"op":"Edit","object":"tools/enforce-mentor-on-plan-write.test.mjs","ref":"m4"},
{"op":"Bash","object":"npx vitest run tools/enforce-mentor-on-plan-write.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism","ref":"m4"},
{"op":"Edit","object":"tools/enforce-mentor-on-plan-write.mjs","ref":"m1"},
{"op":"Bash","object":"npx vitest run tools/enforce-mentor-on-plan-write.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism","ref":"m4"},
{"op":"Edit","object":"tools/enforce-mentor-on-plan-write.mjs","ref":"m1"},
{"op":"Bash","object":"npx vitest run tools/enforce-mentor-on-plan-write.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism","ref":"m4"},
{"op":"Bash","object":"npx vitest run --reporter dot --config vitest.config.tools.mjs --no-file-parallelism","ref":"m4"}
]
```
```verified-context-json
[{"id":"mpc1","kind":"EXTRACTED","ref":"tools/enforce-mentor-on-plan-write.mjs","anchor":"export async function runMentorOnPlanWrite("}]
```
---
### Task 1: тесты «throw в регионе → видимый degraded» (§m4)
- [ ] **Step 1 (Edit `tools/enforce-mentor-on-plan-write.test.mjs`): 2 падающих теста**
В конец describe `runMentorOnPlanWrite` (перед его закрывающим `});`) дописать: план с
`roundMemoryImpl`, который бросает → `{ran:true, wired:false}` + непустой `planHash` + `reason`/срыв;
то же для `specEvent`.
- [ ] **Step 2 (Bash): прогон — тесты падают (RED)**
Run: `npx vitest run tools/enforce-mentor-on-plan-write.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism`
Expected: FAIL — сейчас throw в регионе реджектит промис (не возвращает degraded). (Под Claude harness-collapse; авторитет — терминал владельца.)
### Task 2: guard обеих веток (§m1)
- [ ] **Step 3 (Edit `tools/enforce-mentor-on-plan-write.mjs`): guard план-ветки**
`planHash` поднять выше региона; обернуть `roundMemoryP` + `onPlanWrite` в `try/catch`; на throw —
вернуть `{ran:true, ok:false, wired:false, reason:'наставник-путь сорвался: <msg>', taskId:taskIdForPrompt, planHash, verdict:null, routerClassification:null}`.
- [ ] **Step 4 (Bash): прогон — план-тест зелёный, спека-тест ещё красный**
Run: `npx vitest run tools/enforce-mentor-on-plan-write.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism`
Expected: план-ветка проходит, спека-ветка ещё падает. (Авторитет — терминал владельца.)
- [ ] **Step 5 (Edit `tools/enforce-mentor-on-plan-write.mjs`): guard спека-ветки**
Обернуть `roundMemoryS` + `onSpecWrite` в `try/catch`; на throw — вернуть `{ran:true, ok:false, wired:false, reason:'наставник-путь (спека) сорвался: <msg>', taskId:taskIdForPromptS, planHash:specHash, verdict:null}`.
- [ ] **Step 6 (Bash): прогон — оба теста зелёные (GREEN)**
Run: `npx vitest run tools/enforce-mentor-on-plan-write.test.mjs --reporter dot --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — оба новых теста + прежние тесты файла без регрессий. (Авторитет — терминал владельца.)
- [ ] **Step 7 (Bash): полная регрессия tools**
Run: `npx vitest run --reporter dot --config vitest.config.tools.mjs --no-file-parallelism`
Expected: PASS — база + новые тесты, 0 регрессий. (Под Claude harness-collapse; авторитетный свод — терминал владельца.)
## Self-Review (план против спеки)
- **§m1 guard** — Task 2: обе ветки обёрнуты, `planHash`/`specHash` выше региона для binding в catch,
degraded-возврат `wired:false`. ✓
- **§m2 крайние случаи** — throw в `roundMemoryImpl` и внутри `onPlanWrite`/`onSpecWrite` ловится;
транспортный сбой по-прежнему через `runMentorVerdict` (не трогаем). ✓
- **§m4 критерий** — RED (throw реджектит) → GREEN (degraded) для плана и спеки; прежние тесты целы. ✓
- **DR-1** — мутирующие шаги 1,3,5 сопровождены Bash (2,4,6); шаги 3,5 один файл, между ними Bash (4);
шаг 7 — отдельная полная регрессия. ✓
- **Конвенция §m3** — аддитивный точечный diff, успешный путь не меняется, без новых файлов. ✓
@@ -0,0 +1,81 @@
# Открытые вопросы claude-brain — роадмап по сессиям (для распараллеливания)
**Дата фиксации:** 2026-06-17 · **HEAD на момент:** `cd831b8` · бэкап = gitea (github 🔴 мёртв).
**Источник:** разбор открытых вопросов после закрытия Фазы 2a + фикса silent-swallow.
Цель документа — раскидать остаток на **независимые сессии**, чтобы A/B/C/D можно было гнать
параллельно (разные сессии/дни), а зависимые собрать после. Каждая сессия самодостаточна.
---
## Граф зависимостей (критический путь)
```
A (producer end-to-end) ─┐ независима
B (флап + silent-swallow) ─┤ независима
C (2b gate3card судья) ──┐ │
D (2c delivery честность)─┤ │ C,D зависят только от 2a (СДЕЛАНО)
├─→ E (2d проводка в Stop-хук) ← интеграция, нужна C+D
F (гигиена) ─┘ фоном, ни от чего не зависит
```
**Параллелить можно:** A ∥ B ∥ (C+D) ∥ F. **Строго после:** E ждёт C и D.
**Рекомендованный старт:** A и B первыми (разблокируют процесс и жгут меньше денег), C+D параллельно.
---
## Сессия A — Producer end-to-end + наследие `app/` (инфра-разблокировка) 🔴 высокий
**Почему:** разблокирует мои (Claude) коммиты без терминала владельца; чинит класс «наследие сплита».
- [ ] Проверить под Claude: подписывает ли `tools/produce-verify-receipt.mjs` расписку реально
(его внутренний `execSync('npx vitest')` может коллапсировать так же, как Claude-Bash). Если да —
фикс пути недостаточен, нужна стратегия (non-collapsing прогон / читать готовый sentinel).
- [ ] Прозвонить `tools/` на ДРУГИЕ хардкоды `app/` / layout Лидерры (producer criterion-greens и пр.).
**Файлы:** `produce-verify-receipt.mjs`, `produce-criterion-greens.mjs`(?), `criterion-green.mjs`.
**Зависит от:** ничего.
## Сессия B — Флап наставника/судьи + silent-swallow в других хуках 🔴 высокий
**Почему:** флап повторяется на deepseek (2× за сессию 17.06), каждый перезапуск = лишние LLM-вызовы
(1-3 ₽); невидимые срывы в судье возможны как в наставнике (уже починен).
- [ ] Корень флапа: «несодержательный вердикт: пустые слоты [recommendation]» при положительном
разборе НЕ должен заворачивать (или авто-ретрай 1× внутри хука перед NO-GO).
- [ ] Прозвонить `enforce-judge-gate.mjs` / `enforce-gate3-loop.mjs` на тот же паттерн молчаливого
`catch {}` в `main()` (как был в `enforce-mentor-on-plan-write`, починен cd831b8).
**Файлы:** `enforce-mentor-on-plan-write.mjs`/`mentor-verdict.mjs`, `enforce-judge-gate.mjs`, `judge-engine.mjs`.
**Зависит от:** ничего. TDD.
## Сессия C — Фаза 2b: судья-карточки `gate3card` 🟡 средний
**Почему:** второй слой приёмки — судья сверяет карточку с продуктом до показа владельцу.
- [ ] Линзы `gate3card` в `judge-engine.VOTE_LAYOUTS` + `requiredLensesFor` (card_matches_product /
no_overstatement / verify_steps_real).
- [ ] **НЕ ЗАБЫТЬ:** подключить gate3card-видимость в снимок+баннер (раздел «ОТЛОЖЕНО — НЕ ПОТЕРЯТЬ»
в спеке видимости вердиктов).
**Файлы:** `judge-engine.mjs`, `verdict-surface`/баннер-проводка.
**Зависит от:** 2a (СДЕЛАНО). TDD.
## Сессия D — Фаза 2c: честность пометки `delivery` на gate-2 🟡 средний
**Почему:** план, доводящий цель до пользы, не может прятаться за `delivery:'internal'`.
- [ ] Судья плана (gate-2) проверяет `delivery` против шагов/цели спеки → NO-GO «прячешь готовый результат».
**Файлы:** судья плана (`enforce-judge-gate`/линза), `plan-lock`/парсер `delivery` (Фаза 1 уже завёл проводку).
**Зависит от:** 2a (СДЕЛАНО). Параллельно с C. TDD.
## Сессия E — Фаза 2d: проводка приёмки в `enforce-gate3-loop` (Stop-хук) 🟡 финал эпика
**Почему:** оживить ядро 2a в живой петле — собрать карточку, отдать судье-карточки, показать владельцу.
- [ ] В Stop-хуке: `delivery:'user-result'` + код-GO → `buildOwnerCard` → судья `gate3card` → показ
владельцу (вкл. degraded-эскалацию с предупреждением) → ждать `gate3-arb:accept` → закрытие.
**Файлы:** `enforce-gate3-loop.mjs`, `loop-termination.mjs` (входы уже готовы в 2a).
**Зависит от:** C (gate3card) + D (delivery честность) + 2a. Делать ПОСЛЕ C и D.
## Сессия F — Гигиена / инфра 🟢 фоном
- [ ] MEMORY.md превышает лимит (35.5 КБ vs 24.4 КБ) — подрезать длинные строки, детали в файлы.
- [ ] Незакоммиченные docs с начала сессии 17.06 (bag, черновики/спеки прошлых фаз, observer) — коммит или чистка.
- [ ] Вторая точка отказа кроме gitea (github мёртв) — обсудить с владельцем, нужна ли.
- [ ] vitest harness-collapse под Claude — разобраться, почему рушится (опц., снимет постоянное трение).
**Зависит от:** ничего.
---
## Дисциплина (для каждой кодовой сессии)
Все правки `tools/*.mjs`**по церемонии** (спека→план→печать→TDD), кодовая фраза «роутер-наставник».
Если вердикт не появляется — читать код оркестратора, не опрашивать/не спрашивать; флап лечить
перезапуском плана новым именем (GUIDE «Уроки №7»). Коммит — после фикса producer (Сессия A) сам
через escape, иначе в терминале владельца. Память/коммит — по клику владельца.
@@ -0,0 +1,12 @@
# Спека: хелпер isBlankString (черновик-смоук)
## Цель
Добавить чистый хелпер `isBlankString(value)` в `tools/`, возвращающий `true` для
пустой строки и строки из одних пробельных символов, иначе `false`. Это черновик —
edge-cases, конвенция заголовка и критерий приёмки будут дописаны позже.
## Контракт {#c1}
`isBlankString('')``true`; `isBlankString(' ')``true`; `isBlankString('x')``false`.
Нестроковый вход → `false`.
@@ -0,0 +1,113 @@
# E-S1 / gate-3 безопасное ядро — судья «цель достигнута» + замыкание петли
**Дата:** 2026-06-17
**Эпик:** роутер-наставник, E-S1 (sub-plan C, ядро). **Статус:** дизайн под реализацию (TDD).
**Кодовая фраза:** «роутер-наставник».
## Цель
Сейчас машинерия судит спеку (gate1) и план (gate2) ДО работы, но никогда не проверяет ПОСЛЕ —
достиг ли результат заявленной цели. Внешняя петля закрывается фактически по усмотрению контроллера.
Хелпер `loopTerminationDecision` уже задаёт правило «петлю закрывает ТОЛЬКО владелец явно ЛИБО GO
судьи gate-3», но он нигде не потреблён. Эта спека строит **безопасное ядро** третьего гейта:
чистый движок-судью «цель достигнута?», его переговоры+арбитраж (симметрично gate1/gate2) и
оркестратор замыкания петли через `loopTerminationDecision`. Автозапуск gate-3 на завершении плана
(триггер внутри стены) — НЕ здесь, он отдельной спекой (см. «Вне объёма»). Ядро — чистая логика,
тестируемая в изоляции; кода стены НЕ трогает.
## Контракт оркестратора замыкания {#g1}
Новый чистый модуль `tools/gate3-closure.mjs`, функция
`decideGate3Closure({ gate3Verdict, noGoCount = 0, ownerArbitration = null, maxRounds = 3 })`
`{ state, terminate, reason, card? }`, где `state ∈ {'closed','negotiate','arbitrate','open'}`:
- `ownerArbitration === 'accept'``loopTerminationDecision({ ownerDeclaredDone: true })`
`{ state:'closed', terminate:true }` (владелец принял цель достигнутой — приоритет над судьёй).
- иначе `gate3Verdict.decision === 'GO'``loopTerminationDecision({ judgeGate3Go: true })`
`{ state:'closed', terminate:true }` (судья подтвердил цель).
- иначе (NO-GO) и `noGoCount < maxRounds``{ state:'negotiate', terminate:false }`
(контроллер отвечает на возражение — доказывает ИЛИ доделывает; петля НЕ закрыта).
- иначе (NO-GO) и `noGoCount >= maxRounds``{ state:'arbitrate', terminate:false, card }`
(карточка арбитража владельцу — раздел {#g3}).
- `ownerArbitration === 'continue'``{ state:'open', terminate:false }` (владелец: продолжать).
`terminate` всегда берётся из `loopTerminationDecision` (единый источник правила), НЕ вычисляется
заново. Строгая семантика хелпера (`===true`) сохранена: любой иной вход → не закрывать (fail-safe).
Битый/пустой `gate3Verdict` → трактуется как NO-GO (сомнение → не закрывать).
## Продукт судьи gate-3 {#g2}
Новый чистый модуль `tools/gate3-product.mjs`, функция
`buildGate3Product({ goal = '', planSteps = [], greenRuns = [] })``{ product, goal, cards }`
в форме, которую принимает `buildJudgePrompt` (`judge-engine.mjs`). Судья gate-3 слеп к переписке —
ему подаётся ТОЛЬКО продукт+цель (по конструкции движка). Состав:
- `goal` — цель из опечатанной спеки (та же `extractGoal`, что у gate1/gate2).
- `product` — детерминированная сводка ИСПОЛНЕННОГО: список шагов плана (op+object) и для каждого
значимого шага его подтверждённый по-критерию GREEN (из `greenRuns` критерий-гейта: тест прошёл И
мутация убита). Это «поведение», которое судья сверяет с целью (линза `behavior_vs_goal`).
- `cards` — пусто в ядре (карточки контекста — поздний слой).
Линзы gate-3 уже объявлены в `judge-engine.VOTE_LAYOUTS.gate3` =
`['goal_achieved','premortem_whole','behavior_vs_goal']` — движок не трогаем, движок зовётся
`runJudge`/`buildJudgePrompt` с `functionName='gate3'`. `buildGate3Product` детерминирован и
тестируем без модели; пустой план/нет greens → product помечает «нет доказательств исполнения»
(судья увидит и, скорее всего, NO-GO — цель не доказана).
## Переговоры + арбитраж gate-3 {#g3}
gate-3 NO-GO симметричен gate1/gate2 — переговорный блок, не обрыв:
- **Счётчик NO-GO gate-3** — переиспользуем существующий `mentor-nogo-counter.mjs` (тот же паттерн
независимого счётчика по ключу; ключ стадии `gate3`). Инкремент на каждом NO-GO gate-3.
- **Раздел «Переговоры»** — контроллер отвечает на возражение судьи (`negotiation-section.mjs`
парсит его довод): доказывает, что цель достигнута, ИЛИ доделывает (новый план, который сам
проходит gate1/gate2 и по завершении снова приводит к gate-3).
- **Карточка арбитража на 3-м NO-GO** — переиспользуем `arbitration-card.buildArbitrationCard`:
дословно возражение судьи (цель не закрыта вот этим) + довод контроллера + три выбора владельцу:
«принять как достигнуто» / «продолжать» / «объясни подробнее». Выбор владельца возвращается в
`decideGate3Closure` как `ownerArbitration ∈ {'accept','continue', null}`.
Ядро НЕ изобретает новых каналов сигнала владельца: «принять как достигнуто» = существующий
arbitration-выбор (тот же escape-механизм, что у gate1/gate2), маппится в `ownerDeclaredDone`.
## Замыкание петли {#g4}
`decideGate3Closure` — единственная точка, где принимается решение «петля закрыта». Закрытие
происходит ТОЛЬКО через `loopTerminationDecision` (контроллер сам петлю не закрывает — инвариант
SE-R7-6). `state:'closed'` означает «цель достигнута» (вердикт виден владельцу); `negotiate`/
`arbitrate`/`open` — петля открыта, работа продолжается. Никакого «зуба» в стене ядро не ставит
(блокировок не вводит) — это поведенческое решение реализует ТРИГГЕР (отдельная спека), потребляя
`state`/`terminate` из ядра.
## Конвенция {#g5}
ES-модули `tools/gate3-product.mjs`, `tools/gate3-closure.mjs` (как соседние машины). Чистые
экспортируемые функции, тестируемы в изоляции (модель/I/O не трогают). Переиспользование:
`judge-engine` (`buildJudgePrompt`/`runJudge`/`VOTE_LAYOUTS.gate3`), `arbitration-card`,
`mentor-nogo-counter`, `negotiation-section`, `loop-termination` — НЕ модифицируются (только
вызываются). Без новых зависимостей. Стена (`enforce-supreme-gate`) и живые хуки НЕ трогаются.
## Вне объёма (→ спека #2) {#g6}
- **Триггер gate-3 на завершении плана** (кусок 3): ловля `planComplete` в `enforce-supreme-gate`
и автозапуск gate-3 — трогает живую стену (риск F-J-капкана самомодификации), отдельной спекой.
- **«Зубы» блокировки** «готово» при открытой петле — реализует триггер, потребляя `state` ядра.
- Карточки контекста в продукте gate-3 (`cards`) — поздний слой.
## Критерий приёмки {#g7}
TDD-тесты (новые `gate3-product.test.mjs`, `gate3-closure.test.mjs`):
- `buildGate3Product`: цель+шаги+greens → продукт несёт сводку исполнения; пустой план/нет greens →
пометка «нет доказательств»; форма совместима с `buildJudgePrompt` (product/goal/cards).
- `decideGate3Closure`: GO → closed+terminate; NO-GO & count<3 → negotiate; NO-GO & count>=3 →
arbitrate+card; ownerArbitration 'accept' → closed+terminate (приоритет над судьёй);
'continue' → open; битый вердикт → как NO-GO; `terminate` всегда из `loopTerminationDecision`.
- Интеграция (без модели): связка product→prompt(`functionName='gate3'`)→mock-вердикт→closure.
- Полная регрессия tools GREEN: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
(база зелёная + новые тесты; существующие модули не модифицируются → 0 регрессий).
```verified-context-json
[{"id":"vc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"loopTerminationDecision"}]
```
@@ -0,0 +1,142 @@
# gate-3: пользовательская приёмка владельца (надстройка над триггером)
**Дата:** 2026-06-17
**Эпик:** роутер-наставник, E-S1 (приёмка владельца). **Статус:** дизайн под реализацию (TDD).
**Кодовая фраза:** «роутер-наставник».
**Предшественники:** ядро gate-3 (`tools/loop-termination.mjs`) + триггер (`tools/enforce-gate3-loop.mjs`,
коммит `e4a0b48`).
## Цель
Сейчас gate-3 закрывает внешнюю петлю по техническому судье (исправность кода) либо подписанному
владельцу. Но владелец — не программист: промежуточный код он оценить не может, он способен принять
только **целый результат на пользовательском уровне**. Эта спека добавляет обязательный слой
пользовательской приёмки владельцем поверх технического судьи: на завершении плана, который доводит
цель до **готового пользовательского результата**, петля не закрывается, пока владелец не примет
результат по понятной ему **карточке** (что сделано + как проверить самому). Технический судья
по-прежнему первым гарантирует исправность кода (то, что владелец не видит); владелец вторым
гарантирует пользу (то, что судья не чувствует). Закрыть петлю можно только при обоих «за».
Внутренние этапы (план, не доводящий до пользовательского результата) владельца не беспокоят.
## Поток приёмки {#u1}
На завершении плана (метка «петля открыта» уже пишется триггером) Stop-хук gate-3 выполняет:
1. **Судья по коду** (существующий gate-3, линзы goal_achieved/premortem/behavior_vs_goal):
NO-GO → переговоры/арбитраж как сейчас; GO (код исправен) → шаг 2.
2. План помечен `delivery: internal` → петля закрывается по судье, владельца не беспокоим.
3. План помечен `delivery: user-result`:
a. контроллер строит **пользовательскую карточку** ({#u2});
b. **судья сверяет карточку** с реальным продуктом ({#u4}); приукрашивание → NO-GO, карточка
переделывается, владельца не зовут;
c. карточка честна → показывается владельцу, конец хода блокируется до его подписанного решения;
d. владелец `accept` → петля закрыта; `continue`/не принял → возврат в работу.
Контроллер петлю не закрывает никогда; закрытие `user-result` требует И судью по коду, И подписанного
владельца. Закрытие `internal` — только реальный GO судьи по коду.
## Пользовательская карточка {#u2}
Чистый сборщик `buildOwnerCard({ goal, change, verifySteps, boundary, kind })` → объект с полями
простого языка (не код):
- **goal** — цель из опечатанной спеки, по-человечески;
- **change** — 1–3 фразы «что изменилось для пользователя» (поведение, не реализация);
- **verifySteps** — конкретные воспроизводимые шаги владельца «как проверить самому»; для
`kind:'machinery'` — сценарий/команда с наблюдаемым итогом; для `kind:'screen'` — «открой X →
нажми Y → увидишь Z» (+ ссылка на скриншот/запуск, актуально в продуктовом репозитории);
- **boundary** — честная граница «чего НЕ делает / осталось вне объёма» (анти-приукрашивание).
Карточка не висит на честном слове контроллера: (а) `verifySteps` всегда дают владельцу проверку
руками; (б) карточку до показа сверяет судья ({#u4}). `kind` (machinery/screen) определяется по
объекту работы (правки `tools/`/control-layer → machinery; правки страниц/UI → screen);
в claude-brain путь `screen` не задействован (UI нет), проектируется для продуктового репозитория.
## Пометка плана delivery и её честность {#u3}
- Каждый план несёт обязательную пометку `delivery: internal | user-result`. Пометка входит в
**подписанную печать плана** (как `skills`/`steps`) — подмена ломает подпись.
- Судья плана (gate-2) **проверяет честность пометки** против шагов плана и цели спеки: план,
доводящий цель спеки до пользовательского результата, не может быть `internal` (NO-GO «прячешь
готовый результат»). Нельзя бесконечно прятаться за `internal`.
- Поле `delivery` копируется в метку «петля открыта» (стена читает его из опечатанного плана), чтобы
Stop-хук знал режим без повторного парса.
## Сверка карточки судьёй {#u4}
Отдельный звонок судьи (новый `functionName`, например `gate3card`; набор линз в
`judge-engine.VOTE_LAYOUTS`): «карточка соответствует реальному продукту?». На суд подаётся карточка
+ уже подтверждённые факты (цель спеки, по-критерийные GREEN, исполненные шаги). Линзы:
- `card_matches_product` — каждое утверждение карточки подтверждено фактами продукта;
- `no_overstatement` — нет заявлений сверх подтверждённого;
- `verify_steps_real` — шаги «как проверить» действительно демонстрируют заявленное.
NO-GO → карточка/работа дорабатывается, владелец не вызывается. Degraded судья карточки (wired:false)
карточку НЕ пропускает (fail-safe). Это технический контроль честности — не показывается владельцу
как его приёмка.
## Замыкание петли {#u5}
Расширение `decideGate3Closure` (или новая чистая функция-обёртка над ним): входы добавляют
`delivery` и `cardVerdict`; вводится состояние `await-owner`:
- `delivery:'internal'` + код-GO → `closed` (через `loopTerminationDecision`, judge GO);
- `delivery:'user-result'` + код-GO + карточка не пройдена/не готова → `await-card` (блок, не зовём
владельца);
- `delivery:'user-result'` + код-GO + карточка-честна + нет подписи владельца → `await-owner` (блок,
показываем карточку, ждём подпись);
- подписанный `ownerArbitration:'accept'``closed` (приоритет, как в ядре);
- `continue`/NO-GO ветки — как в существующем ядре (переговоры/арбитраж/open).
Инвариант SE-R7-6 сохранён; `terminate:true` достижим только через `loopTerminationDecision`.
## Точки врезки {#u6}
Надстройка на существующее, переиспользование максимально:
- `loop-termination.mjs` — расширить замыкание (`delivery` + `cardVerdict` + `await-owner`/`await-card`).
- `enforce-gate3-loop.mjs` (Stop-хук) — сборщик карточки, звонок судьи-карточки, показ владельцу,
ожидание подписанного `gate3-arb:accept`.
- `judge-engine.mjs` — набор линз `gate3card` в `VOTE_LAYOUTS` + `requiredLensesFor`.
- `plan-lock.mjs` + парсер плана — `delivery` в подписанной печати; парс из тела плана.
- `enforce-supreme-gate.mjs` (стена) — добавить поле `delivery` в метку «петля открыта».
- судья плана (gate-2) — проверка честности пометки `delivery`.
Переиспользуем без переделки: подписанный канал `gate3-arb:accept`, `arbitration-card`, ядро gate-3.
Стараемся без новых production-файлов; если сборщик карточки выделяется в модуль — требует override
владельца (предупредить в плане).
## Конвенция {#u7}
Чистые экспортируемые функции (`buildOwnerCard`, расширение замыкания) тестируемы в изоляции без
модели/IO. `delivery` — строковое поле плана из фиксированного множества `{internal, user-result}`;
неизвестное/отсутствующее → план не печатается (fail-CLOSE на gate-2). Без новых внешних
зависимостей. Правки стены — минимальные и аддитивные (одно поле в метке).
## Вне объёма {#u8}
- Реальная живая демонстрация на экране (скриншот/запуск) проверяется в продуктовом репозитории
(Лидерра); в claude-brain — только путь `machinery`.
- Регистрация хуков / перезапуск среды — действие владельца.
- Полная M/J-память переговоров для слоя карточки сверх нужд — отдельно.
## Критерий приёмки {#u9}
TDD-тесты:
- `buildOwnerCard`: собирает 4 части простым языком; `kind` machinery/screen ветвится; пустые входы →
честные заглушки, не выдумки.
- замыкание: `internal`+код-GO → closed; `user-result`+код-GO без карточки → await-card;
+карточка-честна без подписи → await-owner; +подписанный accept → closed; degraded карточки →
не пропускает; контроллер без подписи не закрывает.
- честность пометки: план, доводящий цель спеки до результата, помеченный `internal` → судья gate-2
заворачивает (тест на детекторе/линзе честности пометки).
- сверка карточки: карточка сверх подтверждённого → NO-GO; честная → GO.
- стена: поле `delivery` попадает в метку «петля открыта».
- полная регрессия tools GREEN: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
(база зелёная + новые тесты; 0 регрессий в переиспользуемых модулях).
```verified-context-json
[{"id":"vc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"decideGate3Closure"}]
```
@@ -0,0 +1,44 @@
# Живой смоук-тест зубов gate-3 (триггер на завершении плана)
**Дата:** 2026-06-17
**Эпик:** роутер-наставник, E-S1 (триггер). **Статус:** дизайн под исполнение.
**Кодовая фраза:** «роутер-наставник».
## Цель
Доказать вживую, что зарегистрированный Stop-хук триггера gate-3 действительно работает на полном
пути: завершение опечатанного плана открывает петлю проверки цели, а конец хода блокируется, пока
цель не подтверждена реальным GO судьи gate-3 либо подписанным решением владельца. Это проверка
уже реализованной машинерии (не новая функциональность); намеренно одношаговый безвредный план.
## Контракт смоука {#sm1}
Ожидаемое поведение, которое смоук обязан показать:
- на завершении одношагового плана стена пишет подписанную метку «петля открыта»;
- на следующем конце хода Stop-хук читает метку, собирает продукт и зовёт судью gate-3;
- так как содержательный шаг плана НЕ даёт по-критерийного GREEN (пробный шаг — запись текстового
файла, не тестируемый модуль), продукт несёт «нет доказательств исполнения»;
- судья gate-3 на отсутствии доказательств выносит NO-GO → конец хода блокируется с возражением;
- петля закрывается ТОЛЬКО подписанным выбором владельца `gate3-arb:accept:<fingerprint>` либо
реальным GO судьи — контроллер сам её не закрывает.
## Пробный артефакт {#sm2}
Безвредный шаг: запись текстового файла-маркера `.scratch/gate3-probe.txt` с одной строкой-меткой
прогона. Файл одноразовый, в продукт не входит, удаляется владельцем после теста. Никаких правок
кода, нормативки, конфигов — только пробный scratch-файл.
## Критерий приёмки {#sm3}
- Метка `gate3-loop-<сессия>.json` появляется в runtime после завершения плана (косвенно: следующий
конец хода обрабатывается Stop-хуком, а не проходит молча).
- Конец хода после завершения плана блокируется сообщением `[gate3-loop] …` (зубы кусают), пока цель
не подтверждена.
- После подписанного `gate3-arb:accept:<fingerprint>` владельца следующий конец хода проходит
(петля закрыта) — канал владельца работает.
- Никаких побочных изменений в репозитории, кроме одноразового `.scratch/gate3-probe.txt`.
```verified-context-json
[{"id":"vc1","kind":"EXTRACTED","ref":"tools/cost-pricing.mjs","anchor":"export const PRICING = Object.freeze("}]
```