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

22 KiB
Raw Blame History

Канал коммита под ревью (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.mjsclassifyGitCommand: conditional-git пускается при ctx.commitGrantOpen.
  • tools/enforce-router-gate.mjsmain() кладёт 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: REDnpx 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: REDnpx 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 (main ctx)

  • 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: REDnpx 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-тесты держат регресс).