187 lines
16 KiB
Markdown
187 lines
16 KiB
Markdown
# 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 — шаг чеклиста закрытия.
|