Files
brain/docs/superpowers/plans/2026-06-18-agent-commit-channel-plan.md
T
Дмитрий b47a71c66b feat: D2 — канал коммита под ревью (агент коммитит под commit:<hash>)
Опечатанный ревью-план (GO наставника+судьи, judge_mode=live-block) + одно
согласие владельца `FLOOR-ESCAPE: commit:<plan-hash>` → агент делает git
add/commit/push без терминала владельца. Гейт ПРИСУТСТВИЯ (router-gate
git-approval) отходит; гейты КАЧЕСТВА (criterion-gate/verify-gate) НЕ тронуты —
код-коммит всё равно требует по-критерийный GREEN и свежую расписку. Согласия
деплоя (ops-runbook:) и коммита (commit:) — раздельные кнопки.

- escape-grant: обобщён plan-scoped загрузчик (loadPlanScopedGrants/
  planScopedGrantOpen, окно = существование плана); D1 ops-runbook стал тонкой
  обёрткой; добавлены commit: COMMIT_GRANT_PREFIX/loadCommitGrants/commitGrantOpen.
- commit-grant (новый мост план↔router-gate): commitGrantOpenForSession —
  открыт ли commit:<hash> на валидный sealed live-block план сессии.
- shell-content-rules classifyGitCommand: conditional-git пускается при
  ctx.commitGrantOpen; GIT_HARD (force-push/--no-verify/-c) блокирует ПЕРВЫМ
  (качество/безопасность не ослаблены).
- enforce-router-gate: main кладёт ctx.commitGrantOpen (gated через мост).

План: docs/superpowers/plans/2026-06-18-agent-commit-channel-plan.md
Спека: docs/superpowers/specs/2026-06-18-agent-commit-channel-design.md §3.1-3.2.

ОТЛОЖЕНО (требует решения владельца, в хвосте плана):
- §3.3 docs/ops без criterion/verify: .md уже пропускается; расширение на
  не-.md ops-артефакты конфликтует с CLAUDE.md §13 v2.40 — нужен явный список.
- §3.4 десинк push-последним-шагом: рискованная правка снятия печати стены.

+22 теста, свод 4319 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:58:05 +03:00

331 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Канал коммита под ревью (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:<plan-hash>` → агент делает git add/commit/push без терминала владельца; гейты КАЧЕСТВА (criterion/verify) остаются нетронуты, гейт ПРИСУТСТВИЯ (router-gate git-approval) отходит.
**Architecture:** Зеркало D1. `commit:<hash>` грант грузится загрузчиком БЕЗ 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 <file> --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:<hash>`; согласия деплоя и коммита — РАЗДЕЛЬНЫЕ кнопки (`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 = '<prefix><planId>'). */
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:<plan-hash> грант на ВАЛИДНЫЙ
* опечатанный ревью-план сессии (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:<hash> грант (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:<hash> (одно согласие на ревью-план)` };
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:<hash> → 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:<hash>:** `toFloorEscapeRecord` уже захватывает любую `FLOOR-ESCAPE: <action>``commit:<hash>` пишется без правок парсера (как ops-runbook). Подтверждено чтением кода — отдельной задачи не нужно.
- **Норматив/GUIDE:** «коммит силами агента под commit:<hash>» — в Pravila/GUIDE через claude-md-management, когда стена вернётся.
---
## Self-Review
**1. Покрытие §5 (закрытая владельцем сердцевина):**
- ✅ Опечатанный план + commit:<hash> → агент git commit/push (Task 2+3+4).
- ✅ Без commit:<hash> → 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-тесты держат регресс).