441 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/<id>/builder-check.md` SHALL содержать отчёт свежего
builder-check субагента, скопированный в change контроллером; он MUST быть записан
после builder-check и до передачи дизайна владельцу.
`openspec/changes/<id>/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 <id> plan` записывает plan-секцию в существующий `approval.md`
- **THEN** design-секция остаётся дословно прежней
### Requirement: Команда /approve
`/approve <change-id> 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/<id>/`; при отсутствии файла команда
MUST отказать с перечислением отсутствующих и MUST NOT спрашивать владельца.
Вид `plan` SHALL требовать design-секцию с непустыми `by` и `at` (иначе отказ
`design not approved; run /approve <id> design`) и ровно одного плана в
`docs/superpowers/plans/`, чей `**Spec:**` указывает на `<id>`; ноль планов —
отказ, несколько — отказ с перечислением файлов. Утверждаемый план 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: <message>`, — а не литерал `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 <id> design` и
не спрашивает владельца
#### Scenario: /approve plan при двух планах на change
- **WHEN** два файла в `docs/superpowers/plans/` несут `**Spec:**` на один change
- **THEN** команда отказывает, перечисляя оба файла, и `approval.md` не меняется
#### Scenario: Утверждение плана фиксирует sha256
- **WHEN** владелец подтвердил `/approve <id> plan` при единственном плане
- **THEN** plan-секция содержит `by: owner`, `at` в ISO 8601 и sha256 содержимого
плана
#### Scenario: /approve design при change в связанном worktree
- **WHEN** change `openspec/changes/<id>/` с proposal, design и builder-check
существует в `<repo>/.claude/worktrees/<slug>/` (или `.wt/<slug>/`), а сессия
работает в основном 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: <message>`, а не с
литералом `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: <id>, kind: design}`,
предпосылки выполнены, а владелец выбрал Approve
- **THEN** в `approval.md` пишется design-секция (`by: owner`, `at` в ISO 8601
UTC), инструмент возвращает `{approved: true, message}`
#### Scenario: Модель просит утвердить план
- **WHEN** модель вызывает `request_approval` с `{change_id: <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 <id> 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/<id>`;
план без `**Spec:**` и план с legacy-ссылкой в `docs/superpowers/specs/**` MUST
получать отказ `plan must carry **Spec:** pointing to openspec/changes/<id>`
(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 <id> 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/<id>/`, а `approval.md` без design-секции
- **THEN** отказ `BLOCKED [approval-gates]: design not approved; run /approve <id>
design`
#### Scenario: План без Spec
- **WHEN** write в `docs/superpowers/plans/x.md` без строки `**Spec:**`
- **THEN** отказ `plan must carry **Spec:** pointing to openspec/changes/<id>`
#### 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/<plan-name>/**` (ledger, брифы) — старт sdd в Режиме 1;
(b) диспатч `subagent`, `subagent_fork` или `workflow`, в `prompt` или `script`
которого есть ссылка на план — `docs/superpowers/plans/<имя>.md` или
`.superpowers/sdd/<имя>/`.
Для каждого входа гейт MUST определить файл
`docs/superpowers/plans/<plan-name>.md`, взять из него `**Spec:**` → change и
потребовать plan-секцию `approval.md` с sha256, равным sha256 текущего
содержимого плана. Отказы MUST различаться по причине:
- (a) файл плана по ссылке отсутствует или не читается — `plan not approved; run
/approve <id> plan` (fail closed; id остаётся литеральным `<id>`, если план не
прочитан);
- (b) план читается, но без `**Spec:**` или с legacy-ссылкой вне
`openspec/changes/` — тот же `plan not approved; run /approve <id> plan`;
- (c) план и change определены, но plan-секции нет или в ней нет sha256 — тот же
`plan not approved; run /approve <id> plan`, уже с id change;
- (d) plan-секция есть, но sha256 не совпадает с текущим содержимым плана —
`plan changed after approval; re-approve`.
Для входа через `.superpowers/sdd/<plan-name>/` без файла
`docs/superpowers/plans/<plan-name>.md` отказ — `plan not found: <plan-name>`.
Проверяться MUST все план-ссылки, а не первая; возвращается первый отказ, чтобы
утверждённый план не маскировал неутверждённый.
Вызов без план-ссылки MUST NOT гейтиться. Разбор промпта — эвристика по
подстрокам, поэтому разбор fail open: вызов, в котором ссылка не распознана, не
блокируется. Ссылки на план резолвятся от корня репозитория сессии: сначала
файл ищется в самом корне, затем в связанных worktree `.claude/worktrees/*` и
`.wt/*` (первый существующий файл); `arguments.cwd` игнорируется. План,
найденный в связанном worktree, гейтится по `approval.md` своего change — в том
же worktree.
#### Scenario: Ledger до утверждения плана
- **WHEN** write в `.superpowers/sdd/<plan-name>/progress.md`, а plan-секции нет
- **THEN** отказ `BLOCKED [approval-gates]: plan not approved; run /approve <id>
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 <id> plan`
#### Scenario: Один из двух планов не утверждён
- **WHEN** промпт `subagent` ссылается на два плана, и второй не утверждён
- **THEN** диспатч отклонён по второму плану, даже если первый утверждён
#### Scenario: План по ссылке не найден
- **WHEN** `subagent` получает промпт со ссылкой на
`docs/superpowers/plans/missing.md`, которого нет
- **THEN** отказ `plan not approved; run /approve <id> plan` (fail closed), а не
`plan changed after approval`
#### Scenario: План изменён после утверждения
- **WHEN** содержимое плана изменилось после `/approve <id> 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 <hooksDir>/harness_target.py
targets '<cmd>' <cwd>`. К целям применяются гейт 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/<id>/approval.md`
- **THEN** отказ `approval.md is written only by /approve`
#### Scenario: Запись в sdd-каталог через bash
- **WHEN** bash-цель — файл в `.superpowers/sdd/<plan-name>/`, а план не утверждён
- **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 <id> design`
- **THEN** утверждение не отзывается (D-11): хэш фиксируется только для плана