# P3: гейт §3.2 и приёмка §3.4 — что сделано

Дата: 2026-10-14. Основание — `docs/03_implementation/PLAN_3_2_3_4.md` (анализ и
план). Здесь — исполнение: что закрыто, чем доказано и что осталось.

---

## 1. Порядок работ и результат

| # | Шаг плана | Состояние | Доказательство |
|---|---|---|---|
| 0 | Дефект устаревшей ревизии | **закрыт** | `tests/test_rev_cache.py` — 11 из 11 |
| 1 | Правила гейта, состояние, операция `gate`, CLI | **сделано** | `tests/test_gate.py` — 41 из 41 |
| 2 | Хук, конфиг, установщик в профиль DSH | **сделано, установлено** | `tests/test_gate.py` (живой прогон хука), `tools/install_harness_gate.py --check` → exit 0 |
| 3 | Маркер сеанса и привязка метки задачи | **сделано** | `gate.GateState` + `gate.active_task`; проверки в `test_gate.py` |
| 4 | Отчёт `task_stats` и документация | **сделано** | `tests/test_gate_live.py` (раздел 8), README, INTEGRATION, AGENTS.md, навык, `harness/README.md` |
| 5 | Приёмочный прогон | **сделано** | `tests/test_gate_live.py` — 18 из 18, ход прогона ниже |

---

## 2. Шаг 0: дефект устаревшей ревизии

**Что было.** `Facade.rev_info()` читал `rev.json` один раз и кэшировал навсегда;
сброс происходил только при ПОЛНОЙ переиндексации. Долгоживущий процесс
(MCP-сервер) после переиндексации другим процессом продолжал отвечать прошлым
состоянием. Измерено экспериментом: фасад видел `rev-0120`, когда на диске был
`rev-0121`; в тяжёлой форме (данные ревизии удалены ротацией) ответы становились
пустыми — `rev: null`, `ft_chunk: 0`, `counts: 0`, — а `index_status` объявлял
`healthy: true`.

**Что сделано.**

1. `rev_info()` сверяет отпечаток файла ревизии (`st_mtime_ns`, `st_size`) и при
   расхождении перечитывает его и переоткрывает хранилища (`_reopen_stores`).
   Цена — один `stat` на вызов (проверено: 20 вызовов → 20 `stat`).
2. Дешёвый путь свежести тоже замечает смену ревизии и сбрасывает кэш.
3. `index_status` объявляет `healthy: false` с причиной, когда ревизия пуста
   (нет строки ревизии, нулевые файлы и узлы или `ft_chunk = 0`).
4. Телеметрия считает `stale` не только по `stale_warnings`, но и по признаку
   «ответ отдан по неактивной ревизии», и добавляет это предупреждение в конверт.

**Почему это было обязательным до гейта.** Гейт спрашивает RAGRAF о свежести
корпуса; процесс, отвечающий ревизией, данных которой уже нет, давал бы гейту
неверные основания, а метрика `stale_served = 0` (§3.4) оставалась бы зелёной,
не осматривая самый опасный класс ответов.

---

## 3. Шаг 1–3: как устроен гейт

**Правила** (`src/ragraf/gate.py`). Решение принимается по трём фактам из RAGRAF:
есть ли корпус для каталога вызова (реестр разработок, сравнение путей через
`catalog._norm` — иначе короткие 8.3-пути не находились), свеж ли он и
пользовались ли индексом в этом сеансе.

| Что делает агент | Вердикт |
|---|---|
| обращение к индексу (`mcp__ragraf__*`) | `allow`, факт записывается в маркер сеанса |
| безобидная команда, посторонний инструмент | `allow` (гейт не касается) |
| файловый поиск, корпуса нет | `allow` + подсказка «зарегистрируйте корпус» |
| файловый поиск, индекс отстал | `ask` (первое нарушение) → `deny`; в режиме `deny` — сразу `deny` |
| файловый поиск, индекс свеж, индексом не пользовались | `ask` → `deny`; в режиме `warn` — `allow` с подсказкой |
| файловый поиск, индекс свеж и им пользовались | `allow` |
| чтение файла кода | `allow` + подсказка (`symbol`, `search_multi`, `impact`) |
| файловый поиск вне каталогов с корпусом | `allow` |

**Свежесть — по отпечатку входа**, а не полной сверкой: расхождение отпечатка
(пути, размеры, время правки) доказывает изменение после индексации и даёт
`fresh=false` за доли секунды. Полная сверка вызывается только когда отпечатка
нет вовсе. Отставший корпус остаётся отставшим без повторных проверок, свежий
перепроверяется по TTL.

**Формат ответа** — то, что читает протокол хуков DSH: `allow`/`ask` — JSON
`hookSpecificOutput.permissionDecision` (+ `permissionDecisionReason`,
`additionalContext`), `deny` — **код возврата 2** с причиной в stderr
(единственный код, который протокол считает блокировкой).

**Состояние** — `catalog/gate_state.json`: маркеры сеансов, счётчики и журнал
последних 500 решений. Запись атомарная (временный файл + замена), число
сорванных записей видно полем `write_failures`.

**Привязка метки задачи.** Клиент не передаёт свой `session_id` ни CLI, ни
MCP-серверу, поэтому вызовы из этих путей попадали бы в «(без задачи)», и метрика
«доля задач с обращением к индексу» считала бы провал там, где индексом
пользовались (в документах корпуса z четыре отчёта подряд фиксируют
«инструментов `ragraf_*` в сеансе нет — работал через CLI»). Теперь фасад берёт
метку активного сеанса гейта (`gate.active_task`), если клиент её не задал.

---

## 4. Установка в профиль DSH (сделана)

```
python tools/install_harness_gate.py
  патч профиля: C:\Users\...\.dsh\profiles\desktop\cordis.patch.yml
  записей гейта: нет | чужих хуков: hooks-xerj-sync
  резервная копия: cordis.patch.yml.bak-20261014-152955
  Готово.
```

Проверено: `--check` → `exit 0`, в патче четыре записи `insert`
(`mcp-ragraf`, `mcp-xerj`, `hooks-xerj-sync`, `hooks-ragraf-gate`), повторный
запуск ничего не меняет (идемпотентность), чужая запись хука xerj на месте.

**Режим по умолчанию — `warn`, и это измеренное решение.** HARNESS в этой
установке сообщает «approval prompts are disabled: actions that require approval
are rejected automatically», то есть `ask` здесь молча превращается в отказ.
Ставить такой режим по умолчанию значило бы блокировать файловый поиск без
решения владельца; строгие режимы включаются явно (`RAGRAF_GATE_MODE=ask|deny`
или `--mode=` в команде хука).

**Что требуется от владельца:** перезапуск сеанса DSH — конфиг хуков читается
один раз при старте процесса.

---

## 5. Шаг 5: приёмочный прогон (`tests/test_gate_live.py`, 18 из 18)

Живой временный корпус (не боевой: правки боевого корпуса в проверке
недопустимы), решения проходят через `gate.handle_payload` и настоящий скрипт хука.

```
· свеж, без обращений: allow (index_unused_warn)
· свеж, без обращений (ask): ask
· после обращения к индексу: allow (index_used)
· индекс отстал (deny): deny (stale_index), exit 2
· после обращения к индексу при отставшем индексе: deny
· после пересборки: allow (index_used)
· вне корпуса: allow (no_corpus_search)
· состояние гейта: {"allow": 6, "total": 9, "ask": 1, "deny": 2}, потерь записи нет
```

Проверка также убедилась, что временный корпус **убран из реестра** после прогона
(файл реестра восстанавливается из копии) — проверка не имеет права оставлять
след в боевом состоянии.

---

## 6. Что нашлось по дороге (и закрыто)

| Находка | Замер | Правка |
|---|---|---|
| Кэш свежести гейта не сбрасывался: ключ записи и ключ инвалидации были в разной форме пути | после правки файла гейт продолжал считать индекс свежим | ключи через `catalog._norm` (`gate._path_key`) |
| `ttl=0` не сбрасывал кэш: у него нижняя граница 30 с | «сброс» молча не работал | явный `GateState.forget_freshness` |
| Полная сверка попадала в 15-секундный кэш сторожа | гейт считал свежим только что изменённый корпус | проверка по отпечатку входа, без полной сверки |
| Корпус не находился по короткому 8.3-пути (`C:\Users\<пользователь>\...`) | `find_corpus` → `None` для зарегистрированного корпуса | нормализация пути как в реестре |
| Решение `deny` смягчалось «первым нарушением» до `ask` | `--mode=deny` не отказывал | эскалация принадлежит только режиму `ask` |
| Эскалация не работала у клиента без `SessionStart` | три нарушения подряд оставались `ask` | маркер сеанса создаётся и при записи решения |
| `ask` в клиенте с отключёнными подтверждениями = автоматический отказ | политика клиента | по умолчанию `warn`, строгие режимы явно |
| Документированной операции `task_stats` **не было в коде** | `def op_*` её не содержал, а внешние правила и интеграция SagaAI её вызывают | операция реализована (`src/ragraf/facade.py`, `ragraf_task_stats`, CLI `task_stats`) |

---

## 7. Поверхности и числа

| Что | Было | Стало |
|---|---|---|
| операций фасада | 35 | **38** (`gate`, `task_stats`, `task_set`) |
| инструментов MCP | 38 | **40** (`ragraf_task_stats`, `ragraf_task`; `gate` намеренно не выставляется — его зовёт хук) |
| подкоманд CLI `query` | 35 | 38 |

Числа приведены в `README.md`, `INTEGRATION.md`, `RAGRAF_ARTICLE.md`,
`AGENTS-context.md`, `AGENTS.md`, навыке и `HARNESS_INTEGRATION.md`; чекер
(`checks: 19, errors: 0`) их сверяет с кодом.

---

## 8. Метка задачи (`RAGRAF_TASK`) — сделана

**Задача.** Собрать обращения к индексу в одну задачу, чтобы метрика «доля задач
с обращением к индексу» считалась по фактам, а не по обещаниям.

**Почему нельзя было просто «пробросить переменную».** MCP-сервер живёт весь
сеанс, его окружение задаётся при старте клиента, а задача меняется по ходу.
Поэтому сделана цепочка, и каждый её уровень назван в ответе операции:

| Источник | Как задаётся | Область |
|---|---|---|
| `RAGRAF_TASK` | переменная окружения до запуска клиента (DSH передаёт своё окружение дочернему процессу) | весь процесс |
| `ragraf_task` / `task_set` | агент называет задачу в начале работы | корпус, 8 часов |
| маркер сеанса | `SessionStart`-хук пишет `session_id` | активный сеанс |

Явная метка перебивает названную, названная — метку сеанса. Метка для другой
разработки не применяется. Снять — пустое имя или `--clear`.

**Проверено** (`tests/test_task_label.py`, 24 из 24): сроки метки (явный и общий),
изоляция разработок, порядок разрешения в фасаде, наличие операции и инструмента,
чтение `RAGRAF_TASK` сервером MCP и CLI. Найденный по дороге дефект: нижняя
граница срока в 60 секунд делала проверку «срок истёк» невыразимой — убрана.

---

## 9. §3.4: что теперь измеряется
| Метрика | Источник | Порог |
|---|---|---|
| `stale_served` | телеметрия (`usage_call.stale`), теперь включая «ответ по неактивной ревизии» | 0 |
| `saved_calls`, `hit_rate`, `avg_ms`, `misses` | телеметрия | `saved_calls` растёт, `hit_rate` ≥ 0,6 |
| «доля задач с обращением к индексу» | `usage --task` + метка активного сеанса гейта | ≥ 0,8 |
| «0 случаев grep без ensure» | журнал решений гейта (`catalog/gate_state.json`), виден в `task_stats` | 0 отклонённых, все законные разрешены |
| «строка отчёта» | `task_stats` (обращения, факты, заменённое чтение, решения гейта, заметки) + хук `Stop` прикладывает её в конце хода | есть в конце задачи |

---

## 10. Что осталось не сделанным

| Пункт | Состояние |
|---|---|
| **Живая проверка хука в новом сеансе DSH** | не выполнена: конфиг хуков читается один раз при старте процесса, а перезапуск сеанса делает владелец. Всё, что можно было проверить без перезапуска, проверено: скрипт хука прогнан на живых payload (41 проверка), установка подтверждена `--check`, патч профиля разобран YAML-ом |
| **Строгий режим в бою** | выбран безопасный `warn`; переход на `ask`/`deny` — решение владельца (в этом клиенте `ask` = отказ из-за отключённых подтверждений) |
| **Метрика «доля задач ≥ 0,8»** | механизм есть (метка сеанса + `task_stats`), но выборка задач для порога не набрана: она появляется по мере работы, а не одним прогоном |
| **Метки `RAGRAF_TASK` для MCP-вызовов** | клиент их не передаёт; привязка идёт через активный сеанс гейта. Если HARNESS начнёт передавать метку сам — точность вырастет |
| Прочие находки | проверки чужого проекта (`test_css_layer`, `test_class_usage`, три проверки SagaAI) не трогали; SagaAI требует sqlalchemy/streamlit |
