feat: E-S1 gate-3 owner-acceptance phase 1 delivery mark plumbing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-17 10:53:25 +03:00
parent e4a0b48c0a
commit fed3c4f9b8
11 changed files with 595 additions and 8 deletions
@@ -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 <noreply@anthropic.com>" -- 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"}]
```
@@ -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"}]
```
+2 -2
View File
@@ -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 }) {
+10
View File
@@ -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);
});
});
+1 -1
View File
@@ -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);
+4 -1
View File
@@ -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) };
}
+19
View File
@@ -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();
+7
View File
@@ -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';
}
+16 -1
View File
@@ -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');
+3 -3
View File
@@ -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 };
}
+11
View File
@@ -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');
});
});