feat standby: штатный режим - флаг, управляющий хук, сброс, страж в 12 хуков
Сессионный флаг standby-mode + управляющий UserPromptSubmit-хук рукопожатия + SessionStart-сброс. Страж if standbyActive в 12 блокирующих хуках; рельсы floor/snapshot/verify-gate не тронуты. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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("}
|
||||
]
|
||||
```
|
||||
@@ -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-<sess>.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:'<HOME>/.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:'<repo>/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:'<repo>/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 <hook>`.
|
||||
|
||||
---
|
||||
|
||||
### 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 до коммита.
|
||||
@@ -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-<sessionId>.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-<sess>`), впрыскивает:
|
||||
`⚠️ Включить ШТАТНЫЙ — стен нет (останутся пол + запрет пуша). Точно? Подтверди: «да, штатный».`
|
||||
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-<sess>.json` → блок `enforce-runtime-write-deny`
|
||||
(рельс активен всегда).
|
||||
- При штатном ВЫКЛ: Bash `echo > standby-mode-<sess>.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` в плане.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Штатный режим разработки — спека
|
||||
|
||||
## Цель
|
||||
|
||||
Добавить сессионный режим разработки, в котором перечисленный набор PreToolUse/PostToolUse/Stop-хуков
|
||||
пропускает действие по флагу сессии, чтобы итеративная разработка инструментов `tools/*.mjs` не
|
||||
требовала отдельного подтверждения на каждое действие. Набор страховочных хуков (пол, снимок,
|
||||
проверка перед git-операцией) флаг не читает и продолжает работать.
|
||||
|
||||
## Флаг режима {#D1}
|
||||
|
||||
Функция `standbyActive(sessionId)` в `tools/enforce-hook-helpers.mjs`.
|
||||
|
||||
- **Контракт:** возвращает `true` тогда и только тогда, когда часовой-файл `standby-mode-<sessionId>.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:<context>}}`; иначе `{}`.
|
||||
- **Направление отказа:** любая внутренняя ошибка `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("}
|
||||
]
|
||||
```
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 []; } };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user