# Канал коммита под ревью (D2) — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Опечатанный ревью-план (GO наставника+судьи, judge_mode=live-block) + одно согласие владельца `FLOOR-ESCAPE: commit:` → агент делает git add/commit/push без терминала владельца; гейты КАЧЕСТВА (criterion/verify) остаются нетронуты, гейт ПРИСУТСТВИЯ (router-gate git-approval) отходит. **Architecture:** Зеркало D1. `commit:` грант грузится загрузчиком БЕЗ 5-мин окна (окно = существование плана). Мост `commit-grant.mjs` (знает plan-lock+escape-grant) вычисляет `commitGrantOpen` для сессии (валидный sealed live-block план + открытый commit-грант на его хеш). router-gate прокидывает `ctx.commitGrantOpen`; `classifyGitCommand` пускает conditional-git, когда грант открыт. GIT_HARD_PATTERNS (force-push/--no-verify/-c) блокируют ПЕРВЫМ — качество/безопасность не ослаблены. criterion-gate/verify-gate не трогаем. **Tech Stack:** Node ESM, vitest (через PowerShell: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`), HMAC-расписки. **Спека:** [docs/superpowers/specs/2026-06-18-agent-commit-channel-design.md](../specs/2026-06-18-agent-commit-channel-design.md) §3.1-3.2, критерий §5. **Дисциплина:** строго TDD; полный свод через PowerShell после каждого GREEN. Правки машинерии — в ШТАТНОМ. Коммит — дисциплина handoff. **ЗАКРЫТО владельцем (handoff):** коммит делает агент; гейты КАЧЕСТВА остаются; гейт ПРИСУТСТВИЯ → `commit:`; согласия деплоя и коммита — РАЗДЕЛЬНЫЕ кнопки (`ops-runbook:` vs `commit:`). **ВЫНЕСЕНО В ХВОСТ (требует решения владельца, НЕ в этом плане):** - §3.3 «docs/ops-коммит без criterion/verify»: docs (`.md`) уже пропускаются текущим `isDocsOnlyPath`. Расширение на не-`.md` «ops-артефакты» КОНФЛИКТУЕТ с CLAUDE.md §13 v2.40 (конфиги ДОЛЖНЫ включать verify) и не определено, какие пути считать ops-артефактом. Нужен явный список от владельца. - §3.4 десинк (push последним шагом → ложный блок criterion): правка порядка снятия печати в несущей стене (риск для Фазы 5). Отдельная изолированная задача. --- ## File Structure - `tools/escape-grant.mjs` — обобщить загрузчик plan-scoped грантов; добавить `COMMIT_GRANT_PREFIX`, `loadCommitGrants`, `commitGrantOpen`. - `tools/commit-grant.mjs` (новый) — мост: `commitGrantOpenForSession(sess)` → bool (валидный sealed live-block план + открытый commit-грант на его хеш). - `tools/shell-content-rules.mjs` — `classifyGitCommand`: conditional-git пускается при `ctx.commitGrantOpen`. - `tools/enforce-router-gate.mjs` — `main()` кладёт `ctx.commitGrantOpen = commitGrantOpenForSession(sess)`. --- ## Task 1: escape-grant — commit-гранты (обобщённый prefix-загрузчик) **Files:** - Modify: `tools/escape-grant.mjs` - Test: `tools/escape-grant.test.mjs` - [ ] **Step 1: Падающий тест** ```javascript import { loadCommitGrants, commitGrantOpen, COMMIT_GRANT_PREFIX } from './escape-grant.mjs'; describe('commit грант (D2 — окно = существование плана)', () => { const mkFs = (records) => ({ existsSync: () => true, readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n') }); it('COMMIT_GRANT_PREFIX = "commit:"', () => { expect(COMMIT_GRANT_PREFIX).toBe('commit:'); }); it('commitGrantOpen: грант на ЭТОТ plan_id → true; чужой → false', () => { expect(commitGrantOpen('H1', [{ action: 'commit:H1', ts: 1 }])).toBe(true); expect(commitGrantOpen('H2', [{ action: 'commit:H1', ts: 1 }])).toBe(false); }); it('commitGrantOpen: пустой/не-массив/пустой planId → false', () => { expect(commitGrantOpen('H', [])).toBe(false); expect(commitGrantOpen('', [{ action: 'commit:', ts: 1 }])).toBe(false); }); it('loadCommitGrants: запись старше 5 мин НЕ отфильтрована; future-ts отброшен; не-commit игнор', () => { const old = 1000; const now = old + 10 * 60 * 1000; const fs1 = mkFs([{ type: 'floor_escape', action: 'commit:H1', ts: old }]); expect(loadCommitGrants('S', now, { keyImpl: () => null, fsImpl: fs1, runtimeDir: '/rt' }).some((g) => g.action === 'commit:H1')).toBe(true); const fs2 = mkFs([{ type: 'floor_escape', action: 'commit:H1', ts: 5000 }]); expect(loadCommitGrants('S', 1000, { keyImpl: () => null, fsImpl: fs2, runtimeDir: '/rt' })).toEqual([]); const fs3 = mkFs([{ type: 'floor_escape', action: 'ops-runbook:H1', ts: 1 }]); expect(loadCommitGrants('S', 2, { keyImpl: () => null, fsImpl: fs3, runtimeDir: '/rt' })).toEqual([]); }); }); ``` - [ ] **Step 2: RED** — `npx vitest run tools/escape-grant.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` (функций нет). - [ ] **Step 3: Реализация** — обобщить загрузчик в `tools/escape-grant.mjs`. Заменить тело `loadOpsRunbookGrants` на вызов общего, добавить commit-аналоги: ```javascript export const OPS_RUNBOOK_PREFIX = 'ops-runbook:'; export const COMMIT_GRANT_PREFIX = 'commit:'; /** D1/D2: plan-scoped гранты сессии по префиксу — БЕЗ верхней (5-мин) границы окна (окно = * существование опечатанного плана). Нижняя граница времени (не future-ts) остаётся. Подпись * key-gated. Не one-shot (consumed не применяем). keyImpl/fsImpl/runtimeDir инъектируемы. */ export function loadPlanScopedGrants(sessionId, prefix, now = Date.now(), { keyImpl = resolveReceiptKey, fsImpl = fsDefault, runtimeDir } = {}) { const records = readFloorEscapeRecordsAt(sessionId, fsImpl, runtimeDir).filter( (r) => typeof r.action === 'string' && r.action.startsWith(prefix)); if (records.length === 0) return []; let key = null; try { key = keyImpl(); } catch { key = null; } const verified = key ? records.filter((r) => verifyFloorEscapeRecord(r, key)) : records; return verified.map((r) => ({ action: r.action, ts: typeof r.ts === 'number' ? r.ts : 0 })).filter((g) => now - g.ts >= 0); } /** Открыт ли plan-scoped грант (точный action = ''). */ export function planScopedGrantOpen(prefix, planId, grants) { if (!planId || !Array.isArray(grants)) return false; const target = `${prefix}${planId}`; return grants.some((g) => g && g.action === target); } export function loadOpsRunbookGrants(sessionId, now = Date.now(), opts = {}) { return loadPlanScopedGrants(sessionId, OPS_RUNBOOK_PREFIX, now, opts); } export function opsRunbookGrantOpen(planId, grants) { return planScopedGrantOpen(OPS_RUNBOOK_PREFIX, planId, grants); } export function loadCommitGrants(sessionId, now = Date.now(), opts = {}) { return loadPlanScopedGrants(sessionId, COMMIT_GRANT_PREFIX, now, opts); } export function commitGrantOpen(planId, grants) { return planScopedGrantOpen(COMMIT_GRANT_PREFIX, planId, grants); } ``` (NB: удалить прежние самостоятельные тела `loadOpsRunbookGrants`/`opsRunbookGrantOpen` из D1 — заменены тонкими обёртками. D1-тесты в escape-grant.test.mjs покрывают регресс.) - [ ] **Step 4: GREEN + полный свод** — escape-grant.test.mjs зелёный; затем `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` (D1 ops-runbook тесты держатся). --- ## Task 2: commit-grant.mjs — мост (commitGrantOpenForSession) **Files:** - Create: `tools/commit-grant.mjs` - Test: `tools/commit-grant.test.mjs` - [ ] **Step 1: Падающий тест** ```javascript import { describe, it, expect } from 'vitest'; import { commitGrantOpenForSession } from './commit-grant.mjs'; import { freezePlan } from './plan-lock.mjs'; describe('commitGrantOpenForSession (D2 — gated: нет гранта → план не грузим)', () => { const K = 'k-cg'; it('нет commit-грантов → false, план НЕ грузится', () => { let planLoaded = false; const r = commitGrantOpenForSession('S', { loadGrantsImpl: () => [], loadPlanImpl: () => { planLoaded = true; return null; }, keyImpl: () => K, verifyImpl: () => true }); expect(r).toBe(false); expect(planLoaded).toBe(false); }); it('грант на хеш + sealed live-block план → true', () => { const plan = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], judgeMode: 'live-block', key: K, nowMs: 1 }); const r = commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true }); expect(r).toBe(true); }); it('грант на ЧУЖОЙ хеш → false', () => { const plan = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], judgeMode: 'live-block', key: K, nowMs: 1 }); expect(commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: 'commit:OTHER', ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true })).toBe(false); }); it('judge_mode не live-block → false', () => { const plan = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], judgeMode: 'shadow', key: K, nowMs: 1 }); expect(commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true })).toBe(false); }); it('печать невалидна (verifyImpl→false) → false', () => { const plan = freezePlan({ steps: [{ op: 'Write', object: 'a.mjs' }], judgeMode: 'live-block', key: K, nowMs: 1 }); expect(commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => false })).toBe(false); }); }); ``` - [ ] **Step 2: RED** — `npx vitest run tools/commit-grant.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` (модуля нет). - [ ] **Step 3: Реализация** — создать `tools/commit-grant.mjs`: ```javascript #!/usr/bin/env node /** * commit-grant (D2, спека 2026-06-18-agent-commit-channel-design §3.1) — МОСТ план↔router-gate * для «канала коммита под ревью». Вычисляет: открыт ли commit: грант на ВАЛИДНЫЙ * опечатанный ревью-план сессии (sealed + judge_mode='live-block'). Если да — router-gate отпускает * гейт ПРИСУТСТВИЯ (git-approval); гейты КАЧЕСТВА (criterion/verify) остаются. */ import { homedir } from 'node:os'; import { verifyFrozenPlan, loadFrozenPlan } from './plan-lock.mjs'; import { commitGrantOpen, loadCommitGrants } from './escape-grant.mjs'; import { resolveReceiptKey } from './receipt-key-config.mjs'; export function commitGrantOpenForSession(sessionId, { loadGrantsImpl = loadCommitGrants, loadPlanImpl = loadFrozenPlan, keyImpl = resolveReceiptKey, verifyImpl = verifyFrozenPlan, runtimeDir = `${homedir()}/.claude/runtime`, } = {}) { const grants = loadGrantsImpl(sessionId); if (!Array.isArray(grants) || grants.length === 0) return false; const plan = loadPlanImpl({ sessionId, runtimeDir }); if (!plan || !plan.plan_id) return false; if (!commitGrantOpen(plan.plan_id, grants)) return false; if (plan.judge_mode !== 'live-block') return false; // одобрение к энфорсменту const key = keyImpl(); if (!verifyImpl(plan, key)) return false; // печать цела return true; } ``` - [ ] **Step 4: GREEN + полный свод.** --- ## Task 3: classifyGitCommand честит commit-грант + router-gate wiring **Files:** - Modify: `tools/shell-content-rules.mjs` (`classifyGitCommand`) - Modify: `tools/enforce-router-gate.mjs` (`main` ctx) - Test: `tools/shell-content-rules.test.mjs`, `tools/enforce-router-gate.test.mjs` - [ ] **Step 1: Падающий тест (classifyGitCommand)** В `tools/shell-content-rules.test.mjs`: ```javascript describe('classifyGitCommand commit-грант (D2)', () => { it('git commit при ctx.commitGrantOpen → allow (присутствие отходит)', () => { const r = classifyGitCommand('git commit -m x', { commitGrantOpen: true }); expect(r.result).toBe('allow'); expect(r.reason).toMatch(/commit-грант|commit:|ревью-план/i); }); it('git push при ctx.commitGrantOpen → allow', () => { expect(classifyGitCommand('git push gitea main', { commitGrantOpen: true }).result).toBe('allow'); }); it('без гранта и без approve → block (default-deny держит)', () => { expect(classifyGitCommand('git commit -m x', {}).result).toBe('block'); }); it('GIT_HARD (force-push) блокирует ДАЖЕ при commitGrantOpen (качество/безопасность)', () => { expect(classifyGitCommand('git push --force', { commitGrantOpen: true }).result).toBe('block'); }); it('--no-verify блокируется при commitGrantOpen', () => { expect(classifyGitCommand('git commit --no-verify -m x', { commitGrantOpen: true }).result).toBe('block'); }); }); ``` - [ ] **Step 2: RED** — `npx vitest run tools/shell-content-rules.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` (commitGrantOpen игнорируется → commit блокируется). - [ ] **Step 3: Реализация** — в `tools/shell-content-rules.mjs`, `classifyGitCommand`, conditional-ветка: ```javascript // 3. conditional → approve check ИЛИ commit: грант (D2: гейт присутствия отходит). // GIT_HARD_PATTERNS уже отсеяли force-push/--no-verify/-c ВЫШЕ — качество/безопасность не ослаблены. if (GIT_CONDITIONAL_SUB.has(sub)) { if (ctx.commitGrantOpen === true) return { result: 'allow', reason: `git ${sub}: подтверждено commit: (одно согласие на ревью-план)` }; const approved = isApproved(command, ctx.approvedGitOps, ctx.now ?? Date.now()); if (approved) return { result: 'allow', reason: `git ${sub}: подтверждено approve_git_operation` }; return { result: 'block', reason: `git ${sub} требует AskUser approval (approve_git_operation). Запросите подтверждение и повторите.` }; } ``` - [ ] **Step 4: GREEN (shell-content-rules).** - [ ] **Step 5: Падающий тест (router-gate wiring)** В `tools/enforce-router-gate.test.mjs` — проверить, что `classifyBashCommand` пускает git commit при `ctx.commitGrantOpen:true`: ```javascript describe('router-gate ctx.commitGrantOpen (D2 wiring)', () => { it('git commit + commitGrantOpen:true → allow', () => { const r = classifyBashCommand('git commit -m x', { commitGrantOpen: true }); expect(r.result).toBe('allow'); }); it('git commit без гранта → block', () => { expect(classifyBashCommand('git commit -m x', {}).result).toBe('block'); }); }); ``` (NB: `classifyBashCommand` уже прокидывает `ctx` целиком в `classifyGitCommand` через `classifyGitCommand(command, ctx)` — wiring на уровне ctx-объекта; проверить, что ctx прокинут. Если в тест-файле нет импорта `classifyBashCommand` — добавить из enforce-router-gate.mjs.) - [ ] **Step 6: RED → проверить.** Если уже GREEN (ctx прокидывается насквозь) — тест характеризующий, поведение даёт Task 3 Step 3. Если RED (ctx не доходит) — Step 7. - [ ] **Step 7: Реализация router-gate main** — в `tools/enforce-router-gate.mjs`, `main()`, добавить в ctx: ```javascript let commitGrantOpen = false; try { const { commitGrantOpenForSession } = await import('./commit-grant.mjs'); commitGrantOpen = commitGrantOpenForSession(sessionId); } catch { commitGrantOpen = false; } const ctx = { approvedGitOps: loadApprovedGitOps(sessionId), editedFiles: readEditedFiles(sessionId), pathNormalize, protectedPaths, commitGrantOpen, now: Date.now(), }; ``` - [ ] **Step 8: GREEN + полный свод.** --- ## Task 4: Интеграционный критерий §5 + коммит **Files:** - Test: `tools/commit-grant.test.mjs` (или shell-content-rules.test.mjs) — сквозной. - [ ] **Step 1: Сквозной тест критерия §5** ```javascript import { classifyGitCommand } from './shell-content-rules.mjs'; import { commitGrantOpenForSession } from './commit-grant.mjs'; import { freezePlan } from './plan-lock.mjs'; describe('D2 критерий §5 — канал коммита под ревью (сквозной)', () => { const K = 'k-d2e2e'; const plan = freezePlan({ steps: [{ op: 'Write', object: 'tools/x.mjs' }], judgeMode: 'live-block', key: K, nowMs: 1 }); const open = commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: `commit:${plan.plan_id}`, ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true }); it('опечатанный план + commit: → git commit allow', () => { expect(open).toBe(true); expect(classifyGitCommand('git commit -m x', { commitGrantOpen: open }).result).toBe('allow'); }); it('без гранта на этот хеш → git commit block (default-deny держит)', () => { const noGrant = commitGrantOpenForSession('S', { loadGrantsImpl: () => [{ action: 'commit:OTHER', ts: 1 }], loadPlanImpl: () => plan, keyImpl: () => K, verifyImpl: () => true }); expect(classifyGitCommand('git commit -m x', { commitGrantOpen: noGrant }).result).toBe('block'); }); }); ``` - [ ] **Step 2: GREEN (характеризующий — код из Task 1-3).** - [ ] **Step 3: Финальный полный свод + коммит** по дисциплине handoff (явные пути; `node tools/produce-verify-receipt.mjs` через PowerShell → signed GREEN; `.git/CB_MSG.txt`; `git commit -F`). --- ## Хвосты (требуют решения владельца — НЕ в этом плане) - **§3.3 docs/ops без criterion/verify:** docs (`.md`) уже пропускаются. Для не-`.md` «ops-артефактов» нужен явный список путей от владельца (конфликт с CLAUDE.md §13 v2.40 — конфиги ДОЛЖНЫ включать verify). Спросить. - **§3.4 десинк** (push последним шагом → ложный блок criterion-gate): изолированная правка порядка снятия печати в supreme-gate/Фаза 5. Рискованно — отдельная задача с воспроизводящим тестом. - **escape-parser commit::** `toFloorEscapeRecord` уже захватывает любую `FLOOR-ESCAPE: ` → `commit:` пишется без правок парсера (как ops-runbook). Подтверждено чтением кода — отдельной задачи не нужно. - **Норматив/GUIDE:** «коммит силами агента под commit:» — в Pravila/GUIDE через claude-md-management, когда стена вернётся. --- ## Self-Review **1. Покрытие §5 (закрытая владельцем сердцевина):** - ✅ Опечатанный план + commit: → агент git commit/push (Task 2+3+4). - ✅ Без commit: → router-gate block (Task 3, default-deny). - ✅ Код-коммит всё равно требует GREEN-критериев и расписки — criterion/verify НЕ трогаем (по дизайну). - ✅ GIT_HARD (force-push/--no-verify) блокируют даже при гранте (Task 3). - ⚠️ §3.3 docs/ops, §3.4 десинк — вынесены в хвост (underspec/risk), см. выше. **2. Плейсхолдеры:** код во всех шагах конкретный. **3. Согласованность:** `commitGrantOpen` — имя ctx-поля (Task 3) И return commitGrantOpenForSession (Task 2). `commit:` префикс/`COMMIT_GRANT_PREFIX`/`loadCommitGrants`/`commitGrantOpen` (Task 1) → используются в Task 2. Обобщённый `loadPlanScopedGrants`/`planScopedGrantOpen` (Task 1) переиспользуют D1 ops-runbook (тонкие обёртки, D1-тесты держат регресс).