# Память кругов для судьи (Фикс 2a) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline).

**Goal:** Судья в круге 2+ видит свои дословные прошлые претензии + позицию контроллера из «Переговоров»; круг 1 остаётся слеп.

**Architecture:** Хранилище кругов (append/read verbatim) + блок памяти в buildJudgePrompt (только при непустых priors) + проводка в runJudgeGate (загрузка priors, парс «Переговоров», persist на NO-GO).

**Tech Stack:** Node.js ESM, vitest.

## Цель

Закрыть цикл самопротиворечия судьи: с круга 2 подавать ему его собственные дословные претензии и
как контроллер их закрыл, обязав проверять снятие именно этих претензий. Круг 1 — слеп.

```skills-json
["test-driven-development"]
```

## File Structure

- Create: `tools/round-memory-store.mjs` (+`.test.mjs`) — хранилище дословных претензий по (task_id, side).
- Create: `tools/judge-prompt-memory.test.mjs` — тест блока памяти в buildJudgePrompt.
- Modify: `tools/judge-engine.mjs` — `buildJudgePrompt` + priorObjections/controllerResponses.
- Modify: `tools/enforce-judge-gate.mjs` — проводка в `runJudgeGate`.

---

### Task 1: Хранилище кругов

- [ ] **Step 1: Тест** — `tools/round-memory-store.test.mjs`

```javascript
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import fs from 'node:fs';
import { appendRoundObjection, readRoundObjections } from './round-memory-store.mjs';

describe('round-memory-store', () => {
  it('append судья → read дословно по порядку', () => {
    const dir = mkdtempSync(join(tmpdir(), 'rms-'));
    try {
      appendRoundObjection({ taskId: 't1', side: 'judge', objectionVerbatim: 'круг1: нет X', fsImpl: fs, dir });
      appendRoundObjection({ taskId: 't1', side: 'judge', objectionVerbatim: 'круг2: нет Y', fsImpl: fs, dir });
      expect(readRoundObjections({ taskId: 't1', side: 'judge', fsImpl: fs, dir })).toEqual(['круг1: нет X', 'круг2: нет Y']);
    } finally { rmSync(dir, { recursive: true, force: true }); }
  });
  it('side фильтруется; пустая не пишется', () => {
    const dir = mkdtempSync(join(tmpdir(), 'rms-'));
    try {
      appendRoundObjection({ taskId: 't2', side: 'judge', objectionVerbatim: 'j', fsImpl: fs, dir });
      appendRoundObjection({ taskId: 't2', side: 'mentor', objectionVerbatim: 'm', fsImpl: fs, dir });
      expect(appendRoundObjection({ taskId: 't2', side: 'judge', objectionVerbatim: '', fsImpl: fs, dir })).toBe(false);
      expect(readRoundObjections({ taskId: 't2', side: 'judge', fsImpl: fs, dir })).toEqual(['j']);
    } finally { rmSync(dir, { recursive: true, force: true }); }
  });
});
```

- [ ] **Step 2: vitest RED** — `npx vitest run --root app --config vitest.config.tools.mjs round-memory-store` → FAIL.

- [ ] **Step 3: Модуль** — `tools/round-memory-store.mjs`

```javascript
#!/usr/bin/env node
/** round-memory-store — дословные претензии судьи/наставника по (task_id, side) (Фикс 2a). best-effort. */
import fsDefault from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';

export function roundMemoryPath(taskId, dir) {
  const base = dir || join(homedir(), '.claude', 'runtime');
  const safe = String(taskId || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
  return join(base, `round-memory-${safe}.jsonl`);
}

export function appendRoundObjection({ taskId, side, objectionVerbatim, nowMs = null, fsImpl = fsDefault, dir } = {}) {
  if (typeof objectionVerbatim !== 'string' || !objectionVerbatim.trim()) return false;
  try {
    const p = roundMemoryPath(taskId, dir);
    fsImpl.mkdirSync(dirname(p), { recursive: true });
    fsImpl.appendFileSync(p, JSON.stringify({ side: String(side || ''), objection: objectionVerbatim, at: nowMs }) + '\n');
    return true;
  } catch { return false; }
}

export function readRoundObjections({ taskId, side, fsImpl = fsDefault, dir } = {}) {
  try {
    const p = roundMemoryPath(taskId, dir);
    if (!fsImpl.existsSync(p)) return [];
    const out = [];
    for (const l of fsImpl.readFileSync(p, 'utf-8').split(/\r?\n/)) {
      if (!l.trim()) continue;
      let r; try { r = JSON.parse(l); } catch { continue; }
      if (r && r.side === side && typeof r.objection === 'string') out.push(r.objection);
    }
    return out;
  } catch { return []; }
}
```

- [ ] **Step 4: vitest GREEN** — `npx vitest run --root app --config vitest.config.tools.mjs round-memory-store` → PASS (2).

---

### Task 2: Блок памяти в buildJudgePrompt

- [ ] **Step 1: Тест** — `tools/judge-prompt-memory.test.mjs`

```javascript
import { describe, it, expect } from 'vitest';
import { buildJudgePrompt } from './judge-engine.mjs';

describe('buildJudgePrompt round-memory', () => {
  it('круг 1 (нет prior) → нет блока памяти (слеп)', () => {
    const { user } = buildJudgePrompt({ functionName: 'gate2', goal: 'g', product: {}, cards: [] });
    expect(user).not.toContain('ТВОИ ПРОШЛЫЕ ПРЕТЕНЗИИ');
  });
  it('круг 2 → дословная претензия + позиция контроллера + анти-самопротиворечие', () => {
    const { user } = buildJudgePrompt({
      functionName: 'gate2', goal: 'g', product: {}, cards: [],
      priorObjections: ['нет карточки X'],
      controllerResponses: [{ round: 1, position: 'карточка добавлена в тело плана' }],
    });
    expect(user).toContain('ТВОИ ПРОШЛЫЕ ПРЕТЕНЗИИ');
    expect(user).toContain('нет карточки X');
    expect(user).toContain('карточка добавлена в тело плана');
    expect(user).toContain('НЕ противоречь себе');
  });
});
```

- [ ] **Step 2: vitest RED** — `npx vitest run --root app --config vitest.config.tools.mjs judge-prompt-memory` → FAIL (нет блока).

- [ ] **Step 3: Edit `tools/judge-engine.mjs`** — заменить функцию `buildJudgePrompt` целиком:

```javascript
export function buildJudgePrompt({ functionName, requiredLenses = [], product = {}, goal = '', cards = [], priorObjections = [], controllerResponses = [] }) {
  const system = [
    'Ты — судья (критик, не исполнитель). Выноси МОТИВИРОВАННЫЕ возражения с якорем.',
    'Ты СЛЕП к живой переписке сторон — судишь продукт против цели (исключение — твои СОБСТВЕННЫЕ прошлые претензии ниже, для самосогласованности).',
    'План — это БУДУЩЕЕ: слово «проверено»/«сделано» за ФАКТ не принимай (K5).',
    `Функция: ${functionName}.`,
    `Заполни слот на КАЖДУЮ линзу (пустой слот → вердикт невалиден): ${requiredLenses.join(', ')}.`,
    `Премортем по классам провала (по каждому: что от решений НЕ закрыто): ${PREMORTEM_CLASSES.join(', ')}.`,
    'Каждое возражение укажи с якорем {kind, ref}; без якоря оно станет советом, не блоком.',
  ].join('\n');
  // Фикс 2a: круг 2+ — судья видит СВОИ дословные прошлые претензии + как контроллер их закрыл.
  // priorObjections пуст (круг 1) → блока нет (слеп). Анти-самопротиворечие.
  const memoryBlock = (Array.isArray(priorObjections) && priorObjections.length)
    ? [
        '--- ТВОИ ПРОШЛЫЕ ПРЕТЕНЗИИ (дословно — НЕ противоречь себе) ---',
        ...priorObjections.map((o, i) => `[круг ${i + 1}] ${o}`),
        '--- КАК КОНТРОЛЛЕР ИХ ЗАКРЫЛ (его позиция по кругам) ---',
        ...((Array.isArray(controllerResponses) ? controllerResponses : []).map((r) => `[круг ${r.round}] ${r.position}`)),
        'Сначала проверь, СНЯТЫ ли ИМЕННО эти претензии. Новую фатальную претензию поднимай только с обоснованием, почему её не было видно раньше.',
      ].join('\n')
    : '';
  const user = [
    `ЦЕЛЬ (неизменный якорь): ${goal}`,
    `КАРТОЧКИ НАВЫКОВ: ${canonicalJson(cards)}`,
    `ПРОДУКТ НА СУД: ${canonicalJson(product)}`,
    memoryBlock,
  ].filter(Boolean).join('\n');
  return { system, user };
}
```

- [ ] **Step 4: vitest GREEN** — `npx vitest run --root app --config vitest.config.tools.mjs judge-prompt-memory` → PASS (2).

---

### Task 3: Проводка в runJudgeGate

- [ ] **Step 1: Edit `tools/enforce-judge-gate.mjs`** — заменить блок построения promptArgs и получения вердикта (добавить загрузку priors + persist на NO-GO):

Заменить:

```javascript
  const requiredLenses = requiredLensesFor(functionName);
  const promptArgs = { product: g.product, goal: g.goal, cards: g.cards };
  const raw = await callJudgeModel({ functionName, requiredLenses, promptArgs, apiKey, model: deps.model, transport: deps.transport });
  if (raw && raw.unavailable) {
    // M7: причина недоступности протекает в вердикт → лог-WARN + seal-запись её фиксируют.
    return { decision: 'GO', wired: false, unavailable: true, cause: raw.cause ?? null, errorType: raw.errorType ?? null };
  }
  const verdict = runJudge({ functionName, requiredLenses, subRunsRequired: [], subRuns: [], llmCall: () => raw, promptArgs });
```

на:

```javascript
  const requiredLenses = requiredLensesFor(functionName);
  // Фикс 2a: круг 2+ — подать судье его дословные прошлые претензии + позицию контроллера.
  let priorObjections = []; let controllerResponses = [];
  try {
    const sess0 = (event && event.session_id) || 'unknown';
    const taskId0 = loadTaskId({ sessionId: sess0, runtimeDir: runtimeDir() });
    const { readRoundObjections } = await import('./round-memory-store.mjs');
    priorObjections = readRoundObjections({ taskId: taskId0, side: 'judge' });
    const content0 = String((event && event.tool_input && event.tool_input.content) ?? '');
    controllerResponses = parseNegotiationSection(content0).map((r) => ({ round: r.round, position: r.position }));
  } catch { priorObjections = []; controllerResponses = []; }
  const promptArgs = { product: g.product, goal: g.goal, cards: g.cards, priorObjections, controllerResponses };
  const raw = await callJudgeModel({ functionName, requiredLenses, promptArgs, apiKey, model: deps.model, transport: deps.transport });
  if (raw && raw.unavailable) {
    // M7: причина недоступности протекает в вердикт → лог-WARN + seal-запись её фиксируют.
    return { decision: 'GO', wired: false, unavailable: true, cause: raw.cause ?? null, errorType: raw.errorType ?? null };
  }
  const verdict = runJudge({ functionName, requiredLenses, subRunsRequired: [], subRuns: [], llmCall: () => raw, promptArgs });
  // Фикс 2a: на NO-GO — дозаписать дословную претензию текущего круга в round-memory (side='judge').
  try {
    if (verdict && verdict.decision === 'NO-GO') {
      const sess1 = (event && event.session_id) || 'unknown';
      const taskId1 = loadTaskId({ sessionId: sess1, runtimeDir: runtimeDir() });
      const text = formatJudgeObjection(verdict);
      if (text) {
        const { appendRoundObjection } = await import('./round-memory-store.mjs');
        appendRoundObjection({ taskId: taskId1, side: 'judge', objectionVerbatim: text, nowMs: Date.now() });
      }
    }
  } catch { /* best-effort: память кругов не ломает вердикт */ }
```

(`loadTaskId`, `parseNegotiationSection`, `formatJudgeObjection`, `runtimeDir` уже импортированы в файле.)

- [ ] **Step 2: Полная регрессия** — `npx vitest run --root app --config vitest.config.tools.mjs` → PASS (0 регрессий).

---

## steps-json

```steps-json
[
  {"op":"Write","object":"tools/round-memory-store.test.mjs","ref":"D1"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs round-memory-store","ref":"D5"},
  {"op":"Write","object":"tools/round-memory-store.mjs","ref":"D1"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs round-memory-store","ref":"D5"},
  {"op":"Write","object":"tools/judge-prompt-memory.test.mjs","ref":"D2"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs judge-prompt-memory","ref":"D5"},
  {"op":"Edit","object":"tools/judge-engine.mjs","ref":"D2"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs judge-prompt-memory","ref":"D5"},
  {"op":"Edit","object":"tools/enforce-judge-gate.mjs","ref":"D3"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs","ref":"D5"}
]
```

```verified-context-json
[
  {"id":"D2-prompt","kind":"EXTRACTED","ref":"tools/judge-engine.mjs","anchor":"export function buildJudgePrompt("},
  {"id":"D3-negotiation","kind":"EXTRACTED","ref":"tools/negotiation-section.mjs","anchor":"export function parseNegotiationSection("}
]
```

## Переговоры

### Круг 1

(пусто — заполняется при возражениях)
