# 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_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) |
| описания инструментов и промпты | написаны руками | **низкая** — с движком ничем не связаны |

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

Список действий — это надёжно. Если `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`.

**Проверяй исход, а не факт действия.** Заполнить форму и нажать «отправить» —
это ещё не тест: если после этого ничего не проверяется, шаги пройдут зелёным
и при отказе сервера. Нужен признак, который **может не наступить** — редирект,
появление элемента, изменение 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`, `retibe_scenarios`,
`retibe_save_scenario`, `retibe_history`, `retibe_visual_review`.

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

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

**Промпты:** `/author` (написать сценарий), `/debug` (разобрать падение),
`/harden` (найти проверки, которые ничего не проверяют).

В репозитории **22 инструмента и 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'` — привязать кейс к нему

Пункт 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`.

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

Создания и удаления чек-листов и кейсов, и импорта. Чек-лист — артефакт команды,
а не агента; импорт вдобавок сопоставляет по точному совпадению имени и
перезаписывает найденное. Заводить структуру — в веб-интерфейсе, наполнять
покрытием — отсюда.

---

## 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`, который
говорит откуда они взяты. Источников два, и они не равноценны:

- **`tests table`** — посчитано по реальным прогонам. Этому верить можно.
- **`schedule columns`** — собственные счётчики расписания. Их стоит читать с
  подозрением: метод, который должен был писать в `success_count`/`fail_count`,
  до `b297b63` не вызывался ниоткуда, поэтому у расписаний, созданных раньше,
  `passed` и `failed` так и остались нулями при любом числе успешных прогонов.
  Двигался только `runs`.

Инструмент предпочитает первый источник и падает на второй только если статистика
не ответила — и тогда говорит об этом в `countsFrom`, а не подмешивает молча.

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

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

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

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

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

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

---

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

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

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

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

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

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

- **`id` — это ключ, по которому платформа находит чек-лист.** Движок берёт
  `scenario.id || scenario.name`. Без своего строкового `id` ключом становится
  числовой id платформы — и тогда привязка работает для прогона, запущенного
  объектом из `retibe_scenarios`, и не работает для того же сценария, переданного
  инлайном. Со стабильным `id` ключ один в обоих случаях (раздел 10).
- **`retibe_scenarios` — это твоя карта покрытия.** Листинг отдаёт имя, описание
  и число шагов. С префиксами `search:'shop-checkout'` находит область целиком;
  без них двадцать «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. Если что-то не так

| Симптом | Причина |
|---|---|
| `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`, как он сохранён |
| Раскадровка говорит «покраснеть не может ничто» | Сценарий ничего не проверяет. Разбери каждый вердикт из раздела 11 — это не ложная тревога |
| `steps` пустое, а прогон идёт | Ни одно сообщение движка не совпало с шаблоном границы шага, либо канал прогресса не ответил. Смотри `timelineCaveats` и `waitUnsupported` |
| «про прогон ничего не известно» | Канал прогресса не ответил — старый образ или Redis недоступен. Это **не** «прогон молчит»; состояние прогона по-прежнему честное |
| `wait_for:'failure'` вернулся, а прогон идёт | Так и задумано: движок пишет error и на восстановимых ретраях. Приговор — только `state` |
| `Scheduler service is not available` | Роут читает планировщик из памяти одного воркера из двух. Позови ещё раз — само расписание в порядке (раздел 13) |
| Расписание создано, но не сработало | Сверь `nextRunAt` из ответа. Частая причина — не указан `timezone`, и 09:00 оказалось 09:00 UTC |
| Расписание есть, а в календаре его нет | Частота `cron` — платформа не отдаёт по ней ни одной позиции. Пересоздай как `daily`/`weekly` |
| Правил сценарий, а расписание гоняет старое | Расписание держит **копию** шагов с момента создания. Обнови его через `op:'update'` с тем же `scenario` |
