# План §3.2 (технический гейт) и §3.4 (приёмка внедрения)

> **Выполнено 14.10.2026.** Отчёт об исполнении — `docs/03_implementation/P3_REPORT.md`
> (что закрыто, чем доказано, что осталось). Ниже — исходный анализ и план,
> по которому работа шла: он оставлен как есть, чтобы видеть, что было
> предсказано, а что нашлось по дороге.

Основание — ТЗ `docs/TZ-2026-10-14-harness-ragraf-usage.md`, разделы 3.2 и 3.4.
Документ отвечает на два вопроса: **что именно надо сделать** и **на чём это
стоит** — с проверенными фактами об устройстве DSH и с одним найденным дефектом,
который обязан быть закрыт ДО приёмки (§3.4), иначе её метрики будут ложными.

Все утверждения о DSH получены чтением установленного `app.asar` (12 967 файлов,
включая пакеты `@deepseek-ai/dsh-hook-protocol`, `dsh-hooks-claude-code`,
`dsh-hooks-codex`), а не по документации из интернета.

---

## 1. Анализ §3.2: где вообще может стоять гейт

### 1.1 Чего сделать нельзя

ТЗ предлагает «добавить в DSH middleware на инструменты `pwsh` и `read`». Правка
исходников DSH в этой установке **невозможна**: клиент лежит в упакованном
`resources\app.asar`; каталог `...\app.asar\dsh\` как путь недоступен, а в
`app.asar.unpacked` только нативные модули (`node-pty`, `sherpa-onnx` и прочее).
Пакеты `@deepseek-ai/*` в профиле — пустые каталоги (`.pnpm` пуст): код живёт
внутри архива.

### 1.2 Что DSH даёт вместо этого (проверено)

1. **Мост хуков в стиле Claude Code** — пакет `@deepseek-ai/dsh-hooks-claude-code`
   v0.2.0-rc.2, и он **уже смонтирован в этом профиле**: в
   `~/.dsh/profiles/desktop/cordis.patch.yml` есть вставка
   `hooks-xerj-sync` с `configPath: tools\xerj\hooks.json`.
   То есть прецедент «внешний проект владеет хуком» существует и работает.
2. **Событие `PreToolUse` — «до выполнения инструмента: заблокировать инструмент
   или запросить подтверждение»** (README пакета, раздел «What your hooks can do»).
   Событие получает `tool_name` (имя инструмента) и `tool_input` (аргументы):
   в коде `preToolPayload()` возвращает
   `{session_id, transcript_path, cwd, hook_event_name, tool_name, tool_input, tool_use_id}`.
3. **Словарь решений** (`dsh-hook-protocol/lib/index.js`):
   * `hookSpecificOutput.permissionDecision` ∈ `allow` | `deny` | `ask`
     (+ `permissionDecisionReason`) — современная форма;
   * устаревшая верхнеуровневая `decision` ∈ `approve` | `block`
     (значения `deny` там **игнорируются** — в коде это сказано прямо);
   * **код возврата 2 = блокировка, причина берётся из stderr**
     (`BLOCKING_EXIT_CODE = 2`); любой другой ненулевой код — не блокирующая
     ошибка, агент продолжает;
   * слияние нескольких хуков: `deny > ask > allow`, контекст накапливается.
4. **Маскировка по имени инструмента**: `PreToolUse`/`PostToolUse` сопоставляются
   с именем инструмента, шаблон — литеральные альтернативы через `|` или регулярка.
5. **Конфиг** (схема zod из пакета): `configPath` (обязателен), `pluginRoot`,
   `projectDir`, `defaultTimeoutMs` (по умолчанию 600 000), `stderrSummaryMaxChars`.

### 1.3 Границы, которые обязаны попасть в план (и в ответы гейта)

| Ограничение | Следствие для гейта |
|---|---|
| `updatedInput` разбирается, но **не применяется** | гейт не может переписать команду (`Select-String …` → `ragraf search`); он умеет только `allow`/`ask`/`deny` и подсказку текстом |
| Жёсткой остановки прогона нет (`continue:false` только записывается) | «запретить и настоять» нельзя: отказ блокирует **этот** вызов, следующий агент сделает сам |
| `transcript_path` всегда `''` (persistence seam не отдаёт артефактов) | гейт **не может** прочитать историю сеанса; факт «был ли `ensure`» он обязан спрашивать у RAGRAF |
| Конфиг читается один раз при старте процесса | включение гейта требует перезапуска сессии, а не «горячей» правки |
| Один конфиг на процесс | RAGRAF-хук придётся либо вписать в общий `hooks.json`, либо смонтировать вторым пакетом с другим `configPath` |
| Хуки выполняются в каталоге сеанса | путь к скрипту гейта надо задавать абсолютным (`CLAUDE_PROJECT_DIR` подставляется, но не гарантирует корень RAGRAF) |

### 1.4 Вывод анализа

§3.2 **реализуем без правки ядра** — штатным `PreToolUse`-хуком, владельцем
которого будет RAGRAF. Гейт получается «мягким»: он может отказать в конкретном
вызове и объяснить причину, но не может переписать команду или заставить агента
переделать шаг.

---

## 2. План §3.2: гейт как хук RAGRAF

### 2.1 Что решает гейт (правила)

Решение принимается по трём фактам, и все три гейт берёт из RAGRAF, а не из
эвристики по строке:

1. **Есть ли корпус** для каталога вызова (`cwd` из payload).
2. **Свеж ли он** — дешёвая проверка (`watch.quick_check`, замер 0,09 с).
3. **Пользовались ли индексом в этом сеансе** — журнал RAGRAF: вызовы за окно
   сеанса (маркер сеанса ставит `SessionStart`-хук, `RAGRAF_TASK`/метка задачи
   передаётся гейтом в `ragraf gate`).

| Что делает агент | Вердикт | Почему |
|---|---|---|
| `mcp__ragraf__*` (любой инструмент индекса) | `allow` | это и есть работа через индекс; факт записывается в маркер сеанса |
| `pwsh` с `Select-String`, `rg`, `findstr`, `grep -r`, `Get-ChildItem -Recurse` и подобным, **корпуса нет** | `ask` + контекст «корпуса нет, зарегистрируйте: `ensure <каталог>`» | ТЗ: «не делать вид, что индекс проверен» |
| то же, корпус есть, но **устарел** | `deny` (первый раз — `ask`) с причиной «индекс отстал: `ensure`/`freshness`, затем `search_multi`» | именно этот случай ТЗ называет «grep без ensure» |
| то же, корпус свеж, но индексом в сеансе **не пользовались** | `ask` (первый раз) → `deny` (повторно) с причиной «используйте `search_multi`/`symbol`; индекс актуален» | основная цель: сместить работу на индекс |
| то же, корпус свеж и индексом **пользовались** | `allow` | правило не должно мешать |
| `read` файла кода длиннее N строк в проекте с корпусом | `allow` + контекст «есть `symbol`/`search_multi`, `impact` перед правкой» | чтение законно; запрет вызовет ложные отказы |
| `read`/`pwsh` вне проектов с корпусом (системные пути, чужие каталоги) | `allow` | иначе гейт мешает работе |

Ключевое отличие от «наивного» гейта из ТЗ: он не запрещает grep как таковой —
он запрещает **поиск по файлам там, где есть готовый индекс, к которому не
обратились**, и всегда называет причину и замену.

### 2.2 Компоненты (все — в RAGRAF, кроме одной строки в профиле DSH)

| Что | Где | Назначение |
|---|---|---|
| Операция `gate` | `src/ragraf/facade.py` (`op_gate`) + `src/ragraf/gate.py` | принять payload, применить правила, вернуть `allow/ask/deny` + причину, записать решение в журнал |
| CLI `gate` | `src/ragraf/cli.py` | `tools/ragraf.py -p <конфиг> gate --payload -` — читает JSON со stdin, печатает JSON решения |
| Хук-скрипт | `tools/harness_gate.py` | тонкий: stdin → вызов `ragraf gate` → stdout (JSON решения) / exit 2 + stderr |
| Конфиг хуков | `harness/hooks.json` | `PreToolUse` c маскировкой `pwsh|read|mcp__ragraf__.*`, `SessionStart` (маркер сеанса), `Stop` (строка отчёта) |
| Установщик | `tools/install_harness_gate.py` | идемпотентно вставляет в `cordis.patch.yml` запись `insert` рядом с существующей `hooks-xerj-sync`, с резервной копией и проверкой |
| Журнал решений | таблица `gate_call` в control-хранилище | `ts, session_id, tool, verdict, reason, command_digest, corpus, rev_id` — источник метрик §3.4 |

Пример `harness/hooks.json` (форма сверена с рабочим `tools\xerj\hooks.json`):

```json
{
  "hooks": {
    "SessionStart": [
      {"hooks": [{"type": "command", "timeout": 20,
                  "command": "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -NoProfile -File \"tools\\harness_gate.py\" session-start"}]}
    ],
    "PreToolUse": [
      {"matcher": "pwsh|read|Bash|mcp__ragraf__.*",
       "hooks": [{"type": "command", "timeout": 30,
                  "command": "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -NoProfile -File \"tools\\harness_gate.py\" pre-tool"}]}
    ],
    "Stop": [
      {"hooks": [{"type": "command", "timeout": 20,
                  "command": "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -NoProfile -File \"tools\\harness_gate.py\" stop"}]}
    ]
  }
}
```

Запись в патч профиля (рядом с xerj-хуком):

```yaml
- insert:
  - id: hooks-ragraf-gate
    name: '@deepseek-ai/dsh-hooks-claude-code'
    config:
      configPath: harness\hooks.json
      defaultTimeoutMs: 30000
```

### 2.3 Формат ответа гейта (ровно то, что читает протокол)

Разрешить и приложить подсказку (exit 0):

```json
{"hookSpecificOutput": {"hookEventName": "PreToolUse",
  "permissionDecision": "allow",
  "additionalContext": "индекс актуален (rev-0120): search_multi найдёт это быстрее"}}
```

Отказать (экзит 2 — причина уходит модели из stderr):

```
exit 2
stderr: индекс отстал (rev-0118, правки 7): сначала `ensure`, затем `search_multi`.
        Файловый поиск запрещён, пока индекс не актуален.
```

**Почему выбран exit 2, а не только JSON**: в коде протокола ровно этот код
означает блокировку (`BLOCKING_EXIT_CODE = 2`), а никакой другой ненулевой код
блокировкой не является. JSON-форма добавляется рядом (для `ask`), чтобы
подтверждение шло штатным путём согласования.

### 2.4 Шаги и приёмка §3.2

| # | Шаг | Проверка (чем доказано) |
|---|---|---|
| 1 | **Закрыть дефект устаревшей ревизии** (см. §3.1) | тест: два фасада, один переиндексирован → второй видит новую ревизию и не отдаёт пустых ответов |
| 2 | `op_gate` + `gate.py` + запись в журнал | `tests/test_gate.py`: правила на синтетических payload (нет корпуса / устарел / свеж без обращений / свеж с обращениями / не код) — без DSH |
| 3 | CLI `gate` и хук-скрипт | `echo '<payload>' \| python tools/ragraf.py gate --payload -` → JSON; `harness_gate.py pre-tool` на тех же payload |
| 4 | `harness/hooks.json` + установщик | `python tools/install_harness_gate.py --check` — идемпотентность и корректность YAML-патча (резервная копия рядом) |
| 5 | Живая проверка в DSH | вызвать `pwsh` с `Select-String` в проекте с устаревшим индексом → вызов отклонён, причина видна; вызвать `ragraf_search` → разрешён; после `ensure` — grep снова разрешён |
| 6 | Документация | `AGENTS.md` (раздел 8: гейт назван механизмом, а не пожеланием), навык `ragraf-core`, `docs/03_implementation/HARNESS_INTEGRATION.md` |

**Критерий §3.2**: в сеансе, где агент начал с `ensure` и работал индексом,
**ни один** вызов файлового поиска не отклонён (гейт не мешает); в сеансе без
`ensure` **каждый** вызов файлового поиска получил отказ или запрос
подтверждения с названной причиной и заменой. Оба случая проверяются руками и
записываются в журнал (`gate_call`).

### 2.5 Риски и как их снять

| Риск | Снятие |
|---|---|
| `deny` мешает законной работе (поиск строки, которой в индексе нет) | в причине всегда названа замена, а первое нарушение — `ask`, не `deny`; правила ограничены проектами с зарегистрированным корпусом |
| Гейт тормозит каждый вызов инструмента | бюджет ответа: `quick_check` (0,09 с) + один SELECT по журналу; «тяжёлых» проверок в гейте нет, при превышении — `allow` и запись в журнал |
| Конфиг читается один раз при старте | после установки требуется перезапуск сессии — это записывается в отчёт и в инструкцию |
| Хук сломался (код возврата ≠ 0 и ≠ 2) | протокол сам считает это не блокирующей ошибкой: агент продолжит; `gate_call` покажет пропуски |
| Правка `cordis.patch.yml` затрёт чужую вставку (xerj) | установщик редактирует файл по структуре YAML, делает резервную копию и проверяет, что запись xerj осталась |

---

## 3. План §3.4: приёмка внедрения

### 3.1 Блокирующий дефект: сервер отдаёт устаревшую ревизию

Найдено при работе **через MCP-инструменты** (CLI в это же время отвечал верно):

| Что вызвано | Ответ MCP | Правда (CLI/файлы) |
|---|---|---|
| `ragraf_index_status` | `index_rev: rev-0106`, `rev: null`, `ft_chunk: 0`, `counts: {files:0,nodes:0,edges:0}`, **`healthy: true`** | ревизия `rev-0120`, 599 файлов |
| `ragraf_search_multi` по `RAGRAF_TASK`, `task_stats` | `hit_count: 0`, `method: scan` (нет совпадений) | CLI: `hit_count: 3`, `method: fts` |
| `ragraf_freshness` в том же ответе | `rev_id: rev-0120`, `stale: false` | верно |

**Причина в коде**: `Facade.rev_info()` (`src/ragraf/facade.py:1372-1375`) читает
`rev.json` **один раз** и кэширует результат в `self._rev_cache`. Кэш сбрасывается
только тогда, когда **полная** проверка свежести сообщает `reindexed=True`
(`facade.py:1353-1362`). Быстрый путь (`watch.quick_check` по отпечатку входа)
возвращается раньше и `_rev_cache` не трогает.

**Эксперимент (14.10.2026, повторяемый)**: фасад A читает ревизию, другой
процесс пересобирает корпус, фасад A отвечает дальше.

```
фасад A при старте видит ревизию: rev-0120
другой процесс пересобирает корпус…
ревизия на диске после пересборки: rev-0121
фасад A ПОСЛЕ пересборки видит ревизию: rev-0120   ← кэш не сброшен
index_status.rev: rev-0120                          ← отвечает прошлым состоянием
```

Есть и вторая форма того же дефекта, тяжелее: если данные закэшированной ревизии
уже удалены ротацией, ответы становятся **пустыми** — так это и было видно через
MCP: `index_rev: rev-0106`, `rev: null`, `ft_chunk: 0`, `counts: {files:0,…}` —
и при этом **`healthy: true`** и `freshness.stale: false`.

**Почему это блокирует §3.4**:
* `stale_served` в метриках считается по `stale_warnings` → этот случай в него
  **не попадает**, то есть «`stale_served = 0`» сейчас — зелёная проверка,
  которая не осмотрела именно опасный класс ответов;
* гейт §3.2 спрашивает RAGRAF о свежести корпуса; если процесс отвечает старой
  ревизией, гейт будет принимать решения по неверным данным;
* «деградация вместо падения» нарушена: `healthy: true` при нулевых данных — это
  «зелёная проверка, которая ничего не осмотрела» (собственное определение
  провала в `AGENTS.md`).

**Что сделать (шаг 0 плана):**

1. `rev_info()` сверяет `stat` файла ревизии (время правки и размер) с тем, что
   было при чтении, и перечитывает при расхождении — ровно один `stat` на вызов,
   независимо от того, какой путь обновления сработал.
2. Быстрый путь `_ensure_fresh` при `quick.rev_id != current_rev()` сбрасывает
   `_rev_cache`, переоткрывает соединения графа/лексики (та же логика, что уже
   есть для `reindexed=True`) и добавляет в конверт `freshness.rev_changed`.
3. `op_index_status` объявляет `healthy=false` с причиной, если данных нет
   (`file_count == 0` при непустой сумме корпуса или `ft_chunk == 0`).
4. Телеметрия: считать `stale_served` не только по `stale_warnings`, но и по
   признаку «ответ отдан по ревизии, отличной от активной» (поле появится после
   п.1–2).
5. Тест `tests/test_rev_cache.py`: фасад A держит кэш, фасад B индексирует →
   у A `current_rev()` = новая, `index_status.counts.files > 0`, `stale_served`
   не растёт на новом ответе.

### 3.2 Метрики: что считаем, чем, где порог

| Метрика ТЗ | Как считается | Источник | Порог приёмки |
|---|---|---|---|
| `stale_served` | ответы, отданные по неактуальной ревизии (после шага 0 — честно) | `usage_call.stale` | **0** |
| `saved_calls` | вызовы операций из `READ_REPLACING_OPS`, вернувшие факты | `usage_call` (`replaces=1 AND fact_count>0`) | растёт от задачи к задаче |
| `hit_rate` | доля вызовов, вернувших факты | `usage_call` | ≥ 0,6 на задачах по коду |
| `avg_ms` | среднее время вызова | `usage_call.duration_ms` | не растёт (контроль регрессий P1) |
| `misses` | «не найдено» без ошибки | `usage_call.missed` | наблюдение, обосновывается в отчёте |
| **«≥80 % задач с обращением к индексу»** | по метке задачи: доля задач, у которых есть ≥1 вызов операции индекса | `usage --task` / `by_task` + маркер сеанса из гейта | ≥ 0,8 |
| **«0 случаев grep без ensure»** | решения гейта: `verdict ∈ {ask, deny}` по файловому поиску | `gate_call` (новое) | **0 отклонённых** при условии, что все законные случаи разрешены |
| «строка отчёта» | готовый отчёт по задаче | `task_stats` (+ раздел «гейт») | есть в конце задачи |

Три уточнения, без которых метрики останутся ритуалом:

1. **Задачу надо привязывать к сеансу DSH.** Сейчас метка `RAGRAF_TASK` — договор
   (её ставит тот, кто вызывает). Гейт знает `session_id` из payload, поэтому
   `SessionStart`-хук пишет маркер сеанса, а `gate` и вызовы индекса получают этот
   идентификатор как метку задачи. Тогда «доля задач с обращением к индексу»
   считается по фактам, а не по обещаниям.
2. **CLI — тоже обращение к индексу.** xerj-документы корпуса z показывают
   устойчивую картину: «Инструментов `ragraf_*` (MCP) в сеансе нет — работал через
   CLI» (четыре отчёта подряд). Метрика, считающая только MCP-вызовы, покажет
   провал там, где индексом пользовались. `usage` уже считает CLI (канал `tool`),
   но без метки задачи такие вызовы попадают в `(без задачи)` — маркер сеанса это
   закрывает.
3. **Отчёт уже имеет форму.** В тех же документах есть раздел «Проверка по
   RAGRAF»: «Перед работой: `ensure` → status», «После правок: `freshness --force`
   → `reindexed`», «Операции, по которым взяты факты». `task_stats` генерирует
   ровно это; план — добавить в него раздел «гейт» и требовать строку в AGENTS
   (триггерная таблица §3.1 это уже подразумевает).

### 3.3 Шаги и приёмка §3.4

| # | Шаг | Проверка |
|---|---|---|
| 1 | Дефект §3.1 закрыт (шаг 0) | `tests/test_rev_cache.py`; `ragraf_index_status` через MCP показывает реальную ревизию и ненулевые счётчики |
| 2 | Журнал решений гейта (`gate_call`) + учёт в `usage` | `tests/test_gate.py`: после пяти решений `usage --task` показывает `gate_checked=5, gate_denied=2` |
| 3 | Метка задачи из сеанса (`SessionStart` → маркер; `gate`/вызовы наследуют) | `tests/test_gate.py`: `by_task` содержит идентификатор сеанса, а не `(без задачи)` |
| 4 | `task_stats` с разделом «гейт» | печать отчёта; проверка, что в нём есть вызовы, факты, `stale_served`, решения гейта |
| 5 | Приёмочный отчёт на реальной задаче | один сеанс: сначала без `ensure` (гейт отклонил grep), затем с `ensure` (гейт разрешил, `saved_calls` > 0) → в отчёте обе строки |

**Критерий §3.4** (формулировка ТЗ в измеримом виде): на выборке задач
`stale_served = 0`; доля задач с обращением к индексу ≥ 0,8; отклонённых
файловых поисков при актуальном индексе — 0; по каждой задаче есть строка отчёта
с числами. Измерение ведётся двумя инструментами: `usage` (числа) и `task_stats`
(отчёт), оба — по журналу, без ручного счёта.

---

## 4. Порядок работ и оценка

| Очередь | Работа | Почему в этом порядке |
|---|---|---|
| 0 | Дефект устаревшей ревизии (§3.1) | без него и гейт, и метрики опираются на неверные данные |
| 1 | `op_gate` + журнал + правила + тесты (без DSH) | проверяется на синтетических payload, DSH не нужен |
| 2 | Хук-скрипт, `harness/hooks.json`, установщик профиля | требует перезапуска сессии — делать после того, как логика отлажена |
| 3 | Маркер сеанса и привязка метки задачи | даёт метрику «доля задач» |
| 4 | `task_stats` с разделом «гейт», обновление AGENTS/навыка | замыкает §3.4 |
| 5 | Приёмочный прогон и отчёт | доказательство, а не обещание |

Оценка: шаг 0 — правка одного метода и двух проверок; шаги 1–2 — новый модуль
`gate.py` (правила и журнал) и тонкий хук; шаги 3–4 — расширение телеметрии и
отчёта; шаг 5 — ручная проверка. Ни один шаг не требует правки DSH.

---

## 5. Чем помогли RAGRAF и xerj (по фактам этой работы)

### RAGRAF

| Что вызвано | Что дало |
|---|---|
| `ensure` | обнаружил, что индекс отставал (rev-0105), пересобрал; дальше `index_status` показал активную ревизию и стадии |
| `search_multi` (`RAGRAF_TASK`, `task_stats`, `cordis.patch`, `client_setup`) | **дал 0 совпадений там, где строки есть** → это и стало находкой: MCP-сервер отвечал по устаревшей ревизии. Сравнение с CLI (`hit_count: 3`, `method: fts`) превратило догадку в доказательство |
| `index_status` | показал противоречие внутри одного ответа: `rev: null`, `ft_chunk: 0`, `counts: 0` — и одновременно `healthy: true`, `freshness.rev_id: rev-0120` |
| `symbol`/`grep` по `current_rev` | нашёл причину в коде: `facade.py:1372-1375` (`_rev_cache` читается один раз) и `facade.py:1353-1362` (сброс только при полной переиндексации) |
| `search`, `search_multi` по ТЗ | доставил точные формулировки P2 и разделов 3.x из `docs/TZ-2026-10-14-harness-ragraf-usage.md` (файл:строка) — план писан по требованиям, а не по памяти |
| `code` (тело `literal_hits`) | дал текст функции для проверки формулы строки (из этого вышла ловушка 35 в прошлой очереди) |
| `references` (`cross_check`) | показал единственного вызывающего и косвенно — эффект заголовков-заглушек |

### xerj

| Что вызвано | Что дало |
|---|---|
| `xerj_map` (карта полей `autoindex`) | показал состав индексов и полей — по нему видно, что рабочий индекс документов на этом узле — `ax-z-batteryservice-ru-docs`, а каталожная запись `ax-docs` **расходится с реальностью** |
| `xerj_search` по `ax-docs` | вернул 404 «no such index» — доказательство расхождения каталога и данных (метод: проверять существование `ax-*/_search`, а не доверять карте) |
| `xerj_search` по `ax-*` («PreToolUse хуки RAGRAF ensure гейт») | нашёл документы корпуса z с разделами «Проверка по RAGRAF»: реальные примеры строки отчёта (`ensure` → `status`, `freshness --force` → `reindexed`, перечень операций) и **повторяющийся факт «инструментов `ragraf_*` в сеансе нет — работал через CLI»**. Это прямо повлияло на план §3.4: метрика «доля задач с обращением к индексу» обязана считать CLI, иначе покажет провал там, где индексом пользовались |
| `xerj_code_search` (корпус `code`) | отказал: «corpus 'code' is not indexed» — кодовый корпус на узле не собран, поэтому поиск по чужим реализациям в этом плане не использовался |

Отдельно: xerj и RAGRAF **связаны в этой установке хуком** — `hooks-xerj-sync`
в профиле DSH запускает `tools\xerj\session-hook.ps1`, а тот
«спрашивает RAGRAF о свежести индекса и обновляет XERJ только при расхождении
ревизии». Значит §3.2 не первый такой хук: механизм уже проверен на живом
профиле, и RAGRAF в нём уже участвует как источник факта о свежести.

### Границы этой разведки

* Код DSH читался из `app.asar` (архив распарсен вручную: заголовок pickle +
  смещение данных `json_size + 12`, подобранное замером). Это даёт **факты о
  версии 0.2.0-rc.2**, установленной здесь; при обновлении DSH факты надо
  перепроверить.
* Живой прогон `PreToolUse`-хука **не выполнялся**: он требует правки профиля и
  перезапуска сессии. Пункт 5 шага 2 плана — как раз эта проверка.
