From ba584a8335495304bb6d34c78792d7d3b2eb5042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 17 Jun 2026 15:41:09 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20bag-=D1=80=D0=B5=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B1=D0=B0=D0=B3=D0=BE=D0=B2=20=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20plus=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BB=D0=B0=D0=BD=D1=8B=20=D1=84=D0=B0=D0=B7=20?= =?UTF-8?q?gate3=20plus=20=D1=80=D0=BE=D0=B0=D0=B4=D0=BC=D0=B0=D0=BF=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D1=85=20=D0=B2=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B8=D1=8F=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...-17-produce-verify-receipt-app-path-bug.md | 26 + ...6-17-wall-interactive-walk-blockers-bug.md | 157 ++++++ bags/2026-06-17-wall-read-block-bug.md | 80 +++ ...06-17-es1-gate3-owner-card-2a-core-impl.md | 112 ++++ ...6-06-17-es1-gate3-owner-card-phase2a-v2.md | 273 ++++++++++ ...2026-06-17-es1-gate3-owner-card-phase2a.md | 273 ++++++++++ ...26-06-17-es1-gate3-trigger-stop-hook-v2.md | 507 ++++++++++++++++++ .../2026-06-17-es1-gate3-trigger-stop-hook.md | 494 +++++++++++++++++ ...3-owner-acceptance-phase1-delivery-mark.md | 338 ++++++++++++ ...26-06-17-mentor-silent-swallow-fix-impl.md | 96 ++++ ...-06-17-open-items-multi-session-roadmap.md | 81 +++ .../specs/2026-06-16-verdict-surface-smoke.md | 12 + .../2026-06-17-es1-gate3-safe-core-design.md | 113 ++++ ...6-17-gate3-owner-user-acceptance-design.md | 142 +++++ .../2026-06-17-gate3-teeth-live-smoke.md | 44 ++ 15 files changed, 2748 insertions(+) create mode 100644 bags/2026-06-17-produce-verify-receipt-app-path-bug.md create mode 100644 bags/2026-06-17-wall-interactive-walk-blockers-bug.md create mode 100644 bags/2026-06-17-wall-read-block-bug.md create mode 100644 docs/superpowers/plans/2026-06-17-es1-gate3-owner-card-2a-core-impl.md create mode 100644 docs/superpowers/plans/2026-06-17-es1-gate3-owner-card-phase2a-v2.md create mode 100644 docs/superpowers/plans/2026-06-17-es1-gate3-owner-card-phase2a.md create mode 100644 docs/superpowers/plans/2026-06-17-es1-gate3-trigger-stop-hook-v2.md create mode 100644 docs/superpowers/plans/2026-06-17-es1-gate3-trigger-stop-hook.md create mode 100644 docs/superpowers/plans/2026-06-17-gate3-owner-acceptance-phase1-delivery-mark.md create mode 100644 docs/superpowers/plans/2026-06-17-mentor-silent-swallow-fix-impl.md create mode 100644 docs/superpowers/plans/2026-06-17-open-items-multi-session-roadmap.md create mode 100644 docs/superpowers/specs/2026-06-16-verdict-surface-smoke.md create mode 100644 docs/superpowers/specs/2026-06-17-es1-gate3-safe-core-design.md create mode 100644 docs/superpowers/specs/2026-06-17-gate3-owner-user-acceptance-design.md create mode 100644 docs/superpowers/specs/2026-06-17-gate3-teeth-live-smoke.md diff --git a/bags/2026-06-17-produce-verify-receipt-app-path-bug.md b/bags/2026-06-17-produce-verify-receipt-app-path-bug.md new file mode 100644 index 0000000..c14b03f --- /dev/null +++ b/bags/2026-06-17-produce-verify-receipt-app-path-bug.md @@ -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. diff --git a/bags/2026-06-17-wall-interactive-walk-blockers-bug.md b/bags/2026-06-17-wall-interactive-walk-blockers-bug.md new file mode 100644 index 0000000..7362443 --- /dev/null +++ b/bags/2026-06-17-wall-interactive-walk-blockers-bug.md @@ -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»** (круги 9–11): +- 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-проходе. diff --git a/bags/2026-06-17-wall-read-block-bug.md b/bags/2026-06-17-wall-read-block-bug.md new file mode 100644 index 0000000..b0508d5 --- /dev/null +++ b/bags/2026-06-17-wall-read-block-bug.md @@ -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 не ломает очередность. diff --git a/docs/superpowers/plans/2026-06-17-es1-gate3-owner-card-2a-core-impl.md b/docs/superpowers/plans/2026-06-17-es1-gate3-owner-card-2a-core-impl.md new file mode 100644 index 0000000..e87003c --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-es1-gate3-owner-card-2a-core-impl.md @@ -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-хук — не здесь. ✓ diff --git a/docs/superpowers/plans/2026-06-17-es1-gate3-owner-card-phase2a-v2.md b/docs/superpowers/plans/2026-06-17-es1-gate3-owner-card-phase2a-v2.md new file mode 100644 index 0000000..23d0d99 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-es1-gate3-owner-card-phase2a-v2.md @@ -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`) согласованы между тестами и реализацией. ✓ diff --git a/docs/superpowers/plans/2026-06-17-es1-gate3-owner-card-phase2a.md b/docs/superpowers/plans/2026-06-17-es1-gate3-owner-card-phase2a.md new file mode 100644 index 0000000..45a97b6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-es1-gate3-owner-card-phase2a.md @@ -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`) согласованы между тестами и реализацией. ✓ diff --git a/docs/superpowers/plans/2026-06-17-es1-gate3-trigger-stop-hook-v2.md b/docs/superpowers/plans/2026-06-17-es1-gate3-trigger-stop-hook-v2.md new file mode 100644 index 0000000..adf55fe --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-es1-gate3-trigger-stop-hook-v2.md @@ -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-.json`, кэш `gate3-cache-.json`. +- метка: `{ taskId, planId, artifactId, steps, at, sig }`. +- escape-метки владельца: `gate3-arb:accept:` / `gate3-arb:continue:`. + +```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 " -- 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"}] +``` diff --git a/docs/superpowers/plans/2026-06-17-es1-gate3-trigger-stop-hook.md b/docs/superpowers/plans/2026-06-17-es1-gate3-trigger-stop-hook.md new file mode 100644 index 0000000..89e7600 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-es1-gate3-trigger-stop-hook.md @@ -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-.json`; кэш: `gate3-cache-.json` (в `~/.claude/runtime`). +- метка: `{ taskId, planId, artifactId, steps, at, sig }`. +- escape-метки владельца: `gate3-arb:accept:` / `gate3-arb:continue:`. + +```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 " -- 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"}] +``` diff --git a/docs/superpowers/plans/2026-06-17-gate3-owner-acceptance-phase1-delivery-mark.md b/docs/superpowers/plans/2026-06-17-gate3-owner-acceptance-phase1-delivery-mark.md new file mode 100644 index 0000000..92ff634 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-gate3-owner-acceptance-phase1-delivery-mark.md @@ -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 " -- 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"}] +``` diff --git a/docs/superpowers/plans/2026-06-17-mentor-silent-swallow-fix-impl.md b/docs/superpowers/plans/2026-06-17-mentor-silent-swallow-fix-impl.md new file mode 100644 index 0000000..4786eff --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-mentor-silent-swallow-fix-impl.md @@ -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:'наставник-путь сорвался: ', 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:'наставник-путь (спека) сорвался: ', 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, успешный путь не меняется, без новых файлов. ✓ diff --git a/docs/superpowers/plans/2026-06-17-open-items-multi-session-roadmap.md b/docs/superpowers/plans/2026-06-17-open-items-multi-session-roadmap.md new file mode 100644 index 0000000..e3cb66d --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-open-items-multi-session-roadmap.md @@ -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, иначе в терминале владельца. Память/коммит — по клику владельца. diff --git a/docs/superpowers/specs/2026-06-16-verdict-surface-smoke.md b/docs/superpowers/specs/2026-06-16-verdict-surface-smoke.md new file mode 100644 index 0000000..22e483b --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-verdict-surface-smoke.md @@ -0,0 +1,12 @@ +# Спека: хелпер isBlankString (черновик-смоук) + +## Цель + +Добавить чистый хелпер `isBlankString(value)` в `tools/`, возвращающий `true` для +пустой строки и строки из одних пробельных символов, иначе `false`. Это черновик — +edge-cases, конвенция заголовка и критерий приёмки будут дописаны позже. + +## Контракт {#c1} + +`isBlankString('')` → `true`; `isBlankString(' ')` → `true`; `isBlankString('x')` → `false`. +Нестроковый вход → `false`. diff --git a/docs/superpowers/specs/2026-06-17-es1-gate3-safe-core-design.md b/docs/superpowers/specs/2026-06-17-es1-gate3-safe-core-design.md new file mode 100644 index 0000000..5f543c9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-es1-gate3-safe-core-design.md @@ -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"}] +``` diff --git a/docs/superpowers/specs/2026-06-17-gate3-owner-user-acceptance-design.md b/docs/superpowers/specs/2026-06-17-gate3-owner-user-acceptance-design.md new file mode 100644 index 0000000..9348fbb --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-gate3-owner-user-acceptance-design.md @@ -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"}] +``` diff --git a/docs/superpowers/specs/2026-06-17-gate3-teeth-live-smoke.md b/docs/superpowers/specs/2026-06-17-gate3-teeth-live-smoke.md new file mode 100644 index 0000000..309e0fe --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-gate3-teeth-live-smoke.md @@ -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:` либо + реальным GO судьи — контроллер сам её не закрывает. + +## Пробный артефакт {#sm2} + +Безвредный шаг: запись текстового файла-маркера `.scratch/gate3-probe.txt` с одной строкой-меткой +прогона. Файл одноразовый, в продукт не входит, удаляется владельцем после теста. Никаких правок +кода, нормативки, конфигов — только пробный scratch-файл. + +## Критерий приёмки {#sm3} + +- Метка `gate3-loop-<сессия>.json` появляется в runtime после завершения плана (косвенно: следующий + конец хода обрабатывается Stop-хуком, а не проходит молча). +- Конец хода после завершения плана блокируется сообщением `[gate3-loop] …` (зубы кусают), пока цель + не подтверждена. +- После подписанного `gate3-arb:accept:` владельца следующий конец хода проходит + (петля закрыта) — канал владельца работает. +- Никаких побочных изменений в репозитории, кроме одноразового `.scratch/gate3-probe.txt`. + +```verified-context-json +[{"id":"vc1","kind":"EXTRACTED","ref":"tools/cost-pricing.mjs","anchor":"export const PRICING = Object.freeze("}] +```