Merge pull request 'DSH: технические гейты upstream-лупа — builder-check, /approve, гейты A/B/C (#59)' (#74) from feat/59-loop-gates into main

This commit is contained in:
aep 2026-10-25 01:49:53 +03:00
commit 027dd09c8f
20 changed files with 2725 additions and 8 deletions

View File

@ -0,0 +1,186 @@
# Enforce-upstream-loop Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use the `sdd` skill (DSH) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Добавить в DSH-бандл технические гейты upstream-лупа: `/approve` (единственный писатель approval.md), гейт A (план не пишется без утверждённого дизайна), гейт B (sdd/диспатчи не стартуют без утверждённого плана), гейт C (approval.md не пишется инструментами), включая bash-обходы.
**Architecture:** Чистое ядро `dsh/bundle/lib/gates.js` (decision-функции + io approval.md + разрешение план→change→approval + sha256) поверх существующего `ctx.tools.guard`; `/approve` — команда с подтверждением через `ctx.userQuestions.ask`; Bash-цели — через проверенный `hooks/harness_target.py targets`; wiring — в `index.js` после хуков (монотонная цепочка deny). CC-сторона не трогается.
**Tech Stack:** Node builtins (без `@deepseek-ai/*`-импортов), python3 для `targets`, bash для тестовых песочниц.
**Spec:** `openspec/changes/enforce-upstream-loop/` — [proposal.md](openspec/changes/enforce-upstream-loop/proposal.md) + [design.md](openspec/changes/enforce-upstream-loop/design.md) (D-1…D-13; план спорит от них).
**Models (D-9 стоящее решение владельца):** Tasks 1, 2, 4 — `deepseek-official/deepseek-flash`; Task 3 — `deepseek-official/deepseek-v4-pro`; все ревью — `deepseek-official/deepseek-v4-pro`.
## Global Constraints
- Runtime-зависимости — только Node builtins; `package.test.mjs` enforcement обновляется при новых файлах.
- Гейты — DSH-only (бандл); CC-хуки/роли/скрипты не меняются.
- Гейты монотонны: отказ не отменяется последующим слушателем; ошибка разбора — пропуск (как в хуках).
- bounded-задачи (.bounded, нет change/плана) не блокируются (D-6).
- Маркеры находок и статусы — по `~/.dsh/roles/dev.md`; коммиты с `(#59)`.
- Язык субагентов — английский (R-8 от #69); маркеры/статусы/термины — без перевода; отчёты владельцу — по-русски.
## Review Focus
| # | Вход/условие | Ожидаемое поведение |
|---|---|---|
| RF1 | Write плана в `docs/superpowers/plans/` без `**Spec:**` (или legacy-путь) | DENY (D-9) |
| RF2 | Write плана со Spec на change без design-секции | DENY «design not approved» (гейт A) |
| RF3 | `subagent`/`workflow` с план-ссылкой при sha256-расхождении или без plan-секции | DENY «plan not approved / changed» (гейт B, D-10) |
| RF4 | Write в `**/approval.md` инструментами (в т.ч. bash heredoc) | DENY (гейт C, D-1/D-8) |
| RF5 | `/approve design` без builder-check.md; `/approve plan` без design-секции или до плана | Отказ с причиной (D-5/D-1) |
---
### Task 1: Ядро gates.js (чистые decision-функции + approval io)
**Files:**
- Create: `dsh/bundle/lib/gates.js`
- Create: `dsh/tests/gates.test.mjs`
**Interfaces:**
- Consumes: ничего (чистые функции + fs только в io-функциях).
- Produces:
- `parseApproval(text) -> { design?: {by,at}, plan?: {by,at,sha256} }` (yaml-подмножество; толерантен к отсутствию файла → `{}`).
- `serializeApproval(parsed) -> string` (детерминированный вывод секций).
- `extractSpec(content) -> string|null` — строка `**Spec:** <путь>` из markdown (варианты: бэктики, трейлинг-слэш; нормализация).
- `resolveChangeFromSpec(specPath, repoRoot) -> changeId|null` — `openspec/changes/<id>/...` → id; иначе null (legacy `docs/superpowers/specs/` — null).
- `resolvePlanFromSdd(sddPath, repoRoot) -> planPath|null` — `.superpowers/sdd/<план>/...` → `<repoRoot>/docs/superpowers/plans/<план>.md` если существует.
- `planApproved(approval, planPath) -> {ok:boolean, reason:string}` — есть `plan:` и `sha256 === hashPlan(planPath)`; `hashPlan(path) -> string` (sha256 содержимого).
- `checkPlanWrite({tool, filePath, content, repoRoot}) -> {deny?: string}` — гейт A: path под `docs/superpowers/plans/`; Spec извлечён → change → approval.md: нет design → deny «design not approved; run /approve <id> design»; Spec нет/legacy → deny (D-9); иначе ok.
- `checkApprovalWrite({tool, filePath, repoRoot}) -> {deny?: string}` — гейт C: basename `approval.md` → deny «approval.md is written only by /approve».
- `checkDispatch({tool, prompt, script, repoRoot}) -> {deny?: string}` — гейт B: план-ссылки в prompt/script → план → Spec → change → `planApproved`; fail → deny «plan not approved; run /approve <id> plan» / «plan changed after approval; re-approve».
- `checkBashTargets(targets, repoRoot)` — гейты A/C по списку файловых целей (из `harness_target.py targets`).
- [ ] **Step 1: Падающие тесты** в `dsh/tests/gates.test.mjs` (tempDir-фикстуры: репозиторий с `docs/superpowers/plans/`, `openspec/changes/demo/`, approval.md):
```js
test('extractSpec: backticks and trailing slash normalize', ...) // '**Spec:** `openspec/changes/demo/`' -> 'openspec/changes/demo'
test('checkPlanWrite: no Spec -> deny', ...) // RF1
test('checkPlanWrite: legacy docs/superpowers/specs -> deny', ...)
test('checkPlanWrite: design approved -> ok', ...)
test('checkPlanWrite: design not approved -> deny with /approve hint', ...) // RF2
test('checkApprovalWrite: approval.md via tool -> deny', ...) // RF4
test('checkDispatch: prompt plan ref, plan approved -> ok', ...)
test('checkDispatch: no plan section -> deny', ...) // RF3
test('checkDispatch: sha256 mismatch after edit -> deny re-approve', ...) // RF3
test('checkDispatch: workflow script with plan ref -> deny/ok same as subagent', ...)
test('planApproved: happy / missing / mismatch', ...)
test('serialize/parse roundtrip preserves sections', ...)
```
- [ ] **Step 2: Прогони — FAIL** (`node --test dsh/tests/gates.test.mjs`, модуля нет).
- [ ] **Step 3: Реализуй `lib/gates.js`** по интерфейсам. Io-функции читают ФС только по переданным путям; никаких сетевых/глобальных сайд-эффектов.
- [ ] **Step 4: Прогони — PASS**; затем `bash dsh/tests/run.sh` (baseline 110/0).
- [ ] **Step 5: Коммит**
```bash
git add dsh/bundle/lib/gates.js dsh/tests/gates.test.mjs
git commit -m "feat(dsh): gates — чистые decision-функции гейтов A/B/C и approval io (#59)"
```
### Task 2: Команда /approve
**Files:**
- Create: `dsh/bundle/command-approve.js`
- Create: `dsh/tests/approve.test.mjs`
**Interfaces:**
- Consumes: `parseApproval/serializeApproval` (Task 1), `ctx.commands.register` (object-form, как command-role.js), `ctx.userQuestions.ask({...}) -> Promise<{confirmed:boolean}>`-подобное (фейк в тестах).
- Produces: `registerApproveCommand({commands, userQuestions, repoRoot, logger})` — команда `/approve <change-id> design|plan`:
- `design`: требует `<repoRoot>/openspec/changes/<id>/proposal.md`, `design.md`, `builder-check.md`; отказ — текст с перечнем отсутствующего.
- `plan`: требует design-секцию в approval.md и ровно один план со Spec на change (несколько — отказ с перечнем); пишет `plan: {by:'owner', at:<ISO>, sha256:hashPlan(plan)}`.
- Оба: перед записью — `userQuestions.ask` (текст «Approve <id> <kind>?»); нет подтверждения — отказ «not confirmed»; отсутствует userQuestions — отказ «userQuestions service unavailable».
- Запись: read→parse→обновить секцию→serialize→write; чужие секции не трогает.
- [ ] **Step 1: Падающие тесты** (fake commands.register захватывает handler; fake userQuestions возвращает confirmed true/false; temp-репо):
```js
test('approve design refuses without builder-check.md', ...) // RF5
test('approve design writes design section (by owner, at ISO)', ...)
test('approve design refuses without confirmation', ...)
test('approve plan refuses without design section', ...) // RF5
test('approve plan refuses when two plans reference the change', ...)
test('approve plan writes plan section with sha256 of the plan', ...)
test('approve refuses when userQuestions missing', ...)
```
- [ ] **Step 2: Прогони — FAIL.**
- [ ] **Step 3: Реализуй `command-approve.js`** по интерфейсам (парсинг аргументов `<id> <kind>`; неизвестный kind → текст usage).
- [ ] **Step 4: Прогони — PASS**; `bash dsh/tests/run.sh`.
- [ ] **Step 5: Коммит**
```bash
git add dsh/bundle/command-approve.js dsh/tests/approve.test.mjs
git commit -m "feat(dsh): /approve — единственный писатель approval.md с подтверждением владельца (#59)"
```
### Task 3: Wiring в index.js + bash-цели + списки пакета
**Files:**
- Modify: `dsh/bundle/index.js`
- Modify: `dsh/tests/selfcheck.test.mjs` (apply-тесты гейтов)
- Modify: `dsh/bundle/package.json` (`files` += `command-approve.js`)
- Modify: `dsh/tests/run.sh` (`node --check` += `command-approve.js`)
- Modify: `dsh/tests/package.test.mjs` (no-`@deepseek-ai`-список += новые файлы)
**Interfaces:**
- Consumes: Task 1 (gates.js), Task 2 (command-approve.js), существующая цепочка `ctx.tools.guard` (lib/guards.js).
- Produces: в `apply()`:
- `DEFAULTS += { approvalGates: true }`; gatesOk — из selfcheck (tools.guard).
- В guard-цепочке после hook-проверок: Edit/Write/MultiEdit → `checkPlanWrite` + `checkApprovalWrite` + `checkSddWrite`; `subagent`/`subagent_fork`/`workflow` → `checkDispatch` (prompt/script из `exec.arguments`); `Bash` → если в команде грубая подстрока `docs/superpowers/plans`, `approval.md` или `.superpowers/sdd` — `spawnSync python3 hooks/harness_target.py targets '<cmd>' <cwd>`, цели → `checkBashTargets` + `checkSddWrite` для путей под `.superpowers/sdd/`. Любой deny → причина отказа (hook-стиль `BLOCKED [approval-gates]: …`). Ошибка/пропуск парсинга — allow (инвариант хуков).
- `/approve` регистрируется в том же `ctx.inject(['commands', 'userQuestions'])` (userQuestions может отсутствовать — команда сама откажет, D-12).
- `repoRoot` для гейтов — cwd сессии (`sessionCwd`), как в guards.
- [ ] **Step 1: Падающие тесты** (в selfcheck.test.mjs, fake ctx с tools.guard-захватом: apply() → вызвать захваченный guard с fake exec; temp-репо-фикстуры):
```js
test('apply: guard denies plan write without design approval', ...)
test('apply: guard allows plan write after /approve-like approval.md', ...)
test('apply: guard denies subagent dispatch referencing unapproved plan', ...)
test('apply: guard allows dispatch after plan approval (sha match)', ...)
test('apply: bash heredoc into approval.md denied', ...) // RF4
test('apply: bash write under .superpowers/sdd/<plan>/ denied without approval', ...) // checkSddWrite bash-ветка
test('apply: bash write under .superpowers/sdd/<plan>/ allowed with approval', ...)
test('gates: checkSddWrite sha-mismatch deny', ...) // T1.7 pin
test('gates: bare .superpowers/sdd/<name> dir ungated', ...) // T1.7 pin
test('apply: approvalGates:false disables all gates', ...)
```
- [ ] **Step 2: Прогони — FAIL.**
- [ ] **Step 3: Реализуй** wiring + списки пакета (файлы выше). Монотонность: порядок deny не отменяется; пропуск на ошибку разбора.
- [ ] **Step 4: Прогони — PASS**; `bash dsh/tests/run.sh` (110+новые) и `bash tests/run.sh` (CC 630/0 — хуки не трогаются).
- [ ] **Step 5: Коммит**
```bash
git add dsh/bundle/index.js dsh/bundle/package.json dsh/tests/selfcheck.test.mjs dsh/tests/run.sh dsh/tests/package.test.mjs
git commit -m "feat(dsh): wiring гейтов A/B/C в guard-цепочку + bash-цели + /approve (#59)"
```
### Task 4: Скиллы, роли, спека-дельта, доки
**Files:**
- Modify: `dsh/skills/sdd/SKILL.md` (шаг до первого диспатча: проверить approval-маркер плана; BLOCKED → не обходить, вернуть NEEDS_CONTEXT/владельцу)
- Modify: `dsh/install.sh` (`install_role_dsh_notes` — DSH-заметка в roles/dev.md: builder-check обязателен, `/approve` — маркер, гейты A/B/C и их DENY-тексты)
- Modify: `openspec/changes/enforce-upstream-loop/specs/approval-gates/spec.md` (новая capability: ADDED требования — артефакты, /approve-контракт, гейты A/B/C, bash-обход, bounded-исключение, deployment D-13)
- Modify: `dsh/README.md` (Configuration += `approvalGates`; Known limits — гейты активны только в сессиях с новым бандлом), `dsh/PORT.md` (§1/§7 — чем закрыт риск «инструкции ≠ enforcement»)
**Interfaces:** Consumes: поведение Tasks 1–3. Produces: спека-дельта (валидируется `openspec validate enforce-upstream-loop --strict`), тексты скилла/роли, доки.
- [ ] **Step 1: Тесты** — в `dsh/tests/` добавить assert: `dsh/skills/sdd/SKILL.md` содержит строку «approval» / «/approve» (текстовый пин), `dsh/install.sh --roles` вставляет заметку с «/approve» (расширить существующий install-тест или добавить в install.test.mjs).
- [ ] **Step 2: Прогони — FAIL.**
- [ ] **Step 3: Реализуй** тексты и дельту; `openspec validate enforce-upstream-loop --strict` — valid.
- [ ] **Step 4: Прогони — PASS**: `bash dsh/tests/run.sh`, `bash tests/run.sh`, `openspec validate … --strict`.
- [ ] **Step 5: Коммит**
```bash
git add dsh/skills/sdd/SKILL.md dsh/install.sh dsh/tests openspec/changes/enforce-upstream-loop/specs dsh/README.md dsh/PORT.md
git commit -m "docs(dsh): sdd-скилл, roles-заметка, спека approval-gates, доки (#59)"
```
## Предусловия окружения (контроллер проверяет один раз до Task 1)
- `python3`, `git`, `node` в PATH; `bash dsh/tests/run.sh` зелёный на входной точке ветки `feat/59-loop-gates` (baseline 110/0).
- Сеть не нужна; тесты — temp-репозитории; реальный `~/.dsh` не трогается.
- Гейты в этой ветке ещё НЕ активны (D-13): исполнение идёт на старом рантайме, retro-утверждений не требуется; переустановка бандла после merge — шаг чеклиста закрытия.

View File

@ -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 <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
re-run the tests after upgrades.
- **Contract self-check.** The plugin/event API drift is now caught from inside

View File

@ -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 <change-id> 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,20 @@ 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.
- Gate A is content-based: it reads `**Spec:**` from `content` / `new_string`.
An `edit` fragment is checked against its own text, so a fragment carrying a
`**Spec:**` line for an approved change passes, while the usual Spec-less
fragment is denied (rewrite the plan file in full). Bash plan targets and
`str_replace_editor` creates are denied because the gate cannot see their
content; 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`).

View File

@ -0,0 +1,280 @@
/**
* Human-facing `/approve` command for DeepSeek Harness.
*
* `/approve <change-id> design|plan` is the only writer of
* `openspec/changes/<id>/approval.md` (D-1). The command checks the upstream
* artifacts, asks the owner through `userQuestions.ask` (D-12), and only then
* merges the new section into the existing file. A missing user-questions
* service refuses instead of writing, so no model tool can approve itself.
*
* The repository root is resolved per invocation from the receiving agent's
* session (`workingDirectory.get(agent.session)`, T3.1) — the registration
* itself carries no root, because the command runs against whatever agent
* invoked it.
*
* Mirrors `command-role.js`: object-form `commands.register`, the same
* `{ kind, text }` result shape.
*/
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import {
APPROVAL_FILE,
CHANGE_DIR,
PLANS_DIR,
extractSpec,
hasDesignApproval,
hashPlan,
parseApproval,
resolveChangeFromSpec,
serializeApproval,
} from './lib/gates.js'
const USAGE = [
'Usage: /approve <change-id> design|plan',
' design — record the owner approval of the change design',
' plan — record the owner approval of the plan whose **Spec:** is <change-id>',
].join('\n')
const DESIGN_ARTIFACTS = ['proposal.md', 'design.md', 'builder-check.md']
const APPROVE_LABEL = 'Approve'
const CANCEL_LABEL = 'Cancel'
/** Change ids are directory names: keep them one safe path segment. */
const CHANGE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u
function isFile(path) {
try {
return statSync(path).isFile()
} catch {
return false
}
}
function readText(path) {
try {
return readFileSync(path, 'utf8')
} catch {
return null
}
}
/** Session working directory of the invoking agent; process.cwd() fallback. */
function currentDirectory(workingDirectory, agent) {
try {
const cwd = workingDirectory?.get?.(agent?.session)
if (typeof cwd === 'string' && cwd !== '') return cwd
} catch {
// Fall through to the process directory.
}
return process.cwd()
}
function refusal(text) {
return { kind: 'error', text: `Refused: ${text}` }
}
function usage(text) {
return { kind: 'error', text: `${text}\n${USAGE}` }
}
/**
* Read the answer of `userQuestions.ask`.
*
* The DSH UserQuestionService resolves to `{ answers: [{ id, selected }] }`;
* the plan/brief contract also allows `{ approved }` / `{ confirmed }` (and a
* bare `true`) so the command stays testable without the runtime. Anything
* else — a skipped question, a cancel, a timeout, null — is not an approval.
*/
function isApproved(answer) {
if (answer === true) return true
if (answer === null || typeof answer !== 'object') return false
const flag = answer.approved ?? answer.confirmed
if (flag !== undefined) return Boolean(flag)
const items = Array.isArray(answer.answers) ? answer.answers : []
return items.some(
(item) => item !== null && typeof item === 'object' && Array.isArray(item.selected) && item.selected.includes(APPROVE_LABEL),
)
}
/**
* Ask the owner and return `{ ok: true }` or a refusal `{ ok: false, text }`.
* The detail shows exactly what is being approved; the answer is never
* inferred from the absence of an error.
*/
async function askOwner(userQuestions, { changeId, kind, detail, agent, signal }) {
if (typeof userQuestions?.ask !== 'function') {
return { ok: false, text: 'userQuestions service unavailable; /approve requires it' }
}
const request = {
questions: [
{
id: `approve-${kind}`,
header: 'Approve',
question: `Approve ${changeId} ${kind}?`,
detail,
options: [
{
label: APPROVE_LABEL,
description: `Write the ${kind} approval to openspec/changes/${changeId}/approval.md.`,
},
{ label: CANCEL_LABEL, description: 'Write nothing.' },
],
},
],
}
if (agent !== undefined) request.agent = agent
if (signal !== undefined) request.signal = signal
let answer
try {
answer = await userQuestions.ask(request)
} catch (error) {
return { ok: false, text: `userQuestions failed: ${String(error?.message ?? error)}` }
}
return isApproved(answer) ? { ok: true } : { ok: false, text: 'not confirmed' }
}
/** Merge one section into approval.md, leaving every other section as is. */
function writeApproval(root, changeId, mutate) {
const path = join(root, CHANGE_DIR, changeId, APPROVAL_FILE)
const next = mutate(parseApproval(readText(path)))
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, serializeApproval(next), 'utf8')
return path
}
/** Existing plans under docs/superpowers/plans/ whose **Spec:** is `<id>`. */
function plansForChange(root, changeId) {
const plansDir = join(root, PLANS_DIR)
let names
try {
names = readdirSync(plansDir)
} catch {
return []
}
const found = []
for (const name of [...names].sort()) {
if (!name.endsWith('.md')) continue
const path = join(plansDir, name)
if (!isFile(path)) continue
const content = readText(path)
if (content === null) continue
if (resolveChangeFromSpec(extractSpec(content)) === changeId) found.push(path)
}
return found
}
function approvalText(root, path) {
return relative(root, path).split('\\').join('/')
}
async function approveDesign({ root, changeId, userQuestions, agent, signal }) {
const changeDir = join(root, CHANGE_DIR, changeId)
const missing = DESIGN_ARTIFACTS.filter((name) => !isFile(join(changeDir, name)))
if (missing.length > 0) {
const listed = missing.map((name) => `openspec/changes/${changeId}/${name}`).join(', ')
return refusal(`design approval requires the change artifacts; missing: ${listed}.`)
}
const asked = await askOwner(userQuestions, {
changeId,
kind: 'design',
detail: `Change: openspec/changes/${changeId} (proposal.md, design.md, builder-check.md).`,
agent,
signal,
})
if (!asked.ok) return refusal(asked.text)
const at = new Date().toISOString()
const path = writeApproval(root, changeId, (approval) => ({
...approval,
design: { by: 'owner', at },
}))
return {
kind: 'success',
text: `Approved ${changeId} design at ${at}; wrote ${approvalText(root, path)}.`,
}
}
async function approvePlan({ root, changeId, userQuestions, agent, signal }) {
const approval = parseApproval(readText(join(root, CHANGE_DIR, changeId, APPROVAL_FILE)))
if (!hasDesignApproval(approval)) {
return refusal(`design not approved; run /approve ${changeId} design`)
}
const plans = plansForChange(root, changeId)
if (plans.length === 0) {
return refusal(
`no plan for ${changeId} found under ${PLANS_DIR.split('\\').join('/')}/ with **Spec:** openspec/changes/${changeId}.`,
)
}
if (plans.length > 1) {
const listed = plans.map((path) => approvalText(root, path)).join(', ')
return refusal(`several plans reference ${changeId}: ${listed}. Keep exactly one.`)
}
const [planPath] = plans
const sha256 = hashPlan(planPath)
if (sha256 === '') {
return refusal(`cannot read plan ${approvalText(root, planPath)}.`)
}
const asked = await askOwner(userQuestions, {
changeId,
kind: 'plan',
detail: `Plan: ${approvalText(root, planPath)} (sha256 ${sha256}).`,
agent,
signal,
})
if (!asked.ok) return refusal(asked.text)
const at = new Date().toISOString()
const path = writeApproval(root, changeId, (current) => ({
...current,
plan: { by: 'owner', at, sha256 },
}))
return {
kind: 'success',
text: `Approved ${changeId} plan at ${at} (sha256 ${sha256}); wrote ${approvalText(root, path)}.`,
}
}
export function registerApproveCommand({ commands, userQuestions, workingDirectory, logger }) {
commands.register({
name: 'approve',
description: 'Утвердить design или plan change (единственный писатель approval.md)',
input: { hint: '<change-id> design|plan' },
handler: async ({ agent, rawInput, signal } = {}) => {
const parts =
typeof rawInput === 'string' ? rawInput.trim().split(/\s+/u).filter((part) => part !== '') : []
if (parts.length !== 2) {
return usage('Missing arguments: expected <change-id> design|plan.')
}
const [changeId, kind] = parts
if (!CHANGE_ID.test(changeId)) {
return usage(`Invalid change id "${changeId}".`)
}
if (kind !== 'design' && kind !== 'plan') {
return usage(`Unknown kind "${kind}".`)
}
const root = resolve(currentDirectory(workingDirectory, agent))
try {
if (kind === 'design') {
return await approveDesign({ root, changeId, userQuestions, agent, signal })
}
return await approvePlan({ root, changeId, userQuestions, agent, signal })
} catch (error) {
logger?.warn?.('[dsh-harness] /approve failed: %s', error?.message ?? error)
return refusal(`/approve failed: ${String(error?.message ?? error)}`)
}
},
})
}

View File

@ -17,13 +17,22 @@
* installation, so only Node builtins and files in this package are safe.
*/
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { registerRoleCommand } from './command-role.js'
import { registerCheckCommand } from './command-check.js'
import { registerApproveCommand } from './command-approve.js'
import { createGuard } from './lib/guards.js'
import {
checkApprovalWrite,
checkBashTargets,
checkDispatch,
checkPlanWrite,
checkSddWrite,
} from './lib/gates.js'
import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js'
import { isLegacyClaudePath, warnLegacyOnce } from './lib/legacy.js'
import { createUserMessage } from './lib/message.js'
@ -73,9 +82,108 @@ function sessionCwd(workingDirectory, agent) {
return process.cwd()
}
/** DSH tools that write file content (the Edit/Write/MultiEdit family). */
const WRITE_TOOLS = new Set(['write', 'edit', 'multi_edit', 'str_replace_editor'])
/** Coarse substrings that make a Bash command worth target-parsing (D-8). */
const BASH_GATE_HINTS = ['docs/superpowers/plans', 'approval.md', '.superpowers/sdd']
/**
* Write targets of one Bash command, parsed by the same parser the hooks use
* (`harness_target.py targets`). `null` on any spawn/parse failure — the gate
* fails open exactly like the hooks do.
*/
function bashWriteTargets(hooksDir, command, cwd, logger) {
const parser = join(hooksDir, 'harness_target.py')
let result
try {
result = spawnSync('python3', [parser, 'targets', command, cwd], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 15_000,
})
} catch (error) {
logger?.warn?.('[dsh-harness] approval gates: bash target parse failed open: %s', error?.message ?? String(error))
return null
}
if (result.error || result.status !== 0) {
logger?.warn?.(
'[dsh-harness] approval gates: harness_target.py failed open (%s)',
result.error?.message ?? `exit ${result.status}`,
)
return null
}
const targets = []
for (const line of String(result.stdout ?? '').split(/\r?\n/u)) {
if (!line.startsWith('file ')) continue
const path = line.slice('file '.length).trim().split(/\s+/u)[0]
if (path !== '') targets.push(path)
}
return targets
}
/**
* Approval gates A/B/C for one tool call, run after the hook guards.
* Returns the first deny reason or undefined. All failures fail open: a call
* that names no plan/path is never blocked, and a broken bash parser pass is
* allowed, mirroring the hook invariant.
*/
function approvalGateDeny({ exec, repoRoot, hooksDir, logger }) {
const name = exec?.name
const args = exec?.arguments ?? {}
if (WRITE_TOOLS.has(name)) {
const rawPath = args.file_path ?? args.path
if (typeof rawPath !== 'string' || rawPath === '') return undefined
const filePath = isAbsolute(rawPath) ? resolve(rawPath) : resolve(repoRoot, rawPath)
const content =
typeof args.content === 'string'
? args.content
: typeof args.new_string === 'string'
? args.new_string
: null
for (const verdict of [
checkApprovalWrite({ filePath, repoRoot }),
checkPlanWrite({ filePath, content, repoRoot }),
checkSddWrite({ filePath, repoRoot }),
]) {
if (verdict.deny !== undefined) return verdict.deny
}
return undefined
}
if (name === 'subagent' || name === 'subagent_fork' || name === 'workflow') {
return checkDispatch({
prompt: typeof args.prompt === 'string' ? args.prompt : undefined,
script: typeof args.script === 'string' ? args.script : undefined,
repoRoot,
}).deny
}
if (name === 'bash') {
const command = args.command
if (typeof command !== 'string' || command === '') return undefined
if (!BASH_GATE_HINTS.some((hint) => command.includes(hint))) return undefined
const targets = bashWriteTargets(hooksDir, command, repoRoot, logger)
if (targets === null) return undefined
// Plans/approval.md through the shared target check (T3.7), then the
// additional sdd pass for `.superpowers/sdd/` targets.
const bashVerdict = checkBashTargets(targets, repoRoot)
if (bashVerdict.deny !== undefined) return bashVerdict.deny
for (const target of targets) {
const sdd = checkSddWrite({ filePath: target, repoRoot })
if (sdd.deny !== undefined) return sdd.deny
}
return undefined
}
return undefined
}
const DEFAULTS = {
enabled: true,
guards: true,
approvalGates: true,
bootstrap: true,
bootstrapSubagents: false,
roleCommand: true,
@ -130,11 +238,21 @@ export function apply(ctx, rawConfig = {}) {
// `tools/pre-execute` waterfall, so no other listener can turn a denial
// back into permission.
ctx.tools.guard((exec) => {
const reason = guard.check(exec, sessionCwd(ctx.workingDirectory, exec.agent))
const repoRoot = sessionCwd(ctx.workingDirectory, exec.agent)
const reason = guard.check(exec, repoRoot)
if (reason !== undefined) {
ctx.logger?.info?.('[dsh-harness] blocked %s: %s', exec.name, reason.split('\n')[0])
return reason
}
return reason
if (config.enabled && config.approvalGates) {
const gateDeny = approvalGateDeny({ exec, repoRoot, hooksDir, logger: ctx.logger })
if (gateDeny !== undefined) {
const gateReason = `BLOCKED [approval-gates]: ${gateDeny}`
ctx.logger?.info?.('[dsh-harness] blocked %s: %s', exec.name, gateReason.split('\n')[0])
return gateReason
}
}
return undefined
})
}
@ -188,6 +306,22 @@ export function apply(ctx, rawConfig = {}) {
})
}
// /approve has its own activation switch (T3.2), independent of /role and
// /dsh-harness-check: the command resolves its repoRoot per invocation from
// the receiving agent, so the wiring passes the service, not a root. The
// commands availability is checked directly — the self-check skips the
// `commands` feature when both roleCommand and checkCommand are off.
if (config.enabled && config.approvalGates && typeof ctx.inject === 'function') {
ctx.inject(['commands'], (commandCtx) => {
registerApproveCommand({
commands: commandCtx.commands,
userQuestions: typeof ctx.get === 'function' ? ctx.get('userQuestions') : undefined,
workingDirectory: ctx.workingDirectory,
logger: ctx.logger,
})
})
}
ctx.logger?.info?.(
'[dsh-harness] ready: hooks=%s, bootstrap=%s, /role=%s',
config.guards ? hooksDir : 'off',

428
dsh/bundle/lib/gates.js Normal file
View File

@ -0,0 +1,428 @@
/**
* Approval gates A/B/C of the upstream loop: the pure decision core plus the
* approval.md / plan io it needs.
*
* No DSH runtime is imported here. Every decision function takes plain values;
* where it must touch the filesystem it does so only through an explicit
* repository root and only synchronously (`readFileSync` / `existsSync`).
*
* Failure policy mirrors the bash hooks: a call that references no plan is
* never blocked, while a *referenced* plan that cannot be read or tied to an
* approved change is denied (fail closed). Parsing never throws: malformed
* YAML or markdown degrades to "missing", which the gates reject.
*/
import { createHash } from 'node:crypto'
import { readFileSync, statSync } from 'node:fs'
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
export const APPROVAL_FILE = 'approval.md'
export const PLANS_DIR = join('docs', 'superpowers', 'plans')
export const CHANGE_DIR = join('openspec', 'changes')
export const SDD_DIR = join('.superpowers', 'sdd')
const SPEC_DENY = 'plan must carry **Spec:** pointing to openspec/changes/<id>'
const APPROVAL_DENY = 'approval.md is written only by /approve'
const PLAN_NO_APPROVAL = 'no plan approval'
const PLAN_CHANGED = 'plan changed after approval'
const PLAN_CHANGED_DENY = 'plan changed after approval; re-approve'
/** Scalar keys of each approval.md section, in serialization order. */
const SECTION_FIELDS = {
design: ['by', 'at'],
plan: ['by', 'at', 'sha256'],
}
/** `openspec/changes/<id>` anywhere in a path (also inside an absolute path). */
const CHANGE_PATH = /(?:^|\/)openspec\/changes\/([^/\s]+)/u
/** `.superpowers/sdd/<plan-name>` anywhere in a path or a prompt. */
const SDD_PATH = /(?:^|\/)\.superpowers\/sdd\/([^/\s]+)/u
/** A direct plan reference; the match ends at `.md`, prose may follow it. */
const DIRECT_PLAN = /^docs\/superpowers\/plans\/([^/\s"'`()<>[\]{}]+\.md)/u
const DIRECT_PREFIX = 'docs/superpowers/plans/'
const SDD_PREFIX = '.superpowers/sdd/'
/**
* Characters that end a path token when scanning a prompt backwards. `=`
* separates `--flag=<path>` and `:` ends a prose label like `Plan:<path>`.
*/
const TOKEN_STOP = new Set([
' ',
'\t',
'\n',
'\r',
'"',
"'",
'`',
'(',
')',
'[',
']',
'{',
'}',
'<',
'>',
',',
';',
':',
'=',
'&',
'|',
])
function isFile(path) {
try {
return statSync(path).isFile()
} catch {
return false
}
}
function readText(path) {
try {
return readFileSync(path, 'utf8')
} catch {
return null
}
}
/** Strip a comment / quotes / backticks from one yaml scalar. */
function scalar(raw) {
let value = String(raw ?? '').trim()
const comment = value.search(/\s#/u)
if (comment !== -1 && !/^['"`]/u.test(value)) value = value.slice(0, comment).trim()
if (
(value.startsWith('`') && value.endsWith('`')) ||
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1).trim()
}
return value
}
/** `openspec/changes/<id>` when the value points into a change, else null. */
function changeSpec(value) {
const match = CHANGE_PATH.exec(value)
if (!match) return null
const id = match[1]
if (id === '.' || id === '..') return null
return `openspec/changes/${id}`
}
function isInside(parent, child) {
const rel = relative(parent, child)
return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
}
/**
* Parse the tolerant yaml subset used by approval.md: top-level `design:` and
* `plan:` blocks with scalar `by` / `at` / `sha256` children. A section key is
* kept even when its block is empty; unknown sections and malformed lines are
* ignored. Any non-string input yields `{}` — the caller passes null when the
* file is missing.
*/
export function parseApproval(text) {
const parsed = {}
if (typeof text !== 'string' || text === '') return parsed
let section = null
for (const rawLine of text.split(/\r?\n/u)) {
const line = rawLine.replace(/[ \t]+$/u, '')
if (line.trim() === '' || /^\s*#/u.test(line)) continue
const top = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line)
if (top) {
// Own-key check, not `in`: a crafted `__proto__:`/`constructor:` line
// must be ignored instead of touching the prototype.
const known = Object.prototype.hasOwnProperty.call(SECTION_FIELDS, top[1])
section = known ? {} : null
if (section) parsed[top[1]] = section
continue
}
if (section === null) continue
const child = /^\s+([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line)
if (!child) continue
const value = scalar(child[2])
if (value !== '') section[child[1]] = value
}
return parsed
}
/**
* Deterministic approval.md text: sections in `design`, `plan` order, fields
* in a fixed order, one blank line between sections, no trailing spaces, and
* a final newline. An absent or non-object section is skipped.
*/
export function serializeApproval(parsed) {
const sections = []
for (const [section, fields] of Object.entries(SECTION_FIELDS)) {
const source = parsed?.[section]
if (!source || typeof source !== 'object') continue
const lines = [`${section}:`]
for (const field of fields) {
const value = source[field]
if (value === undefined || value === null) continue
const text = String(value).trim()
if (text === '') continue
lines.push(` ${field}: ${text}`)
}
sections.push(lines.join('\n'))
}
return sections.length === 0 ? '' : `${sections.join('\n\n')}\n`
}
/**
* Normalized spec path named by the plan's `**Spec:**` line, or null.
*
* Backticks, a markdown link target and a trailing slash are stripped. A path
* into `openspec/changes/<id>/...` is normalized to `openspec/changes/<id>`;
* any other path (legacy `docs/superpowers/specs/...`) is returned as written
* so the caller can reject it explicitly (D-9).
*/
export function extractSpec(content) {
if (typeof content !== 'string' || content === '') return null
const match = /^[ \t]*\*\*Spec:\*\*[ \t]*(.+?)[ \t]*$/mu.exec(content)
if (!match) return null
let value = match[1].trim()
const backtick = /^`([^`]+)`/u.exec(value)
const link = /^\[[^\]]*\]\(([^)]+)\)/u.exec(value)
if (backtick) value = backtick[1]
else if (link) value = link[1]
else value = value.split(/\s+/u)[0]
value = value
.trim()
.replace(/^[`"']+/u, '')
.replace(/[`"']+$/u, '')
.replace(/^\.\//u, '')
.replace(/[/.,;:]+$/u, '')
if (value === '') return null
return changeSpec(value) ?? value
}
/**
* Change id of a spec path: `openspec/changes/<id>(/...)` -> `<id>`.
* Legacy `docs/superpowers/specs/...` and everything else -> null.
*/
export function resolveChangeFromSpec(specPath) {
if (typeof specPath !== 'string' || specPath === '') return null
const change = changeSpec(specPath)
return change === null ? null : change.slice('openspec/changes/'.length)
}
/**
* Plan file behind an sdd ledger path: `.superpowers/sdd/<plan-name>(/...)` ->
* `<repoRoot>/docs/superpowers/plans/<plan-name>.md`, when that file exists.
*/
export function resolvePlanFromSdd(sddPath, repoRoot) {
if (typeof sddPath !== 'string' || sddPath === '' || !repoRoot) return null
const match = SDD_PATH.exec(sddPath)
if (!match) return null
const name = match[1].replace(/[/.,;:]+$/u, '')
if (name === '' || name === '.' || name === '..') return null
const planPath = join(resolve(repoRoot), PLANS_DIR, `${name}.md`)
return isFile(planPath) ? planPath : null
}
/** sha256 hex of the plan content; '' when the file cannot be read. */
export function hashPlan(planPath) {
try {
return createHash('sha256').update(readFileSync(planPath)).digest('hex')
} catch {
return ''
}
}
/**
* Is the plan approved for its current content (D-10)?
* Fails with `no plan approval` when the section or its sha256 is missing and
* with `plan changed after approval` when the hash does not match (a missing
* plan file is a hash mismatch by construction).
*/
export function planApproved(approval, planPath) {
const plan = approval?.plan
if (!plan || typeof plan !== 'object') return { ok: false, reason: PLAN_NO_APPROVAL }
const expected = typeof plan.sha256 === 'string' ? plan.sha256.trim().toLowerCase() : ''
if (expected === '') return { ok: false, reason: PLAN_NO_APPROVAL }
const actual = hashPlan(planPath)
if (actual === '' || actual !== expected) return { ok: false, reason: PLAN_CHANGED }
return { ok: true, reason: '' }
}
function readApproval(repoRoot, changeId) {
return parseApproval(readText(join(repoRoot, CHANGE_DIR, changeId, APPROVAL_FILE)))
}
/**
* Does approval.md carry a real design approval? An empty `design:` block is
* treated as no approval (R-4/T1.6f): the section must carry the `by` and `at`
* scalars `/approve` writes, so a bare header cannot unlock the plan gate.
*/
export function hasDesignApproval(approval) {
const design = approval?.design
if (!design || typeof design !== 'object') return false
return (
typeof design.by === 'string' &&
design.by !== '' &&
typeof design.at === 'string' &&
design.at !== ''
)
}
/**
* Gate A: a plan may only be written for a change whose design is approved.
* The `**Spec:**` reference is mandatory and must resolve to an openspec
* change (D-9); a plan written elsewhere is not gated.
*/
export function checkPlanWrite({ filePath, content, repoRoot } = {}) {
if (typeof filePath !== 'string' || filePath === '') return {}
const root = resolve(repoRoot || '.')
const target = resolve(root, filePath)
if (!isInside(join(root, PLANS_DIR), target)) return {}
const changeId = resolveChangeFromSpec(extractSpec(content))
if (changeId === null) return { deny: SPEC_DENY }
if (!hasDesignApproval(readApproval(root, changeId))) {
return { deny: `design not approved; run /approve ${changeId} design` }
}
return {}
}
/**
* Gate C: `approval.md` is written only by `/approve`, never by a tool (D-1).
* Applies wherever the file lives, not only inside a change directory.
*/
export function checkApprovalWrite({ filePath, repoRoot } = {}) {
if (typeof filePath !== 'string' || filePath === '') return {}
const target = resolve(resolve(repoRoot || '.'), filePath)
if (basename(target) === APPROVAL_FILE) return { deny: APPROVAL_DENY }
return {}
}
function tokenStart(text, index) {
let start = index
while (start > 0 && !TOKEN_STOP.has(text[start - 1])) start -= 1
return start
}
/**
* All plan references in one string, in textual order: a direct
* `docs/superpowers/plans/x.md` path (absolute or repository-relative) or an
* `.superpowers/sdd/x/` ledger path. Empty when the string references none.
*/
function planReferences(text) {
if (typeof text !== 'string' || text === '') return []
const found = []
for (let at = text.indexOf(DIRECT_PREFIX); at !== -1; at = text.indexOf(DIRECT_PREFIX, at + 1)) {
const match = DIRECT_PLAN.exec(text.slice(at))
if (!match) continue
const start = tokenStart(text, at)
found.push({ index: at, path: text.slice(start, at + match[0].length) })
}
for (let at = text.indexOf(SDD_PREFIX); at !== -1; at = text.indexOf(SDD_PREFIX, at + 1)) {
const match = SDD_PATH.exec(text.slice(at))
if (!match) continue
const name = match[1].replace(/[/.,;:]+$/u, '')
if (name !== '' && name !== '.' && name !== '..') found.push({ index: at, name })
}
return found.sort((left, right) => left.index - right.index)
}
/** Deny text for an unapproved plan; `<id>` stays literal when unknown. */
function denyPlanNotApproved(changeId) {
return { deny: `plan not approved; run /approve ${changeId ?? '<id>'} plan` }
}
/** Plan path a parsed reference points to (the file may be missing). */
function referencePlanPath(reference, repoRoot) {
if (typeof reference.name === 'string') {
return (
resolvePlanFromSdd(`${SDD_PREFIX}${reference.name}`, repoRoot) ??
join(repoRoot, PLANS_DIR, `${reference.name}.md`)
)
}
return isAbsolute(reference.path) ? resolve(reference.path) : resolve(repoRoot, reference.path)
}
/**
* Full gate-B chain for one referenced plan: read it, take its `**Spec:**`,
* resolve the change, verify the plan approval against the file content.
* Missing/spec-less plans fail closed.
*/
function planVerdict(repoRoot, planPath) {
const content = readText(planPath)
if (content === null) return denyPlanNotApproved()
const changeId = resolveChangeFromSpec(extractSpec(content))
if (changeId === null) return denyPlanNotApproved()
const verdict = planApproved(readApproval(repoRoot, changeId), planPath)
if (verdict.ok) return {}
if (verdict.reason === PLAN_NO_APPROVAL) return denyPlanNotApproved(changeId)
return { deny: PLAN_CHANGED_DENY }
}
/**
* Gate B: a dispatch may only start when every plan it references is approved
* for its current content (D-10). The check covers the prompt and, when
* given, the workflow script; the first failing reference denies, so an
* approved plan cannot mask a later unapproved one. No plan reference at all
* means no gate.
*/
export function checkDispatch({ prompt, script, repoRoot } = {}) {
const root = resolve(repoRoot || '.')
const references = [...planReferences(prompt), ...planReferences(script)]
for (const reference of references) {
const verdict = planVerdict(root, referencePlanPath(reference, root))
if (verdict.deny !== undefined) return verdict
}
return {}
}
/**
* Gate B for Mode 1 starts: a write under `.superpowers/sdd/<plan-name>/`
* (ledger, briefs) is allowed only when that plan is approved for its current
* content (D-7). A missing plan file fails closed with `plan not found`.
*/
export function checkSddWrite({ filePath, repoRoot } = {}) {
if (typeof filePath !== 'string' || filePath === '') return {}
const root = resolve(repoRoot || '.')
const target = resolve(root, filePath)
const sddDir = join(root, SDD_DIR)
if (!isInside(sddDir, target)) return {}
const segments = relative(sddDir, target).split(sep)
if (segments.length < 2) return {}
const name = segments[0]
if (name === '' || name === '.' || name === '..') return {}
const planPath = resolvePlanFromSdd(`${SDD_PREFIX}${name}`, root)
if (planPath === null) return { deny: `plan not found: ${name}` }
return planVerdict(root, planPath)
}
/**
* Gates A and C for a list of absolute write targets (from
* `harness_target.py targets`). First denial wins. A target under plans/ has
* no content here, so it can never carry a `**Spec:**` line and is denied —
* exactly the bash-heredoc case the tool-level gates would otherwise miss.
*/
export function checkBashTargets(targets, repoRoot) {
if (!Array.isArray(targets)) return {}
const root = resolve(repoRoot || '.')
for (const raw of targets) {
if (typeof raw !== 'string' || raw === '') continue
const target = resolve(root, raw)
const approval = checkApprovalWrite({ filePath: target, repoRoot: root })
if (approval.deny !== undefined) return approval
const plan = checkPlanWrite({ filePath: target, content: null, repoRoot: root })
if (plan.deny !== undefined) return plan
}
return {}
}

View File

@ -13,6 +13,7 @@
"index.js",
"command-role.js",
"command-check.js",
"command-approve.js",
"lib",
"dsh-tools.md",
"cordis.patch.yml",

View File

@ -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)

View File

@ -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 (см. ниже) и скан плана на конфликты; рулинги до
Task 1.
2. Бриф implementer'а — **файлом** `<workspace>/briefs/task-<n>.md`: контекст,

349
dsh/tests/approve.test.mjs Normal file
View File

@ -0,0 +1,349 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
import { registerApproveCommand } from '../bundle/command-approve.js'
import { hashPlan, parseApproval, serializeApproval } from '../bundle/lib/gates.js'
import { cleanup, tempDir, write } from './helpers.mjs'
const PLAN_TEXT = [
'# Plan X',
'',
'**Spec:** `openspec/changes/demo/`',
'',
'## Task 1',
'',
'Do the work.',
'',
].join('\n')
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u
const APPROVE = 'Approve'
/** A throwaway repository with the design artifacts and one demo plan. */
function repoFixture({ builderCheck = true, withPlan = true } = {}) {
const tmp = tempDir('dsh-approve-')
const repo = join(tmp, 'repo')
write(join(repo, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n')
write(join(repo, 'openspec', 'changes', 'demo', 'design.md'), '# Design\n')
if (builderCheck) write(join(repo, 'openspec', 'changes', 'demo', 'builder-check.md'), '# Check\n')
let planPath = null
if (withPlan) {
planPath = join(repo, 'docs', 'superpowers', 'plans', 'plan-x.md')
write(planPath, PLAN_TEXT)
}
return { tmp, repo, planPath }
}
function approvalPath(repo) {
return join(repo, 'openspec', 'changes', 'demo', 'approval.md')
}
/** An ask() answer batch shaped like the DSH UserQuestionService result. */
function approveAnswer(kind = 'design') {
return { answers: [{ id: `approve-${kind}`, selected: [APPROVE], custom: undefined }] }
}
/**
* Wire the command against a fake `commands.register` that captures the
* registered definition, and a scripted `userQuestions.ask`. The fake
* `workingDirectory` resolves every session to the fixture repo.
*/
function setup(repo, { answer, missingService = false } = {}) {
const captured = {}
const commands = {
register(definition) {
Object.assign(captured, definition)
},
}
const calls = []
let userQuestions
if (!missingService) {
userQuestions = {
async ask(request) {
calls.push(request)
return answer
},
}
}
registerApproveCommand({
commands,
userQuestions,
workingDirectory: { get() { return repo } },
logger: { warn() {} },
})
return { captured, calls, run: (rawInput) => captured.handler({ rawInput }) }
}
function shaOf(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}
test('approve design refuses without builder-check.md', async () => {
const { tmp, repo } = repoFixture({ builderCheck: false })
try {
const harness = setup(repo, { answer: approveAnswer('design') })
const result = await harness.run('demo design')
assert.match(result.text, /builder-check\.md/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
assert.equal(harness.calls.length, 0, 'the owner is not asked when artifacts are missing')
} finally {
cleanup(tmp)
}
})
test('approve design writes design section (by owner, at ISO)', async () => {
const { tmp, repo } = repoFixture()
try {
const existingPlan = { by: 'aep', at: '2026-10-25T11:30:00+04:00', sha256: 'a'.repeat(64) }
write(approvalPath(repo), serializeApproval({ plan: existingPlan }))
const harness = setup(repo, { answer: approveAnswer('design') })
const result = await harness.run('demo design')
assert.equal(result.kind, 'success')
const request = harness.calls[0].questions[0]
assert.equal(request.question, 'Approve demo design?')
const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8'))
assert.equal(parsed.design.by, 'owner')
assert.match(parsed.design.at, ISO)
assert.equal(Number.isNaN(Date.parse(parsed.design.at)), false)
assert.deepEqual(parsed.plan, existingPlan, 'the pre-existing plan section is untouched')
} finally {
cleanup(tmp)
}
})
test('approve design refuses without confirmation', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, {
answer: { answers: [{ id: 'approve-design', selected: ['Cancel'] }] },
})
const result = await harness.run('demo design')
assert.match(result.text, /not confirmed/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
test('approve plan refuses without design section', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, { answer: approveAnswer('plan') })
const result = await harness.run('demo plan')
assert.match(result.text, /design not approved/u)
assert.match(result.text, /\/approve demo design/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
assert.equal(harness.calls.length, 0, 'the owner is not asked before the design gate passes')
} finally {
cleanup(tmp)
}
})
test('approve plan refuses when two plans reference the change', async () => {
const { tmp, repo } = repoFixture()
try {
write(join(repo, 'docs', 'superpowers', 'plans', 'plan-y.md'), PLAN_TEXT)
write(
approvalPath(repo),
serializeApproval({ design: { by: 'aep', at: '2026-10-25T10:00:00+04:00' } }),
)
const before = readFileSync(approvalPath(repo), 'utf8')
const harness = setup(repo, { answer: approveAnswer('plan') })
const result = await harness.run('demo plan')
assert.match(result.text, /plan-x\.md/u)
assert.match(result.text, /plan-y\.md/u)
assert.equal(readFileSync(approvalPath(repo), 'utf8'), before, 'approval.md is untouched')
assert.equal(harness.calls.length, 0, 'the owner is not asked when the plan is ambiguous')
} finally {
cleanup(tmp)
}
})
test('approve plan writes plan section with sha256 of the plan', async () => {
const { tmp, repo, planPath } = repoFixture()
try {
const design = { by: 'aep', at: '2026-10-25T10:00:00+04:00' }
write(approvalPath(repo), serializeApproval({ design }))
const harness = setup(repo, { answer: approveAnswer('plan') })
const result = await harness.run('demo plan')
assert.equal(result.kind, 'success')
const request = harness.calls[0].questions[0]
assert.equal(request.question, 'Approve demo plan?')
const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8'))
assert.equal(parsed.plan.by, 'owner')
assert.match(parsed.plan.at, ISO)
assert.equal(parsed.plan.sha256, shaOf(planPath))
assert.equal(parsed.plan.sha256, hashPlan(planPath))
assert.deepEqual(parsed.design, design, 'the design section is untouched')
} finally {
cleanup(tmp)
}
})
test('approve refuses when userQuestions is missing', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, { missingService: true })
const result = await harness.run('demo design')
assert.match(result.text, /userQuestions service unavailable/u)
assert.match(result.text, /\/approve requires it/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
test('approve with unknown kind returns usage', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, { answer: approveAnswer() })
const unknown = await harness.run('demo review')
assert.match(unknown.text, /Usage: \/approve <change-id> design\|plan/u)
assert.equal(unknown.kind, 'error')
const missing = await harness.run('demo')
assert.match(missing.text, /Usage: \/approve <change-id> design\|plan/u)
assert.equal(harness.calls.length, 0, 'nothing is asked')
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
} finally {
cleanup(tmp)
}
})
// Pins for the Task 2 review findings (R-5): branches without a first test.
test('approve plan refuses when no plan references the change', async () => {
const { tmp, repo } = repoFixture({ withPlan: false })
try {
write(
approvalPath(repo),
serializeApproval({ design: { by: 'aep', at: '2026-10-25T10:00:00+04:00' } }),
)
const before = readFileSync(approvalPath(repo), 'utf8')
const harness = setup(repo, { answer: approveAnswer('plan') })
const result = await harness.run('demo plan')
assert.equal(result.kind, 'error')
assert.match(result.text, /no plan for demo found/u)
assert.match(result.text, /docs\/superpowers\/plans\//u)
assert.equal(readFileSync(approvalPath(repo), 'utf8'), before, 'approval.md is untouched')
assert.equal(harness.calls.length, 0, 'the owner is not asked without a plan')
} finally {
cleanup(tmp)
}
})
test('approve refuses an unsafe change id', async () => {
const { tmp, repo } = repoFixture()
try {
const harness = setup(repo, { answer: approveAnswer('design') })
const result = await harness.run('../x design')
assert.match(result.text, /Invalid change id "\.\.\/x"\./u)
assert.match(result.text, /Usage: \/approve <change-id> design\|plan/u)
assert.equal(existsSync(approvalPath(repo)), false, 'no approval.md is written')
assert.equal(harness.calls.length, 0, 'the owner is not asked for an invalid id')
} finally {
cleanup(tmp)
}
})
test('approve accepts the fallback answer shapes {approved}/{confirmed}/true', async () => {
const { tmp, repo } = repoFixture()
try {
const shapes = [{ approved: true }, { confirmed: true }, true]
for (const answer of shapes) {
const harness = setup(repo, { answer })
const result = await harness.run('demo design')
assert.equal(result.kind, 'success', `answer shape ${JSON.stringify(answer)} approves`)
const parsed = parseApproval(readFileSync(approvalPath(repo), 'utf8'))
assert.equal(parsed.design.by, 'owner')
assert.match(parsed.design.at, ISO)
}
} finally {
cleanup(tmp)
}
})
// T3.1 regression: the repo root is resolved per handler invocation from the
// invocation agent's session, not fixed at registration.
test('approve resolves the session cwd per invocation (T3.1)', async () => {
const first = repoFixture()
const second = repoFixture()
try {
const roots = new Map([
['s1', first.repo],
['s2', second.repo],
])
const captured = {}
const commands = { register(definition) { Object.assign(captured, definition) } }
registerApproveCommand({
commands,
userQuestions: { async ask() { return approveAnswer('design') } },
workingDirectory: { get(session) { return roots.get(session) } },
logger: { warn() {} },
})
const firstResult = await captured.handler({ agent: { session: 's1' }, rawInput: 'demo design' })
assert.equal(firstResult.kind, 'success')
assert.equal(existsSync(approvalPath(first.repo)), true, 'approval.md written in the s1 repo')
assert.equal(existsSync(approvalPath(second.repo)), false, 'the s2 repo is untouched')
const secondResult = await captured.handler({ agent: { session: 's2' }, rawInput: 'demo design' })
assert.equal(secondResult.kind, 'success')
assert.equal(existsSync(approvalPath(second.repo)), true, 'approval.md written in the s2 repo')
} finally {
cleanup(first.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)
}
})

468
dsh/tests/gates.test.mjs Normal file
View File

@ -0,0 +1,468 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
import {
checkApprovalWrite,
checkBashTargets,
checkDispatch,
checkPlanWrite,
checkSddWrite,
extractSpec,
hashPlan,
parseApproval,
planApproved,
resolveChangeFromSpec,
resolvePlanFromSdd,
serializeApproval,
} from '../bundle/lib/gates.js'
import { cleanup, tempDir, write } from './helpers.mjs'
const PLAN_TEXT = [
'# Plan X',
'',
'**Spec:** `openspec/changes/demo/`',
'',
'## Task 1',
'',
'Do the work.',
'',
].join('\n')
const DESIGN = { by: 'aep', at: '2026-10-25T10:00:00+04:00' }
const PLAN = { by: 'aep', at: '2026-10-25T11:30:00+04:00' }
/** A throwaway repository with the plan/change artifacts the gates read. */
function repoFixture() {
const tmp = tempDir('dsh-gates-')
const repo = join(tmp, 'repo')
write(join(repo, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n')
write(join(repo, 'openspec', 'changes', 'demo', 'design.md'), '# Design\n')
const planPath = join(repo, 'docs', 'superpowers', 'plans', 'plan-x.md')
write(planPath, PLAN_TEXT)
return { tmp, repo, planPath }
}
function approvalPath(repo) {
return join(repo, 'openspec', 'changes', 'demo', 'approval.md')
}
/** Independent sha256, so the tests do not trust the implementation's own io. */
function shaOf(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}
/**
* Write approval.md. `design` false omits the design section; leaving `planSha`
* undefined omits the plan section; `null` writes an empty plan section.
*/
function writeApproval(repo, { design = true, planSha } = {}) {
const sections = []
if (design) {
sections.push(['design:', [` by: ${DESIGN.by}`, ` at: ${DESIGN.at}`]])
}
if (planSha !== undefined) {
const lines = [` by: ${PLAN.by}`, ` at: ${PLAN.at}`]
if (planSha !== null) lines.push(` sha256: ${planSha}`)
sections.push(['plan:', lines])
}
const text = `${sections.map(([head, lines]) => [head, ...lines].join('\n')).join('\n\n')}\n`
write(approvalPath(repo), text)
return text
}
test('extractSpec: backticks and trailing slash normalize', () => {
assert.equal(extractSpec('**Spec:** `openspec/changes/demo/`'), 'openspec/changes/demo')
assert.equal(
extractSpec(
'intro\n\n**Spec:** `openspec/changes/demo/` — [proposal.md](openspec/changes/demo/proposal.md)\n',
),
'openspec/changes/demo',
)
assert.equal(extractSpec('**Spec:** openspec/changes/demo/proposal.md'), 'openspec/changes/demo')
assert.equal(extractSpec('no spec line here'), null)
assert.equal(extractSpec(null), null)
})
test('checkPlanWrite: no Spec -> deny', () => {
const { tmp, repo } = repoFixture()
try {
const result = checkPlanWrite({
filePath: join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md'),
content: '# Plan\n\nNo spec reference.\n',
repoRoot: repo,
})
assert.equal(result.deny, 'plan must carry **Spec:** pointing to openspec/changes/<id>')
} finally {
cleanup(tmp)
}
})
test('checkPlanWrite: legacy docs/superpowers/specs -> deny', () => {
const { tmp, repo } = repoFixture()
try {
writeApproval(repo)
const result = checkPlanWrite({
filePath: join(repo, 'docs', 'superpowers', 'plans', 'plan-legacy.md'),
content: '# Plan\n\n**Spec:** docs/superpowers/specs/old/spec.md\n',
repoRoot: repo,
})
assert.equal(result.deny, 'plan must carry **Spec:** pointing to openspec/changes/<id>')
} finally {
cleanup(tmp)
}
})
test('checkPlanWrite: design approved -> ok', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo)
const result = checkPlanWrite({ filePath: planPath, content: PLAN_TEXT, repoRoot: repo })
assert.equal(result.deny, undefined)
} finally {
cleanup(tmp)
}
})
test('checkPlanWrite: design not approved -> deny with /approve hint', () => {
const { tmp, repo } = repoFixture()
try {
const filePath = join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md')
const missing = checkPlanWrite({ filePath, content: PLAN_TEXT, repoRoot: repo })
assert.equal(missing.deny, 'design not approved; run /approve demo design')
writeApproval(repo, { design: false, planSha: 'deadbeef' })
const planOnly = checkPlanWrite({ filePath, content: PLAN_TEXT, repoRoot: repo })
assert.equal(planOnly.deny, 'design not approved; run /approve demo design')
} finally {
cleanup(tmp)
}
})
test('checkPlanWrite: files outside plans/ are not gated', () => {
const { tmp, repo } = repoFixture()
try {
const result = checkPlanWrite({
filePath: join(repo, 'openspec', 'changes', 'demo', 'proposal.md'),
content: '# Proposal\n',
repoRoot: repo,
})
assert.equal(result.deny, undefined)
} finally {
cleanup(tmp)
}
})
test('checkApprovalWrite: approval.md via tool -> deny', () => {
const { tmp, repo } = repoFixture()
try {
const absolute = checkApprovalWrite({ filePath: approvalPath(repo), repoRoot: repo })
assert.equal(absolute.deny, 'approval.md is written only by /approve')
const relative = checkApprovalWrite({
filePath: 'openspec/changes/demo/approval.md',
repoRoot: repo,
})
assert.equal(relative.deny, 'approval.md is written only by /approve')
const other = checkApprovalWrite({
filePath: join(repo, 'openspec', 'changes', 'demo', 'proposal.md'),
repoRoot: repo,
})
assert.equal(other.deny, undefined)
} finally {
cleanup(tmp)
}
})
test('checkDispatch: prompt plan ref, plan approved -> ok', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo, { planSha: shaOf(planPath) })
const result = checkDispatch({
prompt: `Implement task 1. Read ${planPath} before you start.`,
repoRoot: repo,
})
assert.equal(result.deny, undefined)
} finally {
cleanup(tmp)
}
})
test('checkDispatch: no plan section -> deny', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo)
const result = checkDispatch({ prompt: `Read ${planPath}.`, repoRoot: repo })
assert.equal(result.deny, 'plan not approved; run /approve demo plan')
} finally {
cleanup(tmp)
}
})
test('checkDispatch: sha256 mismatch after edit -> deny re-approve', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo, { planSha: shaOf(planPath) })
write(planPath, `${PLAN_TEXT}\nadded after approval\n`)
const result = checkDispatch({ prompt: `Read ${planPath}.`, repoRoot: repo })
assert.equal(result.deny, 'plan changed after approval; re-approve')
} finally {
cleanup(tmp)
}
})
test('checkDispatch: workflow script with plan ref -> deny/ok same as subagent', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo, { planSha: shaOf(planPath) })
const direct = checkDispatch({
prompt: 'Run the workflow.',
script: `const plan = ${JSON.stringify(planPath)}\n`,
repoRoot: repo,
})
assert.equal(direct.deny, undefined)
writeApproval(repo)
const sdd = checkDispatch({
prompt: 'Run the workflow.',
script: 'cat .superpowers/sdd/plan-x/progress.md',
repoRoot: repo,
})
assert.equal(sdd.deny, 'plan not approved; run /approve demo plan')
} finally {
cleanup(tmp)
}
})
test('checkDispatch: no plan reference -> ok', () => {
const { tmp, repo } = repoFixture()
try {
const result = checkDispatch({ prompt: 'Answer the question about the weather.', repoRoot: repo })
assert.equal(result.deny, undefined)
assert.equal(checkDispatch({ prompt: '', script: '', repoRoot: repo }).deny, undefined)
} finally {
cleanup(tmp)
}
})
test('resolveChangeFromSpec: only openspec/changes resolve', () => {
assert.equal(resolveChangeFromSpec('openspec/changes/demo'), 'demo')
assert.equal(resolveChangeFromSpec('openspec/changes/demo/proposal.md'), 'demo')
assert.equal(resolveChangeFromSpec('docs/superpowers/specs/demo/spec.md'), null)
assert.equal(resolveChangeFromSpec('openspec/specs/hook-guards/spec.md'), null)
assert.equal(resolveChangeFromSpec(null), null)
})
test('resolvePlanFromSdd: .superpowers/sdd -> existing plan file', () => {
const { tmp, repo, planPath } = repoFixture()
try {
assert.equal(resolvePlanFromSdd('.superpowers/sdd/plan-x/progress.md', repo), planPath)
assert.equal(
resolvePlanFromSdd('/abs/repo/.superpowers/sdd/plan-x/briefs/task-1.md', repo),
planPath,
)
assert.equal(resolvePlanFromSdd('.superpowers/sdd/missing/progress.md', repo), null)
assert.equal(resolvePlanFromSdd('docs/superpowers/plans/plan-x.md', repo), null)
} finally {
cleanup(tmp)
}
})
test('planApproved: happy / missing / mismatch', () => {
const { tmp, repo, planPath } = repoFixture()
try {
const approved = {
design: DESIGN,
plan: { ...PLAN, sha256: shaOf(planPath) },
}
assert.equal(hashPlan(planPath), shaOf(planPath))
assert.equal(planApproved(approved, planPath).ok, true)
assert.deepEqual(planApproved({}, planPath), { ok: false, reason: 'no plan approval' })
assert.deepEqual(planApproved({ design: DESIGN }, planPath), {
ok: false,
reason: 'no plan approval',
})
assert.deepEqual(
planApproved({ plan: { ...PLAN, sha256: 'a'.repeat(64) } }, planPath),
{ ok: false, reason: 'plan changed after approval' },
)
} finally {
cleanup(tmp)
}
})
test('serialize/parse roundtrip preserves sections', () => {
const approval = { design: DESIGN, plan: { ...PLAN, sha256: 'b'.repeat(64) } }
const text = serializeApproval(approval)
assert.equal(
text,
[
'design:',
` by: ${DESIGN.by}`,
` at: ${DESIGN.at}`,
'',
'plan:',
` by: ${PLAN.by}`,
` at: ${PLAN.at}`,
` sha256: ${'b'.repeat(64)}`,
'',
].join('\n'),
)
assert.deepEqual(parseApproval(text), approval)
assert.equal(serializeApproval(parseApproval(text)), text)
assert.doesNotMatch(text, /[ \t]+\n/)
const designOnly = serializeApproval({ design: DESIGN })
assert.doesNotMatch(designOnly, /plan:/)
assert.deepEqual(parseApproval(designOnly), { design: DESIGN })
})
test('parseApproval tolerates malformed and missing input', () => {
assert.deepEqual(parseApproval(null), {})
assert.deepEqual(parseApproval(undefined), {})
assert.deepEqual(parseApproval(''), {})
assert.deepEqual(parseApproval('design:\n by: aep\n at: x\n'), {
design: { by: 'aep', at: 'x' },
})
assert.deepEqual(parseApproval('design:\n\nplan:\n sha256: abc\n'), {
design: {},
plan: { sha256: 'abc' },
})
assert.deepEqual(parseApproval('garbage [not yaml\n stray: value\n'), {})
assert.deepEqual(parseApproval('design:\r\n by: aep\r\n'), { design: { by: 'aep' } })
})
test('checkBashTargets: heredoc-equivalent targets deny', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo)
assert.equal(checkBashTargets([join(repo, 'a.txt')], repo).deny, undefined)
assert.equal(checkBashTargets([], repo).deny, undefined)
assert.equal(checkBashTargets(undefined, repo).deny, undefined)
assert.equal(
checkBashTargets([approvalPath(repo)], repo).deny,
'approval.md is written only by /approve',
)
assert.equal(
checkBashTargets([planPath], repo).deny,
'plan must carry **Spec:** pointing to openspec/changes/<id>',
)
assert.equal(
checkBashTargets(['openspec/changes/demo/approval.md'], repo).deny,
'approval.md is written only by /approve',
)
// First deny wins, in target order.
assert.equal(
checkBashTargets([approvalPath(repo), planPath], repo).deny,
'approval.md is written only by /approve',
)
assert.equal(
checkBashTargets([planPath, approvalPath(repo)], repo).deny,
'plan must carry **Spec:** pointing to openspec/changes/<id>',
)
} finally {
cleanup(tmp)
}
})
test('checkSddWrite: plan not approved -> deny', () => {
const { tmp, repo } = repoFixture()
try {
writeApproval(repo)
const result = checkSddWrite({
filePath: join(repo, '.superpowers', 'sdd', 'plan-x', 'progress.md'),
repoRoot: repo,
})
assert.equal(result.deny, 'plan not approved; run /approve demo plan')
} finally {
cleanup(tmp)
}
})
test('checkSddWrite: approved plan -> ok, files elsewhere are not gated', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo, { planSha: shaOf(planPath) })
const ledger = checkSddWrite({
filePath: join(repo, '.superpowers', 'sdd', 'plan-x', 'briefs', 'task-1.md'),
repoRoot: repo,
})
assert.equal(ledger.deny, undefined)
assert.equal(
checkSddWrite({ filePath: join(repo, 'README.md'), repoRoot: repo }).deny,
undefined,
)
assert.equal(
checkSddWrite({
filePath: join(repo, 'docs', 'superpowers', 'plans', 'plan-x.md'),
repoRoot: repo,
}).deny,
undefined,
)
} finally {
cleanup(tmp)
}
})
test('checkSddWrite: missing plan -> fail-closed deny', () => {
const { tmp, repo } = repoFixture()
try {
writeApproval(repo)
const result = checkSddWrite({
filePath: join(repo, '.superpowers', 'sdd', 'ghost-plan', 'progress.md'),
repoRoot: repo,
})
assert.equal(result.deny, 'plan not found: ghost-plan')
} finally {
cleanup(tmp)
}
})
test('checkDispatch: all plan references are checked, not only the first', () => {
const { tmp, repo, planPath } = repoFixture()
try {
const otherPlan = join(repo, 'docs', 'superpowers', 'plans', 'plan-y.md')
write(otherPlan, '# Plan Y\n\n**Spec:** `openspec/changes/demo2/`\n')
writeApproval(repo, { planSha: shaOf(planPath) })
// The first reference is approved; the second one is not. A first-only
// check would pass, so this pin proves every reference is checked.
const result = checkDispatch({
prompt: `Read ${planPath} first, then ${otherPlan}.`,
repoRoot: repo,
})
assert.equal(result.deny, 'plan not approved; run /approve demo2 plan')
} finally {
cleanup(tmp)
}
})
test('checkDispatch: sdd reference with an approved plan -> ok', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo, { planSha: shaOf(planPath) })
const result = checkDispatch({
prompt: 'Start Mode 1: write .superpowers/sdd/plan-x/progress.md, then the briefs.',
repoRoot: repo,
})
assert.equal(result.deny, undefined)
} finally {
cleanup(tmp)
}
})
test('checkPlanWrite: empty design section counts as not approved', () => {
const { tmp, repo, planPath } = repoFixture()
try {
write(join(repo, 'openspec', 'changes', 'demo', 'approval.md'), 'design:\n')
const result = checkPlanWrite({ filePath: planPath, content: PLAN_TEXT, repoRoot: repo })
assert.equal(result.deny, 'design not approved; run /approve demo design')
} finally {
cleanup(tmp)
}
})

View File

@ -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')

View File

@ -14,6 +14,7 @@ test('package declares a DSH bundle patch and the runtime files', () => {
assert.equal(pkg.dsh?.bundle?.patch, './cordis.patch.yml')
assert.equal(pkg.exports?.['.'], './index.js')
assert.ok(pkg.files.includes('index.js'))
assert.ok(pkg.files.includes('command-approve.js'))
assert.ok(pkg.files.includes('lib'))
assert.ok(pkg.files.includes('hooks'))
assert.ok(existsSync(join(bundleDir, 'cordis.patch.yml')))
@ -56,8 +57,10 @@ test('runtime files have no external @deepseek-ai imports', () => {
'index.js',
'command-role.js',
'command-check.js',
'command-approve.js',
'lib/message.js',
'lib/guards.js',
'lib/gates.js',
'lib/bootstrap.js',
'lib/role.js',
'lib/legacy.js',

View File

@ -23,7 +23,7 @@ ensure_bundle_hooks() {
ensure_bundle_hooks
for f in dsh/bundle/index.js dsh/bundle/command-role.js dsh/bundle/command-check.js dsh/bundle/lib/*.js dsh/tests/*.mjs; do
for f in dsh/bundle/index.js dsh/bundle/command-role.js dsh/bundle/command-check.js dsh/bundle/command-approve.js dsh/bundle/lib/*.js dsh/tests/*.mjs; do
node --check "$f"
done

View File

@ -1,8 +1,12 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import test from 'node:test'
import { apply } from '../bundle/index.js'
import { collectSelfCheck } from '../bundle/lib/selfcheck.js'
import { checkSddWrite } from '../bundle/lib/gates.js'
import { registerCheckCommand } from '../bundle/command-check.js'
import { cleanup, git, makeRepo, write } from './helpers.mjs'
const FEATURES = ['guards', 'bootstrap', 'commands', 'workingDirectory']
@ -165,5 +169,305 @@ test('apply wires guards, bootstrap, and commands with a full ctx', () => {
assert.doesNotThrow(() => apply(ctx))
assert.equal(calls.guard, 1, 'guards wired')
assert.equal(calls.on, 1, 'bootstrap wired')
assert.equal(calls.inject, 1, 'commands wired')
assert.equal(calls.inject, 2, 'commands wired (role/check + /approve)')
})
// ---------------------------------------------------------------------------
// Approval gates (Task 3): apply() wires the A/B/C gates after the hooks.
// ---------------------------------------------------------------------------
const PLAN_TEXT = [
'# Plan X',
'',
'**Spec:** `openspec/changes/demo/`',
'',
'## Task 1',
'',
'Do the work.',
'',
].join('\n')
/**
* A throwaway repository on a feature branch (the hooks must pass) with the
* change artifacts and one demo plan. `design` writes a design-approved
* approval.md; `planSha` additionally writes the plan section (default: the
* real sha256 of the fixture plan).
*/
function gateFixture({ design = false, planSha } = {}) {
const repo = makeRepo()
git(repo, ['checkout', '-b', 'feat/x'])
write(join(repo, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n')
write(join(repo, 'openspec', 'changes', 'demo', 'design.md'), '# Design\n')
const planPath = join(repo, 'docs', 'superpowers', 'plans', 'plan-x.md')
write(planPath, PLAN_TEXT)
if (design || planSha !== undefined) {
const sections = []
if (design) {
sections.push('design:\n by: owner\n at: 2026-10-25T10:00:00.000Z')
}
if (planSha !== undefined) {
sections.push(`plan:\n by: owner\n at: 2026-10-25T11:00:00.000Z\n sha256: ${planSha}`)
}
write(join(repo, 'openspec', 'changes', 'demo', 'approval.md'), `${sections.join('\n\n')}\n`)
}
return { repo, planPath }
}
function planShaOf(planText) {
return createHash('sha256').update(planText).digest('hex')
}
/** apply() against a fake ctx: captured guard + inject, session cwd = repo. */
function gateHarness(repo, config = {}) {
const guardFns = []
const injected = []
const ctx = {
tools: { guard(fn) { guardFns.push(fn) } },
on() {},
inject(names, callback) { injected.push({ names, callback }) },
workingDirectory: { get() { return repo } },
get() { return undefined },
logger: { info() {}, warn() {}, error() {} },
}
apply(ctx, config)
return {
guard: (exec) => guardFns[0]({ agent: { session: 's1' }, ...exec }),
injected,
}
}
function call(name, args) {
return { name, arguments: args }
}
test('apply: guard denies plan write without design approval', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('write', {
file_path: join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md'),
content: PLAN_TEXT,
}))
assert.match(reason ?? '', /BLOCKED \[approval-gates\]: design not approved; run \/approve demo design/)
} finally {
cleanup(repo)
}
})
test('apply: guard allows plan write after approval.md with design', () => {
const { repo } = gateFixture({ design: true })
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('write', {
file_path: join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md'),
content: PLAN_TEXT,
}))
assert.equal(reason, undefined)
} finally {
cleanup(repo)
}
})
test('apply: guard denies subagent dispatch referencing unapproved plan', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('subagent', {
prompt: 'Implement docs/superpowers/plans/plan-x.md Task 1.',
}))
assert.match(reason ?? '', /BLOCKED \[approval-gates\]: plan not approved; run \/approve demo plan/)
} finally {
cleanup(repo)
}
})
test('apply: guard allows dispatch after plan approval (sha match)', () => {
const { repo } = gateFixture({ design: true, planSha: planShaOf(PLAN_TEXT) })
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('subagent', {
prompt: 'Implement docs/superpowers/plans/plan-x.md Task 1.',
}))
assert.equal(reason, undefined)
} finally {
cleanup(repo)
}
})
test('apply: bash heredoc into approval.md denied', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('bash', {
command: "cat > openspec/changes/demo/approval.md <<'EOF'\nby: me\nEOF\n",
}))
assert.match(reason ?? '', /BLOCKED \[approval-gates\]: approval\.md is written only by \/approve/)
} finally {
cleanup(repo)
}
})
test('apply: bash write under .superpowers/sdd/<plan>/ denied without approval', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('bash', {
command: 'mkdir -p .superpowers/sdd/plan-x && printf "x" > .superpowers/sdd/plan-x/progress.md',
}))
assert.match(reason ?? '', /BLOCKED \[approval-gates\]: plan not approved; run \/approve demo plan/)
} finally {
cleanup(repo)
}
})
test('apply: bash write under .superpowers/sdd/<plan>/ allowed with approval', () => {
const { repo } = gateFixture({ design: true, planSha: planShaOf(PLAN_TEXT) })
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('bash', {
command: 'mkdir -p .superpowers/sdd/plan-x && printf "x" > .superpowers/sdd/plan-x/progress.md',
}))
assert.equal(reason, undefined)
} finally {
cleanup(repo)
}
})
test('gates: checkSddWrite sha-mismatch deny', () => {
const { repo } = gateFixture({ design: true, planSha: 'a'.repeat(64) })
try {
const result = checkSddWrite({
filePath: join(repo, '.superpowers', 'sdd', 'plan-x', 'progress.md'),
repoRoot: repo,
})
assert.equal(result.deny, 'plan changed after approval; re-approve')
} finally {
cleanup(repo)
}
})
test('gates: bare .superpowers/sdd/<name> dir ungated', () => {
const { repo } = gateFixture()
try {
const result = checkSddWrite({
filePath: join(repo, '.superpowers', 'sdd', 'plan-x'),
repoRoot: repo,
})
assert.equal(result.deny, undefined)
} finally {
cleanup(repo)
}
})
test('apply: approvalGates:false disables all gates', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo, { approvalGates: false })
assert.equal(
harness.guard(call('write', {
file_path: join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md'),
content: PLAN_TEXT,
})),
undefined,
)
assert.equal(
harness.guard(call('subagent', {
prompt: 'Implement docs/superpowers/plans/plan-x.md Task 1.',
})),
undefined,
)
assert.equal(
harness.guard(call('bash', {
command: "cat > openspec/changes/demo/approval.md <<'EOF'\nby: me\nEOF\n",
})),
undefined,
)
} finally {
cleanup(repo)
}
})
test('apply: /approve registered when commands present; absent userQuestions still registers', () => {
const { repo } = gateFixture({ design: true })
try {
const harness = gateHarness(repo)
assert.equal(harness.injected.length, 2, 'role/check and /approve inject blocks ran')
// A cordis-like command context: declared services resolve, any other key
// (e.g. `agent`) throws — the wiring must not read it (T3.1).
const registered = []
const commandCtx = new Proxy(
{ commands: { register(definition) { registered.push(definition) } } },
{
get(target, prop) {
if (prop in target) return target[prop]
throw new Error(`unknown service "${String(prop)}"`)
},
},
)
for (const { callback } of harness.injected) {
assert.doesNotThrow(() => callback(commandCtx), 'the inject callback must not touch ctx.agent')
}
const approve = registered.find((definition) => definition.name === 'approve')
assert.ok(approve, '/approve is registered')
assert.equal(typeof approve.handler, 'function')
assert.equal(approve.input.hint, '<change-id> design|plan')
} finally {
cleanup(repo)
}
})
test('apply: /approve registers on approvalGates alone (role/check commands off)', () => {
const { repo } = gateFixture({ design: true })
try {
const harness = gateHarness(repo, { roleCommand: false, checkCommand: false })
assert.equal(harness.injected.length, 1, 'only the /approve inject block runs')
const registered = []
harness.injected[0].callback({
commands: { register(definition) { registered.push(definition) } },
})
assert.ok(
registered.some((definition) => definition.name === 'approve'),
'/approve is registered without role/check commands',
)
assert.ok(
!registered.some((definition) => definition.name === 'role'),
'/role is not registered',
)
} finally {
cleanup(repo)
}
})
test('apply: approvalGates:false leaves /approve unregistered', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo, { approvalGates: false })
const registered = []
for (const { callback } of harness.injected) {
callback({ commands: { register(definition) { registered.push(definition) } } })
}
assert.ok(
!registered.some((definition) => definition.name === 'approve'),
'/approve is not registered when approvalGates is off',
)
} finally {
cleanup(repo)
}
})
test('apply: bash targets prefer approval/plan denies before sdd denies', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('bash', {
command: 'printf "y" > .superpowers/sdd/plan-x/progress.md && printf "x" > openspec/changes/demo/approval.md',
}))
assert.match(reason ?? '', /BLOCKED \[approval-gates\]: approval\.md is written only by \/approve/)
} finally {
cleanup(repo)
}
})

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-25

View File

@ -0,0 +1,19 @@
# Builder-check: enforce-upstream-loop
Дата: 25.10.2026. Свежий субагент (v4-pro, сессия ff61ea11). Результат —
6 вопросов; все адьюдицированы контроллером (владелец делегировал
согласования до пробуждения) в design.md как D-7…D-13:
1. На чём срабатывает гейт B → D-7: write в `.superpowers/sdd/<plan>/**`
+ диспатчи subagent/subagent_fork/workflow с план-ссылками.
2. Bash-обход в скоупе? → D-8: да, через `harness_target.py targets`.
3. Spec-less планы → D-9: DENY по умолчанию.
4. workflow гейтится → D-7: да, тот же канал.
5. Утверждение переживает правки плана → D-10: sha256 в `plan:`.
6. Бутстрап in-flight change → D-13: гейты активны только в сессиях с
новым бандлом; #59 исполняется на старом рантайме; retro-утверждений
не требуется.
Подтверждено рантаймом: `ctx.tools.guard` видит subagent-инструменты
(name + arguments.prompt/script); `ctx.userQuestions.ask` — блокирующее
подтверждение владельца (DELEGATED_CALLER отклоняется).

View File

@ -0,0 +1,107 @@
# Design: enforce-upstream-loop — технические гейты цикла
Issue: #59. Статус: **черновик до builder-check**; владелец 25.10.2026:
«оба гейта сразу», согласования отменены — решения ниже фиксируются в
«Decisions made without discussion» и действуют как решения владельца.
## Контекст
Сессия ce9187ca: модель пропустила builder-check, рационализировала
пропуск, план появился до утверждения дизайна. Цель — гейты, которые
нельзя пропустить текстом.
## Артефакты и их писатели
| Артефакт | Пишет | Когда |
|---|---|---|
| `openspec/changes/<id>/builder-check.md` | контроллер (отчёт свежего субагента, скопированный в change) | после builder-check, до передачи дизайна владельцу |
| `openspec/changes/<id>/approval.md` | только команда `/approve` | владелец утверждает design/plan |
| План `docs/superpowers/plans/*.md` | writing-plans-флоу | после design-утверждения (гейт A) |
| Диспатч implementer'а | sdd-флоу | после plan-утверждения (гейт B) |
Формат `approval.md` (yaml):
```yaml
design:
by: aep
at: 2026-10-25T10:00:00+04:00
plan:
by: aep
at: 2026-10-25T11:30:00+04:00
sha256: 3f2c9a… (hex, 64 знака)
```
## Механика гейтов (в бандле, DSH-only)
`ctx.tools.guard` уже зовёт bash-хуки (`lib/guards.js`). Новая
`lib/gates.js` добавляет **content-проверки** после хуков, в той же
цепочке (порядок: guard-main/require-plan → approval-gates; оба — deny с
причиной, монотонно):
1. **Гейт A (план до approve дизайна).** Для Edit/Write/MultiEdit с
путём под `docs/superpowers/plans/`: из содержимого (`tool_input.content`
или `new_string`) извлечь `**Spec:** <путь>`; если путь указывает в
`openspec/changes/<id>/` и `approval.md` без секции `design:` — DENY
«design not approved; run /approve <id> design». Содержимое без
`**Spec:**` или со Spec-ссылкой вне `openspec/changes/` (legacy
`docs/superpowers/specs/`) — DENY «plan must carry **Spec:** pointing to
openspec/changes/<id>» (D-9); правка без содержимого (bash-цель,
`str_replace_editor` create) и фрагмент `edit` без `**Spec:**` тоже 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 <id> 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:` с непустыми `by`/`at` и существование ровно одного плана со
Spec на этот change (несколько — отказ с перечислением). Дописывает/обновляет секцию,
остальные секции не трогает. Владелец-агент различие: команда пишет
`by: owner`; вызовы не от владельца — отказ (как отличить: DSH не
даёт ролей; используем факт вызова из интерактивной сессии и
AskUserQuestion-подтверждение: `/approve` спрашивает подтверждение
перед записью, ответ «да» = владелец).
Исторические планы: четыре файла написаны до лупа (T1.3, T4.3) —
`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:**`.
По D-9 запись в любой из них — DENY: они заморожены, новая работа идёт
отдельным планом. Это ожидаемое поведение, а не регресс.
## Decisions made without discussion
| # | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| D-1 | `/approve` — единственный писатель approval.md | Маркер, который не может подделать модель (нет прямых записей в approval.md — гейт A защищает и его? нет: approval.md не в plans/; добавлю guard: любые Edit/Write в `**/approval.md` из сессии — DENY, кроме команды) | Обход остаётся через API-инструменты — принимаем как остаточный риск, фиксируем в спеке |
| D-2 | Гейты в бандле (DSH-only), CC-хуки не трогаем | CC-поток не должен меняться; один enforcement-слой на рантайм | Два мира правил (CC/DSH) — уже так, осознанно |
| D-3 | Презентация дизайна владельцу не гейтится напрямую; закрыта цепочкой артефактов | Гейтить сообщения модели технически нельзя; цепочка: builder-check → approve → план → sdd делает пропуск видимым на первом же BLOCKED | Модель может «показать» дизайн без builder-check.md — тогда `/approve` откажет, и это видно владельцу |
| D-4 | `/approve` требует подтверждения (вопрос владельцу) перед записью | Отделить владельца от модели в DSH (ролей нет) | Лишний клик на каждое утверждение — приемлемо |
| D-5 | builder-check.md пишет контроллер после завершения субагента | Единственная точка, где отчёт субагента гарантированно сохранён в change | Файл можно подделать вручную — принимаем: гейт против случайного пропуска, не против злого умысла |
| D-6 | Гейты не срабатывают для bounded-задач (нет change/плана) | Bounded-цикл не проходит лупа и не должен блокироваться | Никакого |
| D-7 | Гейт B = два механизма: (a) write в `.superpowers/sdd/<plan>/**` (ledger — старт sdd в Mode 1) → план → Spec → change → plan-утверждение; (b) диспатчи `subagent`/`subagent_fork`/`workflow` с план-ссылкой в промпте/скрипте → та же проверка | Mode 1 передаёт брифы файлами, промпт короткий без пути плана; ledger пишет контроллер — write-гейт реально ловит старт sdd | Ложный блок при смене имён каталогов — митигируется нормализацией путей |
| 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-10 | `plan:` в approval.md несёт `sha256` файла плана; гейт B сверяет хэш — план изменён после утверждения → DENY «plan changed after approval; re-approve» | Утверждение привязывается к содержимому исполняемого объекта | Правки плана после approve требуют re-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 и исполняемую фичу во времени | Окно до переустановки бандла — гейтов нет; фиксируем шаг переустановки в чеклисте закрытия |
## Тесты (идеи для плана)
- parse approval.md / отказы / до-записи существующих секций;
- гейт A: write плана со Spec на неутверждённый change → DENY; без Spec → OK; утверждённый → OK;
- гейт B: subagent с промптом-ссылкой на план: без plan-секции → DENY, с ней → OK; промпт без ссылки → OK;
- `/approve design` без builder-check.md → отказ; полный флоу design→plan → OK;
- guard на записи в `**/approval.md` вне команды → DENY.
## На потом (в план)
- wiring `lib/gates.js` в `ctx.tools.guard` цепочку + конфиг `approvalGates: true`;
- `command-approve.js` + регистрация; sdd-skill текст; roles-заметка;
- спека-дельта capability `approval-gates`; README/PORT; тесты.

View File

@ -0,0 +1,63 @@
# Proposal: enforce-upstream-loop — технические гейты цикла
Issue: #59. Источники: сессия ce9187ca 24.10.2026 (модель пропустила
builder-check и рационализировала пропуск; спека и план ушли владельцу одним
пакетом), решение владельца 25.10.2026 «оба гейта сразу», согласования по
этой фиче владельцем отменены (решения — в design.md).
## Why
Луп brainstorm→design→builder-check→approve→plan→approve→sdd существует в
superpowers 7.0.0 как инструкции. В DSH-сессии 24.10.2026 модель пропустила
builder-check, предложила его «вхолостую» и сняла предложение; план был
написан до того, как владелец утвердил дизайн. Инструкции не являются
механизмом: нужен технический гейт, который нельзя рационализировать.
## What Changes
1. **Артефакты**: `openspec/changes/<id>/builder-check.md` (отчёт свежего
builder-check субагента) и `openspec/changes/<id>/approval.md` (маркер
утверждения, yaml-секции `design:` и `plan:` с `by`/`at`).
2. **Команда `/approve <change-id> design|plan`** в бандле — единственный
писатель `approval.md`:
- `design` — отказ, пока нет `proposal.md`+`design.md`+`builder-check.md`;
- `plan` — отказ, пока нет design-утверждения и файла плана в
`docs/superpowers/plans/`, чей `**Spec:**` указывает на этот change.
3. **Гейт A (вход)**: bundle-guard отклоняет Edit/Write/MultiEdit в
`docs/superpowers/plans/**`, если в содержимом есть строка `**Spec:**`
со ссылкой на change без design-утверждения — план физически не
создаётся до утверждения дизайна. «Уход к владельцу» закрывается цепочкой:
approve дизайна требует builder-check; план требует approve дизайна;
исполнение требует approve плана.
4. **Гейт B (исполнение)**: bundle-guard отклоняет диспатчи `subagent`/
`subagent_fork`, чей промпт ссылается на план в
`docs/superpowers/plans/` или на `.superpowers/sdd/<plan>/`, если
`approval.md` change'а не содержит `plan:` — sdd не стартует без
утверждённого плана. Причина отказа: «plan not approved; run
`/approve <change-id> plan`».
5. **Тексты**: `dsh/skills/sdd/SKILL.md` — шаг «проверь approval-маркер до
первого диспатча, при BLOCKED не обходи»; `roles/dev.md` (DSH-заметка
`--roles`) — builder-check обязателен, пропуск — только явное решение
владельца.
## Impact
- Affected: `dsh/bundle/` (новая `lib/gates.js`, `command-approve.js`,
wiring в `index.js`), `dsh/skills/sdd/SKILL.md`, `dsh/tests/`,
`dsh/install.sh` (`--roles`-заметка), спека-дельта (новая capability
`approval-gates`), `dsh/README.md`/`dsh/PORT.md`.
- Не трогаем: CC-сторону (хуки, роли, скрипты) — гейты живут только в
DSH-бандле; bounded-задачи без change/плана не задеваются.
- Breaking: нет для существующих потоков с утверждёнными change/планами;
для неутверждённых — ожидаемый BLOCKED с подсказкой `/approve`.
## Non-goals
Свой UI апрува; переписывание sdd-протокола; гейт на сообщения модели
(презентация дизайна владельцу не перехватывается — закрыта цепочкой
артефактов); CC-сторона.
## Открытые вопросы
Нет: решения по enforcement-точкам приняты (design.md, «Decisions made
without discussion»), владелец согласования отменил до пробуждения.

View File

@ -0,0 +1,323 @@
# 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-цель, `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: вызов, в котором ссылка не распознана, не
блокируется. Диспатч-гейт резолвит ссылки против 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** `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`: любой 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`) обычно получает отказ: гейт проверяет только
`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): хэш фиксируется только для плана