From 09620f368ee3b0e65783f696b91cbec3ab5ced7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 12 Jun 2026 15:48:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(m7):=20re-plan=20=D0=BD=D0=B0=20=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=20(impl-=D0=BA=D0=B0=D1=80=D0=B2=D1=83=D1=82?= =?UTF-8?q?)=20+=20=D1=8D=D1=81=D0=BA=D0=B0=D0=BB=D0=B0=D1=86=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=D1=83=D0=B4=D1=8C=D0=B8=20(escape-honor=20+=20=D1=81?= =?UTF-8?q?=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=20NO-GO)=20+=20docs-=D1=85?= =?UTF-8?q?=D0=B2=D0=BE=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/bugs.md | 5 ++ docs/observer/STATUS.md | 47 ++++++++++--------- .../specs/2026-06-12-router-mentor-roadmap.md | 6 +-- tools/enforce-judge-gate.mjs | 38 +++++++++++++++ tools/enforce-supreme-gate.mjs | 22 ++++++++- tools/judge-escalation.test.mjs | 34 ++++++++++++++ 6 files changed, 124 insertions(+), 28 deletions(-) create mode 100644 tools/judge-escalation.test.mjs diff --git a/docs/bugs.md b/docs/bugs.md index 0ec5d229..8860a686 100644 --- a/docs/bugs.md +++ b/docs/bugs.md @@ -18,6 +18,7 @@ ``` Не дублирует: + - `docs/Открытые_вопросы_v8_3.md` — там бизнес/продуктовые вопросы (`Биз-*`, `CTO-*`, `Ю-*`, `Диз-*`) - `docs/superpowers/specs/` — там design specs - `docs/superpowers/plans/` — там implementation plans @@ -77,10 +78,12 @@ **Где:** [tools/enforce-router-gate.mjs](../tools/enforce-router-gate.mjs) (matcher Bash) + [tools/enforce-powershell-gate.mjs](../tools/enforce-powershell-gate.mjs) (matcher PowerShell). **Симптом:** Невозможно выполнить ни одну git-команду из проектной сессии — даже read-only (`git rev-parse`, `git status`). Обе оболочки default-deny: + - Bash: `[router-gate] команда не в whitelist — default-deny (§5.1)` - PowerShell: `[powershell-gate] cmdlet «git» не в whitelist — default-deny (§5.1.2)` **Детали:** + - 30.05.2026 заказчик попросил закоммитить spec + plan фичи lead-region-resolution в ветку `feat/lead-region-resolution`. Попытка `git switch -c` + `git commit -- ` через Bash → блок router-gate. Тот же git через PowerShell-tool → блок powershell-gate (F1 из v3.8 — PowerShell-путь закрыт). - Архитектурно git из проектной сессии под v4 **неработоспособен**: router-gate default-deny на Bash блокирует git-команду ДО того, как сработает side-channel `approve_git_operation` (§4.5 / §3.5 v3.5 design) — то есть механизм AskUserQuestion-одобрения git-операции недостижим, потому что сама команда не доходит до gate-логики одобрения. - Файлы spec + plan **сохранены на диск** (untracked, не потеряны): `docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md` + `docs/superpowers/plans/2026-05-29-lead-region-resolution.md`. Просто не закоммичены. @@ -88,6 +91,7 @@ **Статус:** открыт. Связано с общей темой over-blocking защиты v4 (см. запись выше про memory). **Решение:** TBD. Варианты: + - (1) Добавить git в whitelist router-gate / powershell-gate под AskUserQuestion-одобрение (восстановить путь S8 v3.5 — gate должен пропускать git-команду ДО проверки whitelist, если есть свежий `approve_git_operation`). - (2) Закоммитить вручную заказчиком (вне Claude-сессии) или из сессии с ослабленной защитой. - (3) Дождаться фикса v4 (зона параллельной router-gate v4 сессии). @@ -101,6 +105,7 @@ **Где:** [tools/enforce-router-gate.mjs](../tools/enforce-router-gate.mjs) whitelist (§5.1). **Симптом:** Невозможно реализовать ни одну кодовую фичу из проектной сессии — гейт режет инструменты разработки: + - `php artisan test` / `php artisan migrate` → `команда не в whitelist — default-deny` (whitelist разрешает только `list`/`route:list`/`migrate:status`). - `./vendor/bin/pest` / `composer test` → не в whitelist → блок. - `cd app && <что-угодно>` → цепочка → не в whitelist → блок. А т.к. artisan лежит в `app/`, до даже whitelisted-команд (`php artisan migrate:status`) не добраться без `cd app`. diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 6e99a3e5..16541980 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-12T07:52:35.138Z +Last updated: 2026-06-12T08:38:34.416Z | Контролёр | Состояние | Детали | |---|---|---| @@ -8,7 +8,7 @@ Last updated: 2026-06-12T07:52:35.138Z | C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files | | C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 2 week(s) ago | | C4 Сигнальный статус | ✅ | This file (self-reference) | -| C5 Observer-coverage | ✅ | 1016 episode(s) this month · Stop-hook + post-commit OK | +| C5 Observer-coverage | ✅ | 1029 episode(s) this month · Stop-hook + post-commit OK | | C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync | ## Кто на посту (оборона М1–М6) @@ -39,21 +39,21 @@ Last updated: 2026-06-12T07:52:35.138Z | Время | Действие | Причина | |---|---|---| -| 2026-06-12T07:50:46.978Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | -| 2026-06-12T07:47:10.194Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | -| 2026-06-12T07:45:36.089Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | -| 2026-06-12T07:42:58.116Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | -| 2026-06-12T07:32:35.103Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | -| 2026-06-12T07:26:14.531Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | -| 2026-06-12T07:11:57.995Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/8d15e1a4-4cf9-4bbd-8565-5e7aa8cc344 | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/8d15e1a4-4cf9-4bbd-8565-5e7aa8cc344 | -| 2026-06-12T07:10:09.575Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/memory/project_router_mentor_main_s | §6: правка ЗАКОНА (content edits a rules/discipline section) требует escape владельца — скил недостаточен | -| 2026-06-12T07:07:33.824Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/8d15e1a4-4cf9-4bbd-8565-5e7aa8cc344 | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/8d15e1a4-4cf9-4bbd-8565-5e7aa8cc344 | -| 2026-06-12T07:04:51.171Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/8d15e1a4-4cf9-4bbd-8565-5e7aa8cc344 | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/8d15e1a4-4cf9-4bbd-8565-5e7aa8cc344 | +| 2026-06-12T08:30:55.553Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/f3950d20-72ed-4576-b13b-e863cc9afdd | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/f3950d20-72ed-4576-b13b-e863cc9afdd | +| 2026-06-12T08:28:26.644Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/f3950d20-72ed-4576-b13b-e863cc9afdd | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/f3950d20-72ed-4576-b13b-e863cc9afdd | +| 2026-06-12T08:23:50.976Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/f3950d20-72ed-4576-b13b-e863cc9afdd | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/f3950d20-72ed-4576-b13b-e863cc9afdd | +| 2026-06-12T08:22:39.219Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/2b6762d2-da4b-4f9e-b7d4-c7dd7896b10 | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/2b6762d2-da4b-4f9e-b7d4-c7dd7896b10 | +| 2026-06-12T08:21:49.847Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/f3950d20-72ed-4576-b13b-e863cc9afdd | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/f3950d20-72ed-4576-b13b-e863cc9afdd | +| 2026-06-12T08:17:11.915Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/f3950d20-72ed-4576-b13b-e863cc9afdd | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/f3950d20-72ed-4576-b13b-e863cc9afdd | +| 2026-06-12T08:16:42.806Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/f3950d20-72ed-4576-b13b-e863cc9afdd | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/f3950d20-72ed-4576-b13b-e863cc9afdd | +| 2026-06-12T08:11:27.068Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/2b6762d2-da4b-4f9e-b7d4-c7dd7896b10 | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/2b6762d2-da4b-4f9e-b7d4-c7dd7896b10 | +| 2026-06-12T08:10:12.924Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | +| 2026-06-12T08:01:58.123Z | write:c:/users/administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | path «C:/Users/Administrator/.claude/projects/c---------------------crm-------------/08ffc075-92c9-49b2-9a80-33075dee4aa | ## Метрики (информационные, не алерты) -- Observer evidence: 1016 episodes this month, 0 observer_error markers, 0 PII matches before filter -- Legacy v1 episodes (not in factor analysis): 1016 +- Observer evidence: 1029 episodes this month, 0 observer_error markers, 5 PII matches before filter +- Legacy v1 episodes (not in factor analysis): 1029 - Last /brain-retro: 16 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). @@ -63,15 +63,15 @@ Baseline дисциплины роутера (этап 2 router discipline overh | Тип задачи | Эпизодов | % с триггер-матчем | % через скил | |---|---|---|---| -| planning | 138 | 7.2% | 20.3% | -| analysis | 38 | 5.3% | 0.0% | +| planning | 140 | 7.1% | 20.7% | +| analysis | 39 | 5.1% | 0.0% | | feature | 32 | 9.4% | 3.1% | -| bugfix | 30 | 13.3% | 16.7% | +| bugfix | 32 | 12.5% | 15.6% | | cleanup | 1 | 0.0% | 0.0% | -Router step distribution: 1: 501, 2: 361, 3: 18, 5: 116 +Router step distribution: 1: 508, 2: 366, 3: 18, 5: 117 -Boundaries applied (ADR / границы): 8 of 996 эпизодов (0.8%). +Boundaries applied (ADR / границы): 8 of 1009 эпизодов (0.8%). ## Активные многоэтапные проекты @@ -89,10 +89,10 @@ Boundaries applied (ADR / границы): 8 of 996 эпизодов (0.8%). | Компонент | Токены (in/out) | USD | |---|---|---| -| Classifier (Sonnet 4.6) | 59182/218321 | $3.45 | +| Classifier (Sonnet 4.6) | 61793/222668 | $3.53 | | Self-assessment (Sonnet 4.6) | 0/0 | $0.00 | | Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 | -| **Итого** | | **$3.45** | +| **Итого** | | **$3.53** | ## Аномалии классификатора @@ -105,7 +105,7 @@ Episodes since last run: 542 / threshold: 10 ## Reviewer: субагент vs fallback -0 эпизодов проверено из 1016. +0 эпизодов проверено из 1029. ## Reviewer findings @@ -131,7 +131,8 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3508 | MsMpEng | 1.65ч | 16667722.4ч | +| 3508 | MsMpEng | 1.74ч | 220164.6ч | +| 1248 | svchost | 1.00ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/docs/superpowers/specs/2026-06-12-router-mentor-roadmap.md b/docs/superpowers/specs/2026-06-12-router-mentor-roadmap.md index e29219c3..e2739b94 100644 --- a/docs/superpowers/specs/2026-06-12-router-mentor-roadmap.md +++ b/docs/superpowers/specs/2026-06-12-router-mentor-roadmap.md @@ -30,9 +30,9 @@ вместе с регистрацией М1-М6: хук `enforce-normative-content-rules` УЖЕ живой, а коммит расширяет его матчер на `tools/enforce-*.mjs` (ad-hoc → ЗАКОН → нужен escape М6, которого до Фазы 8 нет) — отдельный cherry-pick запрёт Claude от правок enforce-инструментов. -- [ ] Регистрация М1-М6 в `.claude/settings.json` (шаг владельца — Claude файл закрыт). -- [ ] Увольнение v4-зоопарка (§10): старые enforce-хуки заменяются машинами. -- [ ] План отката. +- [x] ~~Регистрация М1-М6 в `.claude/settings.json`~~ (выполнено владельцем 12.06). +- [x] ~~Увольнение v4-зоопарка (§10): старые enforce-хуки заменяются машинами.~~ +- [x] ~~План отката.~~ Бэкап `.claude/settings.json.bak-phase8-2026-06-12-flip` снят. Фаза 8 попутно закрывает три известных занозы: diff --git a/tools/enforce-judge-gate.mjs b/tools/enforce-judge-gate.mjs index ebf7986d..85ddf9dd 100644 --- a/tools/enforce-judge-gate.mjs +++ b/tools/enforce-judge-gate.mjs @@ -231,6 +231,16 @@ export async function runJudgeTurn(event, { mode, logImpl = logVerdictLine, warn try { warnImpl(event); } catch { /* best-effort */ } } if (mode === 'shadow') return { block: false }; // D28: логирует, не блокирует + // M7: escape-валве владельца (эскалация). Судья чтит escape-грант, как стена/пол: упёрся + // NO-GO → владелец кликает «разрешаю» → судья пропускает. «Не смог договориться → вышел на владельца». + try { + const eg = await import('./escape-grant.mjs'); + const sess = event && event.session_id; + const action = eg.canonicalAction(event && event.tool_name, (event && event.tool_input) || {}); + if (eg.escapeGrantOpen(action, eg.loadFloorEscapes(sess), eg.loadConsumed(sess))) { + return { block: false, message: 'judge: разрешено аварийным выходом владельца (escape)' }; + } + } catch { /* escape недоступен → обычное решение судьи */ } const d = decide({ mode, verdict, floorBlocked: false }); return { block: d.block, message: d.message }; } @@ -300,6 +310,22 @@ function sealTurnProd(event, verdict, mode) { }); } +const JUDGE_ESCALATE_AFTER = 3; +/** + * M7 эскалация (round-control C-12): счётчик ПОДРЯД идущих NO-GO судьи в сессии. + * blocked=true → +1; blocked=false (allow) → сброс 0. Возвращает новый счёт. + * fsImpl/dir инъектируемы для тестов. Best-effort — ошибка I/O не ломает судью. + */ +export function bumpJudgeNoGo({ sessionId, blocked, fsImpl = fsDefault, dir = runtimeDir() } = {}) { + const safe = String(sessionId || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_'); + const path = join(dir, `judge-nogo-${safe}.json`); + let count = 0; + try { count = (JSON.parse(fsImpl.readFileSync(path, 'utf8')).count) || 0; } catch { count = 0; } + const next = blocked ? count + 1 : 0; + try { fsImpl.mkdirSync(dir, { recursive: true }); fsImpl.writeFileSync(path, JSON.stringify({ count: next })); } catch { /* best-effort */ } + return next; +} + async function main() { let event, mode; try { @@ -310,6 +336,18 @@ async function main() { try { result = await runJudgeTurn(event, { mode, onWiredSeal: sealTurnProd }); // inert/shadow/live-block внутри } catch { exitDecision({ block: mode === 'live-block' }); return; } // fail-CLOSE только в live-block + // M7 эскалация (round-control C-12): подряд идущие NO-GO судьи. allow → сброс. После 3-го подряд — + // сообщение «ЭСКАЛАЦИЯ ВЛАДЕЛЬЦУ» (судья сам выходит на владельца; продавить — escape, который судья + // уже чтит). Счётчик best-effort, на сам факт block не влияет. + try { + const judgedPlan = extractGate1Product(event).shouldJudge || extractGate2Product(event).shouldJudge; + if (judgedPlan) { + const n = bumpJudgeNoGo({ sessionId: (event && event.session_id) || 'unknown', blocked: !!result.block }); + if (result.block && n >= JUDGE_ESCALATE_AFTER) { + result = { ...result, message: `[judge-gate] ЭСКАЛАЦИЯ ВЛАДЕЛЬЦУ: судья отказал ${n} раз(а) подряд по плану. Реши — дай escape на это действие, чтобы продавить, либо подтверди отказ. (round-control C-12)` }; + } + } + } catch { /* счётчик best-effort */ } if (result.block) { logGuardBlock(event, 'М4 Судья', result.message); exitDecision({ block: true, message: result.message || '[judge-gate] block' }); } else exitDecision({ block: false }); } diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index a00bf752..7e6d9f41 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -18,6 +18,19 @@ import { assertSafeSessionId } from './action-journal.mjs'; import { classifyDestructive } from './classify-destructive.mjs'; import { canonicalAction, escapeGrantOpen, escapeAllowsEvent, loadFloorEscapes, loadConsumed } from './escape-grant.mjs'; import { logGuardBlock } from './guard-block-log.mjs'; +import { existsSync } from 'node:fs'; + +// M7 Ф8 (bootstrap): авторская запись НОВОГО артефакта/плана (Write нового .md в +// docs/superpowers/{specs,plans}/) — это КАНАЛ печати, не реализация. В разговорном режиме +// пропускаем: судья оценит содержимое и опечатает ТОЛЬКО на GO. Перезапись существующего +// файла НЕ разрешаем (existsSync). До печати запись власти не даёт — стена закрыта для правок. +const AUTHORING_PATH_RE = /(^|[/\\])docs[/\\]superpowers[/\\](?:specs|plans)[/\\][^/\\]+\.md$/i; +export function isAuthoringWrite(toolUse, { existsImpl = existsSync } = {}) { + if (!toolUse || toolUse.name !== 'Write') return false; + const fp = String(toolUse.input?.file_path || ''); + if (!AUTHORING_PATH_RE.test(fp)) return false; + try { return !existsImpl(fp); } catch { return false; } +} // Узкий технический allowlist загрузки (НЕ «карта критического») — без него // нельзя создать первый план: writing-plans пишет план, AskUser/EnterPlanMode @@ -178,6 +191,11 @@ function extOf(p) { */ export function decide({ toolUse, frozenPlan, frozenArtifact = null, stepPtr = 0, key, verifyImpl = verifyFrozenPlan, verifyArtifactImpl = verifyFrozenArtifact, normalize }) { if (isSeed(toolUse)) return { decision: 'allow', reason: 'seed (bootstrap D12/D13)' }; + // M7 Ф8 (re-plan): авторская запись НОВОГО плана/спеки разрешена и В РЕЖИМЕ РЕАЛИЗАЦИИ — + // иначе «по ходу работы возникло препятствие, нужно переписать план» не сработает (стена + // держит старый план, новый не записать). Безопасно: новый план так же судят наставник + + // судья + freeze-gate; запись черновика власти не даёт до печати (как в разговорном режиме). + if (isAuthoringWrite(toolUse)) return { decision: 'allow', reason: 'authoring write (re-plan, build-loop) — impl-режим' }; if (frozenPlan && (toolUse?.name === 'Read' || toolUse?.name === 'Grep')) { const p = actionOf(toolUse).object; const ev = decideReadEvent({ @@ -254,14 +272,14 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k return { decision: 'allow', mode: 'escape', reason: 'разрешено аварийным выходом владельца (floor_escape) — указатель не двигается' }; } if (!frozenPlan) { - if (isSeed(toolUse) || isObserveOnly(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe (разговорный режим)' }; + if (isSeed(toolUse) || isObserveOnly(toolUse) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/authoring (разговорный режим)' }; return { decision: 'block', mode: 'conversational', reason: 'разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана)' }; } if (!frozenArtifact || !verifyArtifactImpl(frozenArtifact, key)) { // F-B (аудит 2026-06-07): observe-only (Read/Grep/Glob/readonly-Bash/TodoWrite) пускаем // и в этом деградированном состоянии — инвариант finding 9 «смотрящие не душатся» + // согласованность с decide() (там observe-only allow безусловно). Бэкстоп держит только мутаторы. - if (isSeed(toolUse) || isObserveOnly(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe (бэкстоп: артефакт не опечатан)' }; + if (isSeed(toolUse) || isObserveOnly(toolUse) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/authoring (бэкстоп: артефакт не опечатан)' }; return { decision: 'block', mode: 'conversational', reason: 'нет опечатанного артефакта разговорной фазы — вернись в разговор (бэкстоп C-10)' }; } // SE-2 (fail-closed whitelist): энфорсмент ТОЛЬКО при live-block на ОБЕИХ печатях. diff --git a/tools/judge-escalation.test.mjs b/tools/judge-escalation.test.mjs new file mode 100644 index 00000000..76c63948 --- /dev/null +++ b/tools/judge-escalation.test.mjs @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { bumpJudgeNoGo } from './enforce-judge-gate.mjs'; + +describe('M7 эскалация судьи: счётчик подряд идущих NO-GO (round-control C-12)', () => { + it('blocked подряд → счёт растёт; allow → сброс в 0', () => { + const dir = mkdtempSync(join(tmpdir(), 'judge-nogo-')); + try { + expect(bumpJudgeNoGo({ sessionId: 's1', blocked: true, dir })).toBe(1); + expect(bumpJudgeNoGo({ sessionId: 's1', blocked: true, dir })).toBe(2); + expect(bumpJudgeNoGo({ sessionId: 's1', blocked: true, dir })).toBe(3); // ← порог эскалации + expect(bumpJudgeNoGo({ sessionId: 's1', blocked: false, dir })).toBe(0); // allow → сброс + expect(bumpJudgeNoGo({ sessionId: 's1', blocked: true, dir })).toBe(1); // снова с 1 + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + + it('разные сессии — независимые счётчики', () => { + const dir = mkdtempSync(join(tmpdir(), 'judge-nogo-')); + try { + expect(bumpJudgeNoGo({ sessionId: 'a', blocked: true, dir })).toBe(1); + expect(bumpJudgeNoGo({ sessionId: 'b', blocked: true, dir })).toBe(1); + expect(bumpJudgeNoGo({ sessionId: 'a', blocked: true, dir })).toBe(2); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + + it('битый/опасный sessionId не ломает (санитизация пути) и стартует с 1', () => { + const dir = mkdtempSync(join(tmpdir(), 'judge-nogo-')); + try { + expect(bumpJudgeNoGo({ sessionId: '../../evil', blocked: true, dir })).toBe(1); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); +});