diff --git a/docs/superpowers/plans/2026-06-17-gate3-owner-acceptance-phase1-delivery-mark-v2.md b/docs/superpowers/plans/2026-06-17-gate3-owner-acceptance-phase1-delivery-mark-v2.md new file mode 100644 index 0000000..7b7c8fb --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-gate3-owner-acceptance-phase1-delivery-mark-v2.md @@ -0,0 +1,357 @@ +# gate-3 приёмка владельца — Фаза 1: проводка пометки delivery — Implementation Plan (v2) + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline; под стеной Task запрещён). Шаги — checkbox (`- [ ]`). + +**Goal:** провести обязательную пометку плана `delivery: internal|user-result` от тела плана через подписанную печать до метки «петля открыта», обратносовместимо (по умолчанию `internal` = текущее поведение). + +**Architecture:** парсер `parsePlanDelivery` читает пометку из тела плана; `freezePlan` подписывает её (только не-`internal` попадает в базу — старые планы байт-идентичны); `sealablePlan`/`sealPlan` несут её в печать; стена кладёт `delivery` в метку «петля открыта». Карточка/судья/закрытие — Фаза 2. + +**Tech Stack:** Node ESM, vitest, HMAC-печать (`plan-lock`/`receipt-sign`). + +## Цель + +Подготовить фундамент пользовательской приёмки: каждый план несёт машиночитаемую пометку +`delivery`, которая надёжно (через подпись) доходит до Stop-хука gate-3. Эта фаза — чистая проводка +без логики приёмки: по умолчанию `internal` сохраняет текущее поведение бит-в-бит; ветка +`user-result` будет задействована Фазой 2. + +**Delivery:** internal + +## Переговоры + +### Круг 1 + +**Судье:** По замечанию, что Task 4 правит только вызов в `main` и «не отражает изменения сигнатуры +`runGate` и ветки `planComplete`»: эти изменения здесь НЕ требуются, и вот почему (проверяемо по +закоммиченному `e4a0b48`). `writeLoopOpen` — это замыкание, СТРОЯЩЕЕСЯ в `main()`, и оно ЗАХВАТЫВАЕТ +`frozenPlan` из области видимости `main`; внутри зовёт `writeLoopOpenMarker({ …, steps: +(frozenPlan && frozenPlan.steps) || [], … })`. `runGate` вызывает это замыкание БЕЗ аргументов +(`writeLoopOpen()` в ветке `planComplete`). Следовательно `delivery` читается ВНУТРИ замыкания прямо +из захваченного `frozenPlan` — достаточно добавить `delivery: frozenPlan?.delivery ?? 'internal'` в +ОДИН вызов `writeLoopOpenMarker` (Task 4 Step 5). Сигнатура `runGate` (уже принимает `writeLoopOpen` +как параметр с `e4a0b48`) и ветка `planComplete` (зовёт `writeLoopOpen()` без аргументов) остаются +корректными без правок. Добавлять правки в эти два участка — лишняя мутация живой стены (риск F-J +из урока самомодификации) без функционального эффекта. Прошлая задача правила три участка, потому что +ВВОДИЛА сам параметр `writeLoopOpen`; здесь он уже есть — вводится лишь новое поле в уже захваченных +данных. + +## Структура файлов + +- **Изменить** `tools/plan-skills.mjs` — добавить `parsePlanDelivery(content)` (зеркало `parsePlanSkills`). +- **Изменить** `tools/plan-lock.mjs` — `freezePlan` принимает `delivery` и кладёт в подписанную базу + только если `!== 'internal'` (обратная совместимость старых печатей). +- **Изменить** `tools/seal-orchestration.mjs` — `sealablePlan` несёт `delivery`; `sealPlan` передаёт его в `freezePlan`. +- **Изменить** `tools/enforce-gate3-loop.mjs` — `writeLoopOpen` подписывает поле `delivery` в метке. +- **Изменить** `tools/enforce-supreme-gate.mjs` — ОДИН вызов `writeLoopOpenMarker` в замыкании `main` передаёт `frozenPlan.delivery` (сигнатура `runGate` и ветка `planComplete` не трогаются — см. Переговоры). +- Тесты — в соответствующих `*.test.mjs`. + +```skills-json +["test-driven-development"] +``` + +--- + +### Task 1: parsePlanDelivery (RED→GREEN) + +**Files:** +- Modify: `tools/plan-skills.mjs` +- Modify: `tools/plan-skills.test.mjs` + +- [ ] **Step 1: Падающий тест** + +```javascript +import { parsePlanDelivery } from './plan-skills.mjs'; + +describe('parsePlanDelivery', () => { + it('читает user-result из маркера', () => { + expect(parsePlanDelivery('текст\n**Delivery:** user-result\nещё')).toBe('user-result'); + }); + it('читает internal', () => { + expect(parsePlanDelivery('**Delivery:** internal')).toBe('internal'); + }); + it('по умолчанию internal (нет маркера)', () => { + expect(parsePlanDelivery('план без пометки')).toBe('internal'); + }); + it('мусорное значение → internal (fail-safe)', () => { + expect(parsePlanDelivery('**Delivery:** whatever')).toBe('internal'); + }); +}); +``` + +- [ ] **Step 2: Прогон — падает** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: FAIL — `parsePlanDelivery` не экспортирован. + +- [ ] **Step 3: Реализация в plan-skills.mjs** + +Добавить в конец файла: + +```javascript +/** Пометка поставки плана: `**Delivery:** internal|user-result`. По умолчанию/мусор → 'internal' + * (fail-safe: владельца не дёргаем без явной пометки результата). Зеркало parsePlanSkills. */ +export function parsePlanDelivery(content) { + const m = String(content ?? '').match(/(^|\n)\*\*Delivery:\*\*\s*(internal|user-result)\b/i); + return m ? m[2].toLowerCase() : 'internal'; +} +``` + +- [ ] **Step 4: Прогон — зелено** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: PASS. + +--- + +### Task 2: freezePlan подписывает delivery (обратносовместимо) + +**Files:** +- Modify: `tools/plan-lock.mjs` +- Modify: `tools/plan-lock.test.mjs` + +- [ ] **Step 1: Падающий тест** + +```javascript +import { freezePlan, verifyFrozenPlan } from './plan-lock.mjs'; + +describe('freezePlan delivery', () => { + const KEY = 'k-deliv'; + it('user-result попадает в подписанную печать и верифицируется', () => { + const p = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'user-result', key: KEY, nowMs: 1 }); + expect(p.delivery).toBe('user-result'); + expect(verifyFrozenPlan(p, KEY)).toBe(true); + }); + it('internal (по умолчанию) НЕ добавляет поле — старые печати байт-идентичны', () => { + const a = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], key: KEY, nowMs: 1 }); + const b = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'internal', key: KEY, nowMs: 1 }); + expect('delivery' in a).toBe(false); + expect(a.sig).toBe(b.sig); + }); + it('подмена delivery ломает подпись', () => { + const p = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'user-result', key: KEY, nowMs: 1 }); + expect(verifyFrozenPlan({ ...p, delivery: 'internal' }, KEY)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Прогон — падает** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: FAIL — `delivery` не сохраняется/не подписывается. + +- [ ] **Step 3: Реализация — правка freezePlan** + +```javascript +// old: +export function freezePlan({ steps, skills = [], artifactId = null, judgeMode = null, key, nowMs }) { + assertValidJudgeMode(judgeMode); + const sealedSteps = withCriterionIds(steps); + const id = planId(sealedSteps); + // judge_mode входит в ПОДПИСАННУЮ базу (VA-2/SE-2): стена различает shadow- и live-печать, + // подмена режима ломает подпись. Существующие печати без judge_mode: base без поля → verify ок. + const base = { plan_id: id, artifact_id: artifactId, judge_mode: judgeMode, skills: Array.isArray(skills) ? skills : [], frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps: sealedSteps }; + return { ...base, sig: signPayload(base, key, RECEIPT_DOMAINS.FROZEN_PLAN) }; +} +// new: +export function freezePlan({ steps, skills = [], artifactId = null, judgeMode = null, delivery = 'internal', key, nowMs }) { + assertValidJudgeMode(judgeMode); + const sealedSteps = withCriterionIds(steps); + const id = planId(sealedSteps); + // judge_mode входит в ПОДПИСАННУЮ базу (VA-2/SE-2): стена различает shadow- и live-печать, + // подмена режима ломает подпись. Существующие печати без judge_mode: base без поля → verify ок. + const base = { plan_id: id, artifact_id: artifactId, judge_mode: judgeMode, skills: Array.isArray(skills) ? skills : [], frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps: sealedSteps }; + // E-S1 Фаза 1: delivery в подписанную базу ТОЛЬКО если не-'internal' — internal-планы + // (умолчание) остаются байт-идентичны старым печатям (обратная совместимость подписи). + if (delivery && delivery !== 'internal') base.delivery = delivery; + return { ...base, sig: signPayload(base, key, RECEIPT_DOMAINS.FROZEN_PLAN) }; +} +``` + +- [ ] **Step 4: Прогон — зелено** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: PASS. + +--- + +### Task 3: sealablePlan/sealPlan несут delivery + +**Files:** +- Modify: `tools/seal-orchestration.mjs` +- Modify: `tools/seal-orchestration.test.mjs` + +- [ ] **Step 1: Падающий тест** + +```javascript +import { sealablePlan } from './seal-orchestration.mjs'; + +describe('sealablePlan delivery', () => { + it('несёт delivery из тела плана', () => { + const md = '## Цель\nx\n**Delivery:** user-result\n```steps-json\n[{"op":"Write","object":"a.mjs"}]\n```'; + expect(sealablePlan(md).delivery).toBe('user-result'); + }); + it('без пометки → internal', () => { + const md = '```steps-json\n[{"op":"Write","object":"a.mjs"}]\n```'; + expect(sealablePlan(md).delivery).toBe('internal'); + }); +}); +``` + +- [ ] **Step 2: Прогон — падает** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: FAIL — `delivery` отсутствует в sealablePlan. + +- [ ] **Step 3: Импорт parsePlanDelivery** + +```javascript +// old: +import { parsePlanSkills } from './plan-skills.mjs'; +// new: +import { parsePlanSkills, parsePlanDelivery } from './plan-skills.mjs'; +``` + +- [ ] **Step 4: Прогон (импорт не ломает)** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: PASS. + +- [ ] **Step 5: sealablePlan несёт delivery + sealPlan передаёт в freezePlan** + +```javascript +// old: +export function sealablePlan(md) { return { steps: parsePlanSteps(md), skills: parsePlanSkills(md) }; } // {steps,skills} +// new: +export function sealablePlan(md) { return { steps: parsePlanSteps(md), skills: parsePlanSkills(md), delivery: parsePlanDelivery(md) }; } // {steps,skills,delivery} +``` + +И в `sealPlan` передать delivery в freezeImpl: + +```javascript +// old: + const seal = freezeImpl({ steps: planObj.steps, skills: planObj.skills, artifactId: currentArtifact.artifact_id, judgeMode, key, nowMs }); +// new: + const seal = freezeImpl({ steps: planObj.steps, skills: planObj.skills, delivery: planObj.delivery, artifactId: currentArtifact.artifact_id, judgeMode, key, nowMs }); +``` + +- [ ] **Step 6: Прогон — зелено** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: PASS. + +--- + +### Task 4: метка «петля открыта» несёт delivery + +**Files:** +- Modify: `tools/enforce-gate3-loop.mjs` +- Modify: `tools/enforce-gate3-loop.test.mjs` +- Modify: `tools/enforce-supreme-gate.mjs` + +- [ ] **Step 1: Падающий тест (подпись метки с delivery)** + +```javascript +import { signLoopMarker, verifyLoopMarker } from './enforce-gate3-loop.mjs'; + +describe('loop marker delivery', () => { + const KEY = 'k-loop-deliv'; + it('delivery в подписанной метке верифицируется и ломается при подмене', () => { + const m = signLoopMarker({ taskId: 't', planId: 'p', artifactId: 'a', steps: [], delivery: 'user-result', at: 1 }, KEY); + expect(m.delivery).toBe('user-result'); + expect(verifyLoopMarker(m, KEY)).toBe(true); + expect(verifyLoopMarker({ ...m, delivery: 'internal' }, KEY)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Прогон — падает** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: FAIL — `writeLoopOpen` не кладёт delivery в payload (метка без поля). + +- [ ] **Step 3: writeLoopOpen подписывает delivery** + +```javascript +// old: +export function writeLoopOpen({ taskId, planId, artifactId, steps, at, key, runtimeDir, sess, fsImpl = fsDefault }) { + const marker = signLoopMarker({ taskId: taskId || null, planId, artifactId: artifactId || null, steps: steps || [], at: at || 0 }, key); +// new: +export function writeLoopOpen({ taskId, planId, artifactId, steps, delivery = 'internal', at, key, runtimeDir, sess, fsImpl = fsDefault }) { + const marker = signLoopMarker({ taskId: taskId || null, planId, artifactId: artifactId || null, steps: steps || [], delivery: delivery || 'internal', at: at || 0 }, key); +``` + +- [ ] **Step 4: Прогон — зелено** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: PASS. + +- [ ] **Step 5: стена передаёт frozenPlan.delivery в метку (ОДИН вызов в замыкании main)** + +Изменяется ТОЛЬКО вызов `writeLoopOpenMarker` внутри замыкания `writeLoopOpen` в `main()` — +`frozenPlan` уже захвачен этим замыканием (см. раздел «Переговоры»): + +```javascript +// old: + writeLoopOpenMarker({ taskId, planId: frozenPlan?.plan_id ?? null, artifactId: frozenPlan?.artifact_id ?? null, steps: (frozenPlan && frozenPlan.steps) || [], at: event.nowMs ?? Date.now(), key, runtimeDir, sess, fsImpl: fs }); +// new: + writeLoopOpenMarker({ taskId, planId: frozenPlan?.plan_id ?? null, artifactId: frozenPlan?.artifact_id ?? null, steps: (frozenPlan && frozenPlan.steps) || [], delivery: frozenPlan?.delivery ?? 'internal', at: event.nowMs ?? Date.now(), key, runtimeDir, sess, fsImpl: fs }); +``` + +- [ ] **Step 6: Прогон — зелено** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: PASS. + +--- + +### Task 5: Финальная регрессия + коммит + +- [ ] **Step 1: Полный свод (авторитетно — владелец в терминале)** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: PASS — база + новые тесты, 0 регрессий (internal-печати байт-идентичны старым). + +- [ ] **Step 2: Коммит (через escape владельца)** + +```bash +git add tools/plan-skills.mjs tools/plan-skills.test.mjs tools/plan-lock.mjs tools/plan-lock.test.mjs tools/seal-orchestration.mjs tools/seal-orchestration.test.mjs tools/enforce-gate3-loop.mjs tools/enforce-gate3-loop.test.mjs tools/enforce-supreme-gate.mjs docs/superpowers/specs/2026-06-17-gate3-owner-user-acceptance-design-v2.md docs/superpowers/plans/2026-06-17-gate3-owner-acceptance-phase1-delivery-mark-v2.md +git commit -m "feat: E-S1 gate-3 owner-acceptance phase 1 delivery mark plumbing" -m "Co-Authored-By: Claude Opus 4.8 " -- tools/plan-skills.mjs tools/plan-skills.test.mjs tools/plan-lock.mjs tools/plan-lock.test.mjs tools/seal-orchestration.mjs tools/seal-orchestration.test.mjs tools/enforce-gate3-loop.mjs tools/enforce-gate3-loop.test.mjs tools/enforce-supreme-gate.mjs docs/superpowers/specs/2026-06-17-gate3-owner-user-acceptance-design-v2.md docs/superpowers/plans/2026-06-17-gate3-owner-acceptance-phase1-delivery-mark-v2.md +git push gitea main +``` + +## Критерий приёмки + +- `parsePlanDelivery`: user-result/internal/умолчание/мусор покрыты. +- `freezePlan`: user-result в подписи + verify; internal байт-идентичен старой печати (sig равны); подмена ломает подпись. +- `sealablePlan`: несёт delivery из тела; без пометки → internal. +- метка: delivery в подписи, подмена ломает; стена передаёт `frozenPlan.delivery` одним вызовом. +- полная регрессия tools зелёная, 0 регрессий. + +```steps-json +[ + {"op": "Edit", "object": "tools/plan-skills.test.mjs", "ref": "u3"}, + {"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u3"}, + {"op": "Edit", "object": "tools/plan-skills.mjs", "ref": "u3"}, + {"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u3"}, + {"op": "Edit", "object": "tools/plan-lock.test.mjs", "ref": "u3"}, + {"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u3"}, + {"op": "Edit", "object": "tools/plan-lock.mjs", "ref": "u3"}, + {"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u3"}, + {"op": "Edit", "object": "tools/seal-orchestration.test.mjs", "ref": "u6"}, + {"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u6"}, + {"op": "Edit", "object": "tools/seal-orchestration.mjs", "ref": "u6"}, + {"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u6"}, + {"op": "Edit", "object": "tools/seal-orchestration.mjs", "ref": "u6"}, + {"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u6"}, + {"op": "Edit", "object": "tools/enforce-gate3-loop.test.mjs", "ref": "u6"}, + {"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u6"}, + {"op": "Edit", "object": "tools/enforce-gate3-loop.mjs", "ref": "u6"}, + {"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u6"}, + {"op": "Edit", "object": "tools/enforce-supreme-gate.mjs", "ref": "u6"}, + {"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "u6"} +] +``` + +```verified-context-json +[{"id":"vc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"decideGate3Closure"}] +``` diff --git a/docs/superpowers/specs/2026-06-17-gate3-owner-user-acceptance-design-v2.md b/docs/superpowers/specs/2026-06-17-gate3-owner-user-acceptance-design-v2.md new file mode 100644 index 0000000..43d13b9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-gate3-owner-user-acceptance-design-v2.md @@ -0,0 +1,165 @@ +# gate-3: пользовательская приёмка владельца (надстройка над триггером) v2 + +**Дата:** 2026-06-17 +**Эпик:** роутер-наставник, E-S1 (приёмка владельца). **Статус:** дизайн под реализацию (TDD). +**Кодовая фраза:** «роутер-наставник». +**Предшественники:** ядро gate-3 (`tools/loop-termination.mjs`) + триггер (`tools/enforce-gate3-loop.mjs`, +коммит `e4a0b48`). + +## Цель + +Сейчас gate-3 закрывает внешнюю петлю по техническому судье (исправность кода) либо подписанному +владельцу. Но владелец — не программист: промежуточный код он оценить не может, он способен принять +только **целый результат на пользовательском уровне**. Эта спека добавляет обязательный слой +пользовательской приёмки владельцем поверх технического судьи: на завершении плана, который доводит +цель до **готового пользовательского результата**, петля не закрывается, пока владелец не примет +результат по понятной ему **карточке** (что сделано + как проверить самому). Технический судья +по-прежнему первым гарантирует исправность кода (то, что владелец не видит); владелец вторым +гарантирует пользу (то, что судья не чувствует). Закрыть петлю можно только при обоих «за». +Внутренние этапы (план, не доводящий до пользовательского результата) владельца не беспокоят. + +## Поток приёмки {#u1} + +На завершении плана (метка «петля открыта» уже пишется триггером) Stop-хук gate-3 выполняет: + +1. **Судья по коду** (существующий gate-3, линзы goal_achieved/premortem/behavior_vs_goal): + NO-GO → переговоры/арбитраж как сейчас; GO (код исправен) → шаг 2. +2. План помечен `delivery: internal` → петля закрывается по судье, владельца не беспокоим. +3. План помечен `delivery: user-result`: + a. контроллер строит **пользовательскую карточку** ({#u2}); + b. **судья сверяет карточку** с реальным продуктом ({#u4}); приукрашивание → NO-GO, карточка + переделывается, владельца не зовут; + c. карточка честна → показывается владельцу, конец хода блокируется до его подписанного решения; + d. владелец `accept` → петля закрыта; `continue`/не принял → возврат в работу. + +Контроллер петлю не закрывает никогда; закрытие `user-result` требует И судью по коду, И подписанного +владельца. Закрытие `internal` — только реальный GO судьи по коду. + +## Пользовательская карточка {#u2} + +Чистый сборщик `buildOwnerCard({ goal, change, verifySteps, boundary, kind, honestyChecked })` → +объект с полями простого языка (не код): + +- **goal** — цель из опечатанной спеки, по-человечески; +- **change** — 1–3 фразы «что изменилось для пользователя» (поведение, не реализация); +- **verifySteps** — конкретные воспроизводимые шаги владельца «как проверить самому»; для + `kind:'machinery'` — сценарий/команда с наблюдаемым итогом; для `kind:'screen'` — «открой X → + нажми Y → увидишь Z» (+ ссылка на скриншот/запуск, актуально в продуктовом репозитории); +- **boundary** — честная граница «чего НЕ делает / осталось вне объёма» (анти-приукрашивание); +- **honestyChecked** — флаг «сверена ли карточка судьёй»; `false` → карточка несёт видимое + предупреждение «автоматическая сверка честности недоступна» ({#u4}). + +Карточка не висит на честном слове контроллера: (а) `verifySteps` всегда дают владельцу проверку +руками; (б) карточку до показа сверяет судья ({#u4}). `kind` (machinery/screen) определяется по +объекту работы (правки `tools/`/control-layer → machinery; правки страниц/UI → screen); +в claude-brain путь `screen` не задействован (UI нет), проектируется для продуктового репозитория. + +## Пометка плана delivery и её честность {#u3} + +- Каждый план несёт обязательную пометку `delivery: internal | user-result`. Пометка входит в + **подписанную печать плана** (как `skills`/`steps`) — подмена ломает подпись. +- Судья плана (gate-2) **проверяет честность пометки** против шагов плана и цели спеки: план, + доводящий цель спеки до пользовательского результата, не может быть `internal` (NO-GO «прячешь + готовый результат»). Нельзя бесконечно прятаться за `internal`. +- Поле `delivery` копируется в метку «петля открыта» (стена читает его из опечатанного плана), чтобы + Stop-хук знал режим без повторного парса. + +## Сверка карточки судьёй {#u4} + +Отдельный звонок судьи (новый `functionName`, например `gate3card`; набор линз в +`judge-engine.VOTE_LAYOUTS`): «карточка соответствует реальному продукту?». На суд подаётся карточка ++ уже подтверждённые факты (цель спеки, по-критерийные GREEN, исполненные шаги). Линзы: + +- `card_matches_product` — каждое утверждение карточки подтверждено фактами продукта; +- `no_overstatement` — нет заявлений сверх подтверждённого; +- `verify_steps_real` — шаги «как проверить» действительно демонстрируют заявленное. + +Исходы: + +- **GO** → карточка помечается `honestyChecked:true`, показывается владельцу на приёмку; +- **NO-GO (содержательный, wired:true)** → карточка/работа дорабатывается, владелец НЕ вызывается; +- **degraded (wired:false, судья карточки не дозвонился)** → карточка НЕ помечается проверенной + (честность автоматически не подтверждена — не подделываем), НО конец хода НЕ зависает: карточка + эскалируется владельцу с `honestyChecked:false` и видимым предупреждением «автоматическая сверка + честности недоступна — проверь по шагам сам». Владелец сохраняет полный рычаг: подписанный + `gate3-arb:accept` (берёт проверку на себя через `verifySteps`), `continue` (вернуть в работу) или + `plan-done` (досрочно завершить). Это симметрично деградации судьи по коду (block + рычаг + владельца) и сохраняет инвариант живучести: владелец никогда не заперт. + +Degraded-эскалация — единственный путь, где владельцу показывается непроверенная карточка, и она +всегда явно помечена непроверенной. Это технический контроль честности — сам по себе не заменяет +приёмку владельца. + +## Замыкание петли {#u5} + +Расширение `decideGate3Closure` (или новая чистая функция-обёртка над ним): входы добавляют +`delivery` и `cardVerdict`; вводится состояние `await-owner` (с под-флагом `unverified` для +degraded-карточки): + +- `delivery:'internal'` + код-GO → `closed` (через `loopTerminationDecision`, judge GO); +- `delivery:'user-result'` + код-GO + карточка содержательно НЕ пройдена (wired NO-GO) → `await-card` + (блок, владельца НЕ зовём, дорабатываем карточку); +- `delivery:'user-result'` + код-GO + карточка-честна (GO) + нет подписи владельца → `await-owner` + (блок, показываем проверенную карточку, ждём подпись); +- `delivery:'user-result'` + код-GO + карточка degraded (wired:false) + нет подписи владельца → + `await-owner` с `unverified:true` (блок, показываем карточку с предупреждением, ждём подпись/решение + владельца) — живучесть сохранена; +- подписанный `ownerArbitration:'accept'` → `closed` (приоритет, как в ядре); +- `continue`/код-NO-GO ветки — как в существующем ядре (переговоры/арбитраж/open). + +Инвариант SE-R7-6 сохранён; `terminate:true` достижим только через `loopTerminationDecision`. Ни +degraded судья по коду, ни degraded судья карточки сами петлю не закрывают — закрытие `user-result` +всегда требует подписанного владельца. + +## Точки врезки {#u6} + +Надстройка на существующее, переиспользование максимально: + +- `loop-termination.mjs` — расширить замыкание (`delivery` + `cardVerdict` + `await-owner`/`await-card` + + degraded-карточка → `await-owner unverified`). +- `enforce-gate3-loop.mjs` (Stop-хук) — сборщик карточки, звонок судьи-карточки, показ владельцу + (включая degraded-эскалацию с предупреждением), ожидание подписанного `gate3-arb:accept`. +- `judge-engine.mjs` — набор линз `gate3card` в `VOTE_LAYOUTS` + `requiredLensesFor`. +- `plan-lock.mjs` + парсер плана — `delivery` в подписанной печати; парс из тела плана. +- `enforce-supreme-gate.mjs` (стена) — добавить поле `delivery` в метку «петля открыта». +- судья плана (gate-2) — проверка честности пометки `delivery`. + +Переиспользуем без переделки: подписанный канал `gate3-arb:accept`, `arbitration-card`, +`buildDegradedFeedback`, ядро gate-3. Стараемся без новых production-файлов; если сборщик карточки +выделяется в модуль — требует override владельца (предупредить в плане). + +## Конвенция {#u7} + +Чистые экспортируемые функции (`buildOwnerCard`, расширение замыкания) тестируемы в изоляции без +модели/IO. `delivery` — строковое поле плана из фиксированного множества `{internal, user-result}`; +неизвестное/отсутствующее → план не печатается (fail-CLOSE на gate-2). Без новых внешних +зависимостей. Правки стены — минимальные и аддитивные (одно поле в метке). + +## Вне объёма {#u8} + +- Реальная живая демонстрация на экране (скриншот/запуск) проверяется в продуктовом репозитории + (Лидерра); в claude-brain — только путь `machinery`. +- Регистрация хуков / перезапуск среды — действие владельца. +- Полная M/J-память переговоров для слоя карточки сверх нужд — отдельно. + +## Критерий приёмки {#u9} + +TDD-тесты: + +- `buildOwnerCard`: собирает 4 части простым языком; `kind` machinery/screen ветвится; пустые входы → + честные заглушки, не выдумки; `honestyChecked:false` → карточка несёт видимое предупреждение. +- замыкание: `internal`+код-GO → closed; `user-result`+код-GO+карточка wired-NO-GO → await-card (без + владельца); +карточка GO без подписи → await-owner; +подписанный accept → closed; degraded + карточки → await-owner с `unverified:true` (НЕ висит, владелец зовётся с предупреждением); + контроллер без подписи не закрывает. +- честность пометки: план, доводящий цель спеки до результата, помеченный `internal` → судья gate-2 + заворачивает (тест на детекторе/линзе честности пометки). +- сверка карточки: карточка сверх подтверждённого → NO-GO; честная → GO; degraded → эскалация с + предупреждением, не auto-pass и не вечный блок. +- стена: поле `delivery` попадает в метку «петля открыта». +- полная регрессия tools GREEN: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` + (база зелёная + новые тесты; 0 регрессий в переиспользуемых модулях). + +```verified-context-json +[{"id":"vc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"decideGate3Closure"}] +``` diff --git a/tools/enforce-gate3-loop.mjs b/tools/enforce-gate3-loop.mjs index bdd6d20..cb4ecb2 100644 --- a/tools/enforce-gate3-loop.mjs +++ b/tools/enforce-gate3-loop.mjs @@ -23,8 +23,8 @@ export function verifyLoopMarker(marker, key) { return verifyReceipt(marker, key export function loopMarkerPath(runtimeDir, sess) { return join(runtimeDir, `gate3-loop-${sess}.json`); } export function cachePath(runtimeDir, sess) { return join(runtimeDir, `gate3-cache-${sess}.json`); } -export function writeLoopOpen({ taskId, planId, artifactId, steps, at, key, runtimeDir, sess, fsImpl = fsDefault }) { - const marker = signLoopMarker({ taskId: taskId || null, planId, artifactId: artifactId || null, steps: steps || [], at: at || 0 }, key); +export function writeLoopOpen({ taskId, planId, artifactId, steps, delivery = 'internal', at, key, runtimeDir, sess, fsImpl = fsDefault }) { + const marker = signLoopMarker({ taskId: taskId || null, planId, artifactId: artifactId || null, steps: steps || [], delivery: delivery || 'internal', at: at || 0 }, key); try { fsImpl.mkdirSync(runtimeDir, { recursive: true }); fsImpl.writeFileSync(loopMarkerPath(runtimeDir, sess), JSON.stringify(marker)); } catch { /* best-effort */ } } export function readLoopOpen({ runtimeDir, sess, key, fsImpl = fsDefault }) { diff --git a/tools/enforce-gate3-loop.test.mjs b/tools/enforce-gate3-loop.test.mjs index fb21c30..1ed0f88 100644 --- a/tools/enforce-gate3-loop.test.mjs +++ b/tools/enforce-gate3-loop.test.mjs @@ -91,3 +91,13 @@ describe('buildGate3ProductFromMarker', () => { expect(out.product).toContain('green'); }); }); + +describe('loop marker delivery', () => { + const KEY = 'k-loop-deliv'; + it('delivery в подписанной метке верифицируется и ломается при подмене', () => { + const m = signLoopMarker({ taskId: 't', planId: 'p', artifactId: 'a', steps: [], delivery: 'user-result', at: 1 }, KEY); + expect(m.delivery).toBe('user-result'); + expect(verifyLoopMarker(m, KEY)).toBe(true); + expect(verifyLoopMarker({ ...m, delivery: 'internal' }, KEY)).toBe(false); + }); +}); diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index 66a539e..b217859 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -488,7 +488,7 @@ async function main() { writeLoopOpen: () => { // E-S1: метка «петля открыта» на planComplete (in-band) let taskId = null; try { taskId = loadTaskId({ sessionId: sess, runtimeDir, fsImpl: fs }); } catch { taskId = null; } - writeLoopOpenMarker({ taskId, planId: frozenPlan?.plan_id ?? null, artifactId: frozenPlan?.artifact_id ?? null, steps: (frozenPlan && frozenPlan.steps) || [], at: event.nowMs ?? Date.now(), key, runtimeDir, sess, fsImpl: fs }); + writeLoopOpenMarker({ taskId, planId: frozenPlan?.plan_id ?? null, artifactId: frozenPlan?.artifact_id ?? null, steps: (frozenPlan && frozenPlan.steps) || [], delivery: frozenPlan?.delivery ?? 'internal', at: event.nowMs ?? Date.now(), key, runtimeDir, sess, fsImpl: fs }); }, }); if (r.block) logGuardBlock(event, 'М2 Стена', r.message); diff --git a/tools/plan-lock.mjs b/tools/plan-lock.mjs index d8f9c8a..d321389 100644 --- a/tools/plan-lock.mjs +++ b/tools/plan-lock.mjs @@ -59,13 +59,16 @@ export function assertValidJudgeMode(mode) { /** Заморозить план: проставить id + версию артефакта + время + подпись-печать. * artifactId — на какой опечатанный артефакт опирается план (null, если без артефакта). * 5.1: каждый шаг получает детерминированный criterion_id ДО planId/печати → id запечатан. */ -export function freezePlan({ steps, skills = [], artifactId = null, judgeMode = null, key, nowMs }) { +export function freezePlan({ steps, skills = [], artifactId = null, judgeMode = null, delivery = 'internal', key, nowMs }) { assertValidJudgeMode(judgeMode); const sealedSteps = withCriterionIds(steps); const id = planId(sealedSteps); // judge_mode входит в ПОДПИСАННУЮ базу (VA-2/SE-2): стена различает shadow- и live-печать, // подмена режима ломает подпись. Существующие печати без judge_mode: base без поля → verify ок. const base = { plan_id: id, artifact_id: artifactId, judge_mode: judgeMode, skills: Array.isArray(skills) ? skills : [], frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps: sealedSteps }; + // E-S1 Фаза 1: delivery в подписанную базу ТОЛЬКО если не-'internal' — internal-планы + // (умолчание) остаются байт-идентичны старым печатям (обратная совместимость подписи). + if (delivery && delivery !== 'internal') base.delivery = delivery; return { ...base, sig: signPayload(base, key, RECEIPT_DOMAINS.FROZEN_PLAN) }; } diff --git a/tools/plan-lock.test.mjs b/tools/plan-lock.test.mjs index 6f9742a..5e57f7b 100644 --- a/tools/plan-lock.test.mjs +++ b/tools/plan-lock.test.mjs @@ -4,6 +4,25 @@ import { freezePlan, verifyFrozenPlan, planId } from './plan-lock.mjs'; import { actionMatchesStep, nextStep } from './plan-lock.mjs'; import { saveFrozenPlan, loadFrozenPlan, removeFrozenPlan } from './plan-lock.mjs'; +describe('freezePlan delivery', () => { + const KEY = 'k-deliv'; + it('user-result попадает в подписанную печать и верифицируется', () => { + const p = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'user-result', key: KEY, nowMs: 1 }); + expect(p.delivery).toBe('user-result'); + expect(verifyFrozenPlan(p, KEY)).toBe(true); + }); + it('internal (по умолчанию) НЕ добавляет поле — старые печати байт-идентичны', () => { + const a = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], key: KEY, nowMs: 1 }); + const b = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'internal', key: KEY, nowMs: 1 }); + expect('delivery' in a).toBe(false); + expect(a.sig).toBe(b.sig); + }); + it('подмена delivery ломает подпись', () => { + const p = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], delivery: 'user-result', key: KEY, nowMs: 1 }); + expect(verifyFrozenPlan({ ...p, delivery: 'internal' }, KEY)).toBe(false); + }); +}); + describe('removeFrozenPlan (Фаза 5 — чистое завершение: стена снимает печать)', () => { const fsWithUnlink = () => { const s = new Map(); diff --git a/tools/plan-skills.mjs b/tools/plan-skills.mjs index d90955e..e5bdf6d 100644 --- a/tools/plan-skills.mjs +++ b/tools/plan-skills.mjs @@ -19,3 +19,10 @@ export function extractPlanGoal(content) { const para = text.split(/\n\s*\n/).map((s) => s.trim()).find((s) => s && !s.startsWith('#')); return para || ''; } + +/** Пометка поставки плана: `**Delivery:** internal|user-result`. По умолчанию/мусор → 'internal' + * (fail-safe: владельца не дёргаем без явной пометки результата). Зеркало parsePlanSkills. */ +export function parsePlanDelivery(content) { + const m = String(content ?? '').match(/(^|\n)\*\*Delivery:\*\*\s*(internal|user-result)\b/i); + return m ? m[2].toLowerCase() : 'internal'; +} diff --git a/tools/plan-skills.test.mjs b/tools/plan-skills.test.mjs index dc43229..50930ce 100644 --- a/tools/plan-skills.test.mjs +++ b/tools/plan-skills.test.mjs @@ -1,6 +1,21 @@ // tools/plan-skills.test.mjs import { describe, it, expect } from 'vitest'; -import { parsePlanSkills, extractPlanGoal } from './plan-skills.mjs'; +import { parsePlanSkills, extractPlanGoal, parsePlanDelivery } from './plan-skills.mjs'; + +describe('parsePlanDelivery', () => { + it('читает user-result из маркера', () => { + expect(parsePlanDelivery('текст\n**Delivery:** user-result\nещё')).toBe('user-result'); + }); + it('читает internal', () => { + expect(parsePlanDelivery('**Delivery:** internal')).toBe('internal'); + }); + it('по умолчанию internal (нет маркера)', () => { + expect(parsePlanDelivery('план без пометки')).toBe('internal'); + }); + it('мусорное значение → internal (fail-safe)', () => { + expect(parsePlanDelivery('**Delivery:** whatever')).toBe('internal'); + }); +}); describe('parsePlanSkills', () => { const md = ['# План', '```skills-json', '["executing-plans","test-driven-development"]', '```', '## Цель', 'x'].join('\n'); diff --git a/tools/seal-orchestration.mjs b/tools/seal-orchestration.mjs index 490ae6f..c3f736f 100644 --- a/tools/seal-orchestration.mjs +++ b/tools/seal-orchestration.mjs @@ -12,12 +12,12 @@ */ import { buildArtifact } from './artifact-from-spec.mjs'; import { parsePlanSteps } from './plan-steps-parse.mjs'; -import { parsePlanSkills } from './plan-skills.mjs'; +import { parsePlanSkills, parsePlanDelivery } from './plan-skills.mjs'; import { contentHash, sealOnApproval, requiresOwnerSeal } from './judge-seal-channel.mjs'; import { freezeArtifact, freezePlan, planId } from './plan-lock.mjs'; export function sealableArtifact(md) { return buildArtifact(md); } // {sections, source_sha} -export function sealablePlan(md) { return { steps: parsePlanSteps(md), skills: parsePlanSkills(md) }; } // {steps,skills} +export function sealablePlan(md) { return { steps: parsePlanSteps(md), skills: parsePlanSkills(md), delivery: parsePlanDelivery(md) }; } // {steps,skills,delivery} export function judgedHashOf(obj) { return contentHash(obj); } function isRealGo(v) { return !!(v && v.wired === true && v.decision === 'GO'); } @@ -59,7 +59,7 @@ export function sealPlan({ md, currentArtifact, verdict, key, judgeMode, nowMs, if (d.via === 'wired-go' && verdict.judged_hash !== judgedHashOf(planObj)) { return { sealed: false, reason: 'judged_hash mismatch (SD-1/TOCTOU)' }; } - const seal = freezeImpl({ steps: planObj.steps, skills: planObj.skills, artifactId: currentArtifact.artifact_id, judgeMode, key, nowMs }); + const seal = freezeImpl({ steps: planObj.steps, skills: planObj.skills, delivery: planObj.delivery, artifactId: currentArtifact.artifact_id, judgeMode, key, nowMs }); return { sealed: true, seal, via: d.via }; } diff --git a/tools/seal-orchestration.test.mjs b/tools/seal-orchestration.test.mjs index ae0f75b..d50c382 100644 --- a/tools/seal-orchestration.test.mjs +++ b/tools/seal-orchestration.test.mjs @@ -112,3 +112,14 @@ describe('ownerSealActionForContent (SP3-c)', () => { expect(ownerSealActionForContent(specMd)).toBe(ownerSealAction(judgedHashOf(sealableArtifact(specMd)))); }); }); + +describe('sealablePlan delivery', () => { + it('несёт delivery из тела плана', () => { + const md = '## Цель\nx\n**Delivery:** user-result\n```steps-json\n[{"op":"Write","object":"a.mjs","ref":"x"}]\n```'; + expect(sealablePlan(md).delivery).toBe('user-result'); + }); + it('без пометки → internal', () => { + const md = '```steps-json\n[{"op":"Write","object":"a.mjs","ref":"x"}]\n```'; + expect(sealablePlan(md).delivery).toBe('internal'); + }); +});