# approval-gates Specification ## Purpose Технические гейты цикла разработки в DSH-бандле: builder-check → утверждение дизайна → план → утверждение плана → sdd. Инструкции superpowers сами по себе не являются механизмом (сессия ce9187ca 24.10.2026 пропустила builder-check, а план появился до утверждения дизайна), поэтому бандл проверяет артефакты утверждения на каждом переходе и отказывает с `BLOCKED [approval-gates]: <причина>`. Гейты живут только в DSH-бандле (D-2); Claude Code-хуки и поток не меняются. ## Requirements ### Requirement: Артефакты утверждения `openspec/changes//builder-check.md` SHALL содержать отчёт свежего builder-check субагента, скопированный в change контроллером; он MUST быть записан после builder-check и до передачи дизайна владельцу. `openspec/changes//approval.md` SHALL быть маркером утверждения владельцем. Писать его MUST только код бандла — команда `/approve` или инструмент `request_approval` (D-1), и оба MUST писать только после подтверждения владельца. Прямые записи модели (`write`, `edit`, `multi_edit`, `str_replace_editor`, `bash`) MUST NOT создавать или менять этот файл (гейт C ниже, без изменений). Формат `approval.md` — yaml с секциями `design:` (поля `by`, `at`) и `plan:` (поля `by`, `at`, `sha256`); `sha256` — hex-хэш sha256 содержимого файла плана на момент утверждения (D-10). Запись MUST опускать пустые поля, разбор MUST быть терпимым: неизвестные секции и строки игнорируются, битый файл деградирует в «секции нет», и гейт отказывает. Секция `design:` SHALL считаться утверждением только при непустых `by` и `at`: пустая секция — не утверждение (T1.8). Запись одной секции MUST NOT менять содержимое другой. #### Scenario: Пустая секция design не разблокирует план - **WHEN** `approval.md` содержит заголовок `design:` без полей `by` и `at` - **THEN** гейт A отказывает с `design not approved`, как будто секции нет #### Scenario: Утверждение плана не теряет утверждение дизайна - **WHEN** `/approve plan` записывает plan-секцию в существующий `approval.md` - **THEN** design-секция остаётся дословно прежней ### Requirement: Команда /approve `/approve design|plan` SHALL быть ручным писателем `approval.md` — одним из двух писателей бандла, наравне с инструментом `request_approval` (следующее требование); оба пишут только после подтверждения владельца, прямые записи модели — MUST NOT (гейт C). Команда MUST регистрироваться при `enabled` и `approvalGates: true`, независимо от флагов `roleCommand` и `checkCommand`: иначе гейты остаются без команды-разблокировки (T3.2). `change-id` MUST быть одним безопасным сегментом пути; небезопасный id или неизвестный вид — usage-отказ без записи. Вид `design` SHALL требовать существования `proposal.md`, `design.md` и `builder-check.md` в `openspec/changes//`; при отсутствии файла команда MUST отказать с перечислением отсутствующих и MUST NOT спрашивать владельца. Вид `plan` SHALL требовать design-секцию с непустыми `by` и `at` (иначе отказ `design not approved; run /approve design`) и ровно одного плана в `docs/superpowers/plans/`, чей `**Spec:**` указывает на ``; ноль планов — отказ, несколько — отказ с перечислением файлов. Утверждаемый план MUST получать `sha256` своего текущего содержимого. Подтверждение владельца MUST идти через `ctx.userQuestions.ask` (D-12): вопрос с вариантами Approve и Cancel; без сервиса команда MUST отказать, а не писать. При подтверждении пишутся `by: owner` и `at` в ISO 8601 UTC; при отмене или неподтверждении — отказ `not confirmed`, файл не меняется. Сбой самого диалога (исключение из `ctx.userQuestions.ask`) MUST давать отказ с текстом ошибки — `userQuestions failed: `, — а не литерал `not confirmed` (T1.5 #77): владелец должен видеть причину, по которой вопрос не дошёл, а отмена и сбой различаются в логе. `approval.md` не меняется ни в одном из этих случаев. Команда MUST NOT создавать план и не трогать `proposal.md`, `design.md`, `builder-check.md`. #### Scenario: /approve design без builder-check - **WHEN** в change есть proposal и design, но нет `builder-check.md` - **THEN** команда отказывает с перечислением `builder-check.md`, владелец не спрашивается, `approval.md` не создаётся #### Scenario: /approve plan без утверждения дизайна - **WHEN** `approval.md` не содержит непустой design-секции - **THEN** команда отказывает с `design not approved; run /approve design` и не спрашивает владельца #### Scenario: /approve plan при двух планах на change - **WHEN** два файла в `docs/superpowers/plans/` несут `**Spec:**` на один change - **THEN** команда отказывает, перечисляя оба файла, и `approval.md` не меняется #### Scenario: Утверждение плана фиксирует sha256 - **WHEN** владелец подтвердил `/approve plan` при единственном плане - **THEN** plan-секция содержит `by: owner`, `at` в ISO 8601 и sha256 содержимого плана #### Scenario: /approve design при change в связанном worktree - **WHEN** change `openspec/changes//` с proposal, design и builder-check существует в `/.claude/worktrees//` (или `.wt//`), а сессия работает в основном checkout - **THEN** команда находит артефакты и пишет `approval.md` в change этого worktree #### Scenario: /approve plan при плане в связанном worktree - **WHEN** план со `**Spec:**` на change лежит в `docs/superpowers/plans/` связанного worktree - **THEN** команда находит его, фиксирует sha256 и пишет plan-секцию в `approval.md` change этого worktree #### Scenario: /approve без сервиса вопросов - **WHEN** `ctx.userQuestions` отсутствует - **THEN** команда отказывает с `userQuestions service unavailable; /approve requires it` и `approval.md` не пишется #### Scenario: Сбой диалога вопросов - **WHEN** `ctx.userQuestions.ask` завершается исключением - **THEN** команда отказывает с `userQuestions failed: `, а не с литералом `not confirmed`, и `approval.md` не меняется #### Scenario: Отмена в диалоге - **WHEN** владелец ответил Cancel (или вопрос остался без ответа) - **THEN** команда отказывает с `not confirmed` и `approval.md` не меняется ### Requirement: Инструмент request_approval Инструмент модели `request_approval` SHALL быть вторым писателем `approval.md`: модель инициирует утверждение, владелец только подтверждает в диалоге, а маркер пишет код бандла (`lib/approval.js`) — с теми же проверками предпосылок и той же записью, что у `/approve`. Инструмент MUST регистрироваться при `enabled` и `approvalGates: true`, если в контексте доступен `ctx.tools.register`; `userQuestions` MUST резолвиться лениво (не через `inject`), чтобы отсутствие сервиса не парковало весь бандл. Параметры инструмента — `change_id` (строка) и `kind` (`design` | `plan`). Валидация MUST быть собственной, в инструменте: пустой или небезопасный `change_id` и неизвестный `kind` — usage-отказ без записи и без вопроса владельцу; проверки предпосылок и отказы те же, что у `/approve` (design — proposal/design/builder-check, plan — утверждённый дизайн, ровно один план, sha256), и любой отказ MUST NOT писать `approval.md`. Подтверждением SHALL считаться только ответ `answers[0].selected[0] === 'Approve'`; Cancel и отсутствие ответа — отказ `not confirmed` без записи; ошибка диалога — отказ с текстом ошибки без записи. При отсутствии сервиса `userQuestions` инструмент MUST отказать (`userQuestions service unavailable`), а не писать. #### Scenario: Модель просит утвердить дизайн - **WHEN** модель вызывает `request_approval` с `{change_id: , kind: design}`, предпосылки выполнены, а владелец выбрал Approve - **THEN** в `approval.md` пишется design-секция (`by: owner`, `at` в ISO 8601 UTC), инструмент возвращает `{approved: true, message}` #### Scenario: Модель просит утвердить план - **WHEN** модель вызывает `request_approval` с `{change_id: , kind: plan}`, дизайн утверждён, план единственный, владелец выбрал Approve - **THEN** plan-секция содержит `by: owner`, `at` в ISO 8601 и sha256 текущего содержимого плана #### Scenario: Владелец отменил утверждение - **WHEN** в диалоге `request_approval` владелец выбрал Cancel - **THEN** инструмент возвращает `{approved: false, message: 'not confirmed'}`, а `approval.md` не меняется #### Scenario: Некорректные аргументы request_approval - **WHEN** модель передала неизвестный `kind` или небезопасный `change_id` (например, `../x`) - **THEN** инструмент возвращает usage-отказ без записи, владелец не спрашивается #### Scenario: Отсутствует builder-check при утверждении дизайна моделью - **WHEN** `kind: design`, а `builder-check.md` в change нет - **THEN** инструмент отказывает с перечислением отсутствующего файла, владелец не спрашивается, `approval.md` не создаётся #### Scenario: request_approval plan без утверждённого дизайна - **WHEN** `kind: plan`, а design-секции нет - **THEN** инструмент отказывает `design not approved; run /approve design`, владелец не спрашивается, `approval.md` не меняется #### Scenario: request_approval без сервиса вопросов - **WHEN** `userQuestions` отсутствует - **THEN** инструмент возвращает `{approved: false}` с `userQuestions service unavailable`, владелец не спрашивается, `approval.md` не пишется ### Requirement: Гейт A — план до утверждения дизайна Для write/edit/multi_edit/`str_replace_editor` с целевым путём под `docs/superpowers/plans/` гейт SHALL извлекать `**Spec:**` из содержимого (`content` или `new_string`). Ссылка MUST указывать в `openspec/changes/`; план без `**Spec:**` и план с legacy-ссылкой в `docs/superpowers/specs/**` MUST получать отказ `plan must carry **Spec:** pointing to openspec/changes/` (D-9). Правка без содержимого (bash-цель, `str_replace_editor` create) MUST получать тот же отказ: гейт не видит содержимого и не может подтвердить Spec — fail closed. Фрагмент `edit` проверяется по своему `new_string` (content-based): фрагмент, который сам несёт `**Spec:**` на утверждённый change, проходит; фрагмент без Spec, с legacy-ссылкой или с неутверждённым change — DENY. Если change определён, а его design-секция не является утверждением, гейт MUST отказать `design not approved; run /approve design`. Утверждённый дизайн разрешает запись плана; запись вне `docs/superpowers/plans/` гейтом A не проверяется. Исторические планы заморожены — все написаны до лупа: четыре файла. `2026-10-11-roles-and-memory` несёт legacy-`**Spec:**` в `docs/superpowers/specs/`; `2026-10-24-dsh-decouple`, `2026-10-24-dsh-port-spike` и `2026-10-24-dsh-role-loader` — без `**Spec:**` (T1.3, T4.3). Любая запись в любой из них MUST получать отказ по D-9; это ожидаемое поведение, а не дефект. #### Scenario: План со Spec на неутверждённый дизайн - **WHEN** write в `docs/superpowers/plans/x.md` с `**Spec:** openspec/changes//`, а `approval.md` без design-секции - **THEN** отказ `BLOCKED [approval-gates]: design not approved; run /approve design` #### Scenario: План без Spec - **WHEN** write в `docs/superpowers/plans/x.md` без строки `**Spec:**` - **THEN** отказ `plan must carry **Spec:** pointing to openspec/changes/` #### Scenario: План после утверждения дизайна - **WHEN** design-секция содержит непустые `by` и `at`, а план несёт `**Spec:**` на этот change - **THEN** запись плана разрешена #### Scenario: Правка плана фрагментом без Spec - **WHEN** edit плана передаёт `new_string` без `**Spec:**` (обычный фрагмент правки) - **THEN** отказ `plan must carry **Spec:** …`: гейт видит только фрагмент, план целиком пишется full-file write #### Scenario: Фрагмент edit со Spec на утверждённый change - **WHEN** edit плана передаёт `new_string`, в котором есть `**Spec:**` на change с непустой design-секцией - **THEN** запись разрешена: гейт проверяет содержимое, а не инструмент #### Scenario: Правка исторического плана - **WHEN** write в `docs/superpowers/plans/2026-10-24-dsh-decouple.md` - **THEN** отказ `plan must carry **Spec:** …` по D-9 #### Scenario: Правка плана с legacy-Spec - **WHEN** write в `docs/superpowers/plans/2026-10-11-roles-and-memory.md`, чей `**Spec:**` указывает в `docs/superpowers/specs/` - **THEN** отказ `plan must carry **Spec:** …` по D-9 ### Requirement: Гейт B — sdd до утверждения плана Гейт SHALL срабатывать на двух входах (D-7): (a) write в `.superpowers/sdd//**` (ledger, брифы) — старт sdd в Режиме 1; (b) диспатч `subagent`, `subagent_fork` или `workflow`, в `prompt` или `script` которого есть ссылка на план — `docs/superpowers/plans/<имя>.md` или `.superpowers/sdd/<имя>/`. Для каждого входа гейт MUST определить файл `docs/superpowers/plans/.md`, взять из него `**Spec:**` → change и потребовать plan-секцию `approval.md` с sha256, равным sha256 текущего содержимого плана. Отказы MUST различаться по причине: - (a) файл плана по ссылке отсутствует или не читается — `plan not approved; run /approve plan` (fail closed; id остаётся литеральным ``, если план не прочитан); - (b) план читается, но без `**Spec:**` или с legacy-ссылкой вне `openspec/changes/` — тот же `plan not approved; run /approve plan`; - (c) план и change определены, но plan-секции нет или в ней нет sha256 — тот же `plan not approved; run /approve plan`, уже с id change; - (d) plan-секция есть, но sha256 не совпадает с текущим содержимым плана — `plan changed after approval; re-approve`. Для входа через `.superpowers/sdd//` без файла `docs/superpowers/plans/.md` отказ — `plan not found: `. Проверяться MUST все план-ссылки, а не первая; возвращается первый отказ, чтобы утверждённый план не маскировал неутверждённый. Вызов без план-ссылки MUST NOT гейтиться. Разбор промпта — эвристика по подстрокам, поэтому разбор fail open: вызов, в котором ссылка не распознана, не блокируется. Ссылки на план резолвятся от корня репозитория сессии: сначала файл ищется в самом корне, затем в связанных worktree `.claude/worktrees/*` и `.wt/*` (первый существующий файл); `arguments.cwd` игнорируется. План, найденный в связанном worktree, гейтится по `approval.md` своего change — в том же worktree. #### Scenario: Ledger до утверждения плана - **WHEN** write в `.superpowers/sdd//progress.md`, а plan-секции нет - **THEN** отказ `BLOCKED [approval-gates]: plan not approved; run /approve plan` #### Scenario: Диспатч с утверждённым планом - **WHEN** `subagent` получает промпт со ссылкой на план, чей sha256 совпадает с plan-секцией - **THEN** диспатч разрешён #### Scenario: План в связанном worktree - **WHEN** промпт диспатча ссылается на `docs/superpowers/plans/<имя>.md`, который существует только в связанном worktree сессии - **THEN** гейт проверяет approval этого плана в change того же worktree; утверждённый план разрешает диспатч, без approval — отказ `plan not approved; run /approve plan` #### Scenario: Один из двух планов не утверждён - **WHEN** промпт `subagent` ссылается на два плана, и второй не утверждён - **THEN** диспатч отклонён по второму плану, даже если первый утверждён #### Scenario: План по ссылке не найден - **WHEN** `subagent` получает промпт со ссылкой на `docs/superpowers/plans/missing.md`, которого нет - **THEN** отказ `plan not approved; run /approve plan` (fail closed), а не `plan changed after approval` #### Scenario: План изменён после утверждения - **WHEN** содержимое плана изменилось после `/approve plan` - **THEN** отказ `plan changed after approval; re-approve`; снимает его только повторное утверждение #### Scenario: Диспатч без ссылки на план - **WHEN** в промпте `subagent` нет ни пути плана, ни sdd-каталога - **THEN** гейт B не применяется ### Requirement: Гейт C и bash-обход `approval.md` MUST писаться только кодом бандла — командой `/approve` или инструментом `request_approval`: любой write/edit с целью, чей basename — `approval.md`, и любая bash-цель с таким именем MUST получать отказ `approval.md is written only by /approve` (D-1). Deny-текст не меняется: он адресован прямым записям модели (`write`/`edit`/`bash`), а `request_approval` пишет маркер кодом бандла и под этот запрет не попадает. Bash-обход MUST быть закрыт (D-8): если команда содержит грубую подстроку `docs/superpowers/plans`, `approval.md` или `.superpowers/sdd`, её файловые цели разбираются тем же парсером, что у хуков, — `python3 /harness_target.py targets '' `. К целям применяются гейт A (без содержимого), гейт C и write-гейт B; возвращается первый отказ. Сбой запуска, разбора или таймаут парсера MUST fail open, как в хуках: сбой разбора входа не блокирует. Команда без плановых подстрок и целей MUST NOT проверяться гейтами. #### Scenario: Heredoc в план без Spec - **WHEN** bash-команда `cat > docs/superpowers/plans/x.md <<'EOF' … EOF` без `**Spec:**` - **THEN** отказ `plan must carry **Spec:** …` (содержимое bash-цели гейту недоступно) #### Scenario: Запись approval.md через bash - **WHEN** bash-команда `echo x > openspec/changes//approval.md` - **THEN** отказ `approval.md is written only by /approve` #### Scenario: Запись в sdd-каталог через bash - **WHEN** bash-цель — файл в `.superpowers/sdd//`, а план не утверждён - **THEN** отказ `plan not approved` #### Scenario: Обычная bash-команда - **WHEN** в команде нет плановых подстрок - **THEN** гейты не запускаются ### Requirement: Исключения и известные пределы Bounded-задача без change и плана MUST NOT гейтиться (D-6): у неё нет план-ссылок, записей в `docs/superpowers/plans/` и sdd-каталога. Если каталог change существует в нескольких корнях, MUST использоваться первый в порядке [корень сессии, `.claude/worktrees/*`, `.wt/*`]; имена worktree сортируются. Несколько планов на один change — MUST отказ с перечислением, как и раньше; план из другого корня показывается с префиксом корня. Гейты SHALL жить в guard-цепочке и быть активными только при `enabled: true`, `guards: true`, успешном self-check `guards` (`guardsOk`) и `approvalGates: true`; `approvalGates: true` при выключенных guards или проваленном self-check MUST NOT их включать (T3.3). Гейты MUST быть активны только в сессии с переустановленным бандлом (D-13): до переустановки их нет, и sdd-скилл делает ручную проверку маркера; после — её дублирует enforcement. Правка плана фрагментом (`edit`) обычно получает отказ: гейт проверяет только `new_string`, а обычный фрагмент не несёт `**Spec:**`, поэтому план целиком пишется full-file write; фрагмент со своей строкой `**Spec:**` на утверждённый change проходит (T3.5, T4.1). Bash-цели и `str_replace_editor` create с невидимым содержимым отказывают всегда; deny-текст про отсутствующий `**Spec:**` для них вводит в заблуждение — реальная причина в невидимом содержимом (T3.4). `terminal_open` / `terminal_send` MUST оставаться вне гейтов: они пишут файлы в обход и гейтов, и bash-хуков — остаточный риск (T3.6). Симлинк вне `docs/superpowers/plans/`, указывающий на план внутри, лексически обходит гейт A — класс остаточного риска D-1; гейт защищает от случайного пропуска, а не от злого умысла (T1.5). Гейты не перехватывают сообщения модели: презентация дизайна владельцу не гейтится, пропуск становится виден на цепочке builder-check → `/approve` → план → sdd (D-3). `/approve` SHALL считать подтверждением ответ `{answers: [{selected: [Approve]}]}`, а также тестовые формы `{approved}`, `{confirmed}` и `true`; в рантайме подтверждение — блокирующий вопрос владельцу, а не эвристика по вызывающему (D-12, T2.3). Тестовые формы MUST NOT использоваться моделью для самоутверждения: команда вызывается только владельцем. #### Scenario: Bounded-правка - **WHEN** в репозитории нет change и плана, а правка не трогает `docs/superpowers/plans/`, `approval.md` и `.superpowers/sdd/` - **THEN** гейты не применяются #### Scenario: approvalGates без guards - **WHEN** профиль задаёт `guards: false` и `approvalGates: true` - **THEN** гейты не работают: они часть guard-цепочки #### Scenario: terminal_send пишет план - **WHEN** `terminal_send` выполняет команду, пишущую в `docs/superpowers/plans/` - **THEN** гейты не срабатывают — документированный предел, не регресс #### Scenario: Правка дизайна после утверждения - **WHEN** `design.md` дополняется решением после `/approve design` - **THEN** утверждение не отзывается (D-11): хэш фиксируется только для плана