# ReTibe MCP — инструкция для Claude Code

Вставь этот файл (или ссылку на него) в тред Claude Code, чтобы он подключился к
платформе ReTibe и писал тебе автотесты.

English version: `docs/CLAUDE_MCP_GUIDE.en.md`. Обе версии держатся в соответствии;
при расхождении правилась сначала русская.

---

## 1. Подключение

```bash
claude mcp add --transport http retibe https://retibe.com/mcp \
  --header "Authorization: Bearer ВАШ_ТОКЕН"
```

Токен выпускается в профиле на https://retibe.com/profile → «API-токены».
Показывается один раз, восстановить нельзя. Отзыв там же — действует мгновенно.

**Две независимые инстанции.** Есть ещё `https://retibe.ru/mcp` — тот же код и
тот же набор инструментов, но **отдельная база**: свои аккаунты, свои сценарии,
своя квота. Токен от одной на другой не работает — вернётся
`Invalid, revoked or expired`. Выпускай токен там, где лежат твои сценарии.

**Вариант со своей машины (stdio).** Если репозиторий ReTibe у тебя локально,
подключайся через stdio: сценарии, параметры и история берутся с платформы, а сами
прогоны выполняются у тебя — быстро, бесплатно и без расхода месячной квоты
(режим `composite`, он включается сам, как только задан `RETIBE_BASE_URL`).

```bash
claude mcp add retibe \
  --env RETIBE_BASE_URL=https://retibe.com \
  --env RETIBE_TOKEN=rtb_... \
  -- node /абсолютный/путь/к/TestBot/bin/retibe-mcp.js
```

Токен тот же самый, из профиля. Он имеет приоритет над `RETIBE_EMAIL` /
`RETIBE_PASSWORD` / `RETIBE_COOKIE`: если задать их вместе с токеном, они будут
проигнорированы, и сервер скажет об этом в stderr. Режим и способ авторизации видны
в первой строке лога:
`backend=composite (runs local, reads https://retibe.com auth=token)`.

Проверка после подключения: попроси Claude вызвать `retibe_whoami`. Он должен
вернуть твой email, план, остаток квот и `build.sha` — коммит, на котором собран
отвечающий тебе образ (зачем это нужно — раздел 6).

**Частота вызовов ограничена.** Публичный эндпоинт режет её до проверки токена:
60 вызовов в минуту на токен (пачкой до 30) и 120 в минуту на адрес (пачкой до 60).
Отказ — HTTP 429 с заголовком `Retry-After` и кодом `-32004`; это «подожди
столько-то секунд», а не «кончилась квота» (`-32003`) и не «токен отозван». Повтор
в цикле без паузы только держит корзину пустой.

**Всё, что токен записывает, записывается в журнал.** Каждый не-GET запрос,
сделанный токеном, оставляет строку: время, метод, путь, код ответа, имя токена и
идентификатор задетой сущности — сценария, расписания, кейса чек-листа, параметра,
прогона. Тела запросов в журнал не попадают: шаги сценариев, значения параметров и
секреты туда физически не доходят — журнал снимается до разбора тела. Работа через
браузер под cookie строк не добавляет: там и так видно, кто это был. Журнал живёт
90 дней (на своей инсталляции — `API_AUDIT_RETENTION_DAYS`), читается инструментом
`retibe_audit` (раздел 8). Чем авторизована сессия, видно в `retibe_whoami`:
`authVia` — `api_token` или `session`.

---

## 2. Чем это отличается от Playwright MCP

Важно понимать до начала, иначе ожидания разойдутся с реальностью.

| | Playwright MCP | ReTibe MCP |
|---|---|---|
| Модель работы | водит браузер вживую | пишет сценарий, прогоняет, читает разбор |
| Видит ли страницу между шагами | да | **нет** — но видит её ДО того, как написан сценарий: `retibe_inspect_page`, раздел 5 |
| Что остаётся после сессии | ничего | сохранённый сценарий, отчёт, история прогонов |
| Повторный прогон | заново объяснять | один вызов по id |
| Где выполняется | локально | на платформе, в её браузерах |

**Вывод: разведка больше не обязана быть чужой.** У ReTibe теперь есть обе её
половины, и на платформе обе смотрят на страницу тем же окружением, тем же
хранилищем параметров и с того же исходящего IP, с которых пойдёт прогон:

- **`retibe_crawl`** — карта сайта: какие страницы вообще есть, на какой глубине,
  какой у них заголовок и сколько на каждой ссылок, форм, кнопок и полей. С этого
  начинают, когда дан домен, а не страница.
- **`retibe_inspect_page`** — разметка ОДНОЙ страницы: заголовки, поля форм с их
  подписями, кнопки, ссылки, iframe'ы с готовым значением `data.frame` и по 1–2
  кандидата в селекторы на элемент, каждый проверен на то, что указывает ровно на
  один элемент.

Разница между ними принципиальная: **обход даёт счётчики, разведка страницы —
селекторы**. «На /checkout две формы» — это повод открыть /checkout, а не то, что
можно вставить в шаг.

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

Они не конкуренты. Рабочая связка: разведать `retibe_inspect_page`, закрепить в
ReTibe, а Playwright'ом исследовать руками то, чего в сценарии ещё нет.

---

## 3. Как просить

Работает хорошо:

> Напиши сценарий ReTibe: открыть https://example.com, проверить что есть
> заголовок, перейти на /pricing, убедиться что видна форма. Провалидируй,
> прогони и покажи результат.

> Посмотри https://shop.example.com/checkout через `retibe_inspect_page` и скажи,
> какими селекторами адресовать поля карты. Угадывать не надо.

> Прогон webtest-123 упал. Сравни его с последним зелёным и скажи, что изменилось.

> Проверь мои сценарии на предмет проверок, которые ничего не проверяют.

> Посмотри чек-лист «Релиз 2.4»: что там ещё руками? Возьми верхний кейс,
> напиши сценарий и привяжи его к этому кейсу.

> Поставь smoke-набор на каждое утро в 07:30 по Москве. Покажи, на какое время
> платформа его поставила.

> Что у меня запланировано на следующую неделю и что упало за прошлую?

Работает плохо:

> Потести сайт.

Слишком расплывчато. Посмотреть страницу Claude теперь может сам
(`retibe_crawl`, `retibe_inspect_page`), а вот чтó на ней важно — знаешь только ты.

---

## 4. Правильный порядок работы

Скажи Claude следовать этому порядку — он заложен в инструменты:

1. **`retibe_env`** — какие есть окружения и ключи параметров
2. **`retibe_crawl`** — если дан домен, а не страница: карта сайта, чтобы выбрать,
   про чтó вообще писать сценарий (раздел 5)
3. **`retibe_inspect_page`** — посмотреть нужную страницу: заголовки, формы с их
   подписями, кнопки, ссылки, iframe'ы и кандидаты в селекторы, проверенные на
   уникальность. Стоит единицу отдельной дешёвой квоты `inspections`, а не
   `web_tests` (раздел 5)
4. **`retibe_actions`** — каталог действий и контракт каждого, которое собирается использовать
5. **`retibe_reference`** — сквозные механики (шаблоны, условия, фикстуры)
6. **`retibe_param`** — если сценарию нужны логин, пароль или базовый URL,
   завести их сейчас (раздел 19). Параметры вперёд, сценарий потом
7. написать сценарий
8. **`retibe_validate_scenario environment:'…'`** — до запуска браузера, занимает
   миллисекунды и не стоит ни единицы квоты
9. **`retibe_validate_scenario view:'storyboard'`** — «а какой из этих шагов
   вообще способен покраснеть?»
10. **`retibe_run_scenario`** → **`retibe_run_result`**

Шаги 2–3 — это не роскошь, а самая дешёвая часть цикла. Придуманный селектор
стоит минуту браузера и единицу `web_tests` за каждую попытку; разведка отвечает
на тот же вопрос за единицу `inspections`, которых на порядок больше. Вставляй
селекторы из ответа как есть.

`retibe_inspect_page` по умолчанию показывает страницу целиком, но ответ обрезан
бюджетом в 8 КБ: `counts` говорит, сколько элементов было на самом деле, а
`truncated` — что именно выкинули, чтобы влезть. Нужна одна часть страницы — сузь
её `selector:'form#checkout'`: это и дешевле по контексту, и точнее. Кандидаты при
этом всё равно проверяются на уникальность по всему документу, а не по поддереву,
так что селектор из суженной разведки остаётся верным в сценарии.

Шаг 8 пропускать нельзя. Платформа **не валидирует сценарии** — она примет
сломанный и будет его гонять. С аргументом `environment` валидатор делает ещё и
предполётную сверку: каждый `{{params.*}}` из сценария проверяется по настоящему
хранилищу этого окружения, и то, чего там нет, приезжает как `PARAM_KEY_MISSING`.
Это важно потому, что ненайденный ключ **не роняет шаг**: резолвер оставляет
плейсхолдер как есть, и в поле логина уезжает текст `{{params.auth.email}}`. Без
аргумента сверки не будет, и ответ скажет об этом полем `unchecked`, а не
промолчит — подробности в разделе 19. Браузер при этом всё равно не запускается и
квота не тратится.

Шаг 9 — это ответ на вопрос, которого список предупреждений не даёт. Сорок
варнингов не говорят, заметит ли тест поломку сайта; раскадровка говорит, и
занимает те же миллисекунды. Если в итоге написано «покраснеть не может ничто» —
сценарий декоративный, и прогонять его бессмысленно.

Если на аккаунте есть чек-листы, перед пунктом 7 полезен нулевой шаг —
**`retibe_checklists`**: он отвечает на «что вообще автоматизировать», а после
сохранения сценария **`retibe_checklist_item op:link`** привязывает кейс к нему.
Привязка — обычная строка, которую платформа не проверяет; как она ломается,
написано в разделе 10.

Как назвать сценарий и сколько их вообще должно быть — раздел 15. Прочитай его
**до** пункта 7: имя и верхнеуровневый `id` потом почти не поменять, потому что
на них завязаны и история, и привязка к чек-листу.

После `retibe_save_scenario` — допиши строку в `docs/TEST_COVERAGE.md`, карту
покрытия проекта (раздел 18). В тот же заход, а не «потом»: тред, который
придёт следующим, начнёт с этого файла.

Сохранение обратимо, и на это можно опираться. Каждое сохранение, меняющее шаги,
имя или описание, оставляет снимок: в ответе `retibe_save_scenario` приходит
`revision`, а `retibe_scenarios {id:47, revisions:true}` показывает последние
двадцать правок с диффом шагов у каждой. Откат — одна команда:
`retibe_save_scenario {op:'revert', id:47, revision:4, confirm:true}`. Без
`confirm` откат отказывается и показывает дифф, который собирался применить, так
что посмотреть, что вернётся, ничего не стоит. Состояние, которое откат вытеснил,
тоже становится ревизией — то есть откат откатывается тем же вызовом. Это не повод
не проверять сценарий перед сохранением: ревизии хранят двадцать последних правок
и ничего не знают о прогонах, чек-листах и `historyId`.

`retibe_examples` и `retibe_docs` в этот порядок намеренно не входят — они
нужны не всегда. Бери их, когда действие незнакомое и контракта из
`retibe_actions` не хватает: `retibe_examples` даст рабочий сценарий целиком,
`retibe_docs` — прозу по разделу. Насколько им верить — раздел 6.

---

## 5. Разведка: карта сайта и разметка страницы

Самая дорогая ошибка в этом продукте — придуманный селектор. Агент пишет
`#submit-button`, на странице `button.btn-primary`, прогон падает на первом шаге, и
минута браузера плюс единица `web_tests` потрачены на опечатку. Два инструмента
закрывают это до прогона.

### `retibe_crawl` — какие страницы вообще есть

Отвечает на вопрос, который идёт первым, когда дан домен: на что вообще писать
сценарий. Обходит сайт с указанного URL и возвращает карту — каждую дошедшую
страницу, её глубину, заголовок и счётчики ссылок, форм, кнопок и полей.

```
retibe_crawl url:'https://shop.example.com'
retibe_crawl url:'https://shop.example.com' max_pages:20 max_depth:2
retibe_crawl crawl_id:'…'
```

| Аргумент | Что делает |
|---|---|
| `url` | откуда начать. Обязателен, если не передан `crawl_id` |
| `crawl_id` | дочитать обход, начатый предыдущим вызовом, вместо нового |
| `max_pages` | сколько страниц посетить, 1–20, по умолчанию 10. Тариф может урезать — ответ говорит, сколько дали на самом деле |
| `max_depth` | 0 — только стартовая страница, 1 — её ссылки, 2 — их ссылки. Только свой домен |
| `wait_ms` | сколько ждать обход, 0–55000, по умолчанию 50000 |

**Он возвращает счётчики, а не селекторы.** «На /checkout две формы и девять
полей» — это повод открыть /checkout разведкой, а не то, что можно вставить в шаг.
Ни одного проверенного адреса элемента здесь нет и быть не может: краулер считает,
а не адресует.

Страница — до тридцати секунд, поэтому инструмент ждёт `wait_ms` и, если не
дождался, отдаёт `crawl_id`; второй вызов с ним дочитывает готовую карту.
Страницы, которые не загрузились, приезжают отдельным списком `deadPages` —
их не смешивают с пустыми. Страницы, опознанные как SPA, приходят **без**
счётчиков (`—`, а не `0`): с них снимаются метрики производительности вместо
разметки. Один обход на аккаунт одновременно; второй получает 409.

### `retibe_inspect_page` — что на странице и как это адресовать

Открывает одну страницу в headless-браузере и возвращает компактный очерк:
заголовки, формы с подписанными полями, кнопки, ссылки, iframe'ы (с готовым
значением для `data.frame`) и у каждого элемента 1–2 кандидата в селекторы, каждый
проверен на момент съёмки на уникальность (`querySelectorAll().length === 1`). Эти
селекторы надо вставлять как есть.

| Аргумент | Что делает |
|---|---|
| `url` | обязателен; может содержать `{{params.*}}` — на платформе они разрешаются |
| `environment` | в каком окружении разрешать параметры |
| `selector` | сузить очерк до поддерева, например `form#checkout` |
| `maxElements` | потолок на каждый список, 1–100, по умолчанию 40 |

Ответ ограничен 8 КБ: `counts` говорит, сколько элементов было на самом деле,
`truncated` — что именно выкинули. `scopeNotFound: true` значит, что `selector` не
нашёл ничего, — тогда пустые списки означают «никуда не смотрели», а не «на
странице пусто». Кандидаты проверяются на уникальность по всему документу, а не по
поддереву, так что селектор из суженной разведки остаётся верным в сценарии.

Одна разведка на аккаунт одновременно; вторая получает `INSPECT_IN_FLIGHT` — ответ
приходит не позже сорока секунд, так что это «подожди», а не «сломалось».

### Квота у разведки своя, и она дешевле

Вызов на платформе списывает единицу счётчика `inspections`, а не `web_tests`:
60 в месяц на Free, 600 на Professional, безлимит на Corporate — на порядок больше,
чем прогонов, именно чтобы агент никогда не экономил на «посмотреть». Остаток виден
в `retibe_whoami` строкой `inspections: 3/60`. Списывается только после состоявшейся
навигации: опечатка в URL, отказ по адресу или мёртвый DNS не стоят ничего.
`retibe_crawl` квоту `inspections` не тратит — его режет тариф по числу страниц.

### Платформа против своей машины

На платформе страница открывается тем же окружением, тем же хранилищем параметров и
с того же исходящего IP, что и прогон. `retibe_inspect_page` работает и без
платформы — локальный бэкенд откроет страницу браузером на твоей машине, — но это
более слабое утверждение, и в ответе оно помечено `source: 'local'`: твоя сеть и
твой IP, квоты нет вовсе, `{{params.*}}` не разрешаются (URL с шаблоном отвергается
до запуска браузера), зато `localhost` разрешён и нужен Chromium на машине.
`retibe_crawl` локального аналога **не имеет**.

Что именно меняется в каждом из этих пунктов и почему в `composite`-режиме разведка
всё равно идёт на платформу (`source: 'remote'`) —
`retibe_docs section:'local-vs-platform-inspection-ru'`.

### Чего разведка не делает

Селекторы проверены на уникальность **в момент съёмки**. Страница, которая
рендерится по-разному от сессии к сессии, их сломает — поэтому предпочитай
кандидатов с `id`, `data-testid` и `name` путям вида `nth-of-type`.

Адреса внутренней сети платформа не открывает: URL, который резолвится в
приватный диапазон, отвергается до запуска браузера — и так же на каждом
редиректе, куда страница попыталась уйти. Что именно запрещено — в разделе 9.

---

## 6. Насколько этим знаниям можно верить

У инструментов справки **разная природа и разная надёжность**. Это не
придирка — от этого зависит, чему верить при расхождении.

| Инструмент | Откуда берётся | Гарантия свежести |
|---|---|---|
| `retibe_actions` | извлечён из `switch` самого движка разбором AST | **высокая** — тест перечитывает движок на каждом прогоне и падает при расхождении |
| имена полей `data.*`, `required`, алиасы | разбор AST движка: чтения `data.*` внутри каждого `case`, включая делегаты и типы их параметров | **высокая** — тест перечитывает движок на каждом прогоне и падает, если движок читает поле, которого нет в контракте |
| описания полей `data.*`, ловушки, примеры | написаны руками против исходника движка | **средняя** — правятся руками; новое поле доезжает как «(undocumented — read by the engine, not yet described)», пока его не описали |
| `retibe_reference` | заморожённый текст, вшит в сборку | **низкая** — не пересобирается ничем |
| `retibe_docs`, `retibe_examples` | файлы `knowledge/` и `examples/`, лежат в образе | **средняя** — правятся руками, с движком не сверяются |
| привязка чек-листа к сценарию | строка, которую кто-то когда-то ввёл руками | **никакая** — ни FK, ни триггера, ни сверки; ломается молча (раздел 10) |
| `docs/TEST_COVERAGE.md` в репозитории | ведётся руками этим же тредом | **никакая** — ничто не сверяет; но это единственное место, где записано «чего нет и почему» (раздел 18) |
| описания инструментов | написаны руками | **низкая** — с движком ничем не связаны |
| промпты `/author`, `/debug`, `/harden` | написаны руками, но закрыты тестом | **средняя** — тест проверяет, что каждый упомянутый инструмент зарегистрирован на этом бэкенде, что ключевые шаги на месте и что текст правильно ветвится по бэкенду; качество формулировок не проверяет ничто |

**Что это значит на практике.**

Список действий — это надёжно. Если `retibe_actions` действие не вернул, его в
движке нет: каталог машинно извлечён из диспетчера и закрыт тестом на дрейф.
Придуманные действия (`web_fill`, `web_type`, `web_hover`) валидатор ловит.

Список полей `data.*` — тоже надёжно, и по той же причине: он извлечён из тел
`case` движка вместе с делегатами и типами их параметров. Если `retibe_actions`
показал поле с типом «(undocumented — read by the engine, not yet described)» —
движок его читает, а описания ещё нет: поле рабочее, смысл уточняй по движку. Гейт
односторонний там, где движок отдаёт `data` целиком (`web_assert`, `api_request`):
для таких действий список полей может быть неполным.

Всё остальное — прозаические утверждения, которые могли отстать от кода
(описания полей в том числе: с движком сверены их имена, но не их смысл).
Веришь им до первого противоречия с поведением; при расхождении прав движок.

**`retibe_docs` и `retibe_examples` на `retibe.com/mcp` работают** — начиная с
`d1069cc`. До него образ собирался без каталогов `knowledge/` и `examples/`, и
оба инструмента отвечали ошибкой на каждый вызов; теперь Dockerfile кладёт их в
`/app`, откуда сервер и резолвит их (`repoRoot = RETIBE_REPO_ROOT ?? cwd`).
Если всё-таки видишь `... is missing from this checkout` — эндпоинт поднят на
образе старше этого коммита, и лечится это передеплоем, а не правкой сценария.

**Публичный эндпоинт может отставать от репозитория — и теперь это видно.**
Деплой ручной: `git pull` плюс пересборка контейнера, без CI/CD, и никакая проверка
на границе не срабатывает. Но эндпоинт сообщает свою сборку: `retibe_whoami` отдаёт
`build: { sha, version, startedAt }`, а `GET /health` — поле `build`. Там же
`publicBase` — адрес, от которого строится каждая ссылка в ответах (отчёт,
скриншоты, JUnit, Allure), и `platform` — адрес, по которому эндпоинт сам ходит в
платформу; внутри деплоя это `http://app:3000`, и так и должно быть. Сравни сборку с
тем, что в репозитории:

    git rev-parse --short=12 origin/main

Совпало — поведение эндпоинта и есть поведение main, и расхождение с этим файлом
надо чинить в коде. Не совпало — правка просто ещё не выкачена, и дебажить нечего.
`build.sha: "dev"` означает образ, собранный без метки (локальный запуск или ручной
`docker compose build` мимо deploy.sh); `build.version` — версия из package.json,
`build.startedAt` — момент старта процесса: свежий sha при старом `startedAt`
значит, что образ пересобрали, а контейнер не перезапустили. Ту же сборку видно ещё
до первого вызова — в ответе `initialize` поле `serverInfo.version` имеет вид
`1.0.0+<sha>`.

---

## 7. Главное, что нужно знать про этот движок

Здесь причина, по которой валидатор вообще написан. Движок принимает почти
что угодно, поэтому сломанный тест не падает — он **проходит зелёным, ничего не
проверяя**. Клоду это сказано в описаниях инструментов, но полезно знать и тебе.

**Проверки, которые не могут упасть.** `api_test`, `security`, `accessibility`,
`seo_analysis`, `graphql_test`, `load_test` и ещё несколько записывают проблему в
findings, а сам шаг остаётся `passed`. API вернул 500 — тест зелёный. Если шаг
должен блокировать релиз, после него нужен настоящий ассерт.

**`web_assert` умеет меньше, чем кажется.** Он проверяет видимость и вхождение
подстроки — причём подстроку только через `data.contains` или его алиас
`data.expectedText` (движок читает `data.contains ?? data.expectedText`). Поля
`assertion` / `expected` / `attribute` / `text` / `value` **не читаются вообще** —
шаг с ними пройдёт на любом видимом элементе с любым текстом.

**У селекторов два движка, и от этого зависит синтаксис.** Одна и та же строка в
`web_click` и в `web_wait` разбирается по-разному:

| Путь | Какие поля по нему идут | `:contains('текст')` | Синтаксис Playwright (`:has-text()`, `text=`, `xpath=`, `role=`) |
|---|---|---|---|
| **A — собственный резолвер движка** (`findElementBySelector`) | `web_click` (`selector`, `buttonSelector`, `waitForSelector`), `web_click_submenu` (`submenuParentSelector`, `submenuSelector`, `waitForSelector`), `web_fill_form` (`formFields[].selector`, `formSubmitSelector`) | **работает**, но только в точной форме `тег:contains('текст в кавычках')`: кавычки обязательны, после закрывающей скобки ничего | работает — всё, что не `:contains()`, уходит в Playwright как есть |
| **B — напрямую в Playwright** | всё остальное: `web_assert.selector`, `web_extract.selector`, `web_wait.waitForSelector`, `web_login.loginSelectors.*`, `web_fill_form.successSelector` и `errorMessageSelectors[]`, `webrtc_verify_stream.videoSelector`, `page_*`, `condition.selector`, `skipIf.condition.selector`, `data.frame` | **синтаксическая ошибка** — такого движка у Playwright нет, отвергается весь селектор целиком | работает |

Путь A ещё и режет строку по запятым и пробует альтернативы по очереди — запятая
ВНУТРИ `:has-text("a, b")` или `:is(a, b)` будет прочитана как отдельная
альтернатива и сломает селектор. Внутри тела `use_fixture` пути A нет вовсе:
фикстурный диспетчер кликает и заполняет голыми вызовами Playwright, так что там
любой селектор — путь B, и `button:contains('Login')`, безупречный в сценарии, в
фикстуре роняет шаг.

Валидатор знает эту таблицу по полям и отвечает тремя кодами, и все три —
**предупреждения**: они называют шаг, который сломается, но не запрещают ни
сохранение, ни прогон — эти правила моложе сценариев, которые они судят, и
сценарий, который отработал вчера, обязан отработать и сегодня. При авторинге
зовите `retibe_validate_scenario strict:true` — там они снова блокируют.
Три кода: `SELECTOR_UNSUPPORTED_PSEUDO` (`:contains()` на пути B),
`SELECTOR_CONTAINS_BAD_SHAPE` (`:contains()` на пути A, но в форме, которую хелпер
не переводит — такую альтернативу движок **выбрасывает, даже не попробовав**, и шаг
падает «ничего не найдено» после полного таймаута) и `SELECTOR_JQUERY_PSEUDO`
(`:eq()`, `:first`, `:input`, `:checkbox`, `:hidden` и ещё полтора десятка — это
jQuery, а не CSS, их не переводит ни один путь). `:has()`, `:is()`, `:not()`,
`:first-child`, `:checked`, а также добавленные Playwright `:visible`,
`:has-text()`, `:text()`, `:nth-match()` — валидны, и валидатор про них молчит.
Строки, которые не CSS (`xpath=…`, `//…`, `text=…`, `id=…`, `role=…`) и любая
строка с `{{` не судятся вовсе.

Те же коды приходят и из `web_evaluate`: валидатор читает в `data.script` строковые
литералы, переданные в `querySelector`, `querySelectorAll`, `closest` и `matches`, и
судит их по пути B — только жёстче, потому что между строкой и браузером там нет
даже Playwright. `:contains()` и jQuery-псевдоклассы в скрипте он ловит; а вот
`:has-text()`, `>> nth=` и `:nth-match()` там тоже не работают, но валидатор про
них в скрипте молчит — держи это в голове сам. Переменная, шаблонная строка или
конкатенация вместо литерала не судятся. Селекторы `page_method` не судятся вовсе:
они лежат в базе page object'ов, а не в сценарии.

**Отсутствие проверяется полем `data.absent: true`.** Оно ждёт у Playwright
состояния `hidden`, а это **исчез из DOM ИЛИ есть, но не виден** (`display: none`,
`visibility: hidden`, нулевой размер) — ровно «попап закрылся», «ошибка пропала».
Так и задумано: модалки чаще прячут, чем удаляют, и ассерт, который ждёт именно
удаления, краснел бы на каждом втором сайте. Шаг честно падает по
`Timeout <timeout>ms`, пока элемент всё ещё на экране, — сломай его нарочно и
убедись.

Два условия. Первое: строго булево `true` — `"true"`, `1` и `"yes"` оставляют
обычную проверку видимости, то есть шаг начинает проверять ровно обратное тому, что
написано; валидатор ловит это кодом `ASSERT_ABSENT_NOT_BOOLEAN`. Второе: вместе с
`absent: true` текст **не** проверяется — движок выходит до сравнения, и
`contains`/`expectedText` на таком шаге мертвы (`ASSERT_ABSENT_IGNORES_TEXT`).

`visible: false` — это **не** отсутствие: ожидание всего лишь ослабляется до
`state: "attached"`, элемент обязан остаться в DOM. Поле осталось для
`<head>`-элементов и скрытых узлов, которые должны быть на месте.

```json
{ "id": "popup-gone", "action": "web_assert",
  "data": { "selector": ".popup", "absent": true, "timeout": 5000 } }
```

**`web_evaluate` годится и как разведка.** Всё, что вернул скрипт, попадает не
только в лог, но и в результат шага, а оттуда — в `extracted` дайджеста (раздел 9).
Там же оказываются значения `web_extract`. `step.storeAs` по-прежнему **ничего не
связывает** между шагами — предупреждение `STOREAS_DEAD` остаётся верным, — но
теперь он хотя бы подписывает значение именем в `extracted`. Чтобы передать
значение из шага в шаг, это всё ещё `data.storeResponseAs`.

**`web_fill_form` проглатывает ошибки полей — но теперь перечитывает форму.** По
умолчанию неудача с отдельным полем не роняет шаг: она уходит в `validationErrors`,
а шаг остаётся `passed`. Сверх этого после заполнения **всех** полей движок
перечитывает каждое и пишет в сообщение шага, что в форме реально лежит:
`#card → 19 симв. | #month → ПУСТО`. Значения именно **описаны, а не выведены** —
эта строка попадает в лог, в HTML-отчёт и в сохранённый результат, а в форме лежат
номера карт, CVV и разрешённые `{{params.*}}`; если нужны сами значения,
`revealValues: true` покажет их, кроме пароля. Поле, которому просили непустое
значение, а оно пусто, значит страница его очистила или отвергла: при
`validateForm: true` это роняет шаг и называет поле, без флага — предупреждение.
Переформатирование провалом **не** считается, иначе номер карты падал бы всегда, а
поле, которое не удалось прочитать уверенно, отмечается как непрочитанное и не роняет
ничего. Чтение повторяет ретаргетинг Playwright, поэтому поле, адресованное через
свой `<label>` или обёртку `role=checkbox`, читается там, куда пришла запись.
Перечитывание идёт после всего цикла, потому что очистка часто межполевая — выбор
года обнуляет месяц. Виджету, который сам чистит поле (теги, автодополнение),
ставь `allowEmptyAfterFill: true` у этого поля; шаг, который **проверяет отказ**
формы (`checkErrorMessages` / `expectedErrors`), от этой проверки освобождён. И если
`formSubmitSelector` стоит в том же шаге, падение означает, что форма не отправлена
вовсе; отдельный `web_click` по кнопке всё равно выполнится, если у шага с формой нет
`critical: true`.

**Проверяй исход, а не факт действия.** Заполнить форму и нажать «отправить» —
это ещё не тест: если после этого ничего не проверяется, шаги пройдут зелёным
и при отказе сервера. Нужен признак, который **может не наступить** — редирект,
появление элемента, изменение URL. Хороший способ убедиться, что проверка
настоящая: сломать сценарий намеренно и увидеть, что он покраснел.

**Скриншот упавшего шага сохраняется по умолчанию** — `retry.screenshot: 'on_fail'`
действует, даже если блока `retry` в шаге нет вовсе: после последней неудачной
попытки движок снимает видимую область и кладёт путь в `screenshot` этого шага,
откуда его показывают и HTML-отчёт, и дайджест `retibe_run_result`, и
`detail:'screenshot'` (раздел 9). Отключить — `retry: { "screenshot": "never" }` на
шаге; это нужно там, где падения массовые: кадр стоит до 15 секунд на зависшей
странице и 100–300 КБ на диске, а ретеншена у каталога скриншотов нет. Остальные
значения: `'on_retry'` — кадр перед каждой повторной попыткой (складывается в
`screenshots[]`, дайджест их не показывает), `'always'` — и то, и другое.

**Визуальный регресс срабатывает в двух случаях** — `web_scroll_screenshot`
плюс `data.screenshotFullPage`, и `use_fixture`, если фикстура несёт
`visualRegression`. На `web_screenshot` он молча не делает ничего.

**И `visualRegression` — поле уровня ШАГА, не `data`.** Вот так правильно:

```json
{ "id": "full", "action": "web_scroll_screenshot",
  "visualRegression": true,
  "data": { "screenshotFullPage": true } }
```

Положить его в `data` — самая естественная ошибка, и до сих пор она обходилась
дороже остальных: валидатор туда не смотрел. Теперь смотрит —
`DATA_FIELD_NOT_READ` с подсказкой, что в сценарии конфиг живёт на уровне шага и
только вместе с `data.screenshotFullPage`. Внутри тела фикстуры
`data.visualRegression` работает — там другой диспетчер, и он читает оба места.

**`{{faker.person.firstName}}` — правильное имя не из той библиотеки.** Движок
резолвит `{{faker.*}}` по своему списку категорий и сам отображает `name.*` в
`person.*` пакета @faker-js/faker, а `address.*` — в `location.*`. Поэтому
современные имена пакета здесь не работают, и не работают тихо:

- путь с неизвестной категорией возвращается **буквальным текстом** — в поле формы
  уходит строка `{{faker.person.firstName}}` посимвольно, шаг зелёный;
- известная категория с неизвестным методом возвращает **значение по умолчанию
  этой категории**: у каждого внутреннего `switch (method)` есть `default:`.
  `{{faker.internet.emial}}` даёт совершенно валидный e-mail, и нигде не сказано,
  что данные не те, которые ты написал.

Валидатор ловит оба случая: `FAKER_PATH_UNKNOWN` («остаётся в строке буквально») и
`FAKER_METHOD_UNKNOWN` — последний называет значение, которое прогон получит на
самом деле. Одинаковый путь в тридцати шагах — одно предупреждение со списком
шагов. Полный список категорий, методов и значений по умолчанию —
`retibe_docs {section:'faker-test-data-generation'}`. Категории без метода
(`{{faker.uuid}}`, `{{faker.email}}`, `{{faker.city}}` и ещё десять) работают; любая
другая категория без метода уходит в свой `default:` — `{{faker.name}}` читается как
«имя», а даёт имя без фамилии.

**Несуществующие действия.** Встроенный ассистент платформы документирует
`web_fill`, `web_type`, `web_hover`, `web_select` и ещё несколько, которых в
движке нет. Валидатор их ловит и предлагает замену.

**`{{params.*}}` в Telegram-сценариях не работают** — телеграм-движок не
резолвит шаблоны вообще.

Бóльшую часть этого валидатор показывает как предупреждения с готовой заменой:
`NO_ASSERTIONS`, `PHANTOM_ACTION` (с точным именем замены), `CANNOT_FAIL`,
`ASSERT_FIELD_IGNORED`, `VR_ON_WRONG_ACTION`, `TEMPLATE_IN_TELEGRAM`,
`DATA_FIELD_NOT_READ`, `FAKER_PATH_UNKNOWN`, `FAKER_METHOD_UNKNOWN`. Если Claude их
проигнорировал — попроси прогнать `retibe_validate_scenario` и разобрать каждое.

Отдельно стоят коды про селекторы — `SELECTOR_UNSUPPORTED_PSEUDO`,
`SELECTOR_CONTAINS_BAD_SHAPE`, `SELECTOR_JQUERY_PSEUDO`: в отличие от остальных
предупреждений такой шаг не «пройдёт, ничего не проверив», а свалится или молча
выключит собственную проверку. И всё же это предупреждения, намеренно: правило,
добавленное в этом году, не должно делать сценарий, сохранённый в прошлом,
незапускаемым и несохраняемым. Чтобы они блокировали при авторинге, зовите
`retibe_validate_scenario strict:true` — это и делает `/harden`.
И особый случай — `PARAM_KEY_MISSING`: он
появляется, только если вызвать валидатор с `environment`, иначе сверять не с чем,
и ответ честно пишет, что не сверял (раздел 19).

**Сценарий, который вообще ничего не проверяет, валидатор теперь ловит.** Открыть,
заполнить, нажать — и ни одного ассерта: это предупреждение `NO_ASSERTIONS`, и оно
всегда идёт первым в списке, чтобы лимит в 40 находок не срезал именно его.

«Проверяет исход» здесь означает ровно то, что движок способен из-за этого
покраснеть: `web_assert` с `contains` (или с `absent: true`), `web_assert_url`,
`web_assert_title`, `web_wait` с `waitForSelector`/`waitForUrl` (пока не выключено
`failOnTimeout: false`), `web_fill_form` с `validateForm: true`, `web_evaluate`, в
скрипте которого есть `throw`, `web_login` с
`loginSuccessUrl`/`loginSuccessSelector`, `api_request` (любой не-2xx роняет шаг),
`page_element` с `assertVisible`/`assertText`, `webrtc_verify_stream`,
`webrtc_wait_connection`, а в Telegram — `expectedResponse` на уровне шага и
`wait_for_message`. Не считаются: `waitForSuccess` + `successSelector` и
`expectedUrl` у `web_fill_form`, `waitForSelector` у `web_click` — движок по ним
пишет только warning, и шаг проходит. Если единственные кандидаты — `use_fixture`
или `page_method`, предупреждение так и скажет: их тело валидатору недоступно,
поэтому ни «да», ни «нет» честно сказать нельзя.

**Ключ в `data`, которого движок не читает,** валидатор тоже называет:
`DATA_FIELD_NOT_READ`. Диспетчер раскладывает `data` целиком, поэтому лишний ключ
ничего не стоит, ничего не поднимает и ничего не делает — шаг зелёный и сделал
меньше, чем написано в JSON. Одна ошибка, повторённая в тридцати шагах, — одно
предупреждение с перечнем шагов, а не тридцать; в `fix` либо ближайшее похожее имя
(«Did you mean data.selectorTimeout?»), либо точный ответ по движку (`apiDelay`
читает только телеграм-`api_request`; `expectedStatus` читает только `api_test`).
Анализаторам (`accessibility`, `seo_analysis`, `performance_analysis`) ответ один на
всех: переключателей у них нет, они всегда гоняют полный встроенный набор.

**Две вещи он по-прежнему не ловит принципиально** — держи их в голове сам:

- **одинокий `visible: false`.** Валидатор скажет про него только внутри `fix` у
  `ASSERT_FIELD_IGNORED`, то есть лишь если в `data` попало ещё и игнорируемое
  поле. Сам по себе шаг проходит чисто — а означает он «элемент на месте, просто
  может быть скрыт». Если имелось в виду «элемента нет», нужен `absent: true`;
- **содержимое фикстуры** — тело `use_fixture` валидатору недоступно.

Промпт `/harden` и `view:'storyboard'` (раздел 11) по-прежнему полезны: раскадровка
показывает то же самое по шагам — какие шаги вообще способны покраснеть и какие из
них проверяют исход, а не механику.

### Все коды валидатора

56 кодов: 21 ошибка, 34 предупреждения и `FRAME_BAD_SHAPE`, который
бывает и тем и другим в зависимости от формы поля. Ошибка значит, что
`retibe_save_scenario` откажется сохранять (`Refusing to save: N error(s)`), а
`retibe_run_scenario` — запускать (`Scenario is invalid (N error(s)); nothing was
run.`); предупреждение — что шаг сохранится и пройдёт, сделав меньше, чем написано в
JSON. `retibe_validate_scenario strict:true` переводит предупреждения в ошибки —
но только в ответе самого валидатора: сохранение и прогон `strict` не читают и
отказывают на ошибках всегда, а на предупреждениях никогда.

Коды, которые встречаются чаще прочих, разобраны выше по тексту этого раздела.
Таблица целиком — по группам «форма сценария и шага», «селекторы», «ассерты»,
«шаблоны», «фреймы», «актёры», «параметры» — лежит за вызовом
`retibe_docs section:'validator-codes-ru'`.

Если код пришёл в находке и незнаком, есть путь короче:
`retibe_validate_scenario view:'codes'` — сценарий не нужен, ответ приходит списком
«код, уровень, одна строка» прямо из валидатора. Это тот же набор, что в таблице, и
отстать от неё он не может: сборка падает, если коды разойдутся.

---

## 8. Инструменты

**Справка:** `retibe_actions` (каталог действий — единственный машинно
извлечённый), `retibe_reference` (сквозные механики), `retibe_docs` (справочник
по авторингу, по разделам), `retibe_examples` (рабочие сценарии как few-shot),
`retibe_doctor` (состояние **локального** окружения прогона; на публичном
эндпоинте инструмента нет вовсе — прогоны там идут на платформе, чей контейнер эта
сессия не осматривает и не меняет). Надёжность у них разная — см. раздел 6.

**Разведка:** `retibe_crawl` (карта сайта — какие страницы есть и сколько на них
форм, кнопок, полей), `retibe_inspect_page` (разметка одной страницы и селекторы,
проверенные на уникальность). Обход возвращает счётчики, разведка — селекторы;
подставить в шаг можно только второе. Раздел 5.

**Работа:** `retibe_validate_scenario` (плюс `view:'storyboard'` — раздел 11, и
`environment` — сверка ключей `{{params.*}}` с настоящим хранилищем, раздел 19),
`retibe_run_scenario` (плюс `id` — прогнать сохранённый сценарий, не пересылая его
тело; плюс `from_step` / `only_steps` — прогнать кусок, раздел 13),
`retibe_run_status` (плюс `wait_for` и `since` — раздел 12), `retibe_run_result`
(плюс `detail:` `log` / `markdown` / `junit` / `screenshot` — раздел 9),
`retibe_list_runs`, `retibe_compare_runs` (что изменилось между двумя прогонами —
раздел 13), `retibe_stop_run`, `retibe_publish_run` (**только в локальном и
composite режиме** — публикует прогон, который шёл на твоей машине, в отчёты и
историю). На `retibe.com/mcp` последнего в списке нет, и он там не нужен: прогон,
запущенный оттуда, попадает в отчёты, историю сценария и календарь сам.

**`retibe_stop_run` останавливает аккаунт, а не прогон.** У платформы эндпоинт
остановки один на пользователя: он гасит тот веб-тест, который идёт *у тебя*, и
никакого адреса по `runId` у него нет. Поэтому инструмент сначала читает список
активных прогонов и, если бежит не тот `runId`, который ты назвал, **отказывается** —
настоящий идентификатор назван прямо в тексте отказа (`Your account is running X,
not Y`), а запрос на остановку не уйдёт. Остановить всё равно можно: `confirm: true`
— это и есть «да, гаси то, что бежит». Задание в очереди (`queued`) не отменяется ни
так, ни иначе. Всякое «не остановил» приходит **ошибкой с текстом**, а не объектом
со `stopped: false` — и это полноценный ответ, а не сбой: когда платформа говорит
`No web test running`, инструмент возвращает ошибку с этим текстом, а не рапортует
об остановке несуществующего прогона.

Есть и третий исход. На платформе несколько воркеров, а браузер живёт только в том,
который запустил прогон, — поэтому остановка уходит ему по каналу кластера. Если он
не подтвердил её в срок, ответ — ошибка с текстом `The stop was sent to the
worker running this test, but it has not confirmed yet`. Это **не** «ничего не
остановлено»: прогон, возможно, закрывается прямо сейчас. Посмотри `retibe_run_status`,
прежде чем звать остановку ещё раз.

**Платформа:** `retibe_whoami` (плюс `build` — какая сборка отвечает, и `authVia` —
чем авторизована сессия), `retibe_audit` (что менял агент за период), `retibe_env`
(плюс `secretKeys` — какие ключи помечены секретными), `retibe_scenarios` (плюс
`revisions:true` — история правок сценария), `retibe_save_scenario` (плюс
`op:'revert'` — откат правки, и `link_to` — привязка кейса чек-листа тем же
вызовом), `retibe_history` (плюс `detail:'steps'` и `view:'flaky'` — раздел 13),
`retibe_visual_review`, `retibe_param` (записать значение `{{params.*}}`).

`retibe_param` закрывает дыру, из-за которой агент останавливался и ждал
человека: ключ можно было завести только руками в веб-интерфейсе. Запись
односторонняя — значение уходит и обратно не читается ни одним инструментом.
Механика и то, какие ключи записать нельзя — раздел 19.

`retibe_audit` отвечает на вопрос «что тут менял агент, пока меня не было».
Платформа записывает каждый не-GET вызов, сделанный API-токеном: метод, путь,
статус, сущность и её id. Тела запросов не записываются никогда, поэтому шаги
сценариев, значения параметров и пароли в журнал физически не попадают. Отсюда
главное ограничение: правки из браузера — и любой сессии с `authVia: "session"` — в
журнале **не видны**, так что пустой ответ означает «не было записей токеном», а не
«ничего не менялось». Инструмент только читает.

Тот же журнал видно и без инструмента: строку сценария или расписания, которую
последним писал токен, интерфейс помечает бэйджем `🤖 агент · <дата>` с именем
токена в подсказке.

Окно по умолчанию, сужение до одной сущности, правила, по которым бэйдж гаснет, и
почему токены стоит называть по назначению — `retibe_docs section:'agent-audit-log-ru'`.

Approve в `retibe_visual_review` не уничтожает старый бэйзлайн — он уезжает в
`history/<шаг>_v<N>_<время>.png`. `op:'versions'` показывает, что там лежит, а
`op:'rollback'` с `version` и `confirm:true` возвращает файл на место. Это файловая
операция: статус записи не меняется, разницу видно только на следующем прогоне.

**Чек-листы:** `retibe_checklists` (карта покрытия — что ещё руками),
`retibe_checklist` (создать чек-лист сразу с кейсами, переименовать, удалить),
`retibe_checklist_item` (добавить кейс, поправить, удалить, привязать к сценарию,
снять привязку, записать ручной результат). Раздел 10.

**Календарь:** `retibe_calendar` (что запланировано и что уже прогонялось),
`retibe_schedule` (создать, поправить, приостановить, удалить расписание, запустить
его прямо сейчас, посчитать стоимость через `dry_run` и привязать исходящий
вебхук). Раздел 14.

**Промпты** — у всех трёх есть обязательные аргументы, без них клиент вернёт
ошибку схемы:

| Промпт | Аргументы | Что делает |
|---|---|---|
| `/author` | `url`, `goal`, и необязательный `environment` | ведёт от разведки страницы до сохранённого сценария с привязанным кейсом чек-листа |
| `/debug` | `runId` | ведёт от красного прогона к причине: результат, сравнение с последним зелёным, матрица шагов, скриншот, лог |
| `/harden` | `scenarioPath` — на публичном эндпоинте это id или имя сохранённого сценария, а не путь к файлу; имя аргумента осталось от локального режима | прогоняет сценарий через слепые пятна раздела 7 и раскадровку |

Пошаговые конвейеры каждого промпта — какие инструменты и в каком порядке они
вызывают, и где текст ветвится по бэкенду — `retibe_docs section:'mcp-prompts-ru'`.

**`retibe_history` без аргументов отдаёт индекс** — какие сценарии вообще имеют
историю и какой у каждого `historyId`. Взять его можно и из списка прогонов:
`retibe_list_runs` отдаёт `historyId` на каждый прогон, а `null` там значит, что
строки истории у этого прогона нет. Сам `historyId` — md5 от формы сценария;
сценарий попадает в индекс после первого прогона **на платформе**.

**Лишний аргумент теперь ошибка, а не тишина.** Схемы всех инструментов строгие:
незнакомый ключ отклоняется с `Unrecognized key: "<ключ>"`, и в `tools/list` у
каждого инструмента стоит `additionalProperties: false` — правило видно до вызова, а
не после. Раньше SDK молча выбрасывал неизвестные ключи, и вызов отвечал не на тот
вопрос: `retibe_actions {name:'web_evaluate'}` возвращал весь каталог из 48
действий, будто действие и не называли. Уверенный неправильный ответ хуже ошибки —
в него верят. Самая частая опечатка здесь одна: у `retibe_actions` аргумент
называется `action`, а `name` — это аргумент `retibe_examples`.

В репозитории **28 инструментов и 3 промпта** — 28 локально и в `composite`, 26 на
публичном эндпоинте: `retibe_doctor` и `retibe_publish_run` там не регистрируются,
потому что оба работают с машиной, на которой запускается браузер. Сколько их на
самом деле отдаёт тот эндпоинт, к которому ты подключился, покажет `/mcp` — и это
честнее любого числа в этом файле, потому что деплой ручной (раздел 6).

---

## 9. Чтение результата

**Состояние прогона**

- **`passed` / `failed`** — тест отработал, вердикт настоящий
- **`infra_error`** — сценарий **не выполнялся**: не поднялся браузер или вышел
  дедлайн. Искать баг в сценарии не нужно. Такой прогон не стоит прогона:
  списанная на старте квота возвращается, повтор бесплатен
- **`unknown`** — прогон ещё не завершён. Это **не** «прошёл»
- **`kind`** — `web` или `telegram`: чем прогон выполнен, а не откуда прочитан
  результат (это `source`). У `telegram` пошаговых вердиктов нет по устройству —
  вердикт только на уровне прогона, и в `issues` это сказано прямым текстом
- **`resultSource`** — откуда взяты шаги дайджеста. `stored_result` — собственный
  результат движка: селектор, действие, скриншот, findings и баллы. Обычно это он —
  веб-прогон сохраняется целиком. `step_projection` — тонкая проекция платформы:
  только имя, статус и длительность. Пустые `scores` при `step_projection` значат
  «не сохранено», а не «ноль баллов»; в `issues` об этом сказано прямой строкой

**Ход прогона**

- **`stepsObserved`** — счётчик входов в шаг. Признак завершения только `state`.
  Точен там, где движок отдал границы шагов данными (в ленте у таких строк стоит
  `✔`), и остаётся оценкой там, где их пришлось распознавать по прозе лога — см.
  раздел 12
- **`steps`** — лента шагов с интервалами. У строк с `✔` интервал — это
  длительность, о которой отчитался движок; у строк без `✔` — промежуток между
  двумя распознанными строками лога, то есть оценка. Что именно перед тобой,
  сказано в `timelineCaveats` каждого ответа
- **`waitEndedBy`** — что завершило ожидание. `timeout` значит «время вышло», а
  не «ничего не произошло»
- **`next_offset`** — курсор для следующего опроса

**Итоги и находки**

- **`totals`** — `total / passed / failed / skipped`, плюс `unknown` — и только
  тогда, когда такие шаги есть. `unknown` — это шаг, у которого вердикт не записан
  вовсе (Telegram-прогон хранит на платформе только определение сценария). В
  `passed` он не складывается никогда: `passed: 0` рядом с `unknown: 5` значит
  «ничего не проверено», а не «ничего не прошло». Шаги со статусом `broken` и
  `error` считаются падениями и попадают в `failingSteps`
- **`issues`** — находки прогона, самые серьёзные первыми, с уровнем в начале
  строки: `[error] …`, `[warning] …`, `[info] …`. Порядок не декоративный: движок
  дописывает запись об упавшем шаге и об остановке по критическому шагу ПОСЛЕДНЕЙ,
  после всех info-находок анализаторов, поэтому список, обрезанный по порядку
  появления, терял ровно ту находку, ради которой его читают. Строка без префикса —
  это строка, которой уровень никто не присваивал: её подставил сам бэкенд
  (`infra_error`, «прогон ещё не завершён»), и такие строки всегда идут первыми
- **`issuesDropped`** — сколько находок не поместилось в лимиты по уровням
  (`error` — 10, `warning` — 5, `info` — 3, без уровня — 3). Перечислены только
  уровни, которые действительно что-то потеряли: `{"info": 12}` значит «есть ещё
  двенадцать info», а отсутствие поля — «больше ничего и не было»
- **`failingSteps[].durationMs`** — миллисекунды, измеренные движком. У прогонов,
  сохранённых до того, как движок начал их писать, длительность выводится из
  старого поля по правилу «≥ 100 — это уже миллисекунды»; тогда в ответе появляется
  **`caveats`** с точной формулировкой того, что это правило ломает: шаг, реально
  шедший 100 с и дольше, выглядит в 1000 раз короче. `caveats` — про то, как
  получен ответ, а не про сайт, поэтому в `issues` эти строки не попадают и при
  обрезке ответа не выбрасываются. Одна дыра этим предупреждением не закрыта:
  автообход (`mode:'auto'`) пишет `durationMs` только начиная с `a87c986`, а в старом
  поле у него лежали миллисекунды, а не секунды. Поэтому у его прогонов, сохранённых
  раньше, шаг короче 100 мс выглядит в 1000 раз **длиннее** — 40 мс читаются как
  40 с, — и `caveats` об этом молчит: правило считает, что такое число угадало

**Что упавший шаг видел в браузере**

- **`consoleErrors`** — до пяти строк из консоли браузера у **упавшего** шага:
  `[-1200ms] Uncaught TypeError: x is not a function`. `[-1200ms]` — сколько времени
  ДО падения строка появилась
- **`networkErrors`** — до пяти запросов, которые не дошли или ответили 4xx/5xx:
  `POST /api/login → 500 (-1300ms)`; вместо статуса может стоять причина
  (`net::ERR_CONNECTION_REFUSED`), если ответа не было вовсе

Оба поля есть только у упавших шагов, и ни одно из них не обрезано по границе шага:
движок держит кольцо событий страницы на весь прогон и кладёт в результат последние
записи. Поэтому ошибка с `-4200ms` — почти наверняка след ПРЕДЫДУЩЕГО шага, а не
этого; это не баг, а обычный случай: 500 приходит на `web_click`, а падает следующий
`web_assert`. Читать эти строки без их возраста нельзя — так чужая ошибка
приписывается текущему шагу. Значения параметров (`{{params.*}}`) в этих строках
маскируются как `***`, как и в сохранённом логе.

Бюджет дайджеста — 4096 байт, и это обещание, а не ориентир: маркеры `truncated`
считаются внутри него. Если ответ не влезает, сначала сокращаются списки (`issues`
до трёх, `extracted` до трёх, `visual` до пяти, `failingSteps` до трёх), а потом
браузерный контекст — раньше текста ошибки, потому что он дороже: у каждого шага
остаются **две** строки, самые близкие к падению, и в `truncated` появляется
`browser errors trimmed`. Если и после этого не помещается, контекст остаётся только
у первого упавшего шага (`failure context dropped`); затем сжимается `links.note`,
`extracted` уходит до одной записи, а `failingSteps` и `issues` — до одного элемента.
Дальше идёт пол бюджета, где
режется уже по весу: контекст снимается и с первого шага
(`failure context dropped from every step`), значения `extracted` и текст ошибок —
до 120 символов, `visual` — до одной строки. Если сам список срезов не влезает, он
схлопывается в одну строку `N cuts were made; …` — какие именно срезы были, тогда
не узнать, но что они были, видно всегда. `caveats` не режется никогда. Пустой
список не возвращается: «полей нет» без маркера про контекст в `truncated` значит
«браузер молчал», а не «мы не показали». Те же строки
видны и без дайджеста: `GET /api/test/reports/<id>/run-result` отдаёт их в
`steps[].consoleErrors` / `steps[].networkErrors` — в том же виде и с тем же
возрастом, до десяти строк каждого вида.

**Что сценарий прочитал и что сравнил**

- **`extracted`** — что сценарий **прочитал** со страницы: значение `web_extract`,
  то, что вернул `web_evaluate`, вывод `page_object` и переменные, записанные через
  `storeAs`. В каждой записи `step`, `action`, `storeAs` и `value`; у записи,
  пришедшей из карты переменных прогона, `step` нет — за ней не стоит отдельный шаг.
  Ключа нет вообще, если сценарий ничего не читал; это не значит, что значения
  потеряли по дороге. Значение режется до 2 КБ, записей не больше десяти;
  обрезанное помечено `truncated: true`, не поместившиеся посчитаны в
  `extractedDropped`
- **`visual`** — что прогон **сравнил**: по одной строке на шаг, снявший
  сравниваемый скриншот. `differencePercent` — процент отличий, `match` — вердикт
  порога шага. На платформе к строке добавляются `comparisonId` и `status` — это id
  и состояние записи ревью, которые берёт `retibe_visual_review`. Ключа `visual` нет
  вовсе, если сравнений не было: пустой список читался бы как «всё совпало», а это
  утверждение о сравнении, которого никто не делал. Сравнение, у которого процент не
  прочитался, в `visual` не попадает совсем — ни с нулём, ни с `null`, — а
  посчитано строкой в `caveats`. Ноль — это ответ движка «картинки совпали», и
  подставлять его вместо неизвестного значило бы выдать дыру за успех. Сырой
  `GET /api/test/reports/<id>/run-result` ведёт себя так же: ключа
  `differencePercent` там нет, если процент не записан (раньше проекция платформы
  писала `0`)
- `match` и `status` отвечают на разные вопросы и расходятся штатно: `match` —
  вердикт порога шага, `status` — вердикт серверного `VISUAL_REGRESSION_THRESHOLD`,
  который и решает, попадёт ли изменение в очередь ревью. `baseline: true` значит,
  что прогон **записал** эталон, а не сравнивал с ним: его `differencePercent: 0`
  ничего не измеряет

**Куда вести дальше**

- **`reportUrl`** — абсолютный адрес HTML-отчёта на платформе. Сырой результат лежит
  в её базе, а не на пути, который можно прочитать файловым инструментом;
  `detail:'raw'` для удалённого прогона отдаёт именно `reportUrl`, а не путь
- **`links`** — ссылки на всё, что прогон оставил после себя: `report`, `junit`,
  `rawResult`, `allure`. Только у прогона на платформе. Ни одна из них не
  открывается анонимно: `report` теперь проверяет владельца, остальные три всегда
  проверяли. Что именно нужно каждой — в `links.note`, там же, в ответе; если
  дайджест не влезает в бюджет, `links.note` сжимается до одной фразы «анонимно не
  открывается ни одна», а в `truncated` появляется `links note shortened`. Все эти
  адреса, как и `reportUrl` и пути скриншотов, построены от публичного адреса
  платформы (`https://retibe.com/…`), а не от внутреннего `http://app:3000`, по
  которому публичный эндпоинт сам ходит в платформу
- **`historyId`** — ключ, который принимает `retibe_history` (scenario_hash
  прогона). `null`, если отчёт есть, а строки прогона под него нет
- **`url`** — адрес, который тестировали. Это никогда не имя сценария: имя лежит в
  `scenarioName`

Скриншоты возвращаются путями, не картинками — кроме явного `detail:'screenshot'`.
Для прогона на платформе путь — это абсолютный URL (`https://…/screenshots/…`), а не
контейнерный `/app/screenshots/…`, который открывается только внутри раннера.

**`retibe_run_result {runId, detail:'screenshot'}`** — единственный ответ во всём
сервере, который содержит байты картинки, и он никогда не приходит сам: его надо
попросить по имени. Возвращается кадр упавшего шага — JPEG около 200 КБ, ширина
1024 — и текстовый блок рядом: какой шаг, какой селектор, откуда взят файл, сколько
байт весит оригинал и сколько ушло в ответ. Один шаг за вызов: по умолчанию первый
упавший шаг, у которого кадр есть, а `step:'<id>'` выбирает другой. Обычный дайджест,
`markdown` и `junit` байтов не содержат никогда, что бы прогон ни снял.

Картинка уменьшена: читай по ней состояние страницы и вёрстку, а не тонкие детали.
Оригинал в полном разрешении остаётся по пути из поля `source`. Если кадра нет,
инструмент говорит почему — зелёный прогон не фотографируют, а у упавшего шага кадра
может не быть, если прогон старше дефолта `on_fail`, браузер уже умер к моменту
падения, или результат прочитан из проекции платформы (это видно в `resultSource`).
На платформе `/screenshots` проверяет владельца, поэтому картинку забирает сам
бэкенд — с тем же `Authorization: Bearer`, с которым ходят остальные вызовы сессии.

Поэтому сценарий годится и как способ **прочитать** страницу, а не только проверить
её: двух шагов — `web_navigate` и `web_evaluate` со скриптом `document.title` —
достаточно, чтобы заголовок пришёл в `extracted[0].value`. На платформе это читается
из сохранённого результата: перезапускать прогон и успевать к 15-минутному кольцу
лога не нужно.

**Счётчики в `retibe_list_runs`.** `passed` / `failed` / `total` берутся из
статистики прогона на платформе. `null` значит «счётчиков нет» — прогон записан до
того, как статистика по шагам появилась, или платформа старше этого релиза, — и это
**не** ноль пройденных шагов; в таком ответе есть `note` об этом. Настоящие итоги в
таком случае даёт `retibe_run_result`. Пропущенные шаги (`skipped`) считаются
отдельно и больше не попадают в `failed`; у старых прогонов `failed` остался
завышенным на число пропусков — задним числом это не пересчитывается.

### Вынести результат наружу

`retibe_run_result` отдаёт дайджест как JSON. Для мест, куда JSON не положишь, у
того же дайджеста есть ещё три вида: `detail:'markdown'` — готовый блок в
пул-реквест, уложенный в 3 КБ; `detail:'junit'` — JUnit XML для CI, который
генерирует платформа (для локального прогона его не существует, пока не сделан
`retibe_publish_run`); `detail:'log'` — последние 300 строк лога прогона.

Ссылка на отчёт подписана: она требует входа на платформу. Каталоги `/reports` и
`/screenshots` больше не раздаются всем — нужна либо браузерная сессия, либо тот же
заголовок `Authorization: Bearer <токен>`, которым пользуется эта сессия. Публичным
остаётся только отчёт гостевого прогона (`/reports/anonymous/…`). Коллеге без
аккаунта отправляйте ссылку из кнопки «Поделиться» в списке отчётов: она
подписана, открывается без входа вместе со скриншотами отчёта и живёт 7 дней.

Что именно попадает в каждый вид и чем каждый ограничен —
`retibe_docs section:'exporting-a-run-result-ru'`.

### Ограничения

- **Telegram — только написание сценариев.** Прогон требует сессии, которая
  создаётся интерактивным входом с SMS.
- **Один тест за раз** на аккаунт; бот- и веб-тесты блокируют друг друга. Веб-прогон,
  запущенный, пока идёт другой, встаёт в очередь платформы (`state: 'queued'`, до
  десяти заданий на аккаунт) и стартует, когда освободится слот; отказ 409 придёт,
  только если очередь полна или на инстансе выключена.
- Прогоны отсюда тратят ту же месячную квоту, что и запуски из веб-интерфейса.
  Квота списывается на старте — иначе она не ограничивала бы, — а для прогона из
  очереди «старт» — это **постановка в очередь**, а не момент, когда он реально
  пойдёт: десять заданий в очереди — это десять списанных `web_tests` сразу. Второй
  раз при запуске из очереди не списывается. Прогон, который не выполнялся
  (`infra_error`, или задание, умершее в очереди до того, как его взял раннер),
  возвращается в квоту. Красный тест прогон стоит: он отработал. Остановленный
  вручную — тоже.
- `retibe_run_scenario` спрашивает остаток **до** запуска и на исчерпанной квоте
  отказывает сам, называя числа: `web_tests quota exhausted: 20/20` — вместо 403,
  который приходил бы уже после того, как сценарий разобран, а вызов сделан. В
  успешном ответе есть `quota: {web_tests: '19/20', remainingAfterThisRun: 0}`.
  Если счётчик прочитать не удалось, прогон **не** отменяется: приходит `quotaNote`,
  и решение остаётся за платформой. Локальный и composite-прогон этого вопроса не
  задают: они не тратят `web_tests`.
- **Разведка страницы — отдельная и более дешёвая квота.** `retibe_inspect_page`
  тратит счётчик `inspections`, а не `web_tests`: 60 в месяц на Free, 600 на
  Professional, безлимит на Corporate. Остаток виден в `retibe_whoami` строкой
  `inspections: 3/60`. Одновременно на аккаунт выполняется одна разведка и один
  обход.
- **Адреса внутренней сети недоступны.** URL, который резолвится в приватный
  диапазон — `127.0.0.1`, `10.x`, `172.16–31.x`, `192.168.x`, `169.254.169.254`,
  `localhost`, `*.internal`, IPv6 `::1`/`fc00::/7`/`fe80::/10` — отклоняется до
  запуска браузера, вместе с редиректами и подресурсами страницы на такие адреса.
  Схемы, кроме `http:` и `https:` (`file:`, `data:`, `ftp:`), тоже отклоняются. Имя,
  которое не удалось разрешить, считается запрещённым, а не разрешённым. Квота на
  этих отказах не тратится. В self-hosted-установке проверку снимает переменная
  окружения `INSPECT_ALLOW_PRIVATE=1` — на схемы она не распространяется. Локальная
  разведка `localhost` разрешает (раздел 5).
- Веб-тест обычно ~минута, самые долгие — до десяти. Вызовы не блокируются:
  старт возвращает `runId`, дальше опрос.

---

## 10. Чек-листы

Ручной чек-лист — это то, что у команды уже есть: список кейсов, часть из них
проверяется руками каждый релиз. `retibe_checklists` показывает, что ещё руками,
`retibe_checklist` и `retibe_checklist_item` заводят кейсы и привязывают их к
сценарию; привязанный кейс получает результат с каждого прогона на платформе.
Привязать можно тем же вызовом, которым сохраняешь сценарий: `link_to` у
`retibe_save_scenario`.

Что ломается чаще остального:

- **Привязка лежит на чек-листе, а не на кейсе** — поле `scenario_id` у чек-листа.
  Один чек-лист смотрит в один сценарий; два кейса про разные сценарии — это два
  чек-листа.
- **Ключ привязки — `scenario.id || scenario.name`, и ни FK, ни триггера за ним
  нет.** Переименовал сценарий, выдал ему `id`, удалил — привязка отвалилась молча и
  навсегда. Единственный способ узнать — `retibe_checklists verify_links:true`.

`retibe_docs section:'checklists-over-mcp-ru'` — всё остальное: рабочий цикл
целиком, создание чек-листа вместе с кейсами, полная механика привязки и что значит
`scenarioKeySource`, откуда берутся результаты в кейсе и при чём здесь
`retibe_publish_run`, три несогласные формулы покрытия и два счётчика, которые ведут
себя не так, как выглядят, `op:'record'` с идемпотентностью по `run_id`, и чего в
инструментах нет намеренно.

---

## 11. Раскадровка: какой шаг вообще способен упасть

`retibe_validate_scenario` c `view:'storyboard'`. Таблица по шагам плюс одна
строка вывода.

```
#  id    action        target               tmpl  verdict
1  open  web_navigate  https://example.com        can fail
2  bad   web_assert    h1                         asserts nothing — data.text is never read; no data.contains
3  sec   security      —                          always green — writes its problems to findings and reports passed

Only step 1 can fail; the other 2 cannot make this scenario go red.
Every "can fail" above is about MECHANICS. No step checks an OUTCOME — nothing
here says what the site should look like after these actions.

canFail: [1]   asserts: []   opaque: []
```

Вторая строка вывода и поле `asserts` — это и есть ответ на вопрос «а проверяет ли
этот сценарий хоть что-нибудь». Если исход всё-таки проверяется, вместо неё будет
`Outcome checks: step(s) 3.`, а `asserts` перечислит эти шаги. `opaque` — шаги
`use_fixture` и `page_method`: их тело лежит не в сценарии, и раскадровка про них
честно говорит «не знаю».

`view` принимает `findings` (по умолчанию — как было), `storyboard` и `both`. У
`view:'storyboard'` ошибки валидатора не прячутся за красивой таблицей: если они
есть, в ответе будет `blockingErrors` с их числом и указание сходить за ними в
`view:'findings'` — прогон до вердиктов всё равно не доживёт.

**Вердикты.** Читаются от худшего к лучшему; показывается тот, который реально
управляет шагом:

| Вердикт | Что значит |
|---|---|
| `no such action` | действия нет в каталоге — шаг упадёт ошибкой, а не проверит. Ловит и опечатку (`web_clikc`), и придуманное действие (`web_hover`) |
| `always green` | пишет проблему в findings и всё равно отдаёт passed |
| `asserts nothing` | `web_assert`, у которого текстовое поле движок не читает |
| `failure ignored` | `optional:true`, и это действие его учитывает |
| `may not run` | шаг под `condition` или `skipIf` — обещать про него нельзя ничего |
| `can fail` | настоящий гейт: этот шаг может покраснеть |

**`can fail` ≠ «проверяет исход».** Вердикт говорит только о том, способен ли шаг
покраснеть. Шаг, который краснеет, когда сайт **лежит**, и шаг, который краснеет,
когда сайт **отвечает неправильно**, — разные вещи; вторые считает поле `asserts`.
Полный `canFail` при пустом `asserts` означает ровно одно: сценарий зелёный на сайте,
который загрузился и сломан. Поэтому в ответе рядом с `canFail` есть ещё два списка:

| Поле | Что в нём |
|---|---|
| `asserts` | номера шагов, которые проверяют **исход** — что именно должно быть на странице |
| `opaque` | номера шагов `use_fixture` / `page_method`: их тело лежит не здесь, и проверяют они что-нибудь или нет, раскадровка не знает |

Полный список того, что считается проверкой исхода (он выведен из движка, а не из
документации, и его же считает `NO_ASSERTIONS`), чем `data.absent: true` отличается
от `data.visible: false` в глазах раскадровки, в каком порядке движок проверяет
`skipIf` и `condition` и почему «`skipIf: {previousStepFailed: true}` плюс настоящий
ассерт» выглядит гейтом, а гейтом не является, откуда взяты вердикты и почему
`environment` и `unchecked` приходят и сюда —
`retibe_docs section:'storyboard-outcome-checks-ru'`. Тот же список действий, в
прозе, есть в разделе 7.

Надёжный способ убедиться, что проверка настоящая, по-прежнему один: сломать
сценарий намеренно и увидеть, что он покраснел.

---

## 12. Опрос прогона: ждать не «сколько», а «чего»

У `retibe_run_status` появились `wait_for`, `since` и `stall_ms`.

| `wait_for` | Возвращается когда |
|---|---|
| `terminal` | прогон дошёл до финального состояния (по умолчанию, как было) |
| `step` | прошла следующая граница шага |
| `failure` | появилась первая строка уровня error — или прогон закончился |
| `stall` | прогон жив, но молчит дольше `stall_ms` (по умолчанию 30 с) |

Смысл в том, что слепой `wait_ms` тратит весь бюджет на прогоне, который упал на
второй секунде. `wait_for:'failure'` возвращается сразу — и это **не** приговор:
`state` всё ещё `running`, а движок пишет error и на восстановимых ретраях.
Что именно завершило ожидание, видно в `waitEndedBy`: `timeout` — «время
вышло», всё остальное — «случилось вот это».

**Ждал падения — не забудь остановить.** `wait_for:'failure'` возвращается, пока
прогон ещё идёт: движок пойдёт дальше по шагам, если они не `critical`. Если это было
то самое падение, `retibe_stop_run` заканчивает прогон сейчас — `retibe_run_status`
сам про это напоминает в `next`. Ждать до конца смысла нет: квота списана в любом
случае.

`since` — курсор. Ответ несёт `next_offset`; передай его в следующий вызов, и
опрос прочитает только новое, а не перечитает и не перезаплатит за уже виденный
хвост.

**Лента шагов.** В ответе появилось поле `steps`:

```
✓ open   ██                       2.4s ✔
✗ submit ████████████████████████ 31.7s ✔
… verify                          ?
```

`✓` выполнен, `✗` упал, `–` пропущен, `…` ещё идёт. `✔` в конце строки значит, что
эта строка **прочитана из данных**, а не угадана: движок публикует границу каждого
шага отдельным событием (`StepEventMeta` — третий, необязательный аргумент
broadcast-колбэка), и у такой строки `spanMs` — это длительность, о которой отчитался
движок, а исход — записанный им статус.

Строки без `✔` распознаны по русской прозе в логе, как было раньше. Их остаётся две
категории: прогон на образе платформы старше этого релиза (во время раскатки `.com` и
`.ru` могут отвечать по-разному — это ожидаемо и видно из `timelineCaveats`), и
несколько строк, которые движок намеренно не размечает, потому что они не закрывают
шаг: восстановимая ошибка перед ретраем, `⏰` таймаут повторов и `🛑` критический шаг.
Для них по-прежнему верно:

- Шаг, чьё сообщение не совпало с шаблоном, в ленте просто отсутствует.
- Длительность — это интервал между двумя распознанными строками, а не время,
  которое движок отчитался потратить. Поэтому это оценка, а не измерение.

Независимо от источника:

- `(retried)` значит, что движок вошёл в шаг повторно; у строки без `✔` интервал
  покрывает все попытки.
- У последнего, ещё идущего шага стоит `?`, а не `0.0s`: показывать ноль рядом с
  шагом, который висит минуту, хуже, чем признать, что цифры нет.
- `timelineCaveats[0]` говорит, откуда взяты строки: прочитаны из событий движка,
  распознаны по прозе или — на смешанном прогоне — и то и другое, с пометкой, что
  строки без `✔` угаданы.
- `durationMs` в событии шага измеряется от входа в шаг до строки завершения, то есть
  **до** фикстуры `afterEach`. На её время он меньше, чем `duration` в результате
  шага.
- В мультиактёрном прогоне у строк с `✔` актёр приходит вместе с событием; у строк
  без `✔` он читается из строки переключения, и нераспознанная строка означает
  «неизвестно», а не «предыдущий».

`stepsObserved` точен для прогона, целиком прочитанного из событий движка. Где
границы всё-таки пришлось распознавать по прозе, остаётся старое ограничение: токен
шага — это `id || action`, так что два шага без `id` с одинаковым действием
неотличимы и считаются за один. Единственное лекарство — давать шагам `id`.

`totalSteps` — число шагов сценария. Платформа читает его из сохранённого сценария;
если не смогла, берётся строка плана, которую движок печатает перед первым шагом
(`📋 План: N шагов`). `0` значит «никто не сказал», а не «шагов нет». Знаменатель для
«шаг 3 из 8» — это он, а не `stepsObserved`.

`elapsedMs` у завершённого прогона — **его длительность**, у идущего — время с
момента старта. Если платформа не отдала ни времени окончания, ни длительности
(старый деплой), поля в ответе **не будет**: «сейчас минус старт» на прогоне, который
закончился вчера, отчитался бы о сутках вместо девяти секунд.

**На публичном эндпоинте это работает через канал прогресса платформы** (
`GET /api/webtest/:testId/progress`, добавлен вместе с этим). До него удалённый
опрос возвращал жёсткие нули и фразу «прогресс недоступен». Если канал не
отвечает, в ответе будет прямо сказано, что про прогон **ничего не известно** —
это не то же самое, что «прогон молчит», и `waitUnsupported` перечислит
условия, которые не удалось соблюсти. Кольцо держит последние 500 строк: если
твой курсор старше, в ответе будет про вытеснение, а не тихий обрыв. И у кольца есть
срок: это Redis с TTL в пятнадцать минут. Через пятнадцать минут после конца прогона
живой хвост пуст — при полностью целом результате.

**У завершённого прогона ленты шагов нет — и это сказано прямо.** Опрос завершённого
теста почти всегда возвращает `timeline` без строк. Раньше вместе со строками
пропадали и `timelineCaveats`, то есть ответ молчал о том, почему он пуст. Теперь
`timelineCaveats` приходят всегда и содержат строку
`The progress ring is not retained after a run completes (TTL 15 min)` — за вердиктом
и упавшими шагами иди в `retibe_run_result`.

### Лог, который переживает кольцо: `retibe_run_result detail:'log'`

Движок кладёт последние **300 строк** своего broadcast прямо в результат прогона, и
там они живут столько же, сколько сам результат. Это единственный лог, который есть
у прогона, законченного час назад.

```
retibe_run_result runId:'webtest-1789360920418' detail:'log' grep:'❌|timeout'
```

Три вещи до того, как делать выводы: это **хвост, а не стенограмма** — режется
всегда начало, и сколько строк не поместилось, написано в `omitted`; подставленные
значения `{{params.*}}` замаскированы в `***` не при чтении, а в момент записи,
внутри движка; кадры скриншотов в лог не попадают никогда.

Лимиты хвоста, поведение `grep` (и что бывает, когда выражение не компилируется),
почему `available:false` — это не «прогон молчал», и почему у автообхода лога нет
вовсе — `retibe_docs section:'stored-run-log-ru'`.

---

## 13. Память о прогонах: что изменилось и что мигает

Красный прогон ставит один вопрос: **это новое?** Дайджест на него не отвечает — он
описывает один прогон. Отвечают `retibe_compare_runs` (что изменилось между двумя
прогонами; на платформе `runA` можно не передавать — возьмётся последний зелёный) и
`retibe_history detail:'steps'` (строка вида `✓✗✓` на каждый шаг, слева самый старый
прогон).

Что надо знать сразу:

- **`flakiness_score` на этот вопрос не отвечает.** Это доля падений, а не доля смен
  знака: у `✓✓✗✗` (регресс), `✗✗✓✓` (починили) и `✓✗✓✗` (флап) он одинаковый — 50.
  Вердикт шага в `detail:'steps'` считается по числу смен знака и различает
  `regressed`, `recovered` и `flaky`.
- **Зелёный частичный прогон — не зелёный сценарий.** `from_step` / `only_steps`
  гонят кусок; такой прогон получает своё имя (`… [partial 15-20]`), не штампует
  кейсы чек-листа и тратит квоту так же, как полный.

`retibe_docs section:'run-memory-ru'` — всё остальное: три источника памяти
платформы и чего нет в каждом, цена `detail:'steps'` (`requests: 1` против N+1),
правила сопоставления шагов и нормализации текста ошибки, семь значений `verdict`,
таблица вердиктов шага, нарезка прогона с автоматическим префиксом подготовки и её
ловушка с логином, рецепт разбора «прогон покраснел», оговорки про локальные прогоны
и `historyId`.

---

## 14. Календарь и расписания

`retibe_calendar` читает (что запланировано и что уже прогонялось),
`retibe_schedule` пишет (создать, поправить, приостановить, удалить, запустить
сейчас, посчитать стоимость через `dry_run`, привязать исходящий вебхук).

Что бьёт в первый же день:

- **Таймзона по умолчанию UTC.** Не указал `timezone` — `time: '09:00'` значит 09:00
  UTC. Это самая частая причина «расписание не сработало»; правду говорит `nextRunAt`
  в ответе, и сверять надо глазами именно его.
- **Расписание держит копию шагов сценария, а не ссылку.** Поправил сценарий —
  расписание гоняет старое, пока не сделаешь `op:'update'` с тем же `scenario`.
- **Каждое срабатывание тратит прогон из месячной квоты.** `daily` на бесплатном
  тарифе (20 `web_tests`) исчерпает её за три недели, дальше каждое срабатывание
  получает отказ. Повторяющееся расписание при этом не выключается и не копит
  `fail_count` — отказ тарифа не считается падением теста, — а просто ждёт своего
  следующего времени. Разовое (`once`) выключается, но не пропадает: `op:'resume'`,
  когда квота обновится, и ближайший проход планировщика (раз в минуту) его
  запустит. В обоих случаях владельцу сообщают — письмом, push и webhook'ом
  `run.skipped`, если они включены. Посчитать заранее — `dry_run:true`.

Частот всего четыре: `once`, `daily`, `weekly`, `monthly`. `cron` инструмент не
принимает, и это не осторожность: в движке эта ветка читает выражение один раз, лишь
бы оно было непустым, и возвращает начало следующего часа UTC.

`retibe_docs section:'calendar-and-schedules-ru'` — всё остальное: почему повторный
`op:'create'` создаёт второе расписание, что происходит с `cron` и `interval` через
веб-API, разбор `dry_run` по полям, два источника счётчиков прогонов и цена правды,
`view:'entries'`, `op:'run_now'`, что бывает с расписанием, когда срабатывание получило
отказ, и событие `run.skipped`, исходящий вебхук целиком (payload, подпись,
повторы, `op:'webhook_test'`) и чего в инструментах нет.

---

## 15. Как называть сценарии и сколько их держать

Имя и верхнеуровневый строковый `id` сценария — не косметика: на них завязаны
история прогонов и привязка к чек-листу, и потом это почти не переделать.

- **Давай каждому сценарию стабильный строковый `id`** вида
  `<проект>-<область>-<что проверяем>`, в нижнем регистре, через дефисы, и то же
  значение в начало `name`. Без него ключом становится числовой id платформы — и
  привязка чек-листа срабатывает для прогона, запущенного объектом из
  `retibe_scenarios`, и **не** срабатывает для того же сценария, переданного
  инлайном. Смена этого ключа у сохранённого сценария — не косметика:
  `retibe_save_scenario` откажет и перечислит осиротевшие чек-листы.
- **Сценариев должно быть мало, и они должны быть длинными.** Квота считает прогоны,
  а не шаги; на аккаунт идёт один тест за раз; поднять браузер стоит около минуты
  даже у крошечного теста. Один сценарий — один пользовательский путь целиком, 30–50
  шагов, а не пять по шесть.
- **Давай шагам `id`.** Лента шагов, привязка кейса, раскадровка и diff при удалении
  опознают шаг по `id || action`: два шага без `id` с одинаковым действием
  неотличимы и считаются за один.

`retibe_docs section:'naming-scenarios-ru'` — всё остальное: как ищет
`retibe_scenarios search:`, весь разбор `keyChange` / `checklistsAffected` /
`dry_run` при смене ключа, отказ при занятом имени, как выбирается вкладка
(`category`) и что теряется при round-trip, где длинный сценарий начинает мешать и
по каким границам его делить, как это ложится на чек-листы, и короткая инструкция,
которую можно дать Клоду дословно.

---

## 16. Сплит-вью: два браузера в одном сценарии

Один сценарий может вести **несколько браузеров одновременно** — покупатель и
продавец, звонящий и принимающий, админ и пользователь. Актёры объявляются объектом
`actors` (ключ и есть id актёра, именно его ищет `step.actor`), блок `webrtc`
включает мультиактёрность; название историческое, звонки тут ни при чём.

- **Нужны ОБА поля: `actors` и `webrtc.enabled: true`.** Одного `actors` мало: всё
  уедет в один браузер, поля `actor` будут проигнорированы, и никто об этом не
  скажет — прогон зелёный, двух участников не было. Валидатор зовёт это
  `ACTORS_WITHOUT_WEBRTC`. Это самая дорогая ошибка в разделе, потому что выглядит
  она как успех.
- **Шаги идут по очереди, а не одновременно.** Параллельного шага нет, барьера нет,
  «одновременно» выразить нечем. Синхронизация — это `web_assert` на элемент,
  который появляется от действия другого участника.

`retibe_docs section:'split-view-over-mcp-ru'` — всё остальное: рабочий пример
целиком, два молчаливых переназначения актёра (`STEP_WITHOUT_ACTOR`,
`UNKNOWN_ACTOR`), какие поля описания актёра движок читает, а какие игнорирует
(`browserArgs`, `device`, `storageState` — нет), почему `splitPreview` — это только
картинка и почему её кадров по MCP не будет, `forceHeaded` на платформе, и что
именно видно про актёров в валидаторе, раскадровке, ленте шагов и дайджесте.

Рабочие примеры: `retibe_examples search:'multi-actor'` — они в конце индекса и
дефолтной страницей не достаются.

---

## 17. Фреймы: когда форма во встроенном документе

Форма карты, reCAPTCHA, Turnstile, Stripe Elements, любой чужой чекаут — всё это
живёт в `<iframe>`, а селектор из главного документа внутрь не видит. Шагу нужен
`data.frame`: CSS-селектор самого iframe, цепочка таких селекторов, `{ "name": … }`
или `{ "url": … }` (это регулярка). Читают его девять действий — от `web_click` до
`web_evaluate`; координатные касания, `page_*`, проверки URL и заголовка и всё
внутри фикстуры — не читают, и валидатор на этом отказывает.

- **Движок читает только `frame`.** `frameSelector`, `iframe`, `frameName`,
  `frameUrl`, `frameLocator`, `iframeSelector` не читаются вообще — а `web_fill_form`
  по умолчанию проглатывает ошибки полей. Отсюда зелёный прогон оплаты, которой не
  было, и поэтому валидатор считает каждое из этих имён **ошибкой**.
- **`:contains()` в `data.frame` не работает.** `resolveFrame` ждёт каждое звено
  цепочки обычным `waitForSelector` со `state: 'attached'`, мимо резолвера движка —
  это путь B из раздела 7, и Chromium отвергает весь селектор целиком. Альтернативы
  через запятую при этом работают: список селекторов — обычный CSS.

`retibe_docs section:'frames-over-mcp-ru'` — всё остальное: пример шага, четыре
формы `frame`, полная таблица того, что фрейм ограничивает, а что нет, бюджет на
резолв (`frameTimeout` → `selectorTimeout` → `timeout` → 10000 мс), диагностика
`frame not found` и как фреймы выглядят в раскадровке.

Рабочий пример: `retibe_examples name:'payment-iframe-checkout-example.json'`.
Справочник движка — `retibe_docs section:'frames-iframes'`.

---

## 18. Карта покрытия в репозитории

Платформа знает, какие сценарии существуют. Она не знает, **почему** они такие и что
решено не покрывать вовсе. Заведи в репозитории `docs/TEST_COVERAGE.md`: таблица по
строке на сценарий (стабильный строковый `id`, инстанция и числовой id платформы —
`com:242`, у `retibe.com` и `retibe.ru` разные базы, — число шагов, чек-лист, одна
фраза «что сломается, если сценарий покраснеет») и раздел **«Не покрыто и почему»**
под ней. Второе важнее первого: таблицу можно восстановить из платформы одним
вызовом, а причины, по которым что-то решили не автоматизировать, — ниоткуда.

Правило одно: **сохранил сценарий — обнови файл в том же заходе.** И честная
оговорка: ничто не заставляет этот файл быть правдой. В фактах прав эндпоинт
(`retibe_scenarios`, `retibe_checklists verify_links:true`), в намерениях — документ.

`retibe_docs section:'coverage-map-ru'` — всё остальное: почему листинга платформы
не хватает, что писать в каждой колонке, когда обновлять, и гейт в CI — что каждый
его прогон тратит единицу квоты, почему код выхода 2 это не падение и почему
`scenario_id` привязан к инстанции.

Гейт — составное действие GitHub, опубликованное как
[`retibe/gate`](https://github.com/retibe/gate): в job достаточно `uses: retibe/gate@v1`
с `token` и `scenario_id`, checkout не нужен. Внутри только curl и jq, браузера в
раннере нет.

---

## 19. Параметры: как дать сценарию его данные

`{{params.*}}` — это то, чем сценарий получает логин, пароль, базовый URL. Читать
ключи умеет `retibe_env`, писать — `retibe_param` (`op:'set' key:'auth.password'
value:'…' secret:true`). Порядок такой: сначала параметры, потом сценарий.

- **Запись односторонняя.** Значение уходит и не возвращается ни одним
  инструментом — `retibe_env` отдаёт только ключи. Отсюда `confirm:true` на
  перезапись и на удаление: старое значение отсюда не прочитать, а на ключ может
  смотреть чужой сценарий.
- **Несуществующий ключ — это не ошибка прогона.** Резолвер оставляет плейсхолдер
  как есть: в поле формы ляжет строка `{{params.auth.email}}`, а шаг будет
  «выполнен». Ловит это только `retibe_validate_scenario` с аргументом
  `environment` — код `PARAM_KEY_MISSING`, предупреждение. Без аргумента сверки не
  делается, и ответ говорит это полем `unchecked`, а не молчанием.
- **Ключ — это позиция в плоском пространстве имён, а не строка в базе.** Дефис или
  пробел хранилище примет, а резолвер (`[a-zA-Z0-9_.]`) не подставит; повтор
  сегмента (`devices.devices`) непредставим вовсе. Инструмент отклоняет оба случая
  до записи и перечитывает каждую запись, откатывая её, если ключ всё равно не виден.

`retibe_docs section:'parameters-over-mcp-ru'` — всё остальное: почему
`action: unchanged` не перезапись, `secretKeys` в `retibe_env` и что всё-таки
попадает в лог, устройство плоского пространства имён и поле `storedIn`, что
проверка `environment` намеренно не трогает (`|default`, ветка `graphql`,
ключ-объект, телеграм), поведение параметров в режиме `composite` (`paramsSource`) и
почему окружение создаётся молча.

---

## 20. Если что-то не так

Таблица идёт по темам: доступ и сборка эндпоинта, разведка, сохранение сценария,
прогон и его результат. Слева — то, что ты увидишь дословно; справа — причина, а не
совет «попробуйте ещё раз».

**Если строки здесь нет, она не потеряна.** Симптомы тем, которые вынесены в
справочник, лежат вместе со своей темой: история прогонов и нарезка —
`retibe_docs section:'run-memory-ru'`, фреймы — `frames-over-mcp-ru`, сплит-вью —
`split-view-over-mcp-ru`, чек-листы — `checklists-over-mcp-ru`, расписания —
`calendar-and-schedules-ru`, параметры — `parameters-over-mcp-ru`, карта покрытия —
`coverage-map-ru`, журнал агента — `agent-audit-log-ru`. В каждой главе это
подраздел «Разбор проблем». Не знаешь, какая тема, — ищи по дословному тексту:
`retibe_docs search:'<строка из ошибки>'`.

| Симптом | Причина |
|---|---|
| `Missing API token` | Заголовок не дошёл — проверь `--header` в команде подключения |
| `Invalid, revoked or expired` | Токен отозван или неверен — либо выпущен на другой инстанции, см. раздел 1 |
| `Too many requests for this token or address` | Слишком часто. Это не квота и не отзыв токена: шлюз держит 60 вызовов в минуту на токен (пачкой до 30) и 120 на адрес. В ответе есть `Retry-After` — подожди столько секунд и продолжай. Повтор в цикле без паузы только удерживает корзину пустой; код ошибки `-32004`, в отличие от `-32003` у квоты |
| `Usage limit exceeded` | Платформа отказала в прогоне — кончились web_tests или security_scan, видно в `retibe_whoami` |
| `web_tests quota exhausted: 20/20` | Это отказ самого `retibe_run_scenario`: он спрашивает остаток ДО запуска, чтобы не разбирать сценарий впустую. Счётчики — в `retibe_whoami`, сбрасываются с расчётным периодом |
| `This account has used N of M API requests` | Отказ шлюза по квоте api_requests — это отдельный счётчик, его тратит ИИ-генератор сценариев, а не MCP-вызовы |
| Тест зелёный, но ничего не проверяет | Прогони `/harden` по сценарию |
| `infra_error` | Не сценарий виноват. На публичном эндпоинте это состояние платформы — `retibe_doctor` там не предлагается, смотри `retibe_whoami` (`platform.note`) и пробуй позже; локально — `retibe_doctor`. Квоту такой прогон не тратит: он возвращается в неё, повтор бесплатен |
| `tool not found` на `retibe_doctor` | Ты на публичном эндпоинте (или `RETIBE_BACKEND=remote`), где инструмента нет: он осматривает машину, на которой запускается браузер, а она там не твоя. `retibe_whoami` → `platform.note` |
| `tool not found` на `retibe_publish_run` | То же самое с другой стороны: публиковать нечего — прогон, запущенный отсюда, уже в отчётах, истории и календаре. `retibe_whoami` → `platform.publishing` |
| `... is missing from this checkout` | Эндпоинт на образе старше `d1069cc` — `knowledge/` в него не попадал. Нужен передеплой, см. раздел 6 |
| `No web examples in this checkout` (или `No examples in this checkout`) | То же самое, но про каталог `examples/`. Слово `web` — потому что на публичном эндпоинте индекс по умолчанию отфильтрован до веб-примеров: Telegram там не запускается |
| Ссылка в ответе ведёт на `http://app:3000/…` | Эндпоинт на образе старше `a87c986`: тогда публичный сайдкар строил отчёт, скриншоты, JUnit и Allure от внутреннего адреса, который снаружи не резолвится. Открывать её бесполезно, сколько бы `links.note` ни объяснял как. Теперь сайдкар без `RETIBE_PUBLIC_BASE_URL` просто не стартует; `GET /health` → `publicBase` показывает, от чего строятся ссылки. Лечится передеплоем (раздел 6) |
| Валидатор говорит не то, что в этом файле | Сверь сборку: `retibe_whoami` → `build.sha` против `git rev-parse --short=12 origin/main`. Разошлись — эндпоинт отстал, нужен передеплой; совпали — это расхождение кода и документа, см. раздел 6 |
| `Refusing to inspect …: … may only target public hosts` | Платформа не инспектирует приватные сети — разведка идёт с её IP. Для внутреннего стенда нужен публичный адрес либо self-hosted с `INSPECT_ALLOW_PRIVATE=1` (раздел 9) |
| `Inspection limit exceeded (N/M this month)` | Кончилась отдельная квота `inspections` — она не общая с `web_tests`. Остаток виден в `retibe_whoami` |
| `An inspection is already running for this account` | Одна разведка на аккаунт одновременно. Дождись ответа — он приходит не позже 40 секунд — и повтори |
| `The URL still contains {{params.…}} after resolving…` | Ключа нет в этом окружении. `retibe_env` — посмотреть, какие есть; `retibe_param` — завести (раздел 19) |
| `No platform is configured, so there is nowhere to inspect` | Локальный режим без `RETIBE_BASE_URL`: разведка живёт на платформе, локального аналога у неё нет. Либо подключись к платформе, либо посмотри страницу браузерным инструментом клиента — и скажи в отчёте, что селекторы не проверялись |
| `This account already has a crawl running` | Один обход на аккаунт. Если `crawl_id` предыдущего сохранился — читай им; иначе подожди минуту: платформа считает обходы на воркер, и в кластере отказать может не тот воркер, который действительно занят |
| `The crawl failed before it produced a map` | Обход умер на первой странице — обычно стартовый URL не загрузился. Проверь его `retibe_inspect_page`, который называет HTTP-статус |
| `Invalid arguments for prompt ...` | У промпта есть обязательные аргументы: `/author` — `url` и `goal`, `/debug` — `runId`, `/harden` — `scenarioPath`. Раздел 8 |
| `search:` ничего не находит, хотя сценарий есть | Поиск идёт подстрокой по имени, описанию и верхнеуровневому строковому `id`. По числовому id платформы и по шагам он не ищет вовсе — такой сценарий открывай через `retibe_scenarios id:N` — раздел 15 |
| `Refusing to save: N error(s)` | В сценарии есть ошибки валидатора — первые пять перечислены в отказе. Предупреждения, включая три кода про селекторы, сохранению не мешают никогда; `strict:true` на сохранение не влияет (раздел 7) |
| `Scenario is invalid (N error(s)); nothing was run.` | То же самое на прогоне: `retibe_run_scenario` валидирует сценарий до запуска браузера и на ошибке не тратит ни секунды, ни квоты. Первая ошибка названа в тексте, остальные — `retibe_validate_scenario` (раздел 7) |
| `Refusing to save: this changes the key…` | Ключ, по которому рекордер находит чек-листы (строковый `id` сценария, иначе имя), меняется. Либо верни прежние `id`/`name`, либо `confirm:true` — и тогда сразу перепривяжи чек-листы из `keyChange.checklistsAffected`. `dry_run:true` показывает то же самое, ничего не записывая (раздел 15) |
| `Refusing to save: this drops N step(s)` | Сохраняемый сценарий короче того, что лежит на платформе. В отказе перечислены исчезающие шаги (по `id \|\| action`; многоточие — значит названы не все). Если это ошибка — впиши обратно все шаги, которые нужны; если удаление сознательное — `confirm:true`, и в ответе придёт `stepsRemoved`. Посмотреть diff, ничего не записывая, — `dry_run:true` |
| `stepsRemoved: null`, хотя шаги удалились | Удалённые шаги делят ключ `id \|\| action` с оставшимися, и diff не может назвать их поимённо. Это не «ничего не удалилось» — число видно в `stepsBefore`/`stepsAfter`. Дай шагам `id` (раздел 15) |
| `No scenario with id N to update.` | У этого аккаунта такого сценария нет — раньше сюда приходила 404 самой платформы. Сверь id через `retibe_scenarios`, либо сохраняй без `id`, и будет создан новый |
| `Scenario N has no revision M — it keeps …` | Хранятся последние двадцать сохранений, старые вычищаются. Список с диффом по шагам — `retibe_scenarios {id:N, revisions:true}` (раздел 8) |
| Раскадровка говорит «покраснеть не может ничто» | Сценарий ничего не проверяет. Разбери каждый вердикт из раздела 11 — это не ложная тревога |
| `steps` пустое, а прогон идёт | Ни одно сообщение движка не совпало с шаблоном границы шага, либо канал прогресса не ответил. Смотри `timelineCaveats` и `waitUnsupported` |
| «про прогон ничего не известно» | Канал прогресса не ответил — старый образ или Redis недоступен. Это **не** «прогон молчит»; состояние прогона по-прежнему честное |
| `wait_for:'failure'` вернулся, а прогон идёт | Так и задумано: движок пишет error и на восстановимых ретраях. Приговор — только `state` |
| `No web test running. Nothing was stopped…` | У аккаунта ничего не бежит. Это ответ платформы, а не сбой инструмента; задание в очереди так не отменяется — дождись старта и останови тогда |
| `The stop was sent to the worker running this test, but it has not confirmed yet` | Прогон живёт на другом воркере кластера, остановка до него ушла, подтверждение не успело. Это не «ничего не остановлено»: проверь `retibe_run_status`, прежде чем повторять (раздел 8) |
| `Your account is running X, not Y` | Эндпоинт остановки работает на аккаунт: остановка убила бы `X`. Проверь `retibe_list_runs`, и если гасить надо именно `X` — повтори с `confirm: true` |
| `No screenshot to show for run …` | Кадра нет, и причин четыре: прогон зелёный (зелёный не фотографируют), упавший шаг кадра не записал, браузер умер раньше, чем шаг сдался, или результат прочитан из проекции платформы — это видно в `resultSource` (раздел 9) |
| `This run recorded its screenshot as a path on the runner` | Прогон был локальный, а читаешь ты его с платформы: путь раннера отсюда никто не раздаёт. Читай прогон на той машине или открой отчёт — в него кадр вшит |
| `JUnit is generated by the platform, and this run is not on it` | Локальный прогон. Сначала `retibe_publish_run`, потом `detail:'junit'` по опубликованному `runId` (раздел 9) |
| `This backend keeps no stored run log` | У этого бэкенда сохранённого лога нет вовсе. Живой хвост, пока прогон идёт, отдаёт `retibe_run_status` (раздел 12) |
