diff --git a/dsh/PORT.md b/dsh/PORT.md index e7ac230..e89c821 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -15,7 +15,7 @@ This is a port, not a copy. Three layers map differently: | Instructions | `~/.claude/CLAUDE.md`, project `CLAUDE.md` | `$DSH_HOME/AGENTS.md`, project `CLAUDE.md`/`AGENTS.md`, nested | almost 1:1 | | Skills | `~/.claude/skills`, `.claude/skills` | `~/.dsh/skills`, `.agents/skills`, `customSkillDirs` | 1:1 format | | Roles | `roles/`, `/role` skill | same files + `/role` command + model-invocable `role` skill | close | -| Hard rules | `PreToolUse` shell hooks | `ctx.tools.guard` Cordis guard | logic reused, wrapper new | +| Hard rules | `PreToolUse` shell hooks | `ctx.tools.guard` Cordis guard + approval gates A/B/C | logic reused, wrapper new | | Superpowers | plugin + `SessionStart` hook | pinned sync of tag `v7.0.1` (`DSH_SUPERPOWERS_REF`) into `~/.dsh/skills` + `agent/pre-step` bootstrap | content adapted by sync, bootstrap new | | SDD | `Task` (synchronous) | `subagent` (asynchronous) / `workflow` | semantics differ | | Gitea | CLI + skills | `bash` + skills | 1:1 | @@ -172,6 +172,7 @@ for the repository layout: config: enabled: true guards: true + approvalGates: true # gates A/B/C; active only inside the guard chain bootstrap: true roleCommand: true selfCheck: true # activation check of the DSH contract; not-ok is logged @@ -354,6 +355,18 @@ profile was removed; the real `desktop` profile was not touched. ## 7. Risks and open questions +- **Instructions are not enforcement — closed by gates A/B/C.** The upstream loop + (builder-check → `/approve design` → plan → `/approve plan` → sdd) + exists in superpowers as text, and the 24.10.2026 session skipped builder-check + while the plan was written before design approval. Change + `enforce-upstream-loop` adds `lib/gates.js` + `command-approve.js`: gate A + denies a plan for an unapproved design, gate B denies an sdd start (a dispatch + or a `.superpowers/sdd/**` write) for an unapproved or changed plan (sha256), + gate C keeps `approval.md` `/approve`-only, and bash write targets are parsed + by `harness_target.py` and gated too. Residual risk: only a session with the + reinstalled bundle is gated (D-13), and `terminal_*` tools stay outside the + chain (T3.6); the contract is pinned in + `openspec/changes/enforce-upstream-loop/specs/approval-gates/spec.md`. - **DSH is 0.2.1-rc.3.** The plugin/event API is not frozen; pin the version and re-run the tests after upgrades. - **Contract self-check.** The plugin/event API drift is now caught from inside diff --git a/dsh/README.md b/dsh/README.md index 3c80e68..6d97d92 100644 --- a/dsh/README.md +++ b/dsh/README.md @@ -9,6 +9,10 @@ separate Cordis bundle plus a thin install helper. - `guard-main` / `require-plan` — the tested bash/Python hooks run through `ctx.tools.guard`; the agent gets `BLOCKED` with the same messages as in Claude Code; +- approval gates A/B/C — `lib/gates.js` + `command-approve.js`: a plan cannot be + written before its change design is approved, sdd cannot start before the plan + is approved (sha256-pinned), and `approval.md` is written only by + `/approve design|plan`; - superpowers bootstrap — `using-superpowers` is injected once per session from `agent/pre-step` and re-injected after compaction; - roles — the model loads them with the `role` skill (`~/.dsh/roles`, then @@ -145,7 +149,9 @@ dsh/ index.js ctx.tools.guard + agent/pre-step + /role command-role.js /role command glue command-check.js dsh-harness-check command glue + command-approve.js /approve command: the only writer of approval.md lib/guards.js DSH tool call -> claude-harness hook JSON + lib/gates.js approval gates A/B/C and approval.md/plan io (pure) lib/bootstrap.js superpowers skill resolution, marker, dedupe lib/role.js role layers and text (pure) lib/selfcheck.js bundle contract self-check (pure) @@ -178,6 +184,7 @@ repository layout: - id: dsh-harness config: guards: true + approvalGates: true # gates A/B/C; effective only inside the guard chain bootstrap: true roleCommand: true selfCheck: true # activation check of the DSH contract; not-ok is logged @@ -196,6 +203,18 @@ repository layout: - The guard adapter uses the repository's bash/Python hooks; if `python3` is missing, the hooks fail open by design. +- Approval gates (change `enforce-upstream-loop`) live inside the guard chain: + they need `enabled: true`, `guards: true`, `approvalGates: true` **and** a + passing `guards` self-check (`guardsOk`); `approvalGates: true` alone does not + enable them. They are active only in a session with the reinstalled bundle + (D-13): an old install keeps the text-only loop, and the `sdd` skill checks the + plan approval by hand. +- Plan files are always rewritten in full: an `edit` fragment cannot carry the + `**Spec:**` line, so gate A denies it — as it denies bash plan targets and + `str_replace_editor` creates, whose content it cannot see. The deny text then + names the missing `**Spec:**`, not the real reason. +- `terminal_open` / `terminal_send` write files outside both the gates and the + bash hooks — a residual risk not covered by the port. - `bundle/hooks` is a symlink for local-directory installation. A published bundle needs a real copy of `hooks/` (already listed in `package.json` `files`). diff --git a/dsh/install.sh b/dsh/install.sh index d95fd5c..5ce3d81 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -617,7 +617,11 @@ note = ( "Режим контроллера — по числу параллельных планов: один план — главная сессия, " "несколько — фоновый контроллер (нужен `maxDepth: 2` из " "`dsh/subagent-models.yml`). В Claude Code строка не действует: там исходный " - "`subagent-driven-development`." + "`subagent-driven-development`. " + "Бандл гейтит цикл: builder-check обязателен до передачи дизайна владельцу " + "(иначе `/approve design` откажет); план пишется только после `/approve design`; " + "sdd стартует только после `/approve plan` (гейты A/B/C, " + "`BLOCKED [approval-gates]`). Пропуск — только явное решение владельца." ) anchor = "- Режим по умолчанию: subagent-driven-development." lines = text.splitlines(keepends=True) diff --git a/dsh/skills/sdd/SKILL.md b/dsh/skills/sdd/SKILL.md index dc9d1e2..e9fefab 100644 --- a/dsh/skills/sdd/SKILL.md +++ b/dsh/skills/sdd/SKILL.md @@ -39,6 +39,12 @@ overlay ребёнок наследует модель сессии. ## Протокол диспатча и отчёта +**Проверка approval-маркера до первого диспатча.** Найди change по строке +`**Spec:**` плана и проверь `openspec/changes//approval.md`: секция `plan:` +есть — можно начинать; нет — не диспатчить, вернуть владельцу `NEEDS_CONTEXT` +с «run /approve plan». Гейты активны только с переустановленным бандлом +(D-13): до него это ручная проверка, после — гейт B (`BLOCKED [approval-gates]`). + 1. Первым делом — ledger (см. ниже) и скан плана на конфликты; рулинги до Task 1. 2. Бриф implementer'а — **файлом** `/briefs/task-.md`: контекст, diff --git a/dsh/tests/approve.test.mjs b/dsh/tests/approve.test.mjs index f993060..8bf52c2 100644 --- a/dsh/tests/approve.test.mjs +++ b/dsh/tests/approve.test.mjs @@ -315,3 +315,35 @@ test('approve resolves the session cwd per invocation (T3.1)', async () => { cleanup(second.tmp) } }) + +// T3.9 pin: the process.cwd() fallback of currentDirectory is part of the +// T3.1 contract — a workingDirectory that throws must not break /approve. +// The test changes the process directory (each test file runs in its own +// process) so the write lands in an assertable place, then restores it. + +test('approve falls back to process.cwd() when the session cwd lookup throws (T3.9)', async () => { + const { tmp, repo } = repoFixture() + const previous = process.cwd() + try { + process.chdir(repo) + const captured = {} + const commands = { register(definition) { Object.assign(captured, definition) } } + registerApproveCommand({ + commands, + userQuestions: { async ask() { return approveAnswer('design') } }, + workingDirectory: { + get() { + throw new Error('cannot get property "session" without inject') + }, + }, + logger: { warn() {} }, + }) + + const result = await captured.handler({ agent: { session: 's1' }, rawInput: 'demo design' }) + assert.equal(result.kind, 'success') + assert.equal(existsSync(approvalPath(repo)), true, 'approval.md is written into process.cwd()') + } finally { + process.chdir(previous) + cleanup(tmp) + } +}) diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs index 3f2d1ed..373817f 100644 --- a/dsh/tests/install.test.mjs +++ b/dsh/tests/install.test.mjs @@ -50,7 +50,10 @@ test('install.sh copies skills and roles instead of linking into ~/.claude', () const sddSkill = join(dshHome, 'skills', 'sdd', 'SKILL.md') assert.ok(existsSync(sddSkill), 'sdd skill was not installed') - assert.match(readFileSync(sddSkill, 'utf8'), /name: sdd/) + const sddText = readFileSync(sddSkill, 'utf8') + assert.match(sddText, /name: sdd/) + assert.match(sddText, /\/approve/, 'the sdd skill must check the plan approval marker before dispatch') + assert.match(sddText, /approval\.md/, 'the sdd skill must name the approval marker file') for (const role of ['dev', 'admin', 'analyst', 'qa']) { assert.ok(existsSync(join(dshHome, 'roles', `${role}.md`)), `missing ${role}.md`) @@ -60,6 +63,9 @@ test('install.sh copies skills and roles instead of linking into ~/.claude', () const devText = readFileSync(devRole, 'utf8') assert.match(devText, /В DSH-сессии исполнение плана идёт по skill `sdd`/) assert.match(devText, /`maxDepth: 2`/) + assert.match(devText, /Бандл гейтит цикл/) + assert.match(devText, /\/approve/, 'the DSH note must point at /approve') + assert.match(devText, /approval-gates/, 'the DSH note must name the approval-gates deny prefix') assert.match(first.stdout, /role dsh notes/) assert.equal(countNotes(devText), 1, 'note must be inserted once') diff --git a/openspec/changes/enforce-upstream-loop/design.md b/openspec/changes/enforce-upstream-loop/design.md index 9fe0db7..ba345ba 100644 --- a/openspec/changes/enforce-upstream-loop/design.md +++ b/openspec/changes/enforce-upstream-loop/design.md @@ -28,6 +28,7 @@ design: plan: by: aep at: 2026-10-25T11:30:00+04:00 + sha256: 3f2c9a… (hex, 64 знака) ``` ## Механика гейтов (в бандле, DSH-only) @@ -42,22 +43,35 @@ plan: или `new_string`) извлечь `**Spec:** <путь>`; если путь указывает в `openspec/changes//` и `approval.md` без секции `design:` — DENY «design not approved; run /approve design». Содержимое без - Spec-ссылки на change — пропуск. -2. **Гейт B (sdd до approve плана).** Для `subagent`/`subagent_fork`: - в промпте найти ссылку на план — `docs/superpowers/plans/<имя>.md` - или `.superpowers/sdd/<план>/`; из плана взять `**Spec:**` → - change; `approval.md` без секции `plan:` — DENY «plan not approved; - run /approve plan». Нет ссылки на план — пропуск. + `**Spec:**` или со Spec-ссылкой вне `openspec/changes/` (legacy + `docs/superpowers/specs/`) — DENY «plan must carry **Spec:** pointing to + openspec/changes/» (D-9); правка без содержимого (bash-цель, + edit-фрагмент) тоже DENY — гейт не видит содержимого. +2. **Гейт B (sdd до approve плана).** Два входа (D-7): (a) запись в + `.superpowers/sdd/<план>/**` (ledger, брифы — старт sdd в Режиме 1) и + (b) диспатч `subagent`/`subagent_fork`/`workflow`, в промпте или + скрипте которого есть ссылка на план — `docs/superpowers/plans/<имя>.md` + или `.superpowers/sdd/<план>/`. Из плана взять `**Spec:**` → change; + `approval.md` без секции `plan:` или с чужим sha256 — DENY «plan not + approved; run /approve plan» / «plan changed after approval; + re-approve». Проверяются все план-ссылки, а не первая; ссылка без + читаемого плана и план без Spec — fail closed. 3. **`/approve`** (`command-approve.js`): `design` требует существование proposal.md, design.md, builder-check.md; `plan` требует секцию - `design:` и существование ровно одного плана со Spec на этот change - (несколько — отказ с перечислением). Дописывает/обновляет секцию, + `design:` с непустыми `by`/`at` и существование ровно одного плана со + Spec на этот change (несколько — отказ с перечислением). Дописывает/обновляет секцию, остальные секции не трогает. Владелец-агент различие: команда пишет `by: owner`; вызовы не от владельца — отказ (как отличить: DSH не даёт ролей; используем факт вызова из интерактивной сессии и AskUserQuestion-подтверждение: `/approve` спрашивает подтверждение перед записью, ответ «да» = владелец). +Исторические планы: четыре файла — `2026-10-11-roles-and-memory`, +`2026-10-24-dsh-decouple`, `2026-10-24-dsh-port-spike`, +`2026-10-24-dsh-role-loader` — написаны до лупа и не несут `**Spec:**` +(T1.3). По D-9 любая их правка — DENY: они заморожены, новая работа идёт +отдельным планом. Это ожидаемое поведение, а не регресс. + ## Decisions made without discussion | # | Решение | Обоснование | Цена ошибки | @@ -72,7 +86,7 @@ plan: | D-8 | Bash-обход закрыт: для Bash-вызовов gates.js прогоняет `python3 hooks/harness_target.py targets '' ` и применяет гейты A/C к целям под `docs/superpowers/plans/` и `**/approval.md` | Тот же парсер, что в хуках (heredoc-aware), без дублирования логики | Стоимость — subprocess на каждый Bash-вызов с подозрительным текстом; фильтруем до прогона по грубой подстроке | | D-9 | План без `**Spec:**` в `openspec/changes/` — DENY по умолчанию (включая legacy `docs/superpowers/specs/`) | Реальные планы всегда несут Spec; .bounded-поток планов не пишет — проверено по репозиторию | Ложный блок легитимного Spec-less плана — такого флоу нет | | D-10 | `plan:` в approval.md несёт `sha256` файла плана; гейт B сверяет хэш — план изменён после утверждения → DENY «plan changed after approval; re-approve» | Утверждение привязывается к содержимому исполняемого объекта | Правки плана после approve требуют re-approve — осознанно | -| D-11 | `design:` — только существование секции (без хэша): design.md дополняется решениями по ходу | Контент дизайна не является исполняемым объектом | Правка дизайна после approve не отзывается — принято | +| D-11 | `design:` — только непустые `by` и `at` (без хэша): design.md дополняется решениями по ходу | Контент дизайна не является исполняемым объектом; пустая секция `design:` утверждением не считается, `by`/`at` должны быть непустыми (T1.8) | Правка дизайна после approve не отзывается — принято | | D-12 | Подтверждение владельца в `/approve` — через `ctx.userQuestions.ask` (блокирующий вопрос); сервис добавляется в inject; отсутствует — `/approve` отказывает с сообщением | Проверенный примитив рантайма (DELEGATED_CALLER не пройдёт) вместо эвристики «интерактивная сессия» | Нет userQuestions в профиле — /approve недоступен, гейты всё равно работают на блокировку | | D-13 | Бутстрап: гейты активны только в сессиях с новым бандлом (после установки/рестарта); сама фича #59 исполняется на старом рантайме — ретро-утверждений не требует; для будущих change контроллер кладёт builder-check.md штатно | Развёртывание разводит enforcement и исполняемую фичу во времени | Окно до переустановки бандла — гейтов нет; фиксируем шаг переустановки в чеклисте закрытия | diff --git a/openspec/changes/enforce-upstream-loop/specs/approval-gates/spec.md b/openspec/changes/enforce-upstream-loop/specs/approval-gates/spec.md new file mode 100644 index 0000000..e9de04a --- /dev/null +++ b/openspec/changes/enforce-upstream-loop/specs/approval-gates/spec.md @@ -0,0 +1,287 @@ +# Spec Delta: approval-gates + +## Purpose + +Технические гейты цикла разработки в DSH-бандле: builder-check → утверждение +дизайна → план → утверждение плана → sdd. Инструкции superpowers сами по себе не +являются механизмом (сессия ce9187ca 24.10.2026 пропустила builder-check, а план +появился до утверждения дизайна), поэтому бандл проверяет артефакты утверждения на +каждом переходе и отказывает с `BLOCKED [approval-gates]: <причина>`. Гейты живут +только в DSH-бандле (D-2); Claude Code-хуки и поток не меняются. + +## ADDED Requirements + +### Requirement: Артефакты утверждения + +`openspec/changes//builder-check.md` SHALL содержать отчёт свежего +builder-check субагента, скопированный в change контроллером; он MUST быть записан +после builder-check и до передачи дизайна владельцу. + +`openspec/changes//approval.md` SHALL быть маркером утверждения владельцем; +его единственный писатель — команда `/approve` (D-1), инструменты модели писать +его 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` и 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`, файл не меняется. Команда 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 без сервиса вопросов + +- **WHEN** `ctx.userQuestions` отсутствует +- **THEN** команда отказывает с `userQuestions service unavailable; /approve + requires it` и `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-цель, edit-фрагмент, `str_replace_editor` +create) MUST получать тот же отказ: гейт не видит содержимого и не может +подтвердить Spec — fail closed. + +Если change определён, а его design-секция не является утверждением, гейт MUST +отказать `design not approved; run /approve design`. Утверждённый дизайн +разрешает запись плана; запись вне `docs/superpowers/plans/` гейтом A не +проверяется. + +Исторические планы, написанные до лупа и не несущие `**Spec:**`, заморожены — +четыре файла: `2026-10-11-roles-and-memory`, `2026-10-24-dsh-decouple`, +`2026-10-24-dsh-port-spike`, `2026-10-24-dsh-role-loader` (T1.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: Правка плана фрагментом + +- **WHEN** edit плана передаёт `new_string` без `**Spec:**` (фрагмент правки) +- **THEN** отказ: план пишется только full-file write + +#### Scenario: Правка исторического плана + +- **WHEN** write в `docs/superpowers/plans/2026-10-24-dsh-decouple.md` +- **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 текущего +содержимого плана. Нет секции или sha256 — отказ `plan not approved; run +/approve plan`; несовпадение хэша или нечитаемый план — отказ `plan changed +after approval; re-approve`; нет файла плана для sdd-каталога — отказ `plan not +found: `; план без `**Spec:**` — отказ `plan not approved` (id +неизвестен). Проверяться MUST все план-ссылки, а не первая; возвращается первый +отказ, чтобы утверждённый план не маскировал неутверждённый. + +Вызов без план-ссылки MUST NOT гейтиться. Разбор промпта — эвристика по +подстрокам, поэтому разбор fail open: вызов, в котором ссылка не распознана, не +блокируется. Диспатч-гейт резолвит ссылки против cwd сессии и игнорирует +`arguments.cwd`: диспатч с cwd в другом worktree и ссылкой на план этого worktree +получает отказ (fail closed, over-blocking, T3.8) — план должен быть утверждён в +репозитории сессии. + +#### 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: Один из двух планов не утверждён + +- **WHEN** промпт `subagent` ссылается на два плана, и второй не утверждён +- **THEN** диспатч отклонён по второму плану, даже если первый утверждён + +#### Scenario: План изменён после утверждения + +- **WHEN** содержимое плана изменилось после `/approve plan` +- **THEN** отказ `plan changed after approval; re-approve`; снимает его только + повторное утверждение + +#### Scenario: Диспатч без ссылки на план + +- **WHEN** в промпте `subagent` нет ни пути плана, ни sdd-каталога +- **THEN** гейт B не применяется + +### Requirement: Гейт C и bash-обход + +`approval.md` MUST писаться только командой `/approve`: любой write/edit с целью, +чей basename — `approval.md`, и любая bash-цель с таким именем MUST получать +отказ `approval.md is written only by /approve` (D-1). + +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-каталога. + +Гейты 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`) MUST всегда получать отказ, план пишется +full-file write (T3.5). 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): хэш фиксируется только для плана