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:
@@ -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"}]
|
||||
```
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user