docs(dsh): sdd-скилл, roles-заметка, спека approval-gates, доки (#59)
This commit is contained in:
parent
653d1df30f
commit
734c80899d
15
dsh/PORT.md
15
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| SDD | `Task` (synchronous) | `subagent` (asynchronous) / `workflow` | semantics differ |
|
||||||
| Gitea | CLI + skills | `bash` + skills | 1:1 |
|
| Gitea | CLI + skills | `bash` + skills | 1:1 |
|
||||||
@ -172,6 +172,7 @@ for the repository layout:
|
|||||||
config:
|
config:
|
||||||
enabled: true
|
enabled: true
|
||||||
guards: true
|
guards: true
|
||||||
|
approvalGates: true # gates A/B/C; active only inside the guard chain
|
||||||
bootstrap: true
|
bootstrap: true
|
||||||
roleCommand: true
|
roleCommand: true
|
||||||
selfCheck: true # activation check of the DSH contract; not-ok is logged
|
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
|
## 7. Risks and open questions
|
||||||
|
|
||||||
|
- **Instructions are not enforcement — closed by gates A/B/C.** The upstream loop
|
||||||
|
(builder-check → `/approve <id> design` → plan → `/approve <id> 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
|
- **DSH is 0.2.1-rc.3.** The plugin/event API is not frozen; pin the version and
|
||||||
re-run the tests after upgrades.
|
re-run the tests after upgrades.
|
||||||
- **Contract self-check.** The plugin/event API drift is now caught from inside
|
- **Contract self-check.** The plugin/event API drift is now caught from inside
|
||||||
|
|||||||
@ -9,6 +9,10 @@ separate Cordis bundle plus a thin install helper.
|
|||||||
- `guard-main` / `require-plan` — the tested bash/Python hooks run through
|
- `guard-main` / `require-plan` — the tested bash/Python hooks run through
|
||||||
`ctx.tools.guard`; the agent gets `BLOCKED` with the same messages as in
|
`ctx.tools.guard`; the agent gets `BLOCKED` with the same messages as in
|
||||||
Claude Code;
|
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 <change-id> design|plan`;
|
||||||
- superpowers bootstrap — `using-superpowers` is injected once per session from
|
- superpowers bootstrap — `using-superpowers` is injected once per session from
|
||||||
`agent/pre-step` and re-injected after compaction;
|
`agent/pre-step` and re-injected after compaction;
|
||||||
- roles — the model loads them with the `role` skill (`~/.dsh/roles`, then
|
- 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
|
index.js ctx.tools.guard + agent/pre-step + /role
|
||||||
command-role.js /role command glue
|
command-role.js /role command glue
|
||||||
command-check.js dsh-harness-check 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/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/bootstrap.js superpowers skill resolution, marker, dedupe
|
||||||
lib/role.js role layers and text (pure)
|
lib/role.js role layers and text (pure)
|
||||||
lib/selfcheck.js bundle contract self-check (pure)
|
lib/selfcheck.js bundle contract self-check (pure)
|
||||||
@ -178,6 +184,7 @@ repository layout:
|
|||||||
- id: dsh-harness
|
- id: dsh-harness
|
||||||
config:
|
config:
|
||||||
guards: true
|
guards: true
|
||||||
|
approvalGates: true # gates A/B/C; effective only inside the guard chain
|
||||||
bootstrap: true
|
bootstrap: true
|
||||||
roleCommand: true
|
roleCommand: true
|
||||||
selfCheck: true # activation check of the DSH contract; not-ok is logged
|
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
|
- The guard adapter uses the repository's bash/Python hooks; if `python3` is
|
||||||
missing, the hooks fail open by design.
|
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/hooks` is a symlink for local-directory installation. A published
|
||||||
bundle needs a real copy of `hooks/` (already listed in `package.json`
|
bundle needs a real copy of `hooks/` (already listed in `package.json`
|
||||||
`files`).
|
`files`).
|
||||||
|
|||||||
@ -617,7 +617,11 @@ note = (
|
|||||||
"Режим контроллера — по числу параллельных планов: один план — главная сессия, "
|
"Режим контроллера — по числу параллельных планов: один план — главная сессия, "
|
||||||
"несколько — фоновый контроллер (нужен `maxDepth: 2` из "
|
"несколько — фоновый контроллер (нужен `maxDepth: 2` из "
|
||||||
"`dsh/subagent-models.yml`). В Claude Code строка не действует: там исходный "
|
"`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."
|
anchor = "- Режим по умолчанию: subagent-driven-development."
|
||||||
lines = text.splitlines(keepends=True)
|
lines = text.splitlines(keepends=True)
|
||||||
|
|||||||
@ -39,6 +39,12 @@ overlay ребёнок наследует модель сессии.
|
|||||||
|
|
||||||
## Протокол диспатча и отчёта
|
## Протокол диспатча и отчёта
|
||||||
|
|
||||||
|
**Проверка approval-маркера до первого диспатча.** Найди change по строке
|
||||||
|
`**Spec:**` плана и проверь `openspec/changes/<id>/approval.md`: секция `plan:`
|
||||||
|
есть — можно начинать; нет — не диспатчить, вернуть владельцу `NEEDS_CONTEXT`
|
||||||
|
с «run /approve <id> plan». Гейты активны только с переустановленным бандлом
|
||||||
|
(D-13): до него это ручная проверка, после — гейт B (`BLOCKED [approval-gates]`).
|
||||||
|
|
||||||
1. Первым делом — ledger (см. ниже) и скан плана на конфликты; рулинги до
|
1. Первым делом — ledger (см. ниже) и скан плана на конфликты; рулинги до
|
||||||
Task 1.
|
Task 1.
|
||||||
2. Бриф implementer'а — **файлом** `<workspace>/briefs/task-<n>.md`: контекст,
|
2. Бриф implementer'а — **файлом** `<workspace>/briefs/task-<n>.md`: контекст,
|
||||||
|
|||||||
@ -315,3 +315,35 @@ test('approve resolves the session cwd per invocation (T3.1)', async () => {
|
|||||||
cleanup(second.tmp)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@ -50,7 +50,10 @@ test('install.sh copies skills and roles instead of linking into ~/.claude', ()
|
|||||||
|
|
||||||
const sddSkill = join(dshHome, 'skills', 'sdd', 'SKILL.md')
|
const sddSkill = join(dshHome, 'skills', 'sdd', 'SKILL.md')
|
||||||
assert.ok(existsSync(sddSkill), 'sdd skill was not installed')
|
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']) {
|
for (const role of ['dev', 'admin', 'analyst', 'qa']) {
|
||||||
assert.ok(existsSync(join(dshHome, 'roles', `${role}.md`)), `missing ${role}.md`)
|
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')
|
const devText = readFileSync(devRole, 'utf8')
|
||||||
assert.match(devText, /В DSH-сессии исполнение плана идёт по skill `sdd`/)
|
assert.match(devText, /В DSH-сессии исполнение плана идёт по skill `sdd`/)
|
||||||
assert.match(devText, /`maxDepth: 2`/)
|
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.match(first.stdout, /role dsh notes/)
|
||||||
assert.equal(countNotes(devText), 1, 'note must be inserted once')
|
assert.equal(countNotes(devText), 1, 'note must be inserted once')
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ design:
|
|||||||
plan:
|
plan:
|
||||||
by: aep
|
by: aep
|
||||||
at: 2026-10-25T11:30:00+04:00
|
at: 2026-10-25T11:30:00+04:00
|
||||||
|
sha256: 3f2c9a… (hex, 64 знака)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Механика гейтов (в бандле, DSH-only)
|
## Механика гейтов (в бандле, DSH-only)
|
||||||
@ -42,22 +43,35 @@ plan:
|
|||||||
или `new_string`) извлечь `**Spec:** <путь>`; если путь указывает в
|
или `new_string`) извлечь `**Spec:** <путь>`; если путь указывает в
|
||||||
`openspec/changes/<id>/` и `approval.md` без секции `design:` — DENY
|
`openspec/changes/<id>/` и `approval.md` без секции `design:` — DENY
|
||||||
«design not approved; run /approve <id> design». Содержимое без
|
«design not approved; run /approve <id> design». Содержимое без
|
||||||
Spec-ссылки на change — пропуск.
|
`**Spec:**` или со Spec-ссылкой вне `openspec/changes/` (legacy
|
||||||
2. **Гейт B (sdd до approve плана).** Для `subagent`/`subagent_fork`:
|
`docs/superpowers/specs/`) — DENY «plan must carry **Spec:** pointing to
|
||||||
в промпте найти ссылку на план — `docs/superpowers/plans/<имя>.md`
|
openspec/changes/<id>» (D-9); правка без содержимого (bash-цель,
|
||||||
или `.superpowers/sdd/<план>/`; из плана взять `**Spec:**` →
|
edit-фрагмент) тоже DENY — гейт не видит содержимого.
|
||||||
change; `approval.md` без секции `plan:` — DENY «plan not approved;
|
2. **Гейт B (sdd до approve плана).** Два входа (D-7): (a) запись в
|
||||||
run /approve <id> plan». Нет ссылки на план — пропуск.
|
`.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 <id> plan» / «plan changed after approval;
|
||||||
|
re-approve». Проверяются все план-ссылки, а не первая; ссылка без
|
||||||
|
читаемого плана и план без Spec — fail closed.
|
||||||
3. **`/approve`** (`command-approve.js`): `design` требует существование
|
3. **`/approve`** (`command-approve.js`): `design` требует существование
|
||||||
proposal.md, design.md, builder-check.md; `plan` требует секцию
|
proposal.md, design.md, builder-check.md; `plan` требует секцию
|
||||||
`design:` и существование ровно одного плана со Spec на этот change
|
`design:` с непустыми `by`/`at` и существование ровно одного плана со
|
||||||
(несколько — отказ с перечислением). Дописывает/обновляет секцию,
|
Spec на этот change (несколько — отказ с перечислением). Дописывает/обновляет секцию,
|
||||||
остальные секции не трогает. Владелец-агент различие: команда пишет
|
остальные секции не трогает. Владелец-агент различие: команда пишет
|
||||||
`by: owner`; вызовы не от владельца — отказ (как отличить: DSH не
|
`by: owner`; вызовы не от владельца — отказ (как отличить: DSH не
|
||||||
даёт ролей; используем факт вызова из интерактивной сессии и
|
даёт ролей; используем факт вызова из интерактивной сессии и
|
||||||
AskUserQuestion-подтверждение: `/approve` спрашивает подтверждение
|
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
|
## Decisions made without discussion
|
||||||
|
|
||||||
| # | Решение | Обоснование | Цена ошибки |
|
| # | Решение | Обоснование | Цена ошибки |
|
||||||
@ -72,7 +86,7 @@ plan:
|
|||||||
| D-8 | Bash-обход закрыт: для Bash-вызовов gates.js прогоняет `python3 hooks/harness_target.py targets '<cmd>' <cwd>` и применяет гейты A/C к целям под `docs/superpowers/plans/` и `**/approval.md` | Тот же парсер, что в хуках (heredoc-aware), без дублирования логики | Стоимость — subprocess на каждый Bash-вызов с подозрительным текстом; фильтруем до прогона по грубой подстроке |
|
| D-8 | Bash-обход закрыт: для Bash-вызовов gates.js прогоняет `python3 hooks/harness_target.py targets '<cmd>' <cwd>` и применяет гейты 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-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-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-12 | Подтверждение владельца в `/approve` — через `ctx.userQuestions.ask` (блокирующий вопрос); сервис добавляется в inject; отсутствует — `/approve` отказывает с сообщением | Проверенный примитив рантайма (DELEGATED_CALLER не пройдёт) вместо эвристики «интерактивная сессия» | Нет userQuestions в профиле — /approve недоступен, гейты всё равно работают на блокировку |
|
||||||
| D-13 | Бутстрап: гейты активны только в сессиях с новым бандлом (после установки/рестарта); сама фича #59 исполняется на старом рантайме — ретро-утверждений не требует; для будущих change контроллер кладёт builder-check.md штатно | Развёртывание разводит enforcement и исполняемую фичу во времени | Окно до переустановки бандла — гейтов нет; фиксируем шаг переустановки в чеклисте закрытия |
|
| D-13 | Бутстрап: гейты активны только в сессиях с новым бандлом (после установки/рестарта); сама фича #59 исполняется на старом рантайме — ретро-утверждений не требует; для будущих change контроллер кладёт builder-check.md штатно | Развёртывание разводит enforcement и исполняемую фичу во времени | Окно до переустановки бандла — гейтов нет; фиксируем шаг переустановки в чеклисте закрытия |
|
||||||
|
|
||||||
|
|||||||
@ -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/<id>/builder-check.md` SHALL содержать отчёт свежего
|
||||||
|
builder-check субагента, скопированный в change контроллером; он MUST быть записан
|
||||||
|
после builder-check и до передачи дизайна владельцу.
|
||||||
|
|
||||||
|
`openspec/changes/<id>/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 <id> plan` записывает plan-секцию в существующий `approval.md`
|
||||||
|
- **THEN** design-секция остаётся дословно прежней
|
||||||
|
|
||||||
|
### Requirement: Команда /approve
|
||||||
|
|
||||||
|
`/approve <change-id> 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/<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`, файл не меняется. Команда 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 без сервиса вопросов
|
||||||
|
|
||||||
|
- **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/<id>`;
|
||||||
|
план без `**Spec:**` и план с legacy-ссылкой в `docs/superpowers/specs/**` MUST
|
||||||
|
получать отказ `plan must carry **Spec:** pointing to openspec/changes/<id>`
|
||||||
|
(D-9). Правка без содержимого (bash-цель, edit-фрагмент, `str_replace_editor`
|
||||||
|
create) MUST получать тот же отказ: гейт не видит содержимого и не может
|
||||||
|
подтвердить Spec — fail closed.
|
||||||
|
|
||||||
|
Если change определён, а его design-секция не является утверждением, гейт MUST
|
||||||
|
отказать `design not approved; run /approve <id> 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/<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: Правка плана фрагментом
|
||||||
|
|
||||||
|
- **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/<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 текущего
|
||||||
|
содержимого плана. Нет секции или sha256 — отказ `plan not approved; run
|
||||||
|
/approve <id> plan`; несовпадение хэша или нечитаемый план — отказ `plan changed
|
||||||
|
after approval; re-approve`; нет файла плана для sdd-каталога — отказ `plan not
|
||||||
|
found: <plan-name>`; план без `**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/<plan-name>/progress.md`, а plan-секции нет
|
||||||
|
- **THEN** отказ `BLOCKED [approval-gates]: plan not approved; run /approve <id>
|
||||||
|
plan`
|
||||||
|
|
||||||
|
#### Scenario: Диспатч с утверждённым планом
|
||||||
|
|
||||||
|
- **WHEN** `subagent` получает промпт со ссылкой на план, чей sha256 совпадает с
|
||||||
|
plan-секцией
|
||||||
|
- **THEN** диспатч разрешён
|
||||||
|
|
||||||
|
#### Scenario: Один из двух планов не утверждён
|
||||||
|
|
||||||
|
- **WHEN** промпт `subagent` ссылается на два плана, и второй не утверждён
|
||||||
|
- **THEN** диспатч отклонён по второму плану, даже если первый утверждён
|
||||||
|
|
||||||
|
#### 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`: любой 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 <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-каталога.
|
||||||
|
|
||||||
|
Гейты 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 <id> design`
|
||||||
|
- **THEN** утверждение не отзывается (D-11): хэш фиксируется только для плана
|
||||||
Loading…
x
Reference in New Issue
Block a user