DSH: технические гейты upstream-лупа — builder-check, /approve, гейты A/B/C (#59) #74

Merged
aep merged 10 commits from feat/59-loop-gates into main 2026-10-25 01:49:55 +03:00
3 changed files with 208 additions and 0 deletions
Showing only changes of commit 4cbcff1c8f - Show all commits

View File

@ -0,0 +1,182 @@
# 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`; `subagent`/`subagent_fork`/`workflow` → `checkDispatch` (prompt/script из `exec.arguments`); `Bash` → если в команде грубая подстрока `docs/superpowers/plans` или `approval.md` — `spawnSync python3 hooks/harness_target.py targets '<cmd>' <cwd>`, цели → `checkBashTargets`. Любой 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: 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

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

@ -68,6 +68,13 @@ plan:
| 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:` — только существование секции (без хэша): design.md дополняется решениями по ходу | Контент дизайна не является исполняемым объектом | Правка дизайна после approve не отзывается — принято |
| D-12 | Подтверждение владельца в `/approve` — через `ctx.userQuestions.ask` (блокирующий вопрос); сервис добавляется в inject; отсутствует — `/approve` отказывает с сообщением | Проверенный примитив рантайма (DELEGATED_CALLER не пройдёт) вместо эвристики «интерактивная сессия» | Нет userQuestions в профиле — /approve недоступен, гейты всё равно работают на блокировку |
| D-13 | Бутстрап: гейты активны только в сессиях с новым бандлом (после установки/рестарта); сама фича #59 исполняется на старом рантайме — ретро-утверждений не требует; для будущих change контроллер кладёт builder-check.md штатно | Развёртывание разводит enforcement и исполняемую фичу во времени | Окно до переустановки бандла — гейтов нет; фиксируем шаг переустановки в чеклисте закрытия |
## Тесты (идеи для плана)