docs(router-mentor): A1 pre-code chain amendments (Write-only, judge-unavailable degraded-allow)

This commit is contained in:
Дмитрий
2026-06-09 08:30:43 +03:00
parent 5b5d8f10e4
commit e311efeeec
2 changed files with 144 additions and 0 deletions
@@ -630,3 +630,115 @@ Expected: GREEN, 0 failed. Прежний baseline ~3292 passed / 2 skipped —
**Placeholder scan:** `<wt>` = `c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo` (литеральный путь в git-командах; PowerShell, `git -C "<wt>"`). Прочих плейсхолдеров нет — весь код приведён.
**Type consistency:** `runJudgeGate(event, deps)` возвращает `{decision, wired, verdict?}`; `runJudgeTurn` читает `verdict.wired` и зовёт `buildVerdictEntry(verdict, nowMs)`; `buildVerdictEntry` читает `judgeResult.verdict` (есть при wired:true). `requiredLensesFor('gate2')` = 5 линз — тесты подают все 5 слотов. `decide({mode, verdict, floorBlocked})` — контракт существующих тестов сохранён (Task 6 их не ломает). Имена функций едины во всех тасках.
---
## Поправки после pre-code adversarial-цепочки (применять ПОВЕРХ Task 2/3/4/5/6)
Спека §9 (audit-context → sharp-edges → variant-analysis vs живой v4-судья). Дельты:
### Δ-A (Task 2 — детектор только на `Write`)
`PLAN_TOOLS` сужается до `Write` (Гейт-2 судит полный план; Edit/MultiEdit несут только фрагмент):
```js
const PLAN_TOOLS = new Set(['Write']); // SE-FIX-2: только полный content; Edit/MultiEdit — фрагмент, не судим
```
`product = String(input.content ?? '').trim();` (без `new_string`).
**Тесты Task 2 правятся:** «Edit плана → product=new_string» → **«Edit плана → shouldJudge:false»**;
добавить «MultiEdit плана → shouldJudge:false». Кейсы Write/путь/слэши/не-план — без изменений.
### Δ-B (Task 3 — `callJudgeModel`: unavailable ≠ malformed)
`callJudgeModel` различает «не смог запуститься» (degraded) и «запустился, но мусор» (NO-GO):
```js
export async function callJudgeModel({ functionName, requiredLenses, promptArgs, apiKey, model = CLASSIFIER_MODEL, transport = callAnthropicAPI }) {
if (!apiKey) return { unavailable: true }; // нет транспорт-ключа → судья недоступен ($0)
const base = buildJudgePrompt({ functionName, requiredLenses, ...promptArgs });
const prompt = { system: base.system + '\n' + JSON_DIRECTIVE, user: base.user };
try {
const text = await transport(prompt, { apiKey, model });
return parseJudgeResponse(text); // ran: валидный → {slots,...}; мусор → {} (NO-GO)
} catch {
return { unavailable: true }; // транспорт бросил → недоступен (не NO-GO)
}
}
```
**Тест Task 3 правится:** «нет apiKey → транспорт не зовётся, `{}`» → **«нет apiKey → `{unavailable:true}`,
транспорт не зовётся»**; «транспорт бросил → `{}`» → **«транспорт бросил → `{unavailable:true}`»**.
Кейс «валидный JSON → распарсен» — без изменений.
### Δ-C (Task 4 — `runJudgeGate`: unavailable → degraded ALLOW, не NO-GO)
После `callJudgeModel`, перед `runJudge`:
```js
const raw = await callJudgeModel({ ... });
if (raw && raw.unavailable) {
return { decision: 'GO', wired: false, unavailable: true }; // VA-FIX-1: судья недоступен → degraded allow (+WARN в main)
}
const verdict = runJudge({ ... llmCall: () => raw, ... });
return { decision: verdict.decision, wired: true, verdict: { ...verdict, functionName: 'gate2' } };
```
**Тест Task 4 правится:** «нет ROUTER_LLM_KEY → вердикт NO-GO» → **«нет ROUTER_LLM_KEY → `wired:false`,
`unavailable:true`, `decision:'GO'`, транспорт не зовётся»**. Кейсы «не активен», «не план», «чистый
вердикт → GO», «якорное NO → NO-GO» — без изменений.
### Δ-D (Task 5/6 — лог только реального вердикта + main гибрид)
`buildVerdictEntry`/`logVerdictLine` зовутся **только при реальном вердикте** (`wired === true`).
При `unavailable` (degraded-allow) — НЕ verdict-запись, а отдельная WARN-строка (best-effort):
```js
export function warnJudgeUnavailable(event, { fsImpl = fsDefault, dir = runtimeDir() } = {}) {
try {
fsImpl.mkdirSync(dir, { recursive: true });
fsImpl.appendFileSync(join(dir, 'judge-verdicts.jsonl'),
JSON.stringify({ kind: 'judge_unavailable', at: null, note: 'нет ROUTER_LLM_KEY или транспорт недоступен' }) + '\n');
} catch { /* best-effort */ }
}
```
`runJudgeTurn` (Task 6) — лог-ветвление + main через `exitDisciplineDecision` (async-aware, fail-CLOSE
только на throw/malformed; MUST-VERIFY-1 verified — `disciplineOutcome` await'ит санк):
```js
export async function runJudgeTurn(event, { mode, logImpl = logVerdictLine, warnImpl = warnJudgeUnavailable, nowMs, ...deps } = {}) {
if (mode === 'inert') return { block: false };
let verdict;
try { verdict = await runJudgeGate(event, deps); }
catch { return { block: mode === 'live-block' }; } // истинный баг → live-block fail-CLOSE; shadow allow
if (verdict && verdict.wired) { // реальный вердикт
try { logImpl(buildVerdictEntry(verdict, nowMs)); } catch { /* best-effort */ }
} else if (verdict && verdict.unavailable) { // судья недоступен → WARN, не verdict
try { warnImpl(event); } catch { /* best-effort */ }
}
if (mode === 'shadow') return { block: false }; // D28: всегда allow
const d = decide({ mode, verdict, floorBlocked: false }); // live-block: degraded-GO → allow; реальный NO-GO → block
return { block: d.block, message: d.message };
}
```
`main()` без изменений относительно Task 6 (он уже зовёт `runJudgeTurn``exitDecision`). NB: degraded-allow
проходит через `decide({mode:'live-block', verdict:{decision:'GO',...}})``finalGate(GO, floorBlocked:false)`
→ allow ✓. Истинный throw уже пойман в `runJudgeTurn` (live-block→block) — это и есть fail-CLOSE на баг.
**Тесты Task 6 правятся:** «live-block + нет ключа» (если присутствовал) трактуется как degraded-allow;
добавить кейс «live-block + unavailable → allow + warnImpl вызван». Кейсы inert/shadow/GO/NO-GO — без изменений.
### Δ-E (документация спенд-семантики)
В docstring шапки `enforce-judge-gate.mjs` (Task 3, при добавлении функций) зафиксировать: **inert=$0 /
shadow=метеренный / block=метеренный**; env строгие (`ENABLED` ровно `1`, `MODE` ровно `block`; опечатка
→ безопасный дефолт). Отложенные residuals (дедуп content-hash, budget-cap) — строкой «follow-up».
### Self-review поправок
Покрытие §9: п.1→Δ-A, п.2→Δ-B/Δ-C, п.3→Δ-D (main гибрид), п.4→Δ-E, п.5→Δ-D (warn-ветвь), п.6→Δ-E
(residuals строкой), п.7→подтверждено (Write-only Δ-A / engine-no-main / secure-default). Type-consistency:
`runJudgeGate` теперь возвращает `{decision,wired,verdict?,unavailable?}`; `runJudgeTurn` ветвится по
`wired`/`unavailable`; `callJudgeModel``{unavailable:true}|{}|{slots,objections,decision?}`. Имена едины.
@@ -136,3 +136,35 @@ PreToolUse event
- Цепочка кода после спеки/плана: `audit-context-building → sharp-edges → variant-analysis →
writing-plans → test-driven-development → verification-before-completion → regression`. НЕ
`requesting-code-review`.
## 9. Поправки после pre-code adversarial-цепочки (2026-06-09)
Цепочка (audit-context-building → sharp-edges → variant-analysis, сверка с живым v4-судьёй
`enforce-llm-judge-per-tool`) дала упрочнения дизайна — применяются поверх §4:
1. **Детектор только на `Write`** (SE-FIX-2). Гейт-2 судит **полный план**; `Edit`/`MultiEdit`
на PreToolUse несут лишь фрагмент (old/new), не весь файл — судить фрагмент бессмысленно
(ложные NO-GO). `writing-plans` создаёт план через `Write` (полный `content`) — момент приёмки
ловится. Побочно снижает повторные срабатывания.
2. **judge-unavailable ≠ malformed verdict** (VA-FIX-1, зеркало v4 `{block:false, degraded:true}`).
`callJudgeModel`: нет `apiKey` ИЛИ транспорт бросил → `{unavailable:true}` (без throw, $0);
транспорт вернул текст, но парс невалиден → `{}`. `runJudgeGate`: `unavailable` →
`{decision:'GO', wired:false, unavailable:true}` (degraded ALLOW + громкий WARN, **не** NO-GO).
Только реальный невалидный вердикт → NO-GO (fail-closed). Различает «судья не может работать»
(operator-misconfig → деградация, как `judgeHealth` floor-only) от «судья сказал НЕТ» (block).
3. **main() гибрид fail-философии** (VA-NOTE-2 + MUST-VERIFY-1). `exitDisciplineDecision` await'ит
async-санк и fail-CLOSE только на throw/malformed. live-block: `await exitDisciplineDecision(async
() => { v = await runJudgeGate(event); logRealVerdict(v); return decide({mode, verdict:v,
floorBlocked:false}); })`. degraded-allow (`unavailable`) → decide(GO) → allow; реальный NO-GO →
block; истинный баг кода (throw) → fail-CLOSE block. shadow: run + log-real + always allow.
4. **Спенд-семантика явная** (SE-DOC-1): **inert = $0** (нет флага/HMAC-ключа); **shadow = метеренный**
(реальный LLM-вызов за каждую запись плана); **block = метеренный**. env строгие:
`ROUTER_MENTOR_JUDGE_ENABLED` ровно `1`, `ROUTER_MENTOR_JUDGE_MODE` ровно `block`; опечатка →
безопасный дефолт (inert/shadow).
5. **Логируем только реальный вердикт** (VA-OK-2, зеркало v4 `verdict!==undefined`): `unavailable`/
degraded-allow → НЕ verdict-запись, а отдельная WARN-строка.
6. **Отложено (documented residuals):** дедуп по content-hash (SE-DEFER-1) + per-session budget-cap
(VA-NOTE-1) — Write-only уже ограничивает частоту; revisit, если shadow-стоимость заметна.
7. **Подтверждено ✓:** движок `judge-engine`/orchestrator без `main()`/транспорта (мис-регистрация
тратить не может); A1 стартует узко (Write-only) → не наследует v4 over-block по оси scope;
`judgeActive` default-inert при опечатке флага/режима (secure-by-default).