# Видимость вердикта (Фикс 1) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline; субагенты под стеной недоступны). Steps use checkbox (`- [ ]`) syntax.

**Goal:** Контроллер видит исход проверки наставника и судьи на каждой записи плана/спеки (GO/NO-GO/degraded/skip), а не только при блокировке.

**Architecture:** Чистый строитель строки исхода (новый модуль) + неблокирующий канал доставки в хелперах + проброс через оркестратор + врезка в main судьи и наставника.

**Tech Stack:** Node.js ESM (`tools/*.mjs`), vitest (`vitest.config.tools.mjs`).

## Цель

Дать контроллеру детерминированную громкую строку исхода судьи/наставника на КАЖДОЙ судимой
записи (план/спека), включая разрешение (GO/печать) и пропуск, чтобы не обращаться к внешним
логам. Канал доставки на разрешении — поле дополнительного контекста PostToolUse-хука.

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

## File Structure

- Create: `tools/verdict-outcome-line.mjs` — чистый строитель строки исхода (один смысл).
- Create: `tools/verdict-outcome-line.test.mjs` — тесты строителя.
- Modify: `tools/enforce-hook-helpers.mjs` — добавить чистый `buildHookStdoutPayload` + `exitWithNotice` (неблокирующая доставка notice на allow), не ломая `exitDecision`.
- Create: `tools/exit-with-notice.test.mjs` — тесты строителя payload.
- Modify: `tools/enforce-mentor-then-judge.mjs` — `spawnHookProc` ловит stdout дочки; `runMentorThenJudge` пробрасывает notice на разрешении.
- Create: `tools/mentor-then-judge-notice.test.mjs` — тест проброса notice.

---

### Task 1: Чистый строитель строки исхода

**Files:**

- Create: `tools/verdict-outcome-line.mjs`
- Test: `tools/verdict-outcome-line.test.mjs`

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

```javascript
import { describe, it, expect } from 'vitest';
import { buildVerdictOutcomeLine } from './verdict-outcome-line.mjs';

describe('buildVerdictOutcomeLine', () => {
  it('GO → опечатано', () => {
    expect(buildVerdictOutcomeLine({ kind: 'go' })).toBe('✅ GO — опечатано');
  });
  it('NO-GO несёт дословный текст', () => {
    expect(buildVerdictOutcomeLine({ kind: 'nogo', text: 'нет карточки X' }))
      .toBe('🚫 NO-GO: нет карточки X');
  });
  it('degraded несёт причину', () => {
    expect(buildVerdictOutcomeLine({ kind: 'degraded', reason: 'нет ключа судьи' }))
      .toBe('⚠ не дозвонился (нет ключа судьи)');
  });
  it('skip несёт причину', () => {
    expect(buildVerdictOutcomeLine({ kind: 'skip', reason: 'нет mentor-GO' }))
      .toBe('⏭ пропуск (нет mentor-GO)');
  });
  it('неизвестный kind / мусор → пустая строка (best-effort, не падает)', () => {
    expect(buildVerdictOutcomeLine({ kind: 'wat' })).toBe('');
    expect(buildVerdictOutcomeLine(null)).toBe('');
  });
  it('NO-GO без текста → дефолтный маркер, не undefined', () => {
    expect(buildVerdictOutcomeLine({ kind: 'nogo' })).toBe('🚫 NO-GO: (без текста)');
  });
});
```

- [ ] **Step 2: Запустить тест — убедиться, что падает**

Run: `npx vitest run --root app --config vitest.config.tools.mjs verdict-outcome-line`
Expected: FAIL — `buildVerdictOutcomeLine is not a function` / модуль не найден.

- [ ] **Step 3: Реализовать модуль**

```javascript
#!/usr/bin/env node
/**
 * verdict-outcome-line — чистый строитель детерминированной строки исхода судьи/наставника
 * для контроллера (Фикс 1, видимость вердикта). Без I/O. Тотален: мусор → '' (не бросает).
 * Образец смысла — objection-delivery.buildObjectionFeedback (дословный текст возражения).
 */
export function buildVerdictOutcomeLine(o) {
  if (!o || typeof o !== 'object') return '';
  const kind = String(o.kind || '');
  if (kind === 'go') return '✅ GO — опечатано';
  if (kind === 'nogo') {
    const t = typeof o.text === 'string' && o.text.trim() ? o.text.trim() : '(без текста)';
    return `🚫 NO-GO: ${t}`;
  }
  if (kind === 'degraded') {
    const r = typeof o.reason === 'string' && o.reason.trim() ? o.reason.trim() : 'причина неизвестна';
    return `⚠ не дозвонился (${r})`;
  }
  if (kind === 'skip') {
    const r = typeof o.reason === 'string' && o.reason.trim() ? o.reason.trim() : 'причина неизвестна';
    return `⏭ пропуск (${r})`;
  }
  return '';
}
```

- [ ] **Step 4: Запустить тест — убедиться, что проходит**

Run: `npx vitest run --root app --config vitest.config.tools.mjs verdict-outcome-line`
Expected: PASS (6 тестов).

---

### Task 2: Неблокирующий канал доставки notice

**Files:**

- Modify: `tools/enforce-hook-helpers.mjs` (рядом с `exitDecision`)
- Test: `tools/exit-with-notice.test.mjs`

Контракт: на разрешении хук может доставить контроллеру строку через JSON-stdout PostToolUse
(`hookSpecificOutput.additionalContext`). Точное имя поля сверить с актуальной докой Claude Code
на этом шаге (claude-code-guide); тест проверяет ЧИСТЫЙ строитель payload, не сам `process.exit`.

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

```javascript
import { describe, it, expect } from 'vitest';
import { buildHookStdoutPayload } from './enforce-hook-helpers.mjs';

describe('buildHookStdoutPayload', () => {
  it('allow без notice → пустой объект (текущее поведение)', () => {
    expect(buildHookStdoutPayload({})).toBe('{}');
  });
  it('allow с notice → JSON с additionalContext', () => {
    const out = JSON.parse(buildHookStdoutPayload({ notice: '✅ GO — опечатано' }));
    expect(out.hookSpecificOutput.hookEventName).toBe('PostToolUse');
    expect(out.hookSpecificOutput.additionalContext).toBe('✅ GO — опечатано');
  });
  it('пустой/непустой notice: пустая строка → {}', () => {
    expect(buildHookStdoutPayload({ notice: '' })).toBe('{}');
  });
});
```

- [ ] **Step 2: Запустить тест — убедиться, что падает**

Run: `npx vitest run --root app --config vitest.config.tools.mjs exit-with-notice`
Expected: FAIL — `buildHookStdoutPayload is not a function`.

- [ ] **Step 3: Реализовать (добавить в `enforce-hook-helpers.mjs` рядом с `exitDecision`)**

```javascript
/**
 * Фикс 1 (видимость вердикта): чистый строитель stdout-payload PostToolUse-хука.
 * notice непустой → JSON с additionalContext (контроллер видит на разрешении, без блока);
 * иначе '{}' (текущее поведение). Канал = hookSpecificOutput.additionalContext (PostToolUse).
 */
export function buildHookStdoutPayload({ notice } = {}) {
  if (typeof notice === 'string' && notice.trim()) {
    return JSON.stringify({
      hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: notice },
    });
  }
  return '{}';
}

/**
 * Фикс 1: exit с неблокирующим notice на разрешении. block → как exitDecision (stderr+exit2).
 * allow+notice → payload в stdout (additionalContext) + exit0. allow без notice → '{}' + exit0.
 */
export function exitWithNotice({ block, message, notice } = {}) {
  if (block) {
    if (message) process.stderr.write(message + '\n');
    process.exit(2);
    return;
  }
  try { process.stdout.write(buildHookStdoutPayload({ notice })); } catch { /* ignore */ }
  process.exit(0);
}
```

- [ ] **Step 4: Запустить тест — убедиться, что проходит**

Run: `npx vitest run --root app --config vitest.config.tools.mjs exit-with-notice`
Expected: PASS (3 теста).

---

### Task 3: Проброс notice через оркестратор

**Files:**

- Modify: `tools/enforce-mentor-then-judge.mjs`
- Test: `tools/mentor-then-judge-notice.test.mjs`

Сейчас `spawnHookProc` ловит только stderr; `runMentorThenJudge` на разрешении возвращает
`message:undefined`. Контракт: ловить и stdout дочки; на разрешении собрать notice-строки
наставника и судьи (по порядку) и вернуть в `notice`.

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

```javascript
import { describe, it, expect } from 'vitest';
import { runMentorThenJudge } from './enforce-mentor-then-judge.mjs';

const stdoutNotice = (s) => JSON.stringify({
  hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: s },
});

describe('runMentorThenJudge notice-проброс', () => {
  it('наставник allow+notice, судья allow+notice → оба notice в порядке', async () => {
    const spawnHook = async (script) => script.includes('mentor')
      ? { code: 0, stderr: '', stdout: stdoutNotice('M:⏭ пропуск') }
      : { code: 0, stderr: '', stdout: stdoutNotice('J:✅ GO — опечатано') };
    const r = await runMentorThenJudge('{}', { spawnHook });
    expect(r.block).toBe(false);
    expect(r.notice).toBe('M:⏭ пропуск\nJ:✅ GO — опечатано');
  });
  it('наставник block → блок, судья не зовётся (как раньше)', async () => {
    const spawnHook = async (script) => script.includes('mentor')
      ? { code: 2, stderr: '🚫 NO-GO: x', stdout: '' }
      : { code: 0, stderr: '', stdout: stdoutNotice('не должно') };
    const r = await runMentorThenJudge('{}', { spawnHook });
    expect(r.block).toBe(true);
    expect(r.message).toBe('🚫 NO-GO: x');
  });
});
```

- [ ] **Step 2: Запустить тест — убедиться, что падает**

Run: `npx vitest run --root app --config vitest.config.tools.mjs mentor-then-judge-notice`
Expected: FAIL — `r.notice` undefined / `spawnHookProc` не отдаёт stdout.

- [ ] **Step 3: Реализовать — правка `spawnHookProc` (ловить stdout)**

В `spawnHookProc` добавить сбор stdout рядом со stderr:

```javascript
    let stdout = '';
    ch.stdout.on('data', (d) => { stdout += String(d); });
    ch.on('close', (code) => resolve({ code: code == null ? 0 : code, stderr: stderr.trim(), stdout: stdout.trim() }));
```

(заменив прежний `ch.on('close', ...)`; строку `let stderr=''` оставить).

- [ ] **Step 4: Реализовать — правка `runMentorThenJudge` (собрать notice)**

```javascript
export async function runMentorThenJudge(eventJson, { spawnHook } = {}) {
  const m = await spawnHook(MENTOR_HOOK, eventJson);
  if (m && m.code === 2) return { block: true, message: m.stderr };
  const j = await spawnHook(JUDGE_HOOK, eventJson);
  if (j && j.code === 2) return { block: true, message: j.stderr };
  const notice = [extractNotice(m), extractNotice(j)].filter(Boolean).join('\n');
  return { block: false, notice: notice || undefined };
}

/** Достать additionalContext из stdout-payload дочернего хука (best-effort). */
export function extractNotice(res) {
  try {
    const o = JSON.parse((res && res.stdout) || '');
    const n = o && o.hookSpecificOutput && o.hookSpecificOutput.additionalContext;
    return typeof n === 'string' ? n : '';
  } catch { return ''; }
}
```

И в `main()` заменить выдачу на неблокирующий notice:

```javascript
import { readStdin, exitWithNotice } from './enforce-hook-helpers.mjs';
// ...
    const r = await runMentorThenJudge(raw, { spawnHook: spawnHookProc });
    exitWithNotice({ block: r.block, message: r.block ? r.message : undefined, notice: r.block ? undefined : r.notice });
```

- [ ] **Step 5: Запустить тест — убедиться, что проходит**

Run: `npx vitest run --root app --config vitest.config.tools.mjs mentor-then-judge-notice`
Expected: PASS (2 теста).

---

### Task 4: Врезка notice в судью и наставника

**Files:**

- Modify: `tools/enforce-judge-gate.mjs` (`runJudgeTurn`/`main` — отдавать `notice` строкой исхода)
- Modify: `tools/enforce-mentor-on-plan-write.mjs` (`main` — отдавать `notice`)

Контракт: где судья/наставник сейчас на разрешении делают `exitDecision({block:false})`,
заменить на `exitWithNotice({block:false, notice: buildVerdictOutcomeLine(...)})`, выведя kind:
GO→`go`, NO-GO→`nogo`+текст, degraded→`degraded`+причина, skip→`skip`+причина. На блоке
поведение не меняется (exit 2 с текстом).

- [ ] **Step 1: Написать тест на маппинг исход→kind (чистая функция-классификатор)**

Создать `tools/verdict-outcome-classify.test.mjs`, тестировать новый экспорт
`classifyJudgeOutcome(result, verdict)` из `enforce-judge-gate.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import { classifyJudgeOutcome } from './enforce-judge-gate.mjs';

describe('classifyJudgeOutcome', () => {
  it('sealed GO', () => expect(classifyJudgeOutcome({ block:false }, { wired:true, decision:'GO' }))
    .toEqual({ kind:'go' }));
  it('degraded', () => expect(classifyJudgeOutcome({ block:true, degraded:true }, { unavailable:true, cause:'no_key' }))
    .toEqual({ kind:'degraded', reason:'нет ключа судьи' }));
  it('skip', () => expect(classifyJudgeOutcome({ block:false }, { skip:'no_mentor_go' }))
    .toEqual({ kind:'skip', reason:'no_mentor_go' }));
  it('nogo несёт текст из message', () => expect(classifyJudgeOutcome({ block:true, message:'🚫 X' }, { wired:true, decision:'NO-GO' }))
    .toEqual({ kind:'nogo', text:'🚫 X' }));
});
```

- [ ] **Step 2: Запустить — FAIL**

Run: `npx vitest run --root app --config vitest.config.tools.mjs verdict-outcome-classify`
Expected: FAIL — `classifyJudgeOutcome is not a function`.

- [ ] **Step 3: Реализовать `classifyJudgeOutcome` в `enforce-judge-gate.mjs`**

```javascript
/** Фикс 1: исход судьи → {kind,text?,reason?} для строки видимости контроллеру. */
export function classifyJudgeOutcome(result, verdict) {
  if (verdict && verdict.skip) return { kind: 'skip', reason: String(verdict.skip) };
  if (result && result.degraded) {
    const reason = verdict && verdict.cause === 'no_key' ? 'нет ключа судьи'
      : (verdict && (verdict.errorType || verdict.cause)) || 'транспорт недоступен';
    return { kind: 'degraded', reason };
  }
  if (result && result.block) return { kind: 'nogo', text: (result.message || '').trim() };
  if (verdict && verdict.wired === true && verdict.decision === 'GO') return { kind: 'go' };
  return { kind: 'skip', reason: 'не судится' };
}
```

- [ ] **Step 4: Запустить — PASS**

Run: `npx vitest run --root app --config vitest.config.tools.mjs verdict-outcome-classify`
Expected: PASS (4 теста).

- [ ] **Step 5: Врезать в `main()` судьи и наставника**

В `enforce-judge-gate.mjs` `main()`: заменить финальный `else exitDecision({ block:false })` на
`exitWithNotice` со строкой исхода (`buildVerdictOutcomeLine(classifyJudgeOutcome(result, result.verdict))`);
импортировать `exitWithNotice` и оба строителя. На блоке — оставить как есть.
В `enforce-mentor-on-plan-write.mjs` — симметрично (kind: GO→go при recordMentorGo,
NO-GO→nogo+текст, degraded→degraded, иначе skip).

- [ ] **Step 6: Полная регрессия**

Run: `npx vitest run --root app --config vitest.config.tools.mjs`
Expected: PASS (вся сюита tools зелёная, 0 регрессий).

---

## steps-json

```steps-json
[
  {"op":"Write","object":"tools/verdict-outcome-line.test.mjs","ref":"D1"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs verdict-outcome-line","ref":"D6"},
  {"op":"Write","object":"tools/verdict-outcome-line.mjs","ref":"D1"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs verdict-outcome-line","ref":"D6"},
  {"op":"Write","object":"tools/exit-with-notice.test.mjs","ref":"D2"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs exit-with-notice","ref":"D6"},
  {"op":"Edit","object":"tools/enforce-hook-helpers.mjs","ref":"D2"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs exit-with-notice","ref":"D6"},
  {"op":"Write","object":"tools/mentor-then-judge-notice.test.mjs","ref":"D3"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs mentor-then-judge-notice","ref":"D6"},
  {"op":"Edit","object":"tools/enforce-mentor-then-judge.mjs","ref":"D3"},
  {"op":"Edit","object":"tools/enforce-mentor-then-judge.mjs","ref":"D3"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs mentor-then-judge-notice","ref":"D6"},
  {"op":"Write","object":"tools/verdict-outcome-classify.test.mjs","ref":"D1"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs verdict-outcome-classify","ref":"D6"},
  {"op":"Edit","object":"tools/enforce-judge-gate.mjs","ref":"D1"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs verdict-outcome-classify","ref":"D6"},
  {"op":"Edit","object":"tools/enforce-judge-gate.mjs","ref":"D1"},
  {"op":"Edit","object":"tools/enforce-mentor-on-plan-write.mjs","ref":"D1"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs","ref":"D6"}
]
```

```verified-context-json
[
  {"id":"D2-exitDecision","kind":"EXTRACTED","ref":"tools/enforce-hook-helpers.mjs","anchor":"export function exitDecision("},
  {"id":"D3-orchestrator","kind":"EXTRACTED","ref":"tools/enforce-mentor-then-judge.mjs","anchor":"export async function runMentorThenJudge("}
]
```

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

### Круг 1

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