claude-harness/docs/superpowers/plans/2026-10-25-enforce-upstream-loop.md

187 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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