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