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

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

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

**Architecture:** Чистый строитель строки исхода + рантайм-хранилище (append/read/mark) + UserPromptSubmit-хук показа + врезка дозаписи в main судьи и наставника. exitDecision и оркестратор не трогаются.

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

## Цель

Доставлять контроллеру строку исхода судьи/наставника на каждой судимой записи через
проверенный канал `UserPromptSubmit.additionalContext`, без зависимости от неподтверждённого
вывода PostToolUse и без изменения stdout существующих хуков.

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

## File Structure

- Create: `tools/verdict-outcome-line.mjs` (+ `.test.mjs`) — чистый строитель строки.
- Create: `tools/verdict-surface-store.mjs` (+ `.test.mjs`) — рантайм-хранилище исходов.
- Create: `tools/enforce-verdict-surface.mjs` (+ `.test.mjs`) — UserPromptSubmit-хук показа.
- Create: `tools/verdict-outcome-classify.test.mjs` — тест классификатора исхода судьи.
- Modify: `tools/enforce-judge-gate.mjs` — `classifyJudgeOutcome` + дозапись в main.
- Modify: `tools/enforce-mentor-on-plan-write.mjs` — дозапись исхода в main.

---

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

- [ ] **Step 1: Тест** — `tools/verdict-outcome-line.test.mjs`

```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('мусор → пусто', () => { expect(buildVerdictOutcomeLine({ kind: 'wat' })).toBe(''); expect(buildVerdictOutcomeLine(null)).toBe(''); });
  it('NO-GO без текста', () => expect(buildVerdictOutcomeLine({ kind: 'nogo' })).toBe('🚫 NO-GO: (без текста)'));
});
```

- [ ] **Step 2: vitest RED**

Run: `npx vitest run --root app --config vitest.config.tools.mjs verdict-outcome-line`
Expected: FAIL (модуль не найден).

- [ ] **Step 3: Модуль** — `tools/verdict-outcome-line.mjs`

```javascript
#!/usr/bin/env node
/** verdict-outcome-line — чистая строка исхода судьи/наставника для контроллера (Фикс 1). Тотальна. */
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: vitest GREEN**

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

---

### Task 2: Хранилище исходов

- [ ] **Step 1: Тест** — `tools/verdict-surface-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 { appendVerdictOutcome, readUnsurfaced, markAllSurfaced } from './verdict-surface-store.mjs';

describe('verdict-surface-store', () => {
  it('append → read непоказанные → mark → пусто', () => {
    const dir = mkdtempSync(join(tmpdir(), 'vss-'));
    try {
      expect(appendVerdictOutcome({ sessionId: 's1', line: '✅ GO — опечатано', fsImpl: fs, dir })).toBe(true);
      expect(appendVerdictOutcome({ sessionId: 's1', line: '🚫 NO-GO: X', fsImpl: fs, dir })).toBe(true);
      expect(readUnsurfaced({ sessionId: 's1', fsImpl: fs, dir })).toEqual(['✅ GO — опечатано', '🚫 NO-GO: X']);
      markAllSurfaced({ sessionId: 's1', fsImpl: fs, dir });
      expect(readUnsurfaced({ sessionId: 's1', fsImpl: fs, dir })).toEqual([]);
    } finally { rmSync(dir, { recursive: true, force: true }); }
  });
  it('пустая строка не пишется', () => {
    const dir = mkdtempSync(join(tmpdir(), 'vss-'));
    try {
      expect(appendVerdictOutcome({ sessionId: 's2', line: '', fsImpl: fs, dir })).toBe(false);
      expect(readUnsurfaced({ sessionId: 's2', fsImpl: fs, dir })).toEqual([]);
    } finally { rmSync(dir, { recursive: true, force: true }); }
  });
});
```

- [ ] **Step 2: vitest RED**

Run: `npx vitest run --root app --config vitest.config.tools.mjs verdict-surface-store`
Expected: FAIL.

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

```javascript
#!/usr/bin/env node
/** verdict-surface-store — рантайм-хранилище строк исхода (Фикс 1). fs инъектируется. best-effort. */
import fsDefault from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';

export function surfacePath(sessionId, dir) {
  const base = dir || join(homedir(), '.claude', 'runtime');
  return join(base, `verdict-surface-${sessionId || 'unknown'}.jsonl`);
}

export function appendVerdictOutcome({ sessionId, line, nowMs = null, fsImpl = fsDefault, dir } = {}) {
  if (typeof line !== 'string' || !line.trim()) return false;
  try {
    const p = surfacePath(sessionId, dir);
    fsImpl.mkdirSync(dirname(p), { recursive: true });
    fsImpl.appendFileSync(p, JSON.stringify({ line, at: nowMs, surfaced: false }) + '\n');
    return true;
  } catch { return false; }
}

export function readUnsurfaced({ sessionId, fsImpl = fsDefault, dir } = {}) {
  try {
    const p = surfacePath(sessionId, 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 && typeof r.line === 'string' && r.surfaced !== true) out.push(r.line);
    }
    return out;
  } catch { return []; }
}

export function markAllSurfaced({ sessionId, fsImpl = fsDefault, dir } = {}) {
  try {
    const p = surfacePath(sessionId, dir);
    if (!fsImpl.existsSync(p)) return false;
    const lines = fsImpl.readFileSync(p, 'utf-8').split(/\r?\n/).filter(Boolean);
    const rewritten = lines.map((l) => {
      try { const r = JSON.parse(l); r.surfaced = true; return JSON.stringify(r); } catch { return l; }
    }).join('\n');
    fsImpl.writeFileSync(p, rewritten ? rewritten + '\n' : '');
    return true;
  } catch { return false; }
}
```

- [ ] **Step 4: vitest GREEN**

Run: `npx vitest run --root app --config vitest.config.tools.mjs verdict-surface-store`
Expected: PASS (2).

---

### Task 3: UserPromptSubmit-хук показа

- [ ] **Step 1: Тест** — `tools/enforce-verdict-surface.test.mjs`

```javascript
import { describe, it, expect } from 'vitest';
import { buildVerdictSurfaceOutput } from './enforce-verdict-surface.mjs';

describe('buildVerdictSurfaceOutput', () => {
  it('пусто → null', () => { expect(buildVerdictSurfaceOutput([])).toBe(null); expect(buildVerdictSurfaceOutput(null)).toBe(null); });
  it('строки → UserPromptSubmit additionalContext', () => {
    const o = buildVerdictSurfaceOutput(['✅ GO — опечатано', '🚫 NO-GO: X']);
    expect(o.hookSpecificOutput.hookEventName).toBe('UserPromptSubmit');
    expect(o.hookSpecificOutput.additionalContext).toContain('✅ GO — опечатано');
    expect(o.hookSpecificOutput.additionalContext).toContain('🚫 NO-GO: X');
  });
});
```

- [ ] **Step 2: vitest RED**

Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-verdict-surface`
Expected: FAIL.

- [ ] **Step 3: Модуль** — `tools/enforce-verdict-surface.mjs`

```javascript
#!/usr/bin/env node
/**
 * enforce-verdict-surface — UserPromptSubmit-хук (Фикс 1): показывает контроллеру непоказанные
 * строки исхода судьи/наставника через additionalContext (проверенный канал, как ruflo-recall).
 * fail-open: любая ошибка → пустой вывод, exit 0. Регистрация в settings.json — шаг владельца.
 */
import { readStdin, parseEventJson } from './enforce-hook-helpers.mjs';
import { readUnsurfaced, markAllSurfaced } from './verdict-surface-store.mjs';

export function buildVerdictSurfaceOutput(lines) {
  if (!Array.isArray(lines) || lines.length === 0) return null;
  const additionalContext = ['Вердикты наставника/судьи (с прошлых записей):', ...lines].join('\n');
  return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext } };
}

async function main() {
  try {
    const event = parseEventJson(await readStdin());
    const sessionId = (event && event.session_id) || 'unknown';
    const out = buildVerdictSurfaceOutput(readUnsurfaced({ sessionId }));
    if (out) { process.stdout.write(JSON.stringify(out)); markAllSurfaced({ sessionId }); }
  } catch { /* fail-open */ }
  process.exit(0);
}

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

- [ ] **Step 4: vitest GREEN**

Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-verdict-surface`
Expected: PASS (2).

---

### Task 4: Классификатор исхода судьи + врезка

- [ ] **Step 1: Тест** — `tools/verdict-outcome-classify.test.mjs`

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

describe('classifyJudgeOutcome', () => {
  it('GO', () => expect(classifyJudgeOutcome({ block:false }, { wired:true, decision:'GO' })).toEqual({ kind:'go' }));
  it('degraded no_key', () => 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 + текст', () => expect(classifyJudgeOutcome({ block:true, message:'🚫 X' }, { wired:true, decision:'NO-GO' })).toEqual({ kind:'nogo', text:'🚫 X' }));
});
```

- [ ] **Step 2: vitest RED**

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

- [ ] **Step 3: Реализация в `enforce-judge-gate.mjs`** — добавить экспорт + дозапись в main

Добавить функцию (рядом с `decide`):

```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: String((result.message || '')).trim() };
  if (verdict && verdict.wired === true && verdict.decision === 'GO') return { kind: 'go' };
  return { kind: 'skip', reason: 'не судится' };
}
```

В `main()` судьи, ПОСЛЕ вычисления `result` и эскалации, ПЕРЕД `if (result.block)` — дозапись
(best-effort, только если запись судится):

```javascript
  try {
    const judged = extractGate1Product(event).shouldJudge || extractGate2Product(event).shouldJudge;
    if (judged) {
      const { appendVerdictOutcome } = await import('./verdict-surface-store.mjs');
      const { buildVerdictOutcomeLine } = await import('./verdict-outcome-line.mjs');
      const line = buildVerdictOutcomeLine(classifyJudgeOutcome(result, result.verdict));
      if (line) appendVerdictOutcome({ sessionId: (event && event.session_id) || 'unknown', line, nowMs: Date.now() });
    }
  } catch { /* best-effort: видимость не ломает решение */ }
```

- [ ] **Step 4: vitest GREEN**

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

- [ ] **Step 5: Врезка в `enforce-mentor-on-plan-write.mjs` main** — дозапись исхода наставника

В `main()`, внутри `if (res && res.ran)`, ПОСЛЕ `const decision = decideMentorObjection(...)`,
ПЕРЕД `if (decision.block)`:

```javascript
      try {
        const { appendVerdictOutcome } = await import('./verdict-surface-store.mjs');
        const { buildVerdictOutcomeLine } = await import('./verdict-outcome-line.mjs');
        const vd = res.verdict && res.verdict.decision;
        const kindObj = decision.degraded ? { kind: 'degraded', reason: res.reason || 'транспорт недоступен' }
          : decision.block ? { kind: 'nogo', text: formatMentorObjection(res) }
          : decision.recordMentorGo ? { kind: 'go' }
          : { kind: 'skip', reason: res.reason || 'не судится' };
        const line = buildVerdictOutcomeLine(kindObj);
        if (line) appendVerdictOutcome({ sessionId: sess, line, nowMs: Date.now() });
      } catch { /* best-effort */ }
```

- [ ] **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":"D2"},
  {"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":"D2"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs verdict-outcome-line","ref":"D6"},
  {"op":"Write","object":"tools/verdict-surface-store.test.mjs","ref":"D3"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs verdict-surface-store","ref":"D6"},
  {"op":"Write","object":"tools/verdict-surface-store.mjs","ref":"D3"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs verdict-surface-store","ref":"D6"},
  {"op":"Write","object":"tools/enforce-verdict-surface.test.mjs","ref":"D1"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs enforce-verdict-surface","ref":"D6"},
  {"op":"Write","object":"tools/enforce-verdict-surface.mjs","ref":"D1"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs enforce-verdict-surface","ref":"D6"},
  {"op":"Write","object":"tools/verdict-outcome-classify.test.mjs","ref":"D2"},
  {"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":"D4"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs verdict-outcome-classify","ref":"D6"},
  {"op":"Edit","object":"tools/enforce-mentor-on-plan-write.mjs","ref":"D4"},
  {"op":"Bash","object":"npx vitest run --root app --config vitest.config.tools.mjs","ref":"D6"}
]
```

```verified-context-json
[
  {"id":"D1-channel","kind":"EXTRACTED","ref":"tools/ruflo-recall-hook.mjs","anchor":"hookEventName: 'UserPromptSubmit'"},
  {"id":"D3-nostdoutchange","kind":"EXTRACTED","ref":"tools/enforce-hook-helpers.mjs","anchor":"export function exitDecision("}
]
```

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

### Круг 1

Контроллер: учёл NO-GO судьи на v1. Канал сменён с неподтверждённого PostToolUse.additionalContext
на проверенный UserPromptSubmit.additionalContext (D1, якорь ruflo-recall). exitDecision не
меняется (D3 — снят execution_risk). Добавлен интеграционный тест store+builder (D5 — снят
verifiability). Покрытие всех записей — врезка в существующие PostToolUse-производители + новый
UserPromptSubmit-хук, регистрация = шаг владельца (D4 — снят spec_fidelity).
