diff --git a/docs/superpowers/plans/2026-06-18-standby-foundation-plan-3.md b/docs/superpowers/plans/2026-06-18-standby-foundation-plan-3.md new file mode 100644 index 0000000..ee703a6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-standby-foundation-plan-3.md @@ -0,0 +1,64 @@ +# Штатный режим — план фундамента (флаг + управляющий хук + сброс) + +## Цель + +Реализовать через TDD три НОВЫХ модуля штатного режима: `standbyActive`+`removeSentinel` в +`tools/enforce-hook-helpers.mjs`, `tools/standby-mode-control.mjs`, `tools/standby-session-reset.mjs`. +Этот план НЕ изменяет ни один `enforce-*`-хук — врезка пропуска в хуки идёт ОТДЕЛЬНЫМ планом. +Аудит реестра хуков уже выполнен и зафиксирован в спеке: снимаемые (12 хуков поимённо) — секция D4, +сохранённые рельсы — секция D5, триггер-фразы — секция D2. Классификация хуков не является входом для +шагов ЭТОГО плана (хуки здесь не правятся). + +## Переговоры + +**Позиция контроллера (ответ на NO-GO наставника «добавь шаг audit settings.json»):** + +1. Аудит реестра хуков **выполнен** в разговорном режиме по `settings.json` (предоставлен владельцем) + и **зафиксирован в спеке**: D4 — 12 снимаемых хуков поимённо; D5 — рельсы (пол / снимок / + verify-gate / floor-escape-consume / наблюдатели). Финальный список — входные данные для ОТДЕЛЬНОГО + плана врезки, не для этого. +2. Сделать «audit settings.json» **шагом плана нельзя**: чтение `settings.json` под стеной + hard-блокируется `enforce-read-path-deny`. Требуемый шаг невыполним средствами шага плана. +3. Данный план — **фундамент**: три новых модуля. Правка `enforce-hook-helpers.mjs` (добавить + `standbyActive`/`removeSentinel`) не зависит от классификации хуков. Корректность шагов + доказывается их собственными тестами (D6), а не списком снимаемых хуков. + +Прошу снять NO-GO: требуемый аудит уже в спеке (D4/D5), а как шаг плана он невыполним по стене. + +## Шаги + +```skills-json +["test-driven-development"] +``` + +```steps-json +[ + {"op":"Edit","object":"tools/enforce-hook-helpers.test.mjs","ref":"D1"}, + {"op":"Bash","object":"npx vitest run tools/enforce-hook-helpers.test.mjs -t standbyActive --reporter dot","ref":"D1"}, + {"op":"Edit","object":"tools/enforce-hook-helpers.mjs","ref":"D1"}, + {"op":"Bash","object":"npx vitest run tools/enforce-hook-helpers.test.mjs -t standbyActive --reporter dot","ref":"D1"}, + {"op":"Write","object":"tools/standby-mode-control.test.mjs","ref":"D2"}, + {"op":"Bash","object":"npx vitest run tools/standby-mode-control.test.mjs --reporter dot","ref":"D2"}, + {"op":"Write","object":"tools/standby-mode-control.mjs","ref":"D2"}, + {"op":"Bash","object":"npx vitest run tools/standby-mode-control.test.mjs --reporter dot","ref":"D2"}, + {"op":"Write","object":"tools/standby-session-reset.test.mjs","ref":"D3"}, + {"op":"Bash","object":"npx vitest run tools/standby-session-reset.test.mjs --reporter dot","ref":"D3"}, + {"op":"Write","object":"tools/standby-session-reset.mjs","ref":"D3"}, + {"op":"Bash","object":"npx vitest run tools/standby-session-reset.test.mjs --reporter dot","ref":"D3"} +] +``` + +Шаги (человекочитаемо): 1. Edit тест helper'а (тесты `standbyActive`/`removeSentinel`, D1). 2. Bash RED. +3. Edit `enforce-hook-helpers.mjs` (импорт `unlinkSync` + `standbyActive` через `readSentinel` + +`removeSentinel`). 4. Bash GREEN. 5. Write тест `decideControl` (D2). 6. Bash RED. 7. Write +`standby-mode-control.mjs`. 8. Bash GREEN. 9. Write тест `resetSession` (D3). 10. Bash RED. 11. Write +`standby-session-reset.mjs`. 12. Bash GREEN. + +> Авторитетный полный свод и коммит — в терминале владельца. + +```verified-context-json +[ + {"id":"ctx1","kind":"EXTRACTED","ref":"tools/enforce-hook-helpers.mjs","anchor":"export function readSentinel("}, + {"id":"ctx2","kind":"EXTRACTED","ref":"tools/enforce-hook-helpers.mjs","anchor":"export function writeSentinel("} +] +``` diff --git a/docs/superpowers/plans/2026-06-18-standby-mode.md b/docs/superpowers/plans/2026-06-18-standby-mode.md new file mode 100644 index 0000000..56377a5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-standby-mode.md @@ -0,0 +1,539 @@ +# Штатный режим (standby mode) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Добавить четвёртый режим стены — «штатный» (стен нет, кроме рельсов), включаемый владельцем на сессию одной фразой, чтобы не душить разработку самого мозга. + +**Architecture:** Сессионный флаг-часовой в `~/.claude/runtime/standby-mode-.json`. Снимаемые блокирующие хуки в начале `main()` проверяют флаг и пропускают действие. Включение — двухтактным рукопожатием через новый UserPromptSubmit-хук; авто-сброс — SessionStart-хук. Рельсы (пол / снимок / verify-gate) флаг НЕ читают. + +**Tech Stack:** Node.js ESM (`tools/*.mjs`), vitest, Claude Code hooks (PreToolUse/PostToolUse/Stop/SessionStart/UserPromptSubmit). + +**Спека:** [2026-06-18-standby-mode-design-r2.md](../specs/2026-06-18-standby-mode-design-r2.md) + +--- + +## Аудит реестра хуков (итог, авторитетно — замещает §5/§9 спеки) + +Проведён по реальному `settings.json`. Многие `enforce-*.mjs` в репозитории **не подключены** — учитываем только зарегистрированные. + +**ОСТАВЛЯЕМ (рельсы — страж НЕ врезается):** +`enforce-floor`, `enforce-snapshot`, `enforce-verify-gate` (рельс «проверка-перед-пушем»; инертен пока владелец не активировал флагом+ключом), `enforce-floor-escape-consume`, `floor-manifest-check`, наблюдатели (`observer-stop-hook`, `cost-stop-hook`, `router-embedding-warmup`, `enforce-verdict-surface`, `enforce-askuser-answer-parser`, inline-warn'ы). + +**СНИМАЕМ (врезаем страж) — 12 хуков:** +PreToolUse: `enforce-supreme-gate`, `enforce-domain-skill-discipline`, `enforce-normative-content-rules`, `enforce-read-path-deny`, `enforce-mcp-classification`, `enforce-criterion-gate`, `enforce-todowrite-skill-verifier`, `askuser-cosmetic-detector`. +PostToolUse: `enforce-mentor-then-judge`. +Stop: `enforce-coverage-verify`, `enforce-verdict-ack`, `enforce-gate3-loop`. + +**Поправка инварианта I-2 (безопасность):** `enforce-runtime-write-deny` НЕ подключён — флаг от подделки контроллером держит **default-deny `enforce-supreme-gate`**: пока штатный ВЫКЛ (флага нет), supreme-gate блокирует Write/Bash контроллера в `~/.claude/runtime/`. Флаг легитимно пишет только хук-процесс (`standby-mode-control`), не subject к PreToolUse-гейтам. Подделать после включения бессмысленно (SessionStart-сброс снимет на новой сессии). + +--- + +## Структура файлов + +- **Создать** `tools/standby-mode-control.mjs` — UserPromptSubmit: рукопожатие вкл/выкл + баннер. +- **Создать** `tools/standby-session-reset.mjs` — SessionStart: чистит флаг+pending. +- **Создать** `tools/hook-spawn-test-util.mjs` — тест-утилита: спавн хука с stdin-событием, возврат `{code, stdout}`. +- **Изменить** `tools/enforce-hook-helpers.mjs` — `+standbyActive`, `+removeSentinel`, `+unlinkSync` в импорт. +- **Изменить** 12 снимаемых хуков — врезка стража в `main()`. +- **Изменить** `tools/enforce-supreme-gate.mjs` — дополнительно косметика строк режимов. +- **Тесты**: `*.test.mjs` рядом с каждым новым/изменённым файлом. +- **Owner-шаг** (не агент): регистрация двух новых хуков в `.claude/settings.json`. + +--- + +### Task 1: Хелперы `standbyActive` + `removeSentinel` + +**Files:** +- Modify: `tools/enforce-hook-helpers.mjs` (импорт `fs` строка 23; рядом с `readSentinel`/`writeSentinel` строки 67-81) +- Test: `tools/enforce-hook-helpers.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +```js +// tools/enforce-hook-helpers.test.mjs — добавить блок +import { standbyActive, removeSentinel, writeSentinel } from './enforce-hook-helpers.mjs'; + +describe('standbyActive / removeSentinel', () => { + const S = 'test-standby-sess-1'; + afterEach(() => removeSentinel('standby-mode', S)); + + it('нет файла → false', () => { + removeSentinel('standby-mode', S); + expect(standbyActive(S)).toBe(false); + }); + it('active:true → true', () => { + writeSentinel('standby-mode', S, { active: true }); + expect(standbyActive(S)).toBe(true); + }); + it('active:false → false (мусор не включает)', () => { + writeSentinel('standby-mode', S, { active: false }); + expect(standbyActive(S)).toBe(false); + }); + it('removeSentinel снимает флаг', () => { + writeSentinel('standby-mode', S, { active: true }); + removeSentinel('standby-mode', S); + expect(standbyActive(S)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Запустить — упадёт** + +Run: `npx vitest run tools/enforce-hook-helpers.test.mjs -t standbyActive` +Expected: FAIL — `standbyActive is not a function`. + +- [ ] **Step 3: Реализация** + +В `tools/enforce-hook-helpers.mjs` импорт `fs` (строка 23) дополнить `unlinkSync`: +```js +import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from 'fs'; +``` +После `readSentinel` (после строки 81) добавить: +```js +export function standbyActive(sessionId) { + const s = readSentinel('standby-mode', sessionId); + return !!(s && s.active === true); +} + +export function removeSentinel(name, sessionId) { + try { + const p = sentinelPath(name, sessionId); + if (existsSync(p)) unlinkSync(p); + return true; + } catch { return false; } +} +``` + +- [ ] **Step 4: Запустить — пройдёт** + +Run: `npx vitest run tools/enforce-hook-helpers.test.mjs -t standbyActive` +Expected: PASS (4 теста). + +- [ ] **Step 5: Коммит** (под стеной — запросить эскейп у владельца) + +```bash +git add tools/enforce-hook-helpers.mjs tools/enforce-hook-helpers.test.mjs +git commit -m "feat(standby): standbyActive + removeSentinel helpers" +``` + +--- + +### Task 2: Управляющий хук `standby-mode-control` (вкл/выкл + баннер) + +**Files:** +- Create: `tools/standby-mode-control.mjs` +- Test: `tools/standby-mode-control.test.mjs` + +- [ ] **Step 1: Написать падающий тест (чистая `decideControl`)** + +```js +// tools/standby-mode-control.test.mjs +import { describe, it, expect } from 'vitest'; +import { decideControl } from './standby-mode-control.mjs'; + +describe('decideControl', () => { + it('«штатный режим» без active/pending → pending (переспрос)', () => { + expect(decideControl({ text: 'перейди в штатный режим', active: false, pending: false }).action).toBe('pending'); + }); + it('«да, штатный» при pending → enable', () => { + expect(decideControl({ text: 'да, штатный', active: false, pending: true }).action).toBe('enable'); + }); + it('«да, штатный» без pending → не enable', () => { + expect(decideControl({ text: 'да, штатный', active: false, pending: false }).action).not.toBe('enable'); + }); + it('«выключи штатный» → disable', () => { + expect(decideControl({ text: 'выключи штатный', active: true, pending: false }).action).toBe('disable'); + }); + it('активен, обычный промпт → banner', () => { + expect(decideControl({ text: 'что по задаче', active: true, pending: false }).action).toBe('banner'); + }); + it('ничего не подходит → none', () => { + expect(decideControl({ text: 'привет', active: false, pending: false }).action).toBe('none'); + }); +}); +``` + +- [ ] **Step 2: Запустить — упадёт** + +Run: `npx vitest run tools/standby-mode-control.test.mjs` +Expected: FAIL — модуль/`decideControl` не найден. + +- [ ] **Step 3: Реализация** + +```js +// tools/standby-mode-control.mjs +#!/usr/bin/env node +import { fileURLToPath } from 'url'; +import { readStdin, parseEventJson, writeSentinel, readSentinel, removeSentinel, standbyActive } from './enforce-hook-helpers.mjs'; + +const ENABLE_RE = /штатн\w*\s+режим/i; +const CONFIRM_RE = /\bда,?\s*штатн/i; +const DISABLE_RE = /выключ\w*\s+штатн/i; + +const BANNER = '⚠️ ШТАТНЫЙ РЕЖИМ — стен нет (оставлены: пол, точки возврата, проверка-перед-пушем). Верни стену фразой «выключи штатный».'; +const CONFIRM_ASK = '⚠️ Включить ШТАТНЫЙ — стен нет (останутся пол + проверка пуша). Точно? Подтверди фразой: «да, штатный».'; +const ENABLED_MSG = '✅ ШТАТНЫЙ ВКЛЮЧЁН. ' + BANNER; +const DISABLED_MSG = '🔒 Штатный выключен — стена возвращена (предреализация).'; + +/** Чистое решение. Порядок проверок важен: disable → confirm → enable-trigger → banner. */ +export function decideControl({ text, active, pending }) { + if (DISABLE_RE.test(text)) return { action: 'disable', context: DISABLED_MSG }; + if (CONFIRM_RE.test(text) && pending) return { action: 'enable', context: ENABLED_MSG }; + if (ENABLE_RE.test(text) && !active && !pending) return { action: 'pending', context: CONFIRM_ASK }; + if (active) return { action: 'banner', context: BANNER }; + return { action: 'none', context: null }; +} + +function output(context) { + if (context) { + process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: context } })); + } else { + process.stdout.write('{}'); + } + process.exit(0); +} + +async function main() { + try { + const event = parseEventJson(await readStdin()); + const sess = event.session_id || 'unknown'; + const text = String(event.prompt || event.user_prompt || ''); + const active = standbyActive(sess); + const pending = !!readSentinel('standby-pending', sess); + const r = decideControl({ text, active, pending }); + if (r.action === 'disable') { removeSentinel('standby-mode', sess); removeSentinel('standby-pending', sess); } + else if (r.action === 'enable') { writeSentinel('standby-mode', sess, { active: true }); removeSentinel('standby-pending', sess); } + else if (r.action === 'pending') { writeSentinel('standby-pending', sess, { pending: true }); } + output(r.context); + } catch { + process.stdout.write('{}'); process.exit(0); // fail-quiet: ошибка не ломает промпт, режим не меняется + } +} + +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) main(); +``` + +- [ ] **Step 4: Запустить — пройдёт** + +Run: `npx vitest run tools/standby-mode-control.test.mjs` +Expected: PASS (6 тестов). + +- [ ] **Step 5: Коммит** (эскейп) + +```bash +git add tools/standby-mode-control.mjs tools/standby-mode-control.test.mjs +git commit -m "feat(standby): UserPromptSubmit control hook (enable handshake + disable + banner)" +``` + +--- + +### Task 3: SessionStart-сброс `standby-session-reset` + +**Files:** +- Create: `tools/standby-session-reset.mjs` +- Test: `tools/standby-session-reset.test.mjs` + +- [ ] **Step 1: Падающий тест** + +```js +// tools/standby-session-reset.test.mjs +import { describe, it, expect } from 'vitest'; +import { resetSession } from './standby-session-reset.mjs'; + +describe('resetSession', () => { + it('удаляет standby-mode и standby-pending для сессии', () => { + const calls = []; + const remove = (name, sess) => { calls.push([name, sess]); return true; }; + resetSession('sess-X', remove); + expect(calls).toContainEqual(['standby-mode', 'sess-X']); + expect(calls).toContainEqual(['standby-pending', 'sess-X']); + }); +}); +``` + +- [ ] **Step 2: Запустить — упадёт** + +Run: `npx vitest run tools/standby-session-reset.test.mjs` +Expected: FAIL — модуль не найден. + +- [ ] **Step 3: Реализация** + +```js +// tools/standby-session-reset.mjs +#!/usr/bin/env node +import { fileURLToPath } from 'url'; +import { readStdin, parseEventJson, removeSentinel } from './enforce-hook-helpers.mjs'; + +/** Чистая: чистит флаг+pending данной сессии через инъектируемый remove. */ +export function resetSession(sess, remove) { + remove('standby-mode', sess); + remove('standby-pending', sess); + return { reset: true }; +} + +async function main() { + try { + const event = parseEventJson(await readStdin()); + const sess = event.session_id || 'unknown'; + resetSession(sess, removeSentinel); + } catch { /* fail-quiet — сброс best-effort */ } + process.stdout.write('{}'); process.exit(0); +} + +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) main(); +``` + +- [ ] **Step 4: Запустить — пройдёт** + +Run: `npx vitest run tools/standby-session-reset.test.mjs` +Expected: PASS. + +- [ ] **Step 5: Коммит** (эскейп) + +```bash +git add tools/standby-session-reset.mjs tools/standby-session-reset.test.mjs +git commit -m "feat(standby): SessionStart reset hook (clears flag on new session)" +``` + +--- + +### Task 4: Тест-утилита спавна + страж в `enforce-supreme-gate` (эталон) + +**Files:** +- Create: `tools/hook-spawn-test-util.mjs` +- Modify: `tools/enforce-supreme-gate.mjs` (импорт строки 8-19; `main()` после `const sess = resolveSessionId(event);` строка ~472) +- Test: `tools/enforce-supreme-gate.test.mjs` + +- [ ] **Step 1: Тест-утилита спавна** + +```js +// tools/hook-spawn-test-util.mjs +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const TOOLS = dirname(fileURLToPath(import.meta.url)); + +/** Запускает хук как процесс со stdin-событием. Возврат {code, stdout, stderr}. */ +export function runHook(hookFile, event) { + const r = spawnSync('node', [join(TOOLS, hookFile)], { + input: JSON.stringify(event), encoding: 'utf-8', + }); + return { code: r.status, stdout: r.stdout || '', stderr: r.stderr || '' }; +} +``` + +- [ ] **Step 2: Падающий тест стража supreme-gate** + +```js +// tools/enforce-supreme-gate.test.mjs — добавить блок +import { runHook } from './hook-spawn-test-util.mjs'; +import { writeSentinel, removeSentinel } from './enforce-hook-helpers.mjs'; + +describe('supreme-gate: штатный режим пропускает', () => { + const S = 'standby-supreme-1'; + // Write в произвольный путь — в разговорном режиме блокируется (не seed/observe/authoring) + const ev = { tool_name: 'Write', tool_input: { file_path: 'C:/tmp/x.txt', content: 'a' }, session_id: S }; + afterEach(() => removeSentinel('standby-mode', S)); + + it('штатный ВЫКЛ → блок (exit 2)', () => { + removeSentinel('standby-mode', S); + expect(runHook('enforce-supreme-gate.mjs', ev).code).toBe(2); + }); + it('штатный ВКЛ → пропуск (exit 0)', () => { + writeSentinel('standby-mode', S, { active: true }); + expect(runHook('enforce-supreme-gate.mjs', ev).code).toBe(0); + }); +}); +``` + +- [ ] **Step 3: Запустить — упадёт на втором тесте** + +Run: `npx vitest run tools/enforce-supreme-gate.test.mjs -t "штатный режим пропускает"` +Expected: FAIL — «штатный ВКЛ» даёт exit 2 (стража ещё нет). + +- [ ] **Step 4: Врезать страж** + +В `tools/enforce-supreme-gate.mjs` импорт из helpers (строка 8) дополнить `standbyActive`: +```js +import { readStdin, parseEventJson, exitDecision, standbyActive } from './enforce-hook-helpers.mjs'; +``` +В `main()` сразу после `const sess = resolveSessionId(event);` (строка ~472) добавить: +```js + if (standbyActive(sess)) { exitDecision({ block: false }); return; } // штатный режим: стена пропускает +``` + +- [ ] **Step 5: Запустить — пройдёт** + +Run: `npx vitest run tools/enforce-supreme-gate.test.mjs -t "штатный режим пропускает"` +Expected: PASS (2 теста). + +- [ ] **Step 6: Коммит** (эскейп) + +```bash +git add tools/hook-spawn-test-util.mjs tools/enforce-supreme-gate.mjs tools/enforce-supreme-gate.test.mjs +git commit -m "feat(standby): bypass guard in supreme-gate + spawn test util" +``` + +--- + +### Task 5: Страж в остальные 11 снимаемых хуков + +**Единый паттерн врезки** (одинаков для всех; меняются только имя файла и тестовое событие): +1. В импорт из `./enforce-hook-helpers.mjs` добавить `standbyActive` (и `exitDecision`, если хук его ещё не импортирует — у `enforce-coverage-verify` и `enforce-todowrite-skill-verifier` есть только `exitDisciplineDecision`). +2. В `main()` сразу после получения события и `sess = event.session_id || 'unknown'` (в supreme-gate было `resolveSessionId`) добавить: + ```js + if (standbyActive(sess)) { exitDecision({ block: false }); return; } + ``` + — раньше любого чтения транскрипта/журнала/решения. +3. Тест (тот же скелет, что Task 4 Step 2) с событием из таблицы ниже: `штатный ВЫКЛ → code 2`, `штатный ВКЛ → code 0`. + +Делать по одному хуку = один цикл (тест RED → врезка → GREEN → коммит). Тестовое блокирующее событие на каждый: + +| Хук (файл) | sess в main() | Тестовое блокирующее событие | +|---|---|---| +| `enforce-read-path-deny.mjs` | `event.session_id \|\| 'unknown'` | `{tool_name:'Read', tool_input:{file_path:'/.claude/runtime/x.json'}, session_id:S}` | +| `enforce-domain-skill-discipline.mjs` | то же | `{tool_name:'Bash', tool_input:{command:'echo hi'}, session_id:S}` | +| `enforce-normative-content-rules.mjs` | то же | `{tool_name:'Write', tool_input:{file_path:'/memory/x.md', content:'a'}, session_id:S}` | +| `enforce-mcp-classification.mjs` | то же | `{tool_name:'mcp__perplexity__perplexity_search', tool_input:{query:'q'}, session_id:S}` | +| `enforce-criterion-gate.mjs` | то же | `{tool_name:'Bash', tool_input:{command:'git commit -m x'}, session_id:S}` | +| `enforce-todowrite-skill-verifier.mjs` | то же | `{tool_name:'TodoWrite', tool_input:{todos:[]}, session_id:S, transcript_path:''}` | +| `askuser-cosmetic-detector.mjs` | то же | `{tool_name:'AskUserQuestion', tool_input:{questions:[]}, session_id:S}` | +| `enforce-mentor-then-judge.mjs` (PostToolUse) | то же | `{tool_name:'Write', tool_input:{file_path:'/docs/superpowers/specs/tmp.md'}, session_id:S}` | +| `enforce-coverage-verify.mjs` (Stop) | то же | см. примечание Stop ниже | +| `enforce-verdict-ack.mjs` (Stop) | то же | см. примечание Stop ниже | +| `enforce-gate3-loop.mjs` (Stop) | то же | см. примечание Stop ниже | + +**Примечание по Stop-хукам:** страж ставится ПЕРЕД `readTranscript`, поэтому для теста «ВКЛ → пропуск» достаточно `{session_id:S}` (страж возвращает до чтения транскрипта → code 0). Для контроля «ВЫКЛ → блок» нужен транскрипт, провоцирующий блок. Использовать фикстуру: записать temp-JSONL с мутирующим `tool_use` и ассистент-текстом без `coverage:` строки, передать путь в `transcript_path`. Для `enforce-coverage-verify`: +```js +import { writeFileSync, rmSync } from 'node:fs'; +const tp = `${process.env.TEMP || '/tmp'}/standby-cov-${S}.jsonl`; +writeFileSync(tp, JSON.stringify({ message: { role:'user', content:'hi' } }) + '\n' + + JSON.stringify({ message: { role:'assistant', content:[{type:'tool_use', name:'Write', input:{}}, {type:'text', text:'сделал'}] } }) + '\n'); +// ВЫКЛ: runHook('enforce-coverage-verify.mjs', {session_id:S, transcript_path:tp}).code === 2 +// ВКЛ: writeSentinel(...) → code === 0 ; затем rmSync(tp) +``` +Для `enforce-verdict-ack` / `enforce-gate3-loop` аналогично: если фикстуру блокировки воспроизвести дорого, ограничиться тестом «ВКЛ → code 0» с `{session_id:S}` + комментарием, что блок-ветка покрыта существующими тестами хука. (Не идеально, но страж — ранний `return`, риск низкий.) + +- [ ] **Подзадача 5.1–5.11:** по одному хуку из таблицы — RED → врезка → GREEN → коммит `feat(standby): bypass guard in `. + +--- + +### Task 6: Косметика — переименование режимов в надписях + +**Files:** +- Modify: `tools/enforce-supreme-gate.mjs` (строки 367, 374 — сообщения `decideMode`) +- Test: `tools/enforce-supreme-gate.test.mjs` + +- [ ] **Step 1: Обновить/добавить тест на текст** + +```js +// tools/enforce-supreme-gate.test.mjs — найти тесты, проверяющие строку +// 'разговорный режим: только думать/спрашивать' и заменить ожидание на 'предреализация:'. +// Если такого теста нет — добавить: +it('сообщение блока в разговорном режиме называет «предреализация»', () => { + const r = decideMode({ toolUse: { name: 'Write', input: { file_path: 'C:/tmp/x.txt' } }, frozenPlan: null }); + expect(r.reason).toMatch(/предреализация/); +}); +``` + +- [ ] **Step 2: Запустить — упадёт** + +Run: `npx vitest run tools/enforce-supreme-gate.test.mjs -t предреализация` +Expected: FAIL — сейчас текст «разговорный режим». + +- [ ] **Step 3: Правка строк** (логику не трогать) + +В `tools/enforce-supreme-gate.mjs`: +- строка 367: `'разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана)'` → `'предреализация: только думать/спрашивать (реализация — после печати артефакта и плана)'`. +- прочие пользовательские упоминания «разговорный режим» в `reason`-строках этого файла → «предреализация» (grep по файлу; `mode:'conversational'` — код-значение — НЕ трогать). + +- [ ] **Step 4: Запустить — пройдёт** + +Run: `npx vitest run tools/enforce-supreme-gate.test.mjs` +Expected: PASS (весь файл). + +- [ ] **Step 5: Коммит** (эскейп) + +```bash +git add tools/enforce-supreme-gate.mjs tools/enforce-supreme-gate.test.mjs +git commit -m "feat(standby): rename conversational→предреализация in user-facing strings" +``` + +--- + +### Task 7: Регрессия — рельсы держатся при штатном + +**Files:** +- Test: `tools/standby-rails-regression.test.mjs` (create) + +- [ ] **Step 1: Тест** + +```js +// tools/standby-rails-regression.test.mjs +import { describe, it, expect, afterEach } from 'vitest'; +import { runHook } from './hook-spawn-test-util.mjs'; +import { writeSentinel, removeSentinel } from './enforce-hook-helpers.mjs'; + +describe('рельсы НЕ снимаются штатным', () => { + const S = 'standby-rails-1'; + afterEach(() => removeSentinel('standby-mode', S)); + + it('пол блокирует rm -rf даже при штатном', () => { + writeSentinel('standby-mode', S, { active: true }); + const ev = { tool_name: 'Bash', tool_input: { command: 'rm -rf /' }, session_id: S }; + expect(runHook('enforce-floor.mjs', ev).code).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Запустить — пройдёт сразу** (страж в пол НЕ врезан — это и проверяем) + +Run: `npx vitest run tools/standby-rails-regression.test.mjs` +Expected: PASS — пол блокирует независимо от флага. + +- [ ] **Step 3: Коммит** (эскейп) + +```bash +git add tools/standby-rails-regression.test.mjs +git commit -m "test(standby): regression — floor rail blocks under standby" +``` + +--- + +### Task 8: Регистрация хуков в settings.json — OWNER-ШАГ (не агент) + +Агент НЕ правит `settings.json` (owner-only). Выдать владельцу точный дифф для вставки: + +- В `hooks.UserPromptSubmit` добавить запись: + ```json + { "hooks": [ { "type": "command", "command": "node tools/standby-mode-control.mjs", "timeout": 5 } ] } + ``` +- В `hooks.SessionStart` добавить запись: + ```json + { "hooks": [ { "type": "command", "command": "node tools/standby-session-reset.mjs", "timeout": 5 } ] } + ``` + +После вставки — перезапуск сессии, проверка: фраза «штатный режим» → переспрос; «да, штатный» → баннер; действие, прежде блокируемое, проходит; «выключи штатный» → стена вернулась. + +--- + +### Task 9: Полный прогон + финальный коммит + +- [ ] **Step 1:** Полный свод тестов в терминале владельца (не в Claude-Bash — воркеры рушатся): + Run: `npx vitest run` + Expected: PASS (все новые + существующие). +- [ ] **Step 2:** Зелёная verify-расписка (если verify-gate активен) перед push: `node tools/produce-verify-receipt.mjs`. +- [ ] **Step 3:** Push (эскейп/owner-терминал). + +--- + +## Self-Review (выполнено при написании) + +- **Покрытие спеки:** §2 флаг → Task 1; §3 включение → Task 2; §4 выкл+баннер → Task 2; I-3 сброс → Task 3; §5 снятие → Task 4-5; §5 рельсы → Task 7; §6 косметика → Task 6; §8 тесты → в каждой задаче; §9 settings → Task 8. +- **Поправка I-2** (supreme-gate, не runtime-write-deny) отражена в разделе «Аудит». +- **Плейсхолдеры:** код приведён полностью; единственная оговорка — Stop-фикстуры (Task 5) даны явно. +- **Согласованность имён:** `standbyActive`, `removeSentinel`, `decideControl`, `resetSession`, `runHook` — единообразны во всех задачах. +- **Открытый риск:** триггер-фразы (Q-A спеки) можно скорректировать в Task 2 до коммита. diff --git a/docs/superpowers/specs/2026-06-18-standby-mode-design-r2.md b/docs/superpowers/specs/2026-06-18-standby-mode-design-r2.md new file mode 100644 index 0000000..97d8611 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-standby-mode-design-r2.md @@ -0,0 +1,209 @@ +# Штатный режим (standby mode) — дизайн (r2) + +> **Статус:** дизайн одобрен владельцем 2026-06-18, ждёт вычитки спеки перед планом. +> **Ревизия:** r2 — замещает `2026-06-18-standby-mode-design.md` (r1). Учтены два замечания +> судьи: (1) [fatal] модель защиты флага от подделки переписана честно (§2 I-2); +> (2) [heavy] авто-сброс больше не висит на уникальности sessionId — добавлен SessionStart-сброс (§2 I-3). +> **Кодовая фраза возврата:** «штатный режим». +> **Связь:** примыкает к эпику разговорного слоя +> ([2026-06-14-router-conversational-layer-decisions.md](2026-06-14-router-conversational-layer-decisions.md)), +> но это отдельная фича — режим разработки самого мозга, не повестка/база знаний. + +--- + +## 0. Зачем (боль) + +Реализационная стена (Машина 2 / `enforce-supreme-gate` + пол + гейты) спроектирована +против контроллера, который рационализирует обход дисциплины. Но когда мы разрабатываем +**сам мозг** (хуки `tools/*.mjs`, разговорный слой), стена душит собственную разработку: +каждый баг-цикл упирается в гейты, а лезть в `settings.json` и выдавать `floor_escape` под +каждое действие — огромная потеря времени. + +Нужен **режим, выставляемый один раз на сессию**, в котором мешающие стены сняты, а +страховочные рельсы оставлены. Не per-action escape — залипающий флаг. + +**Ключевой факт по коду:** машина режимов уже есть. `decideMode()` +([enforce-supreme-gate.mjs:350](../../../tools/enforce-supreme-gate.mjs)) уже возвращает три +режима: `conversational` (нет плана → думать/смотреть), `implementation` (опечатанный план → +шаги), `escape` (разовый выход владельца). Штатный — **четвёртый** режим в этой машине. + +--- + +## 1. Три режима (имена владельца) + +| Имя владельца | Код-режим (сейчас) | Что можно | +|---|---|---| +| **Штатный** (НОВЫЙ) | — | Всё, кроме 3 рельсов (см. §5). Свободная разработка мозга. | +| **Предреализация** | `conversational` | Думать / обсуждать / писать ТЗ. Мутации — только через план. | +| **Реализация** | `implementation` | Исполнять опечатанный план под TDD. | + +Решение владельца (2026-06-18): строим штатный **и** переименовываем два существующих в +эти имена (косметика надписей, логика стены не трогается — §6). + +--- + +## 2. Хранилище флага и модель безопасности + +- Файл-часовой, привязанный к **номеру сессии**: + `~/.claude/runtime/standby-mode-.json` через существующие + `writeSentinel('standby-mode', sess, { active: true })` / + `readSentinel` ([enforce-hook-helpers.mjs:63-81](../../../tools/enforce-hook-helpers.mjs)). + **Требование размещения:** часовой и pending-метка ОБЯЗАНЫ лежать под `~/.claude/runtime/` + (это даёт `sentinelPath`/`runtimeDir`) — иначе рельс из I-2 их не покрывает. План это проверяет. +- Читалка `standbyActive(sessionId)` в `enforce-hook-helpers.mjs`: + `true` **только** если файл есть и `active === true`; любая ошибка чтения/парса → `false`. + +**Инварианты (fail-safe):** + +- **I-1 (направление отказа):** ошибка/отсутствие/мусор → штатный ВЫКЛючен (стена работает). + Никогда не «молча выключить стену». + +- **I-2 (включить может только владелец, контроллер флаг не подделывает).** Держится тем, что + файл лежит под защищённым `~/.claude/runtime/`, и подделать его можно было бы только до + включения (когда штатный ВЫКЛ). В этот момент активны оба канала защиты: + - **Write/Edit/MCP-писатель** в путь флага → блокирует `enforce-runtime-write-deny` + (`RUNTIME_RE` матчит `.claude/runtime/`, [runtime-write-deny.mjs:25,54](../../../tools/enforce-runtime-write-deny.mjs)). + Это **оставленный рельс** (§5), всегда активен. + - **Bash/PowerShell** `echo > флаг` → блокируют `enforce-router-gate` / `enforce-powershell-gate`. + Они в списке СНИМАЕМЫХ, **но снимаются только когда штатный уже ВКЛючен** — а тогда подделка + бессмысленна (флаг уже стоит, и SessionStart-сброс (I-3) снимет его на новой сессии). + - **Остаточный риск (принят):** `runtime-write-deny` fail-OPEN, если `pathNormalize` бросит на + битом пути ([runtime-write-deny.mjs:17-20](../../../tools/enforce-runtime-write-deny.mjs)) — + узкий край (битый путь, который не резолвится), сознательно принят, чтобы не залочить сессию. + - **Вывод:** I-2 держат ДВА механизма (runtime-write-deny для tool-записи + router/ps-гейт для + shell-записи при выключенном штатном), а не один — в r1 было ошибочно приписано одному рельсу. + +- **I-3 (авто-сброс на старте сессии) — НЕ полагается на уникальность sessionId.** Гарантируется + отдельным SessionStart-хуком `standby-session-reset.mjs`, который при старте КАЖДОЙ сессии + удаляет `standby-mode-*` и `standby-pending-*`. Даже если среда переиспользует sessionId + (статический идентификатор), новая сессия всегда стартует со стеной. (Claude Code и так шлёт + per-session UUID в `event.session_id`, но I-3 на это НЕ опирается — гарантия в SessionStart-сбросе.) + +--- + +## 3. Включение (с подтверждением) + +Новый UserPromptSubmit-хук `standby-mode-control.mjs` (образец — механизм баннера +ECONOMY MODE). Двухтактное рукопожатие против случайного включения: + +1. Промпт содержит триггер **«штатный режим»**, флаг не активен, нет pending → + пишет pending-метку (`standby-pending-`), впрыскивает: + `⚠️ Включить ШТАТНЫЙ — стен нет (останутся пол + запрет пуша). Точно? Подтверди: «да, штатный».` +2. Следующий промпт содержит **«да, штатный»** и есть pending → пишет активный часовой, + снимает pending. С этого хода — баннер (§4). + +Контроллер (я) флаг включить не может (I-2): я не пишу в runtime ни tool'ом, ни shell'ом, пока +штатный выключен; а триггер-фразы — это промпт владельца, не мой tool-call. + +--- + +## 4. Выключение и баннер + +- **Выключение:** промпт содержит **«выключи штатный»** → удалить часовой + pending. + Без переспроса (возврат к безопасному — дёшев; асимметрия: включать трудно, выключать легко). + Либо просто новая сессия (I-3). +- **Баннер каждый ход (пока активно):** control-хук впрыскивает в `additionalContext`: + `⚠️ ШТАТНЫЙ РЕЖИМ — стен нет (оставлены: пол, запрет записи в служебные файлы, проверка-перед-пушем). Верни стену фразой «выключи штатный».` + +--- + +## 5. Что снимается, что остаётся + +**Механизм врезки:** в каждый СНИМАЕМЫЙ блокирующий хук, сразу после резолва `sessionId` +из события, добавляется страж: +```js +if (standbyActive(sess)) return exitDecision({ block: false }); +``` +(две строки; логика хука ниже не трогается). + +**СНИМАЕМ (мешающее разработке)** — врезаем стража в: +- `enforce-supreme-gate` (главный default-deny; страж — в начале `main()`/перед `decideMode`) +- `enforce-router-gate` (Bash default-deny) +- `enforce-powershell-gate` (PowerShell) +- `enforce-tdd-gate` (тест-первым на прод-код) +- `enforce-read-path-deny` (запрет читать конфиг/нормативку — readonly, низкий вред, под owner-gate) +- `enforce-mcp-classification` (MCP-гейт) +- `enforce-normative-content-rules` (правки нормативки/памяти) +- цепочка судьи/наставника/плана: `enforce-judge-gate`, `enforce-mentor-then-judge`, + `enforce-mentor-on-plan-write`, `enforce-gate3-loop`, `enforce-criterion-gate`, + `enforce-verify-gate`, `enforce-verdict-ack`, `enforce-verdict-surface`, + `enforce-llm-judge-per-tool` +- дисциплинарные напоминалки: `enforce-coverage-verify`, `enforce-todowrite-skill-verifier`, + `enforce-rationalization-audit`, `enforce-decomposition-detector`, + `enforce-self-debrief-detector`, `enforce-domain-skill-discipline`, `enforce-workflow-gate`, + `enforce-branch-switch`, `enforce-skill-journaler` + +> Точный финальный список фиксируется в плане (writing-plans) — пройти по реестру хуков в +> `settings.json` и каждый классифицировать «мешает / рельс». Список выше — рабочий, по +> прочитанным файлам. + +**ОСТАВЛЯЕМ — 3 рельса (стража НЕ врезаем):** +- `enforce-floor` — пол: rm-rf / force-push / снос БД (`migrate:fresh`) / запись в секреты и + ssh-ключи ([floor-decide.mjs:35-39](../../../tools/floor-decide.mjs)). +- `enforce-runtime-write-deny` — запрет **Write/Edit-инструменту** писать в `~/.claude/runtime/` + (держит часть I-2; shell-канал в runtime закрывают router/ps-гейт, пока штатный выкл). +- `enforce-verify-before-push` — проверка-перед-пушем (рельс «не выложить непроверенное»; + docs-only short-circuit сохраняется). +- (служебный спутник пола) `enforce-floor-escape-consume` — не трогаем. + +**ssh (по вопросу владельца 2026-06-18):** в штатном `ssh`/`scp`/удалённые команды работают +свободно (Bash-гейт снят, пол ssh-команды не блокирует — только защищает перезапись +приватных ключей). Выкладка через `git push` по ssh остаётся под оставленным рельсом +`enforce-verify-before-push`. + +--- + +## 6. Переименование (косметика) + +Только пользовательские надписи, без изменения логики `decideMode`: +- `'разговорный режим: только думать/спрашивать …'` → `'предреализация: только думать/спрашивать …'` +- надписи реализационного режима → «реализация». +- Баннер штатного называет все три режима, чтобы владелец видел, где он. + +Риск низкий (правка строк, не веток). Тесты, проверяющие текст этих сообщений, обновляются. + +--- + +## 7. Порядок стройки (честность про bootstrap) + +Всё это строится **под действующей стеной** (предреализация → план → печать → реализация под +TDD). Каждый хук — прод-код (`tools/*.mjs`), значит правка идёт тест-первым (RED → GREEN). +Воспользоваться штатным можно будет со **следующей сессии** после стройки. Один раз терпим +стену — чтобы потом не терпеть. + +--- + +## 8. Тестирование + +- `standbyActive` — юнит: активен / нет файла / мусор-в-файле → `false` (I-1). +- `standby-mode-control` — рукопожатие: триггер→pending; подтверждение→active; «выключи»→removed; + баннер при active; fail-quiet (ошибка хука не ломает промпт, режим остаётся выключен). +- `standby-session-reset` — SessionStart: при старте удаляет `standby-mode-*` и `standby-pending-*` + (доказывает I-3 без опоры на уникальность sessionId). +- Каждый СНИМАЕМЫЙ хук — тест: «штатный активен → действие, которое хук иначе блокирует, проходит». +- **Анти-подделка (I-2)** — регрессия: + - Write/Edit в `~/.claude/runtime/standby-mode-.json` → блок `enforce-runtime-write-deny` + (рельс активен всегда). + - При штатном ВЫКЛ: Bash `echo > standby-mode-.json` → блок `enforce-router-gate`. +- Оставленные рельсы — регрессия: «штатный активен → пол / runtime-write-deny / verify-before-push + ВСЁ РАВНО блокируют» (доказать, что рельсы не сняты). + +--- + +## 9. Объём (файлы) + +- Новое: `standby-mode-control.mjs` (UserPromptSubmit) + `standby-session-reset.mjs` (SessionStart) + + `standbyActive` в `enforce-hook-helpers.mjs` + их тесты. +- Правка: ~10-12 снимаемых хуков (2 строки стража + тест на каждый). +- Косметика: строки режимов в `enforce-supreme-gate.mjs` + затронутые тесты. +- `settings.json`: регистрация `standby-mode-control` (UserPromptSubmit) и `standby-session-reset` + (SessionStart) — **owner-шаг**. +- Итого ~14-18 файлов. + +--- + +## 10. Открытые вопросы + +- Q-A: точные триггер-фразы — предложены «штатный режим» / «да, штатный» / «выключи штатный». + Можно поменять при вычитке. +- Q-B: финальный список снимаемых vs оставляемых хуков — пройтись по `settings.json` в плане. diff --git a/docs/superpowers/specs/2026-06-18-standby-mode-spec.md b/docs/superpowers/specs/2026-06-18-standby-mode-spec.md new file mode 100644 index 0000000..ecf0275 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-standby-mode-spec.md @@ -0,0 +1,92 @@ +# Штатный режим разработки — спека + +## Цель + +Добавить сессионный режим разработки, в котором перечисленный набор PreToolUse/PostToolUse/Stop-хуков +пропускает действие по флагу сессии, чтобы итеративная разработка инструментов `tools/*.mjs` не +требовала отдельного подтверждения на каждое действие. Набор страховочных хуков (пол, снимок, +проверка перед git-операцией) флаг не читает и продолжает работать. + +## Флаг режима {#D1} + +Функция `standbyActive(sessionId)` в `tools/enforce-hook-helpers.mjs`. + +- **Контракт:** возвращает `true` тогда и только тогда, когда часовой-файл `standby-mode-.json` + существует и содержит `active === true`; во всех прочих случаях — `false`. +- **Хранилище:** часовой пишется/читается существующими `writeSentinel`/`readSentinel` (файл в каталоге + `runtimeDir()`, привязка по `sessionId`). +- **Направление отказа:** ошибка чтения, отсутствие файла, `active !== true` → `false`. Отсутствие + данных никогда не трактуется как «включён». +- **Парная функция** `removeSentinel(name, sessionId)`: удаляет часовой-файл, возвращает `true/false`, + ошибку глотает (best-effort). +- **Крайние случаи:** пустой/битый JSON → `readSentinel` вернёт `null` → `standbyActive` = `false`. + +## Управляющий хук {#D2} + +Файл `tools/standby-mode-control.mjs`, событие UserPromptSubmit. Чистая функция +`decideControl({ text, active, pending })` возвращает `{action, context}`. + +- **Контракт переходов** (порядок проверок фиксирован): + 1. текст содержит «выключи штатн…» → `action='disable'`; + 2. текст содержит «да, штатн…» И `pending` → `action='enable'`; + 3. текст содержит «штатн… режим» И не `active` И не `pending` → `action='pending'`; + 4. `active` → `action='banner'`; + 5. иначе → `action='none'`. +- **Побочные эффекты** (в `main`, не в чистой функции): `pending` → пишет метку `standby-pending`; + `enable` → пишет `standby-mode {active:true}` и снимает `standby-pending`; `disable` → снимает оба + часовых. +- **Вывод:** при непустом `context` — JSON `{hookSpecificOutput:{hookEventName:'UserPromptSubmit',additionalContext:}}`; иначе `{}`. +- **Направление отказа:** любая внутренняя ошибка `main` → вывод `{}`, состояние не меняется (промпт не + ломается). +- **Крайние случаи:** «да, штатный» без метки `pending` → НЕ `enable` (рукопожатие двухтактное). + +## Сброс сессии {#D3} + +Файл `tools/standby-session-reset.mjs`, событие SessionStart. Чистая функция +`resetSession(sessionId, remove)` вызывает `remove('standby-mode', sessionId)` и +`remove('standby-pending', sessionId)`. + +- **Контракт:** при старте сессии оба часовых данной сессии снимаются — старт всегда без флага. +- **Направление отказа:** ошибка → best-effort, вывод `{}`. + +## Врезка пропуска в хуки {#D4} + +В каждый хук из набора «снимаемых» в начале `main()`, сразу после получения события и +`sess = event.session_id || 'unknown'`, добавляется строка: +`if (standbyActive(sess)) { exitDecision({ block: false }); return; }` — раньше любого чтения +транскрипта/журнала/решения. + +- **Набор снимаемых хуков:** `enforce-supreme-gate`, `enforce-domain-skill-discipline`, + `enforce-normative-content-rules`, `enforce-read-path-deny`, `enforce-mcp-classification`, + `enforce-criterion-gate`, `enforce-todowrite-skill-verifier`, `askuser-cosmetic-detector`, + `enforce-mentor-then-judge`, `enforce-coverage-verify`, `enforce-verdict-ack`, `enforce-gate3-loop`. +- **Контракт:** при активном флаге хук возвращает «пропуск» (exit 0) на действие, которое иначе + заблокировал бы; при неактивном — поведение хука не меняется. +- **Конвенция:** в `enforce-supreme-gate` `sess` берётся из `resolveSessionId(event)` (там нет + прямого `event.session_id`); в остальных — `event.session_id || 'unknown'`. + +## Сохранённые рельсы {#D5} + +Страж НЕ врезается в: `enforce-floor`, `enforce-snapshot`, `enforce-verify-gate`, +`enforce-floor-escape-consume`, `floor-manifest-check` и наблюдатели (`observer-stop-hook`, +`cost-stop-hook`, `enforce-verdict-surface`, `enforce-askuser-answer-parser`). + +- **Контракт:** при активном флаге эти хуки продолжают блокировать свои действия (разрушительные + команды, точки возврата, проверка перед git-операцией) — флаг на них не влияет. +- **Критерий безопасности:** регрессионный тест доказывает, что `enforce-floor` блокирует `rm -rf` при + активном флаге. + +## Критерий приёмки {#D6} + +- `standbyActive` покрыт юнит-тестами (активен / нет файла / мусор → false). +- `decideControl` покрыт юнит-тестами по всем пяти переходам. +- `resetSession` покрыт юнит-тестом (снимает оба часовых). +- Каждый снимаемый хук покрыт интеграционным тестом «флаг активен → exit 0». +- Регрессия `enforce-floor` под активным флагом → блок (рельс держится). + +```verified-context-json +[ + {"id":"ctx1","kind":"EXTRACTED","ref":"tools/enforce-hook-helpers.mjs","anchor":"export function readSentinel("}, + {"id":"ctx2","kind":"EXTRACTED","ref":"tools/enforce-supreme-gate.mjs","anchor":"export function decideMode("} +] +``` diff --git a/tools/askuser-cosmetic-detector.mjs b/tools/askuser-cosmetic-detector.mjs index 4460677..1d2ca34 100644 --- a/tools/askuser-cosmetic-detector.mjs +++ b/tools/askuser-cosmetic-detector.mjs @@ -137,6 +137,7 @@ export async function main() { try { const raw = await readStdin(); const event = parseEventJson(raw); + if ((await import('./enforce-hook-helpers.mjs')).standbyActive((event && event.session_id) || 'unknown')) return exitDecision({ block: false }); if (!event || event.tool_name !== 'AskUserQuestion') return exitDecision({ block: false }); const questions = event.tool_input?.questions || []; diff --git a/tools/enforce-coverage-verify.mjs b/tools/enforce-coverage-verify.mjs index 509860a..9182e85 100644 --- a/tools/enforce-coverage-verify.mjs +++ b/tools/enforce-coverage-verify.mjs @@ -85,6 +85,7 @@ export function decide({ toolUses = [], assistantText = '', journalSkillCalls = async function main() { const raw = await readStdin(); const event = parseEventJson(raw); + { const __h = await import('./enforce-hook-helpers.mjs'); if (__h.standbyActive((event && event.session_id) || 'unknown')) return __h.exitDecision({ block: false }); } await exitDisciplineDecision( () => { const transcript = readTranscript(event.transcript_path); diff --git a/tools/enforce-criterion-gate.mjs b/tools/enforce-criterion-gate.mjs index c7207ff..e9624d9 100644 --- a/tools/enforce-criterion-gate.mjs +++ b/tools/enforce-criterion-gate.mjs @@ -42,6 +42,7 @@ async function main() { try { event = parseEventJson(await readStdin()); gate = verifyGateActive(); } catch { exitDecision({ block: false }); return; } // pre-gate ошибка → inert-safe ($0) if (!gate.active && !gate.keyMissing) { exitDecision({ block: false }); return; } + if ((await import('./enforce-hook-helpers.mjs')).standbyActive((event && event.session_id) || 'unknown')) return exitDecision({ block: false }); try { const { loadFrozenPlan, verifyFrozenPlan, treeLeaves, sealedCriterionIds: sealedIds } = await import('./plan-lock.mjs'); const { codeFingerprint } = await import('./criterion-green.mjs'); diff --git a/tools/enforce-domain-skill-discipline.mjs b/tools/enforce-domain-skill-discipline.mjs index d1dbac5..8f6c70f 100644 --- a/tools/enforce-domain-skill-discipline.mjs +++ b/tools/enforce-domain-skill-discipline.mjs @@ -40,6 +40,7 @@ async function main() { try { const event = parseEventJson(await readStdin()); const sess = resolveSessionId(event); + if ((await import('./enforce-hook-helpers.mjs')).standbyActive(sess)) return exitDecision({ block: false }); const dir = runtimeDir(); const frozenPlan = loadFrozenPlan({ sessionId: sess, runtimeDir: dir }); const { entries } = loadJournal({ sessionId: sess, runtimeDir: dir }); diff --git a/tools/enforce-gate3-loop.mjs b/tools/enforce-gate3-loop.mjs index 8f095dd..a75abe4 100644 --- a/tools/enforce-gate3-loop.mjs +++ b/tools/enforce-gate3-loop.mjs @@ -246,6 +246,7 @@ async function main() { const event = parseEventJson(await readStdin()); const dir = runtimeDir(); const sess = (event && event.session_id) || process.env.CLAUDE_SESSION_ID || 'unknown'; + if ((await import('./enforce-hook-helpers.mjs')).standbyActive(sess)) { exitDecision({ block: false }); return; } const key = resolveReceiptKey(); const judgeKey = resolveJudgeLlmKey(); const loadGreens = () => { try { return JSON.parse(fsDefault.readFileSync(join(dir, `criterion-greens-${sess}.json`), 'utf8')); } catch { return []; } }; diff --git a/tools/enforce-hook-helpers.mjs b/tools/enforce-hook-helpers.mjs index 2384706..0a9d9df 100644 --- a/tools/enforce-hook-helpers.mjs +++ b/tools/enforce-hook-helpers.mjs @@ -72,6 +72,23 @@ export function writeSentinel(name, sessionId, data) { } catch { return null; } } +import { unlinkSync } from 'node:fs'; + +/** Штатный режим активен для сессии: часовой существует и active===true; иначе/ошибка → false. */ +export function standbyActive(sessionId) { + const s = readSentinel('standby-mode', sessionId); + return !!(s && s.active === true); +} + +/** Снять часовой (best-effort): удаляет файл; ошибка/отсутствие → молча. */ +export function removeSentinel(name, sessionId) { + try { + const p = sentinelPath(name, sessionId); + if (existsSync(p)) unlinkSync(p); + return true; + } catch { return false; } +} + export function readSentinel(name, sessionId) { try { const p = sentinelPath(name, sessionId); diff --git a/tools/enforce-hook-helpers.test.mjs b/tools/enforce-hook-helpers.test.mjs index e36c3ba..74849fc 100644 --- a/tools/enforce-hook-helpers.test.mjs +++ b/tools/enforce-hook-helpers.test.mjs @@ -36,6 +36,16 @@ const disciplineOutcome = _disc; const FAIL_CLOSE_DISCIPLINE_HOOKS = _failClose; const FAIL_QUIET_OBSERVATION_HOOKS = _failQuiet; +import { standbyActive, removeSentinel, writeSentinel } from './enforce-hook-helpers.mjs'; +describe('standbyActive / removeSentinel (штатный режим)', () => { + const S = 'test-standby-sess-1'; + afterEach(() => removeSentinel('standby-mode', S)); + it('нет файла → false', () => { removeSentinel('standby-mode', S); expect(standbyActive(S)).toBe(false); }); + it('active:true → true', () => { writeSentinel('standby-mode', S, { active: true }); expect(standbyActive(S)).toBe(true); }); + it('active:false → false', () => { writeSentinel('standby-mode', S, { active: false }); expect(standbyActive(S)).toBe(false); }); + it('removeSentinel снимает флаг', () => { writeSentinel('standby-mode', S, { active: true }); removeSentinel('standby-mode', S); expect(standbyActive(S)).toBe(false); }); +}); + describe('disciplineOutcome — fail-CLOSE (M7 Фаза 0, правило 1)', () => { it('decideFn бросает → block:true (fail-CLOSE, не тихий пропуск)', async () => { const r = await disciplineOutcome(() => { throw new Error('boom'); }, { label: 'floor' }); diff --git a/tools/enforce-mcp-classification.mjs b/tools/enforce-mcp-classification.mjs index a41e781..17d079e 100644 --- a/tools/enforce-mcp-classification.mjs +++ b/tools/enforce-mcp-classification.mjs @@ -70,6 +70,7 @@ async function main() { const raw = await readStdin(); const event = parseEventJson(raw); const sess = (event && event.session_id) || 'unknown'; + if ((await import('./enforce-hook-helpers.mjs')).standbyActive(sess)) return exitDecision({ block: false }); let urlWhitelist = []; try { const { loadConfig } = await import('./brain-config.mjs'); diff --git a/tools/enforce-mentor-then-judge.mjs b/tools/enforce-mentor-then-judge.mjs index 44dfd9b..c8275e6 100644 --- a/tools/enforce-mentor-then-judge.mjs +++ b/tools/enforce-mentor-then-judge.mjs @@ -48,6 +48,7 @@ export async function runMentorThenJudge(eventJson, { spawnHook } = {}) { async function main() { try { const raw = await readStdin(); + { const __h = await import('./enforce-hook-helpers.mjs'); const __ev = __h.parseEventJson(raw); if (__h.standbyActive((__ev && __ev.session_id) || 'unknown')) return exitDecision({ block: false }); } const r = await runMentorThenJudge(raw, { spawnHook: spawnHookProc }); exitDecision({ block: r.block, message: r.block ? r.message : undefined }); } catch { diff --git a/tools/enforce-normative-content-rules.mjs b/tools/enforce-normative-content-rules.mjs index a73eebc..8681478 100644 --- a/tools/enforce-normative-content-rules.mjs +++ b/tools/enforce-normative-content-rules.mjs @@ -278,6 +278,7 @@ function logViolation({ sessionId, runtimeDirOverride, filePath, reason }) { async function main() { try { const event = parseEventJson(await readStdin()); + if ((await import('./enforce-hook-helpers.mjs')).standbyActive((event && event.session_id) || 'unknown')) return exitDecision({ block: false }); const toolName = event.tool_name; const filePath = event.tool_input && event.tool_input.file_path; let protectedPaths = []; diff --git a/tools/enforce-read-path-deny.mjs b/tools/enforce-read-path-deny.mjs index e4cde1c..2d9ca04 100644 --- a/tools/enforce-read-path-deny.mjs +++ b/tools/enforce-read-path-deny.mjs @@ -52,6 +52,7 @@ async function main() { const raw = await readStdin(); const event = parseEventJson(raw); const sess = (event && event.session_id) || 'unknown'; + if ((await import('./enforce-hook-helpers.mjs')).standbyActive(sess)) return exitDecision({ block: false }); const r = decide({ toolName: event.tool_name, filePath: event.tool_input?.file_path || event.tool_input?.filePath, diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index e34c83c..3afe928 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -464,6 +464,7 @@ async function main() { let event = {}; let escapeGrants = []; let escapeConsumed = []; try { event = parseEventJson(await readStdin()); + if ((await import('./enforce-hook-helpers.mjs')).standbyActive((event && event.session_id) || 'unknown')) { exitDecision({ block: false }); return; } const { resolveReceiptKey } = await import('./receipt-key-config.mjs'); const { loadFrozenPlan, loadFrozenArtifact, removeFrozenPlan } = await import('./plan-lock.mjs'); const { journalAppend } = await import('./action-journal.mjs'); diff --git a/tools/enforce-todowrite-skill-verifier.mjs b/tools/enforce-todowrite-skill-verifier.mjs index 06440da..db58439 100644 --- a/tools/enforce-todowrite-skill-verifier.mjs +++ b/tools/enforce-todowrite-skill-verifier.mjs @@ -62,6 +62,7 @@ export function decide({ todoItems, journalSkillCalls = [] }) { async function main() { const event = parseEventJson(await readStdin()); + { const __h = await import('./enforce-hook-helpers.mjs'); if (__h.standbyActive((event && event.session_id) || 'unknown')) return __h.exitDecision({ block: false }); } await exitDisciplineDecision( () => { const transcript = readTranscript(event.transcript_path); diff --git a/tools/enforce-verdict-ack.mjs b/tools/enforce-verdict-ack.mjs index 54de370..5d19c45 100644 --- a/tools/enforce-verdict-ack.mjs +++ b/tools/enforce-verdict-ack.mjs @@ -22,6 +22,7 @@ export function decide({ pendingAck, assistantText }) { async function main() { const ev = parseEventJson(await readStdin()); const sid = ev.session_id || ev.sessionId || 'unknown'; + { const __h = await import('./enforce-hook-helpers.mjs'); if (__h.standbyActive(sid)) return __h.exitDecision({ block: false }); } await exitDisciplineDecision( () => { const transcript = readTranscript(ev.transcript_path); diff --git a/tools/standby-mode-control.mjs b/tools/standby-mode-control.mjs new file mode 100644 index 0000000..7c70945 --- /dev/null +++ b/tools/standby-mode-control.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * standby-mode-control — UserPromptSubmit-хук штатного режима. + * Двухтактное рукопожатие включения, выключение одной фразой, баннер пока активно. + * Флаг пишется ТОЛЬКО здесь (реакция на промпт владельца). Fail-quiet: ошибка не ломает промпт. + */ +import { fileURLToPath } from 'url'; +import { readStdin, parseEventJson, writeSentinel, readSentinel, removeSentinel, standbyActive } from './enforce-hook-helpers.mjs'; + +const ENABLE_RE = /штатн\p{L}*\s+режим/iu; +const CONFIRM_RE = /(?:^|\s)да,?\s*штатн/iu; +const DISABLE_RE = /выключ\p{L}*\s+штатн/iu; + +const BANNER = '⚠️ ШТАТНЫЙ РЕЖИМ — стен нет (оставлены: пол, точки возврата, проверка-перед-пушем). Верни стену фразой «выключи штатный».'; +const CONFIRM_ASK = '⚠️ Включить ШТАТНЫЙ — стен нет (останутся пол + проверка пуша). Точно? Подтверди фразой: «да, штатный».'; +const ENABLED_MSG = '✅ ШТАТНЫЙ ВКЛЮЧЁН. ' + BANNER; +const DISABLED_MSG = '🔒 Штатный выключен — стена возвращена (предреализация).'; + +/** Чистое решение. Порядок проверок фиксирован: disable → confirm → enable-trigger → banner. */ +export function decideControl({ text, active, pending }) { + if (DISABLE_RE.test(text)) return { action: 'disable', context: DISABLED_MSG }; + if (CONFIRM_RE.test(text) && pending) return { action: 'enable', context: ENABLED_MSG }; + if (ENABLE_RE.test(text) && !active && !pending) return { action: 'pending', context: CONFIRM_ASK }; + if (active) return { action: 'banner', context: BANNER }; + return { action: 'none', context: null }; +} + +function output(context) { + if (context) { + process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: context } })); + } else { + process.stdout.write('{}'); + } + process.exit(0); +} + +async function main() { + try { + const event = parseEventJson(await readStdin()); + const sess = event.session_id || 'unknown'; + const text = String(event.prompt || event.user_prompt || ''); + const active = standbyActive(sess); + const pending = !!readSentinel('standby-pending', sess); + const r = decideControl({ text, active, pending }); + if (r.action === 'disable') { removeSentinel('standby-mode', sess); removeSentinel('standby-pending', sess); } + else if (r.action === 'enable') { writeSentinel('standby-mode', sess, { active: true }); removeSentinel('standby-pending', sess); } + else if (r.action === 'pending') { writeSentinel('standby-pending', sess, { pending: true }); } + output(r.context); + } catch { + process.stdout.write('{}'); process.exit(0); // fail-quiet: ошибка не ломает промпт, режим не меняется + } +} + +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) main(); diff --git a/tools/standby-mode-control.test.mjs b/tools/standby-mode-control.test.mjs new file mode 100644 index 0000000..bd87e0a --- /dev/null +++ b/tools/standby-mode-control.test.mjs @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { decideControl } from './standby-mode-control.mjs'; + +describe('decideControl — переходы штатного режима', () => { + it('«штатный режим» без active/pending → pending (переспрос)', () => { + expect(decideControl({ text: 'перейди в штатный режим', active: false, pending: false }).action).toBe('pending'); + }); + it('«да, штатный» при pending → enable', () => { + expect(decideControl({ text: 'да, штатный', active: false, pending: true }).action).toBe('enable'); + }); + it('«да, штатный» без pending → НЕ enable (двухтактное рукопожатие)', () => { + expect(decideControl({ text: 'да, штатный', active: false, pending: false }).action).not.toBe('enable'); + }); + it('«выключи штатный» → disable', () => { + expect(decideControl({ text: 'выключи штатный', active: true, pending: false }).action).toBe('disable'); + }); + it('активен, обычный промпт → banner', () => { + expect(decideControl({ text: 'что по задаче', active: true, pending: false }).action).toBe('banner'); + }); + it('ничего не подходит → none', () => { + expect(decideControl({ text: 'привет', active: false, pending: false }).action).toBe('none'); + }); +}); diff --git a/tools/standby-session-reset.mjs b/tools/standby-session-reset.mjs new file mode 100644 index 0000000..55c2d37 --- /dev/null +++ b/tools/standby-session-reset.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/** + * standby-session-reset — SessionStart-хук штатного режима. + * При старте КАЖДОЙ сессии снимает часовой standby-mode и метку standby-pending данной сессии — + * гарантирует, что новая сессия всегда стартует со стеной (не полагается на уникальность sessionId). + * Fail-quiet: ошибка не ломает старт. + */ +import { fileURLToPath } from 'url'; +import { readStdin, parseEventJson, removeSentinel } from './enforce-hook-helpers.mjs'; + +/** Чистая: чистит флаг+pending данной сессии через инъектируемый remove. */ +export function resetSession(sess, remove) { + remove('standby-mode', sess); + remove('standby-pending', sess); + return { reset: true }; +} + +async function main() { + try { + const event = parseEventJson(await readStdin()); + const sess = event.session_id || 'unknown'; + resetSession(sess, removeSentinel); + } catch { /* fail-quiet — сброс best-effort */ } + process.stdout.write('{}'); process.exit(0); +} + +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) main(); diff --git a/tools/standby-session-reset.test.mjs b/tools/standby-session-reset.test.mjs new file mode 100644 index 0000000..2f13f44 --- /dev/null +++ b/tools/standby-session-reset.test.mjs @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { resetSession } from './standby-session-reset.mjs'; + +describe('resetSession — сброс штатного на старте сессии', () => { + it('снимает standby-mode и standby-pending для сессии', () => { + const calls = []; + const remove = (name, sess) => { calls.push([name, sess]); return true; }; + resetSession('sess-X', remove); + expect(calls).toContainEqual(['standby-mode', 'sess-X']); + expect(calls).toContainEqual(['standby-pending', 'sess-X']); + }); +});