Опечатанный ревью-план (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>
22 KiB
Канал коммита под ревью (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 §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: Падающий тест
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-аналоги:
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: Падающий тест
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:
#!/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(mainctx) -
Test:
tools/shell-content-rules.test.mjs,tools/enforce-router-gate.test.mjs -
Step 1: Падающий тест (classifyGitCommand)
В tools/shell-content-rules.test.mjs:
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-ветка:
// 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:
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:
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
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::
toFloorEscapeRecordуже захватывает любуюFLOOR-ESCAPE: <action>→commit:<hash>пишется без правок парсера (как 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-тесты держат регресс).