# 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`. Выпускай токен там, где лежат твои сценарии.

Проверка после подключения: попроси Claude вызвать `retibe_whoami`. Он должен
вернуть твой email, план и остаток квот.

---

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

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

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

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

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

Если Claude не знает разметку сайта, попроси его сначала посмотреть страницу
любым доступным способом (Playwright MCP, WebFetch, или просто открой DevTools и
дай ему селекторы). Угадывать селекторы вслепую он будет плохо.

---

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

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

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

> Прогон webtest-123 упал. Разберись почему и предложи правку.

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

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

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

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

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

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

Слишком расплывчато — Claude не видит страницу и не угадает, что важно.

---

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

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

1. **`retibe_env`** — какие есть окружения и ключи параметров
2. **`retibe_actions`** — каталог действий и контракт каждого, которое собирается использовать
3. **`retibe_reference`** — сквозные механики (шаблоны, условия, фикстуры)
4. написать сценарий
5. **`retibe_validate_scenario`** — до запуска браузера, занимает миллисекунды
6. **`retibe_validate_scenario view:'storyboard'`** — «а какой из этих шагов
   вообще способен покраснеть?»
7. **`retibe_run_scenario`** → **`retibe_run_result`**

Шаг 5 пропускать нельзя. Платформа **не валидирует сценарии** — она примет
сломанный и будет его гонять.

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

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

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

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

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

---

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

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

| Инструмент | Откуда берётся | Гарантия свежести |
|---|---|---|
| `retibe_actions` | извлечён из `switch` самого движка разбором AST | **высокая** — тест перечитывает движок на каждом прогоне и падает при расхождении |
| контракты полей `data.*` | разовое вычитывание движка, дальше правится руками | **средняя** — на сегодня совпадает, но автоматической сверки с телом движка нет |
| `retibe_reference` | заморожённый текст, вшит в сборку | **низкая** — не пересобирается ничем |
| `retibe_docs`, `retibe_examples` | файлы `knowledge/` и `examples/`, лежат в образе | **средняя** — правятся руками, с движком не сверяются |
| привязка чек-листа к сценарию | строка, которую кто-то когда-то ввёл руками | **никакая** — ни FK, ни триггера, ни сверки; ломается молча (раздел 10) |
| `docs/TEST_COVERAGE.md` в репозитории | ведётся руками этим же тредом | **никакая** — ничто не сверяет; но это единственное место, где записано «чего нет и почему» (раздел 17) |
| описания инструментов и промпты | написаны руками | **низкая** — с движком ничем не связаны |

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

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

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

**`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, и никакая проверка на границе
не срабатывает. Версии/коммита эндпоинт не сообщает, так что расхождение
клиенту не видно. Если правка валидатора или описаний уже в main, а поведение
прежнее — скорее всего просто ещё не выкачено.

---

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

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

**Проверки, которые не могут упасть.** `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` **не читаются вообще** —
шаг с ними пройдёт на любом видимом элементе с любым текстом.

**Штатного ассерта отсутствия нет.** `visible: false` выглядит как «его нет», а
означает ровно обратное: ожидание ослабляется до `state: "attached"` — элемент
обязан быть в DOM, просто может быть скрыт. Среди 48 действий ассерта отсутствия
нет ни одного, но обойти это можно через `web_evaluate`: скрипт вида
`if (document.querySelector('.popup')) { throw new Error('всё ещё на месте') } true`
роняет шаг по-настоящему — движок оборачивает исключение в `web_evaluate failed:`
и бросает дальше, если у шага не выставлен `optional`.

**`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. Хороший способ убедиться, что проверка
настоящая: сломать сценарий намеренно и увидеть, что он покраснел.

**Скриншот на упавшем шаге не сохраняется** — движок снимает экран после
успешного действия. Разбирать падение придётся по снимку соседнего шага.

**Визуальный регресс срабатывает в двух случаях** — `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` — самая естественная ошибка, и она обходится дороже
остальных: валидатор туда не смотрит, поэтому отвечает `valid: true` без единого
предупреждения, а регресс не срабатывает. Проверено на живом эндпоинте.

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

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

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

**Но четыре вещи из этого списка он не ловит принципиально** — держи их в голове сам:

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

Для первого случая есть промпт `/harden`: он и сформулирован как проверка того,
чего валидатор не видит. И `view:'storyboard'` (раздел 11) — он отвечает ровно на
этот вопрос механически: перечисляет шаги с вердиктом «способен ли покраснеть» и
в конце пишет, сколько из них способно. `valid: true` при «покраснеть не может
ничто» — это и есть первый пункт списка выше, увиденный одним взглядом.

---

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

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

**Работа:** `retibe_validate_scenario` (плюс `view:'storyboard'` — раздел 11),
`retibe_run_scenario`, `retibe_run_status` (плюс `wait_for` и `since` — раздел 12),
`retibe_run_result`, `retibe_list_runs`, `retibe_stop_run`,
`retibe_publish_run` (нужен в локальном режиме — публикует локальный прогон в
отчёты и историю; прогоны с `retibe.com/mcp` и так на платформе).

**Платформа:** `retibe_whoami`, `retibe_env` (плюс `secretKeys` — какие ключи
помечены секретными), `retibe_scenarios`, `retibe_save_scenario`,
`retibe_history`, `retibe_visual_review`, `retibe_param` (записать значение
`{{params.*}}`).

`retibe_param` закрывает дыру, из-за которой агент останавливался и ждал
человека: ключ можно было завести только руками в веб-интерфейсе. Запись
односторонняя — значение уходит и обратно не читается ни одним инструментом, эти
строки держат пароли. Перезапись существующего ключа требует `confirm:true`: старое
значение отсюда не восстановить, и на ключ может смотреть чужой сценарий. Ключ
проверяется по набору символов самого резолвера (`[a-zA-Z0-9_.]`) — хранилище
приняло бы дефис, а движок оставил бы `{{params.my-key}}` в селекторе дословно.
Каждая запись перечитывается через тот же роут, из которого резолвит движок, и
откатывается, если ключ всё равно не виден.

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

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

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

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

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

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

---

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

- **`passed` / `failed`** — тест отработал, вердикт настоящий
- **`infra_error`** — сценарий **не выполнялся**: не поднялся браузер или вышел
  дедлайн. Искать баг в сценарии не нужно
- **`unknown`** — прогон ещё не завершён. Это **не** «прошёл»
- **`stepsObserved`** — оценка, а не счётчик. Признак завершения только `state`.
  Стала точнее — считается вход в шаг, а не смена токена, — но осталась оценкой:
  границ шагов в движке нет как данных, см. раздел 12
- **`steps`** — лента шагов с интервалами. Интервалы — оценки; чем именно они не
  измерения, написано в `timelineCaveats` каждого ответа
- **`waitEndedBy`** — что завершило ожидание. `timeout` значит «время вышло», а
  не «ничего не произошло»
- **`next_offset`** — курсор для следующего опроса

Скриншоты возвращаются путями, не картинками.

---

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

- **Telegram — только написание сценариев.** Прогон требует сессии, которая
  создаётся интерактивным входом с SMS.
- **Один тест за раз** на аккаунт; бот- и веб-тесты блокируют друг друга.
- Прогоны отсюда тратят ту же месячную квоту, что и запуски из веб-интерфейса.
- Веб-тест обычно ~минута, самые долгие — до десяти. Вызовы не блокируются:
  старт возвращает `runId`, дальше опрос.

---

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

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

**Рабочий цикл.**

1. `retibe_checklists` — список чек-листов с сырыми счётчиками
2. `retibe_checklists id:N only:'unlinked'` — ровно те кейсы, которые ничем не закрыты
3. написать сценарий на один из них, `retibe_validate_scenario`, `retibe_run_scenario`
4. `retibe_save_scenario` — сохранить на платформе
5. `retibe_scenarios id:…` — **перечитать сохранённый**
6. `retibe_checklist_item op:'link'` — привязать кейс к нему

### Создать чек-лист

Если чек-листа ещё нет — он создаётся отсюда же, вместе с кейсами, одним вызовом:

```
retibe_checklist op:'create' name:'shop / checkout — релизный' project:'shop'   cases:[
    {title:'Оплата картой проходит', priority:'critical'},
    {title:'Отказ по карте показывает ошибку', priority:'high'},
    {title:'Купон уменьшает сумму', priority:'medium'}
  ]
```

Каждый кейс — **отдельная запись**, не транзакция: если один не прошёл, чек-лист и
остальные кейсы остаются, а ответ перечислит именно те, что не записались.
Переписывать весь вызов не надо — добери их `retibe_checklist_item op:'add'`.

Имена чек-листов на платформе **не уникальны**, а её собственный импорт ищет
чек-лист по точному совпадению имени. Поэтому создание второго чек-листа с уже
занятым именем требует `confirm:true`, а в ответе будет сказано, что теперь их два
и адресовать надо по id.

| Что нужно | Вызов |
|---|---|
| добавить кейс | `retibe_checklist_item op:'add' checklist_id:N title:'…'` |
| поправить кейс | `retibe_checklist_item op:'update' checklist_id:N item_id:M priority:'high'` |
| удалить кейс | `op:'delete'` + `confirm:true` — вместе с его историей результатов |
| переименовать чек-лист | `retibe_checklist op:'update' id:N name:'…'` |
| убрать из работы, не потеряв историю | `retibe_checklist op:'update' id:N status:'archived'` |
| удалить чек-лист | `retibe_checklist op:'delete' id:N confirm:true` — каскадом уходят все кейсы и все записанные результаты |

`status` в схеме — свободный текст, но интерфейс фильтрует только по `active` и
`archived`: любое третье значение уберёт чек-лист из обоих его фильтров.

Кейс, созданный так, **ни к чему не привязан** — автоматический результат ему
взяться неоткуда, пока не сделаешь `op:'link'`. И `automation_status:'automated'`
сам по себе только двигает процент покрытия: никто не проверяет, что за ним есть
сценарий.

Пункт 5 пропускать нельзя, и причина не в аккуратности: ключ, по которому
платформа потом ищет чек-лист, берётся **из сохранённого сценария**, а не из
вызова привязки.

### Как устроена привязка, и почему она ломается

Платформа связывает прогон и кейс так: у **чек-листа** есть поле `scenario_id`
(строка), у **кейса** — `step_id`. Когда прогон заканчивается, движок берёт
`scenario.id || scenario.name` того объекта, которым его запустили, и ищет
чек-листы с таким `scenario_id`.

Отсюда всё остальное:

- **Один чек-лист = один сценарий.** Поле лежит на чек-листе, не на кейсе.
  Два кейса про разные сценарии — это два чек-листа. `op:'link'` на чек-листе,
  который уже смотрит в другой сценарий, требует `confirm:true`, потому что
  перенаправляет **все** кейсы в нём.
- **Ни FK, ни триггера, ни сверки.** Переименовал сценарий, выдал ему `id`,
  которого раньше не было, удалил — привязка отвалилась молча и навсегда.
  Платформа об этом не сообщает нигде: неудачное совпадение выглядит как
  «подходящих кейсов нет». Единственный способ узнать —
  `retibe_checklists verify_links:true` — с двумя оговорками: он работает только
  на списке (не на открытом чек-листе) и сверяет по твоим 200 последним
  сценариям, поэтому `linksNotFound` — это «не нашёл», а не «сломано». Ответ
  говорит это прямо. Сверяет по всем трём формам ключа, которые движок способен
  выдать: собственный строковый `id` сценария, числовой id платформы и имя. Если
  чек-листы вообще ни на что не смотрят, ответ скажет `nothingToVerify`, а не
  промолчит.
- **`scenarioKeySource` в ответе `op:'link'` важнее самого ключа.** Если там
  `template id`, у сценария нет своего строкового `id`, и ключом стал числовой id
  платформы. Такая привязка срабатывает для прогона, запущенного объектом из
  `retibe_scenarios`, и **не** срабатывает для того же сценария, переданного
  инлайном — там ключом будет имя. Хочешь один ключ на оба случая — дай сценарию
  стабильный строковый `id` верхнего уровня и привяжи заново.
- **`checklist_items.scenario_id` и `scenario_hash` — мёртвые колонки.** Они
  есть, они проиндексированы, их не читает ничто. Инструмент их не пишет.
  `scenario_hash` — это **не** `historyId` из `retibe_history`.
- **Экспорт/импорт рвёт привязку.** Выгрузка чек-листа не содержит
  `scenario_id` уровня чек-листа, поэтому импортированный обратно чек-лист
  выглядит настроенным и не получает результатов.

### Откуда берутся результаты в кейсе

Только из прогона привязанного сценария **на платформе**. Локальный и composite
прогон не штампует чек-листы вообще, и `retibe_publish_run` этого не исправляет:
он отправляет id прогона в качестве имени сценария, а такой ключ не совпадает ни
с чем.

Записывающий код приводит всё, что не `passed`, к `failed` — отменённый или
остановленный прогон виден в кейсе как честное падение. У кейса без `step_id`
результат берётся от прогона целиком, а не от его шага.

### Цифры покрытия

Их **три разные**, и они не совпадают:

| Где | Формула |
|---|---|
| список чек-листов | `automated / total` — `partial` не считается |
| открытый чек-лист | `(automated + partial×0.5) / total` |
| веб-интерфейс | считает четвёртую, в браузере, по проекту |

Поэтому инструмент отдаёт **сырые счётчики** и называет формулу рядом. Проценту
из ответа верить можно только вместе с формулой.

Два счётчика ведут себя не так, как выглядят. `passed` и `failed` считают кейс,
если совпал **любой** из двух его результатов — ручной или авто, — поэтому кейс,
прошедший руками и упавший автоматом, попадает в оба, и сумма может превысить
`total`. `automation_status` и `priority` — свободный текст, платформа их не
валидирует: значение вне `manual/automated/partial/planned` выпадает из всех
счётчиков.

### `op:'record'` — осторожно

Записывает **ручной** результат от имени владельца токена. У платформы нет
пометки «это сделал агент»: человек, открывший чек-лист, увидит тестировщиком
себя. Префикс `[MCP]` в комментарии — единственное, что отличает такую запись.
Строки выполнения **не удаляются** — роута для этого нет. Поэтому нужен
`confirm:true`.

Если вызов вернул ошибку — **не повторяй его**. Роут сначала пишет строку
выполнения и только потом обновляет кейс, без транзакции: результат может быть
уже записан. Перечитай кейс через `retibe_checklists`.

### Чего здесь нет намеренно

**Импорта.** У платформы есть `POST /api/checklists/import`, который принимает
пачку чек-листов сразу, и он сюда не выведен по трём причинам: сопоставляет
чек-листы по **точному совпадению имени**; при `overwrite:true` удаляет все кейсы
найденного и создаёт заново, теряя при этом ключ привязки; а в ответе отдаёт
только счётчики — **без id**, то есть после импорта ты не знаешь, что создал.
`op:'create'` делает то же самое настоящими записями и возвращает id каждого кейса.

Ещё не выводится **запись в `checklist_items.scenario_id`**: эту колонку не читает
ничего, так что запись в неё выглядела бы как привязка и не привязывала бы. Кейсы
привязываются только через `op:'link'` (выше).

---

## 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.
```

`view` принимает `findings` (по умолчанию — как было), `storyboard` и `both`.

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

| Вердикт | Что значит |
|---|---|
| `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` — про форму шага, не про твой сайт: движок
способен покрасить этот шаг, но правильная ли это проверка, раскадровка не знает.
Достижимость шага статически не решается (условия, фикстуры, `optional` у
четырёх действий), поэтому у условного шага честный вердикт — «может не
выполниться», а не «зелёный».

Оба сторожа считаются: движок проверяет **`skipIf` раньше `condition`**
(`WebSiteTester.ts:4707`), а пропущенный шаг записывает как успешный — поэтому
кейс вида «`skipIf: {previousStepFailed: true}` плюс настоящий ассерт» выглядит
гейтом, а на деле молча отключается, как только упал предыдущий шаг. Пустой
сторож (`condition: null`, `condition: {}`) за сторожа не считается: движок его
тоже не считает.

Вердикты выведены из тех же констант, которыми пользуется валидатор
(`CANNOT_FAIL_ACTIONS`, `HONOURS_OPTIONAL`, `ASSERT_IGNORED_FIELDS`,
`PHANTOM_WEB_ACTIONS`). Вторая копия этих списков молча разъехалась бы с
валидатором — то есть ровно тот дефект, против которого всё это и написано.

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

---

## 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` — «время
вышло», всё остальное — «случилось вот это».

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

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

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

`✓` выполнен, `✗` упал, `–` пропущен, `…` ещё идёт. Этому надо верить ровно
настолько, насколько сказано в `timelineCaveats`, и вот почему: **границ шагов в
движке не существует как данных.** Нет ни `stepStart`, ни `stepEnd` — состояние
шага распознаётся по русской прозе в логе. Отсюда:

- Шаг, чьё сообщение не совпало с шаблоном, в ленте просто отсутствует.
- Длительность — это интервал между двумя распознанными строками, а не время,
  которое движок отчитался потратить. Поэтому это оценка, а не измерение.
- `(retried)` значит, что движок вошёл в шаг повторно; интервал покрывает все
  попытки.
- У последнего, ещё идущего шага стоит `?`, а не `0.0s`: показывать ноль рядом с
  шагом, который висит минуту, хуже, чем признать, что цифры нет.

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

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

---

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

Два инструмента: `retibe_calendar` читает, `retibe_schedule` пишет.

```
retibe_calendar                              # что запланировано
retibe_calendar id:5                         # одно расписание
retibe_calendar view:'entries' from:'2026-09-01' to:'2026-09-30'
retibe_schedule op:'create' name:'shop-smoke-nightly' scenario:42 \
                frequency:'daily' time:'07:30' timezone:'Europe/Moscow'
```

### Частот всего четыре

`once`, `daily`, `weekly`, `monthly`. Инструмент `cron` **не принимает**, и это не
ограничение из осторожности — в движке ветка `cron` выглядит так:

```ts
// Simplified cron parser - for production use proper library like node-cron
// This is a placeholder that returns next hour
// TODO: Implement proper cron parsing
```

Выражение читается ровно один раз — чтобы проверить, что оно непустое, — после
чего возвращается начало следующего часа UTC. Практические последствия, если
создавать расписание через веб-API напрямую:

- `scheduleConfig: {cron: "0 9 * * 1-5"}` → **200**, и тест гоняется **каждый час
  круглосуточно**, игнорируя выражение. Квота уходит за сутки.
- `scheduleConfig: {expression: "0 9 * * 1-5"}` → **400**: роут проверяет ключ
  `expression`, а планировщик читает `cron`. То есть «правильный» по сообщению об
  ошибке ключ не работает вообще.
- В календаре такое расписание не показывается никогда — по частотам вне четырёх
  рабочих платформа не отдаёт ни одной позиции.

`interval` упомянут в текстах ошибок роута, но ветки в планировщике у него нет —
тоже отказ.

### `nextRunAt` — единственный честный ответ на «когда»

Его считает платформа, когда расписание записывается. Инструмент возвращает его
в ответе на `create` и `update` и **не пересчитывает сам**. Сверяй глазами: если
там не тот момент, которого ты ждал, расписание неправильное — и узнать это лучше
сейчас, а не через сутки.

**Таймзона по умолчанию UTC.** Не указал `timezone` — `time: '09:00'` означает
09:00 UTC. Это самая частая причина «расписание не сработало». Указывай
IANA-имя: `Europe/Moscow`, `Asia/Almaty`.

У `monthly` `day_of_month` зажимается по длине месяца: 31 значит «последнее число»
в феврале. У `daily` можно сузить до дней недели через `days_of_week` (0 —
воскресенье).

### Счётчики прогонов

`counts` в ответе — это `runs`, `passed`, `failed`, и рядом `countsFrom`, который
говорит откуда они взяты. Источников два:

- **`schedule columns`** — собственные счётчики расписания. **Так по умолчанию**,
  потому что это один мгновенный запрос. Вердикт туда пишется только для прогонов
  после `b297b63`; у расписаний старше этого коммита `passed` и `failed` будут
  нулями при любом числе успешных прогонов — двигался только `runs`.
- **`tests table`** — посчитано по реальным строкам прогонов, верно для любого
  возраста. Включается флагом `real_counts:true`.

Почему не по умолчанию: роут статистики агрегирует **всю** таблицу тестов на каждое
расписание и строит тренд за 30 дней. На инстансе с шестью тысячами прогонов это
**около 15 секунд**, и дальше растёт. Так что за правдой о старом расписании
ходить можно, но не на каждый вызов.

### Расписание держит копию сценария

При создании в расписание копируются **шаги** сценария, а не ссылка на него.
Поправил сценарий — расписание продолжает гонять старую версию. Чтобы подтянуть
новую, вызови `op:'update'` с тем же `scenario`.

### Что показывает `view:'entries'`

Платформа отдаёт под одним ключом три разные сущности; инструмент разделяет их
полем `kind`:

- **`occurrence`** — вычисленное будущее срабатывание. В базе его нет, это
  арифметика по расписанию. Поле `end` — не прогноз, а плоская заглушка «плюс
  пять минут».
- **`run`** — настоящий прогон, прошлый или текущий.

Оговорки, которые инструмент возвращает вместе с данными:

- Идущие прогоны попадают в ответ **независимо от диапазона** — так сделано
  намеренно, чтобы активный тест был виден всегда.
- Завершённые прогоны берутся только из **500 последних** тестов аккаунта. Прогон
  внутри диапазона, но старше этих 500, молча отсутствует.
- Приостановленное расписание всё равно даёт позиции, со `status: 'paused'`.

### `op:'run_now'`

Запускает расписание немедленно. Возвращает `testId`, и его **можно опрашивать**:
`retibe_run_status runId:'<testId>'`. Это работает начиная с `f413edd` —
планировщик запускает веб-тест той же функцией, которая наполняет канал
прогресса, а владение проверяется по таблице `tests`.

**Раньше здесь было «примерно в половине вызовов вернётся
`Scheduler service is not available`, просто позови ещё раз».** Это оказалось
неправдой в обе стороны. Замер: 2 успеха из 10 при прямых запросах и **0 из 20**
через MCP — сайдкар держит одно keep-alive соединение, то есть прилипает к одному
воркеру, и если это не первый воркер, `run_now` не работает никогда, сколько ни
повторяй.

Починено: роут больше не зависит от того, какой воркер ответил. Периодический обход
расписаний по-прежнему живёт только в первом воркере — иначе расписание срабатывало
бы по разу на воркер, — но запустить одно расписание по требованию можно откуда
угодно. Замер после правки: **20 из 20**.

Если `Scheduler service is not available` всё-таки увидишь — значит эндпоинт на
образе старше `f5a3620`.

Прогон отсюда тратит месячную квоту как любой другой и подчиняется блокировке
«один тест за раз».

### Чего в инструментах нет

Удаление требует `confirm:true` — вместе с расписанием удаляется копия сценария
внутри него, и восстановить нельзя (отчёты прошлых прогонов остаются). Если нужно
просто «чтобы не гонялось» — `op:'pause'`: расписание сохраняет и историю, и
время следующего запуска.

Пауза и возобновление идут через роут обновления, а не через `/toggle`: тот
переключает то, что найдёт, поэтому два вызова «приостанови» могли бы оставить
расписание включённым.

---

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

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

### Префикс проекта в имени и в `id`

Давай каждому сценарию **стабильный строковый `id` верхнего уровня** вида
`<проект>-<область>-<что проверяем>`, в нижнем регистре, через дефисы:

```json
{
  "id": "shop-checkout-guest-purchase",
  "name": "shop-checkout-guest-purchase — покупка без регистрации",
  "startUrl": "https://shop.example.com",
  "steps": [ … ]
}
```

Префикс должен стоять **и в `id`, и в `name`**, и в `name` — теми же дефисами.
Причина техническая: `retibe_scenarios search:` уходит в
`name LIKE ? OR description LIKE ?` и **в `id` не смотрит вообще**. Назовёшь
сценарий «shop / checkout — покупка» — `search:'shop-checkout'` его не найдёт,
хотя `id` у него правильный. Проверено на живом эндпоинте.

Почему именно так, а не «Тест логина»:

- **`id` — это ключ, по которому платформа находит чек-лист.** Движок берёт
  `scenario.id || scenario.name`. Без своего строкового `id` ключом становится
  числовой id платформы — и тогда привязка работает для прогона, запущенного
  объектом из `retibe_scenarios`, и не работает для того же сценария, переданного
  инлайном. Со стабильным `id` ключ один в обоих случаях (раздел 10).
- **`retibe_scenarios` — это твоя карта покрытия.** Листинг отдаёт имя, описание
  и число шагов. `search:'shop-checkout'` находит область целиком — но только если
  префикс есть в **имени** или описании: поиск идёт по ним, не по `id`. Без
  префиксов двадцать «Login test 2» не ищутся никак.
- **`verify_links` сверяет по 200 последним сценариям.** По префиксу сразу видно,
  какому проекту принадлежит ключ, который не нашёлся.
- **Переименование сиротит историю.** `historyId` — это md5 от имени, URL, числа
  шагов и списка действий. Поменял имя или добавил шаг — прежняя история
  осиротела. Поэтому имя выбирается один раз.

Держи префиксы короткими и одинаковыми внутри проекта: `shop-`, `crm-`, `api-`.
Область — второй уровень: `shop-auth-`, `shop-checkout-`, `shop-catalog-`.

### Сценариев должно быть мало, и они должны быть длинными

Это не про аккуратность, а про то, как устроены лимиты.

| Что | Почему это упирается в число сценариев |
|---|---|
| **Один тест за раз** на аккаунт, бот- и веб-тесты блокируют друг друга | 40 сценариев по минуте — это 40 минут строго последовательно, параллелить нельзя |
| **Квота считает прогоны, а не шаги** | десять сценариев по 40 шагов — 10 прогонов; сорок по 10 шагов — 40 прогонов за то же покрытие |
| **Фиксированные накладные на прогон** | поднять браузер, дойти до страницы — около минуты p50 даже у крошечного теста. На коротких сценариях накладные и есть весь прогон |
| **`infra_error` считается на прогон** | чем больше запусков, тем больше шансов, что один не поднимется |

Поэтому целься в **единицы-десятки сценариев на проект**, а не в сотни. Один
сценарий — один пользовательский путь целиком, со своей подготовкой: «зашёл,
залогинился, положил в корзину, оформил, проверил письмо» — это один сценарий на
30–50 шагов, а не пять по шесть.

### Где длинный сценарий начинает мешать

Обратная сторона: сценарий, упавший на шаге 3, ничего не говорит про шаги 4–40.
Поэтому делить всё-таки нужно — по этим границам:

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

Внутри длинного сценария решай осознанно, что прерывает прогон: `critical` и
`stopOnFailure` сравниваются строго с `true` (валидатор предупредит, если там
оказалось что-то другое). Без них упавший шаг не останавливает остальные — иногда
это то, что нужно, иногда нет.

### Давай шагам `id`

В длинном сценарии это обязательно, а не пожелание:

- **Лента шагов и `stepsObserved`** опознают шаг по `id || action`. Два шага без
  `id` с одинаковым действием неотличимы и считаются за один (раздел 12).
- **Привязка кейса чек-листа** идёт на `step_id` — без `id` у шага привязать
  конкретный кейс к конкретной проверке нельзя, кейс получит вердикт прогона
  целиком (раздел 10).
- **Раскадровка** называет шаг по `id`; без него читать таблицу в 40 строк
  тяжело.

Имена шагов — тот же принцип, что у сценариев: `login-submit`, `cart-add`,
`checkout-assert-redirect`. Не `step-1`.

### Как это ложится на чек-листы

Группировка и чек-листы связаны жёстче, чем кажется: **один чек-лист смотрит в
один сценарий**. Значит сценарий на 40 шагов, закрывающий 8 ручных кейсов, — это
один чек-лист, где у каждого кейса свой `step_id`. Это и есть главный практический
довод за длинные сценарии: так отображение кейсов на проверки получается
естественным. Разбей те же 8 кейсов на 8 сценариев — понадобится 8 чек-листов.

### Короткая инструкция, которую можно дать Клоду

> Работай с ReTibe так. Имена сценариев — `<проект>-<область>-<что>` в нижнем
> регистре через дефисы, и то же значение ставь в верхнеуровневое поле `id`;
> префикс проекта у нас `shop-`. Каждому шагу давай осмысленный `id`. Не плоди
> мелкие сценарии: один сценарий — один пользовательский путь целиком, 30–50
> шагов, отдельно только то, что требует другой подготовки или другой частоты
> прогона. Перед прогоном всегда `retibe_validate_scenario`, потом его же с
> `view:'storyboard'` — и если «покраснеть не может ничто», переписывай, а не
> запускай.

---

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

Один сценарий может вести **несколько браузеров одновременно** — покупатель и
продавец, звонящий и принимающий, админ и пользователь. Это то, на чём проверяют
всё, что происходит *между* двумя участниками.

```json
{
  "name": "shop / chat — покупатель спрашивает, продавец отвечает",
  "webrtc": { "enabled": true, "splitPreview": true },
  "actors": {
    "buyer":  { "name": "Покупатель", "viewport": { "width": 1280, "height": 720 } },
    "seller": { "name": "Продавец",   "viewport": { "width": 1280, "height": 720 } }
  },
  "steps": [
    { "id": "buyer-login",  "actor": "buyer",  "action": "web_login",  "data": { "…": "…" } },
    { "id": "seller-login", "actor": "seller", "action": "web_login",  "data": { "…": "…" } },
    { "id": "buyer-asks",   "actor": "buyer",  "action": "web_click",  "data": { "buttonSelector": ".send" } },
    { "id": "seller-sees",  "actor": "seller", "action": "web_assert", "data": { "selector": ".msg", "contains": "привет" } }
  ]
}
```

`actors` — **объект, а не массив**: ключ и есть id актёра, и именно его ищет
`step.actor`. Поле `name` — только подпись в логе и в панели превью; адресовать
актёра по имени нельзя.

### Нужны ОБА поля: `actors` и `webrtc.enabled`

Движок открывает второй браузер только когда есть **и** `actors`, **и**
`webrtc.enabled: true`. Одного `actors` мало: всё уедет в один браузер, поля
`actor` будут проигнорированы, и **никто об этом не скажет** — прогон зелёный,
двух участников не было.

`retibe_validate_scenario` теперь ловит это ошибкой `ACTORS_WITHOUT_WEBRTC`.
Проверяй перед первым прогоном: это самая дорогая ошибка в разделе, потому что
она выглядит как успех.

Название `webrtc` историческое. Блок нужен для мультиактёрности как таковой —
звонки тут ни при чём, если ты не тестируешь звонки.

### Шаги идут по очереди, а не одновременно

Порядок — тот же, что в `steps`. Пока действует один актёр, второй ждёт.
**Параллельного шага нет, барьера нет, «одновременно» выразить нечем.** Чтобы
один участник дождался другого, дай ему свой `web_wait` или проверку, которая
проходит только после действия второго — второе лучше: `web_assert` на элемент,
появляющийся от чужого действия, и есть синхронизация.

Контексты создаются **все сразу**, до первого шага, в порядке объявления.

### Два молчаливых переназначения

| Что в сценарии | Что делает движок | Что скажет валидатор |
|---|---|---|
| у шага нет `actor` | выполнит в браузере **первого** актёра, без предупреждения | `STEP_WITHOUT_ACTOR` (warning) |
| `actor` не объявлен | предупредит в логе и выполнит у **первого** актёра — шаг при этом пройдёт | `UNKNOWN_ACTOR` (error), с подсказкой на похожий id |

Второе опаснее: опечатка в id не роняет прогон, а тихо проверяет не того
участника. Раскадровка показывает такой шаг как `membr (unknown)`, а не как
`creator`, — именно чтобы подмена была видна.

### Что из описания актёра работает

| Поле | Работает |
|---|---|
| `viewport` | **да**, по умолчанию 1280×720 |
| `permissions` | **да** — `camera`, `microphone`, `geolocation`, `notifications`. Выдача обёрнута в try: неизвестное имя молча не сработает |
| `userAgent` | **на уровне сети да**, но внутри страницы `navigator.userAgent` жёстко подменён одной строкой macOS Chrome 120 для **всех** актёров. Скрипт на странице разницы не увидит |
| `name`, `description` | подпись |
| `browserArgs` | **нет.** Все актёры делят один браузер — свои флаги дать некому |
| `device` | **нет.** Пресеты доступны только шагом `mobile_device` |

`storageState`, `locale`, `timezone` у актёра **нет вообще**. Поэтому каждый
участник логинится через UI своим `web_login` — подсунуть готовую сессию нечем.
Это же значит, что два актёра — это две независимые сессии в одном браузере, и
cookies они не делят.

### `splitPreview` — это только картинка

`splitPreview: true` включает поток скриншотов обоих браузеров рядом, чтобы
человек смотрел в веб-интерфейсе. На то, сколько браузеров реально работает, он
не влияет: один из примеров в репозитории гоняет двух актёров с
`splitPreview: false`.

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

`forceHeaded: true` на публичном эндпоинте **не работает**: в контейнере есть
Xvfb, но `DISPLAY` не выставлен, и headed-запуск падает. Два браузера при этом
поднимаются штатно — headless этому не мешает. Не ставь `forceHeaded`, если
гоняешь на платформе.

### Что теперь видно по MCP

- **`retibe_validate_scenario`** — одиннадцать проверок про актёров, включая обе
  ловушки выше, неизвестное разрешение и поля, которые движок игнорирует.
- **`view:'storyboard'`** — колонка `actor`: кто делает какой шаг, сколько шагов
  у каждого, и отметки `(none)` / `(unknown)` там, где движок подменит браузер.
- **`retibe_run_status`** — колонка актёра в ленте шагов и состав актёров.
  Атрибуция читается из строки переключения, которую движок пишет перед шагом,
  поэтому шаг без распознанной строки остаётся **без** актёра, а не наследует
  предыдущего.
- **`retibe_run_result`** — блок `actors.perActor`: сколько шагов, сколько прошло
  и упало **в каждом браузере**, плюс `actor` на каждом упавшем шаге. Первый
  вопрос про двухпользовательский прогон — «у кого сломалось» — теперь отвечается
  из дайджеста.
- **`retibe_scenarios`** — у мультиактёрного сценария есть `actors` и
  `multiActor`. `multiActor: false` при непустых `actors` — это ровно тот
  сценарий, который читается как два участника и гоняется как один.
- **`retibe_examples search:'multi-actor'`** — четыре рабочих примера. Они в конце
  индекса, дефолтной страницей не достаются, поэтому именно `search`.

Чего по MCP **нет**: живых кадров сплита и скриншотов шагов, помеченных актёром —
движок кладёт файл скриншота под именем шага, без актёра в имени. Кто делал шаг,
узнаётся из результата шага, а не из имени файла.

---

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

Форма карты, reCAPTCHA, Turnstile, Stripe Elements, любой чужой чекаут — всё это
живёт в `<iframe>`. Селектор из главного документа внутрь не видит, поэтому шаг,
нацеленный на встроенную форму, **молча работает со страницей вокруг неё**.

Добавь шагу `data.frame`:

```json
{ "id": "fill-card", "action": "web_fill_form",
  "data": { "frame": "iframe#card-form",
            "formFields": [{ "selector": "#card-number", "value": "4111111111111111" }],
            "formSubmitSelector": "#pay-now", "validateForm": true } }
```

### Четыре формы

```json
"frame": "iframe#card-form"                  // CSS-селектор самого iframe
"frame": ["#outer", "iframe[name=card]"]     // цепочка: фрейм внутри фрейма
"frame": { "name": "payplus" }               // по атрибуту name
"frame": { "url": "checkout\\." }            // по URL, это РЕГУЛЯРКА
```

CSS-форма ищет `<iframe>` тем же механизмом, что и любой другой селектор:
альтернативы через запятую по порядку, `:contains(text)` работает, сначала
`visible`, потом `attached`. Внутри фрейма и снаружи — один язык селекторов, а не
два.

### Что фрейм ограничивает, а что нет

`frame` ограничивает **поиск элементов**, на один шаг, в браузере текущего актёра.
Читают его девять действий: `web_click`, `web_click_submenu`, `web_fill_form`,
`web_assert`, `web_extract`, `web_wait`, `web_scroll`, `web_login`, `web_evaluate`.

| | |
|---|---|
| `web_assert` с `selector` | ищет внутри фрейма |
| `web_assert` с `urlContains` / `title` | **нет** — URL и заголовок это факты страницы |
| `web_evaluate` | выполняется в контексте фрейма. Это **единственный** способ выполнить скрипт в кросс-доменном фрейме: `page.evaluate` туда не дотягивается вообще |
| `web_scroll` | прокручивает документ фрейма, а не окно |
| `web_login` | заполняет форму во фрейме, но переход и ожидание URL остаются на странице |
| `touch_tap`, `touch_swipe` | **нет** — работают по координатам вьюпорта, ограничивать нечего |
| `page_element`, `page_method` | **нет** — селектор приходит из реестра PageObject |
| внутри фикстуры | **игнорируется**: у фикстур свой урезанный диспетчер. Работу с фреймом держи в сценарии |

Валидатор отказывает на всех «нет» выше — не молча.

### Падает громко, и это намеренно

Ненайденный фрейм роняет шаг сразу, с `frame not found`, и говорит, что искал и
где. Это **другой диагноз**, чем ненайденный элемент, и чинится по-другому. Если
`frame` указывает не на iframe, так и будет сказано: `"#notaframe" matched an
element in the page, but it is not a frame`.

Бюджет на резолв: `data.frameTimeout`, иначе `data.selectorTimeout`, иначе
`data.timeout`, иначе 10000 мс.

### Имя поля имеет значение

Движок читает **`frame`** и ничего больше. `frameSelector`, `iframe`, `frameName`,
`frameUrl`, `frameLocator`, `iframeSelector` — **не читаются**. А поскольку
`web_fill_form` по умолчанию проглатывает ошибки отдельных полей, сценарий с таким
именем даёт **зелёный прогон оплаты, которой не было**. Поэтому валидатор считает
каждое из этих имён **ошибкой**, а не предупреждением.

### Это видно в раскадровке

```
#  id                   action          target
3  frame-is-there       web_assert      iframe#card-form ▸ #card-number
4  fill-card            web_fill_form   iframe#card-form ▸ {formFields, v…
8  shop-shows-receipt   web_assert      .order-receipt
```

Префикс `▸` показывает, какие шаги ушли в другой документ, а какие остались на
странице. В сценарии на сорок шагов иначе этого не увидеть.

Готовый пример: `retibe_examples name:'payment-iframe-checkout-example.json'` —
оплата картой сквозь фрейм, от чекаута до чека. Подробности — `retibe_docs
section:'frames-iframes'`.

---

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

Платформа знает, какие сценарии существуют. Она не знает, **почему** они такие и
что решено не покрывать вовсе. Это знание живёт в проекте, рядом с кодом, который
тесты и проверяют.

Заведи в репозитории `docs/TEST_COVERAGE.md` и веди его как часть работы, а не
«когда-нибудь потом».

### Почему не хватает `retibe_scenarios`

Листинг отдаёт имя, описание, число шагов и дату правки. Этого мало, и причины
практические:

- **Новый тред начинает с нуля.** Файл в репозитории он читает бесплатно, вместе
  с кодом. Листинг платформы — это вызов, и до него надо ещё додуматься.
- **Числовой `id` привязан к инстанции.** У `retibe.com` и `retibe.ru` разные
  базы: сценарий 242 на одной не имеет никакого отношения к 242 на другой.
  В документе инстанция называется явно, иначе число бессмысленно.
- **«Чего нет» платформа не хранит вообще.** Что решено не автоматизировать и
  почему, что пробовали и бросили — под это нет поля. А это самое ценное знание:
  без него следующий тред потратит день на то, от чего уже отказались.
- **Документ версионируется вместе с кодом.** Правка формы регистрации и правка
  строки покрытия ложатся в один коммит, и в ревью видно, обновили ли тест.
- **Привязки к чек-листам рвутся молча** (раздел 10). Если записано, какой кейс к
  чему привязан, расхождение хотя бы заметно.

### Что в нём

Таблица, по строке на сценарий:

| Колонка | Что в ней |
|---|---|
| `id` | стабильный строковый id из раздела 14 — `shop-checkout-guest-purchase` |
| платформа | инстанция и числовой id: `com:242`. Две инстанции — две строки |
| шагов | из листинга; расхождение с реальностью — первый признак, что файл отстал |
| чек-лист | id чек-листа и кейса, если привязан; `—`, если нет |
| что закреплено | одна фраза: что сломается на сайте, если сценарий покраснеет |

Под таблицей — раздел **«Не покрыто и почему»**. Он важнее таблицы: таблицу можно
восстановить из платформы за один вызов, а этот раздел — ниоткуда.

Туда же пиши границы, найденные опытом: «вход после регистрации не проверяется —
платформа требует подтверждения почты, обхода нет», «остановка виджета не
проверяется — открытый баг такой-то». Каждая такая строка — это сэкономленный
день чьей-то работы.

### Когда обновлять

Правило простое: **сохранил сценарий — обнови файл в том же заходе.**

- после `retibe_save_scenario` — строка в таблице;
- после `retibe_checklist_item op:'link'` — колонка чек-листа;
- когда решил чего-то не покрывать — строка в «Не покрыто и почему», сразу, пока
  причина свежая;
- когда сценарий удалён на платформе — убери строку, не оставляй «призраков».

Это стоит написать и в `CLAUDE.md` проекта, чтобы правило действовало во всех
тредах, а не только в том, где о нём вспомнили.

### Честная оговорка

Ничто не заставляет этот файл быть правдой. Ни платформа, ни валидатор, ни тест —
он расходится с реальностью ровно так же молча, как привязка чек-листа. Поэтому:

- **в фактах прав эндпоинт.** Сверка — `retibe_scenarios` для таблицы и
  `retibe_checklists verify_links:true` для привязок;
- **в намерениях прав документ.** Почему сценарий такой и чего в нём нет
  сознательно — этого на платформе нет ни в каком виде.

Раз в несколько заходов полезно попросить: «сверь `docs/TEST_COVERAGE.md` с
`retibe_scenarios` и покажи расхождения». Это один вызов и пара минут.

---

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

| Симптом | Причина |
|---|---|
| `Missing API token` | Заголовок не дошёл — проверь `--header` в команде подключения |
| `Invalid, revoked or expired` | Токен отозван или неверен, выпусти новый в профиле |
| `Usage limit exceeded` | Платформа отказала в прогоне — кончились web_tests или security_scan, видно в `retibe_whoami` |
| `This account has used N of M API requests` | Отказ шлюза по квоте api_requests — это отдельный счётчик, его тратит ИИ-генератор сценариев, а не MCP-вызовы |
| Тест зелёный, но ничего не проверяет | Прогони `/harden` по сценарию |
| `infra_error` | Не сценарий виноват; посмотри `retibe_doctor` и `retibe_whoami`, потом попробуй позже |
| `... is missing from this checkout` | Эндпоинт на образе старше `d1069cc` — `knowledge/` в него не попадал. Нужен передеплой, см. раздел 5 |
| `No examples in this checkout` | То же самое, но про каталог `examples/` |
| Валидатор говорит не то, что в этом файле | Эндпоинт отстал от репозитория — деплой ручной, см. раздел 5 |
| Кейс привязан, а результатов нет | Прогон был локальный, либо ключ не совпал. `retibe_checklists verify_links:true`, потом сверь `scenarioKeySource` (раздел 10) |
| `Case N is not in checklist M` | Id кейса из другого чек-листа. Инструмент проверяет принадлежность до записи — на платформе этой проверки не было |
| `... already points at "..."` | Один чек-лист смотрит в один сценарий. Либо `confirm:true` и переносишь все кейсы, либо отдельный чек-лист |
| `"step-N" is not a step id` | У шага нет своего `id`, либо он другой. Движок адресует шаг именно по `id`, как он сохранён |
| `"..." is not a scenario hash` | В `retibe_history` ушёл runId или id сценария. Вызови его **без аргументов** — получишь индекс с настоящими `historyId` |
| `Invalid arguments for prompt ...` | У промпта есть обязательные аргументы: `/author` — `url` и `goal`, `/debug` — `runId`, `/harden` — `scenarioPath`. Раздел 7 |
| `search:` ничего не находит, хотя сценарий есть | Поиск идёт по имени и описанию, не по `id`. Префикс должен быть в имени — раздел 14 |
| Раскадровка говорит «покраснеть не может ничто» | Сценарий ничего не проверяет. Разбери каждый вердикт из раздела 11 — это не ложная тревога |
| `steps` пустое, а прогон идёт | Ни одно сообщение движка не совпало с шаблоном границы шага, либо канал прогресса не ответил. Смотри `timelineCaveats` и `waitUnsupported` |
| «про прогон ничего не известно» | Канал прогресса не ответил — старый образ или Redis недоступен. Это **не** «прогон молчит»; состояние прогона по-прежнему честное |
| `wait_for:'failure'` вернулся, а прогон идёт | Так и задумано: движок пишет error и на восстановимых ретраях. Приговор — только `state` |
| Шаг «заполнил» форму оплаты, а оплаты не было | Поле названо `frameSelector` / `iframe` / `frameName` вместо `frame`. Движок читает только `frame`, остальное не читается, и `web_fill_form` проглатывает ошибки полей — отсюда зелёный прогон. Валидатор зовёт это `FRAME_FIELD_IGNORED` (ошибка) — раздел 16 |
| `frame not found: no element matched …` | Фрейма нет на странице к моменту шага. Это НЕ таймаут элемента: шаг упал на резолве документа. Добавь `web_assert` с `frame` и `frameTimeout` перед работой — раздел 16 |
| `… matched an element, but it is not a frame` | `frame` указывает на обычный элемент. Целься в сам `<iframe>` |
| `web_evaluate` во фрейме возвращает ошибку доступа | Без `frame` скрипт идёт в контекст страницы, а кросс-доменный фрейм оттуда недостижим принципиально. Поставь `frame` — тогда скрипт выполняется в самом фрейме |
| `FRAME_NOT_SUPPORTED` | Действие не умеет фреймы: координатные `touch_*`, `page_element`/`page_method`, проверки URL и заголовка, и любой шаг внутри фикстуры. Список читающих `frame` — в разделе 16 |
| Прогон зелёный, но второго браузера не было | В сценарии есть `actors`, но нет `webrtc.enabled: true`. Движку нужны оба. Валидатор зовёт это `ACTORS_WITHOUT_WEBRTC` — раздел 15 |
| Шаг сработал «не у того» участника | `actor` указывает на необъявленный id. Движок предупреждает в логе и выполняет у первого актёра, шаг проходит. Ищи `UNKNOWN_ACTOR`, сверь id с ключами `actors` |
| В ленте у шагов нет актёра, хотя актёров два | Строку переключения движка не удалось распознать. Атрибуция читается из прозы лога, поэтому пропуск означает «неизвестно», а не «предыдущий» |
| `forceHeaded: true`, а браузер headless | На платформе headed не поднимается: Xvfb в образе есть, `DISPLAY` не выставлен. Два браузера при этом работают — раздел 15 |
| Кадров сплит-вью по MCP не видно | Их и не будет: это картинки, они отбрасываются до лога. Доходят состав актёров и переключения — раздел 15 |
| `You already have a checklist named …` | Имена не уникальны, а импорт платформы ищет по имени. Правь существующий по id, возьми другое имя или `confirm:true` — раздел 10 |
| Кейс создан, а автоматического результата нет | Новый кейс ни к чему не привязан. `retibe_checklist_item op:'link'` — раздел 10 |
| Чек-лист исчез из фильтров интерфейса | `status` — свободный текст, но интерфейс знает только `active` и `archived` |
| `Scheduler service is not available` | Эндпоинт старше `f5a3620`: до него `run_now` работал только на том воркере, где живёт планировщик, а через MCP — практически никогда. Нужен передеплой (раздел 13) |
| Расписание создано, но не сработало | Сверь `nextRunAt` из ответа. Частая причина — не указан `timezone`, и 09:00 оказалось 09:00 UTC |
| Расписание есть, а в календаре его нет | Частота `cron` — платформа не отдаёт по ней ни одной позиции. Пересоздай как `daily`/`weekly` |
| В `TEST_COVERAGE.md` сценарий есть, а на платформе нет | Файл отстал или сценарий удалён. Сверь `retibe_scenarios`, почини строку — в фактах прав эндпоинт (раздел 17) |
| Правил сценарий, а расписание гоняет старое | Расписание держит **копию** шагов с момента создания. Обнови его через `op:'update'` с тем же `scenario` |
