# Подключение детерминированного судьи дисциплины навыков — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:test-driven-development (inline, деликатное ядро — НЕ субагенты). Steps use checkbox (`- [ ]`).

**Goal:** Сделать «объявил навык в опломбированном плане → МОЖЕШЬ вызвать (стена) → ОБЯЗАН вызвать (детерминированный судья)», закрыв дыру «объявил ≠ вызвал».

**Architecture:** (1) объявленные `skills-json` вшиваются в ПОДПИСАННУЮ печать плана; (2) стена пускает вызов навыка из печати; (3) новый PreToolUse-хук блокирует первый мутирующий шаг, пока объявленный навык не вызван (по журналу M1, через готовый `domain-skill-discipline`).

**Tech Stack:** Node ESM, vitest (`vitest.config.tools.mjs`), существующие модули `seal-orchestration` / `plan-lock` / `plan-skills` / `domain-skill-discipline` / `action-journal` / `enforce-skill-journaler`.

**⚠️ Безопасность исполнения:** Задачи 1-2 правят живой код стены/печати. Под включённой стеной битый Edit → fail-CLOSE → кирпич. **Исполнять при СНЯТОЙ стене** (maintenance), прогон регрессии до возврата. Задача 3 — отдельный файл, безопасна.

---

## Task 1: Навыки в подписанную печать плана

**Files:**

- Modify: `tools/seal-orchestration.mjs` (sealablePlan :19, sealPlan :42)
- Modify: `tools/plan-lock.mjs` (freezePlan :62)
- Test: `tools/plan-skills-in-seal.test.mjs` (создать)

- [ ] **Step 1: Написать падающий тест**

```js
import { describe, it, expect } from 'vitest';
import { sealablePlan, sealPlan } from './seal-orchestration.mjs';
import { verifyFrozenPlan } from './plan-lock.mjs';

const KEY = 'k'.repeat(64);
const MD = [
  '```skills-json',
  '["test-driven-development"]',
  '```',
  '```steps-json',
  '[{"op":"Write","object":"a.mjs","ref":"D1"}]',
  '```',
].join('\n');

describe('skills в подписанной печати (Вариант 1)', () => {
  it('sealablePlan несёт skills из skills-json', () => {
    expect(sealablePlan(MD).skills).toEqual(['test-driven-development']);
  });
  it('печать плана содержит skills и остаётся валидной по подписи', () => {
    const planObj = sealablePlan(MD);
    const verdict = { wired: true, decision: 'GO', judged_hash: (await import('./seal-orchestration.mjs')).judgedHashOf(planObj) };
    const r = sealPlan({ md: MD, currentArtifact: { artifact_id: 'art1' }, verdict, key: KEY, judgeMode: 'live-block' });
    expect(r.sealed).toBe(true);
    expect(r.seal.skills).toEqual(['test-driven-development']);
    expect(verifyFrozenPlan(r.seal, KEY)).toBe(true);
  });
});
```

- [ ] **Step 2: Прогнать — RED**

Run: `npx vitest run --root app --config vitest.config.tools.mjs plan-skills-in-seal`
Expected: FAIL (`skills` undefined).

- [ ] **Step 3: sealablePlan + sealPlan + freezePlan — минимум**

`tools/seal-orchestration.mjs`: импорт `import { parsePlanSkills } from './plan-skills.mjs';`; строка 19 →

```js
export function sealablePlan(md) { return { steps: parsePlanSteps(md), skills: parsePlanSkills(md) }; }
```

sealPlan (:42) → прокинуть skills:

```js
const seal = freezeImpl({ steps: planObj.steps, skills: planObj.skills, artifactId: currentArtifact.artifact_id, judgeMode, key, nowMs });
```

`tools/plan-lock.mjs` freezePlan (:62) → принять и подписать skills:

```js
export function freezePlan({ steps, skills = [], artifactId = null, judgeMode = null, key, nowMs }) {
  assertValidJudgeMode(judgeMode);
  const sealedSteps = withCriterionIds(steps);
  const id = planId(sealedSteps);
  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) };
}
```

(plan_id = только steps → не меняется; skills — доп. подписанное поле; verify/verifyReceipt generic подхватят. Старые печати без skills остаются валидны — base без поля.)

- [ ] **Step 4: Прогнать — GREEN**

Run: `npx vitest run --root app --config vitest.config.tools.mjs plan-skills-in-seal`
Expected: PASS (2 теста).

- [ ] **Step 5: Регрессия (seal/judge judged_hash консистентны — обе стороны через sealablePlan)**

Run: `npx vitest run --root app --config vitest.config.tools.mjs`
Expected: 0 регрессий (база на момент старта + новые).

---

## Task 2: Стена пускает объявленный в печати навык

**Files:**

- Modify: `tools/enforce-supreme-gate.mjs` (decide — после isSeed)
- Test: `tools/supreme-gate-declared-skill.test.mjs` (создать)

- [ ] **Step 1: Падающий тест**

```js
import { describe, it, expect } from 'vitest';
import { isPlanDeclaredSkill } from './enforce-supreme-gate.mjs';

describe('стена: навык из печати разрешён к вызову', () => {
  const fp = { skills: ['claude-md-management', 'test-driven-development'] };
  it('Skill из skills печати → true (по суффиксу)', () => {
    expect(isPlanDeclaredSkill({ name: 'Skill', input: { skill: 'claude-md-management:revise-claude-md' } }, fp)).toBe(true);
  });
  it('Skill вне печати → false', () => {
    expect(isPlanDeclaredSkill({ name: 'Skill', input: { skill: 'brainstorming' } }, fp)).toBe(false);
  });
  it('нет печати/нет skills → false', () => {
    expect(isPlanDeclaredSkill({ name: 'Skill', input: { skill: 'x' } }, null)).toBe(false);
  });
});
```

- [ ] **Step 2: RED**

Run: `npx vitest run --root app --config vitest.config.tools.mjs supreme-gate-declared-skill`
Expected: FAIL (`isPlanDeclaredSkill` не экспортирован).

- [ ] **Step 3: Реализация в `enforce-supreme-gate.mjs`**

Добавить экспорт (рядом с `isSeed`, использовать существующий `skillSuffix`):

```js
export function isPlanDeclaredSkill(toolUse, frozenPlan) {
  if (!toolUse || toolUse.name !== 'Skill' || !frozenPlan) return false;
  const declared = Array.isArray(frozenPlan.skills) ? frozenPlan.skills.map(skillSuffix) : [];
  return declared.includes(skillSuffix(toolUse.input?.skill));
}
```

В `decide(...)` сразу после строки `if (isSeed(toolUse)) return {...}`:

```js
if (isPlanDeclaredSkill(toolUse, frozenPlan)) return { decision: 'allow', reason: 'навык объявлен в опломбированном плане (вызов разрешён, указатель не двигается)' };
```

(Возврат без `advance` → `runGate` указатель НЕ двигает — это обеспечение плана, не шаг.)

- [ ] **Step 4: GREEN**

Run: `npx vitest run --root app --config vitest.config.tools.mjs supreme-gate-declared-skill`
Expected: PASS (3).

- [ ] **Step 5: Регрессия**

Run: `npx vitest run --root app --config vitest.config.tools.mjs`
Expected: 0 регрессий.

---

## Task 3: Детерминированный судья — хук перед мутирующим шагом

**Files:**

- Create: `tools/enforce-domain-skill-discipline.mjs`
- Test: `tools/enforce-domain-skill-discipline.test.mjs`

- [ ] **Step 1: Падающий тест (чистое решение)**

```js
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-domain-skill-discipline.mjs';

describe('судья дисциплины: объявленный навык должен быть вызван', () => {
  it('объявлен, не вызван → блок', () => {
    const r = decide({ frozenPlan: { skills: ['claude-md-management'] }, journalSkillCalls: [] });
    expect(r.block).toBe(true);
    expect(r.reason).toMatch(/claude-md-management/);
  });
  it('объявлен и вызван (по суффиксу) → allow', () => {
    const r = decide({ frozenPlan: { skills: ['claude-md-management'] }, journalSkillCalls: ['claude-md-management:revise-claude-md'] });
    expect(r.block).toBe(false);
  });
  it('пустой skills → allow', () => {
    expect(decide({ frozenPlan: { skills: [] }, journalSkillCalls: [] }).block).toBe(false);
  });
  it('нет печати → allow', () => {
    expect(decide({ frozenPlan: null, journalSkillCalls: [] }).block).toBe(false);
  });
});
```

- [ ] **Step 2: RED**

Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-domain-skill-discipline`
Expected: FAIL (модуль не существует).

- [ ] **Step 3: Реализация (чистое `decide` + fail-CLOSE main)**

```js
#!/usr/bin/env node
/**
 * enforce-domain-skill-discipline — детерминированный судья: объявленные в ОПЛОМБИРОВАННОМ
 * плане навыки (frozenPlan.skills) обязаны быть РЕАЛЬНО вызваны (журнал M1) до первого
 * мутирующего шага. Иначе блок. Поверх готового domain-skill-discipline. fail-CLOSE.
 */
import { readStdin, parseEventJson, exitDecision, runtimeDir } from './enforce-hook-helpers.mjs';
import { domainCallDiscipline } from './domain-skill-discipline.mjs';
import { loadFrozenPlan } from './plan-lock.mjs';
import { loadJournal } from './action-journal.mjs';
import { extractSkillCalls } from './enforce-skill-journaler.mjs';
import { resolveSessionId } from './enforce-supreme-gate.mjs';

const suffix = (n) => { const s = String(n || '').toLowerCase(); return s.includes(':') ? s.split(':').pop() : s; };

export function decide({ frozenPlan, journalSkillCalls = [] }) {
  const declared = (frozenPlan && Array.isArray(frozenPlan.skills)) ? frozenPlan.skills : [];
  if (declared.length === 0) return { block: false, reason: null };
  const r = domainCallDiscipline({
    recommendedDomainSkills: declared.map(suffix),
    invokedSkills: (journalSkillCalls || []).map(suffix),
  });
  return r.ok ? { block: false, reason: null } : { block: true, reason: r.reason };
}

async function main() {
  try {
    const event = parseEventJson(await readStdin());
    const sess = resolveSessionId(event);
    const dir = runtimeDir();
    const frozenPlan = loadFrozenPlan({ sessionId: sess, runtimeDir: dir });
    const { entries } = loadJournal({ sessionId: sess, runtimeDir: dir });
    const journalSkillCalls = extractSkillCalls(entries);
    const r = decide({ frozenPlan, journalSkillCalls });
    exitDecision({ block: r.block, message: r.block ? `[skill-discipline] ${r.reason}` : undefined });
  } catch { exitDecision({ block: true, message: '[skill-discipline] внутренняя ошибка — fail-CLOSE' }); }
}

import { fileURLToPath } from 'node:url';
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
```

- [ ] **Step 4: GREEN**

Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-domain-skill-discipline`
Expected: PASS (4).

- [ ] **Step 5: Регрессия**

Run: `npx vitest run --root app --config vitest.config.tools.mjs`
Expected: 0 регрессий.

---

## Task 4: Регистрация (шаг владельца) + сквозной смоук

- [ ] **Step 1: Регистрация хука** — владелец добавляет в `settings.json` PreToolUse-блок matcher `Edit|Write|MultiEdit|Bash`: `node tools/enforce-domain-skill-discipline.mjs`, timeout 10. (Порядок: ПОСЛЕ supreme-gate.)
- [ ] **Step 2: Сквозной смоук (владелец, под стеной)** — церемония, где план объявляет `["claude-md-management"]` + шаг Edit памяти: до вызова скила первый мутирующий шаг блокируется судьёй; после вызова (стена пускает, Task 2) — проходит. Подтвердить в логах/поведении.

---

## Self-Review

- **Spec coverage:** §«Вход» → Task 1 (skills в печать). §A → Task 2. §B → Task 3. §регистрация/безопасность → Task 4 + шапка. ✓
- **Placeholder scan:** конкретный код в каждом шаге, без TODO. ✓
- **Type consistency:** `frozenPlan.skills` (Task 1 кладёт, Task 2/3 читают); `skillSuffix`/`suffix` для матча declared↔invoked; `domainCallDiscipline({recommendedDomainSkills, invokedSkills})` — сигнатура из модуля. ✓
- **Открытый риск:** `extractSkillCalls` форма имён в журнале — на Step 3 Task 3 нормализуем суффиксом с обеих сторон (declared и invoked), матч устойчив.
