# eva-agent-mvp — решения агента Спека и дизайн — в `jira-hub/openspec/changes/wiki-module/` (issue aep/jira-hub#91, PR aep/jira-hub#92): - решения 10, 11, 14 и Context (форматы ссылок Eva); - требования `wiki` «Синхронизация вики» и «Вложения вики по запросу»; - `modules` «Агенты модулей»; - `hub-api` «Маршруты агента» (общие `/agent/*`). Issue агента — aep/eva-agent#1. Версия агента — 1. Агент работает за модуль `wiki`: имя агента в хабе `wiki`, токен — `jira-hub tokens` → «агент wiki». Владелец 24.10.2026 разрешил делать агента ночью без обсуждения («завтра хочу уже иметь рабочий mvp»). Поэтому все решения ниже приняты агентом и перечислены в разделе «Decisions made without discussion». ## Context - **Источник для копирования — jira-agent v14** (`../jira-agent/src/agent.py`, `run.py`). Его части, не связанные с Jira, отлажены с v2 по v14: - HTTP-слой: `Http`, `HttpError`, `RedirectAway`, `TooBig`, `_SameOriginRedirect`; - транспорт `FolderTransport` и клиент хаба `HubClient`; - журнал `Journal`, цикл `Runner`, самообновление `Updater`; - `acquire_lock`, CLI `run|check|selftest|prune`, `launcher_version`. - **Клиент Eva и обход дерева — по образцу** `../eva_wiki_export.py`: - JSON-RPC 2.2: `POST /api/?m=<метод>`, `Authorization: Bearer`, тело `{jsonrpc: "2.2", method, callid, kwargs}`; - повторы на 429/502/503/504 и сетевых сбоях; - `CmfDocument.get`/`list`, страницы списка по 200; - дерево — по `parent`/`tree_parent` из списка документов проекта (`project.code`), запасной путь — обход через `tree_parent.code`. - **Хаб принимает:** - пачки `POST /agent/wiki/pages {command_id, pages}` — до 50 страниц, тело до 16 МБ (`MAX_BODY` хаба); - форму страницы проверяет `modules/wiki/stage.py`; - отчёт `wiki_sync done {root_title, codes, eva_calls}`: `codes[0]` должен быть кодом корня; - отчёт `wiki_fetch done {file, size}`: файл должен лежать в хранилище хаба с этим размером. ## Decisions made without discussion Дата всех решений — 2026-10-24. 1. **Скопировать общие части jira-agent v14, а не выделять библиотеку.** Обоснование: решение владельца 23.10 — однофайловый агент на stdlib на виртуалке. Копия сохраняет отлаженное поведение. Цена ошибки: правки общих частей придётся повторять в двух агентах. 2. **`run.py` — дословная копия запускателя jira-agent** (`LAUNCHER_VERSION = 2`), вместе с переменной окружения `JIRA_AGENT_LAUNCHER`. Обоснование: в протоколе самообновления важны имена `agent.py`, `update.json` и `AGENT_VERSION`; переменная — внутреннее дело пары запускатель–агент. Цена ошибки: переименование переменной в обоих файлах. 3. **Конфиг `agent.json`:** - обязательны `hub_url`, `hub_token`, `eva.base_url`, `eva.token`; - необязательны `verify_ssl`, `ca_bundle`, `timeout` (60), `poll_seconds` (5), `auto_update` (true). Обоснование: формат jira-agent, где ключ `jira` заменён на `eva`. Цена ошибки: правка шаблона и `load_config`. 4. **Пауза между вызовами Eva — `wiki_pacing` из `GET /agent/settings`** (по умолчанию 0.2 с). - 429/502/503/504 и сетевые сбои: до 4 попыток с ожиданием 1, 2, 4 с. - При 429 учитывается `Retry-After` (не больше 60 с). - Число 429 уходит в `rate_limited` отчёта, число вызовов Eva — в `jira_calls`: поле протокола хаба, имя общее для всех агентов. Цена ошибки: Eva режет запросы; правится настройкой без выпуска агента. 5. **`wiki_sync` — порядок работы:** 1. Корень берётся через `CmfDocument.get` по коду из `params.root`. Нет корня — `failed` «страница <код> не найдена или нет прав». 2. Дерево строится как в `collect_subtree`. 3. Обход в глубину от корня с защитой от циклов (`seen` по `id`). Соседи — по `(orderno is None, orderno, name)`. 4. По каждой странице отдельный `CmfDocument.get(fields=["code","name","text"])`. 5. Страницы уходят пачками по мере обхода: 50 страниц или 4 МБ JSON, что наступит раньше. 6. Отчёт `codes` — коды в порядке обхода, корень первым. - Поля страницы: `eva_id = str(id)`, `parent_code` — код родителя по обходу (у корня `null`), `orderno` — целое или `null`, `url = urljoin(base_url + "/", "project/Document/")`. Цена ошибки: дерево не совпадёт с Eva; лечится правкой обхода и новой версией через самообновление. 6. **Ссылки разбирает `html.parser.HTMLParser` из stdlib.** - Берутся `img[src]` и `a[href]`. - Адрес разрешается `urljoin(base_url + "/", ref)`. «Свой» — тот же `scheme` и `netloc`, что у `eva.base_url`. - **Вложение** — свой адрес с путём, начинающимся на `/files/`. Из `url` убираются query и fragment. Миниатюра `<путь>.meta/thumbnail.jpg` означает файл `<путь>`. - **Ссылка на страницу** — свой адрес с `DOC-<цифры>` в пути (код в верхнем регистре). - **`static/…`** и прочие ссылки не передаются: значки убирает хаб, прочие остаются в тексте. - **Имя вложения** — `alt` или `title`, затем текст ссылки, затем последний сегмент пути после `unquote`. - Каждая ссылка передаётся отдельной записью `{ref, url, name}` или `{ref, code}`, `ref` — ровно значение атрибута. Повторы по `url` склеивает хаб. Обоснование: форматы из выгрузки ТЗ АРМ КА (design хаба, Context). Цена ошибки: вложение продублируется или потеряется; проверяется на первой живой синхронизации, правится агентом. 7. **Без `?version=1` в `url` вложения.** Обоснование: миниатюра и файл дают один адрес, хаб склеивает вложение. Цена ошибки: если Eva без `version` не отдаёт файл, `wiki_fetch` падает; правка одной строки. 8. **`wiki_fetch`:** - адрес не своего хоста — `failed` «чужой хост», запроса нет; - файл скачивается с Bearer-токеном Eva потоком до 10 МБ + 1 байт; - `Content-Length` больше 10 МБ или прочитано больше — `failed` «файл больше 10 МБ — откройте в Eva»; - иначе `POST /agent/files` и `done {file, size}`; - HTTP 404 — `failed` «файл не найден в Eva». Цена ошибки: большие файлы только ссылкой. 9. **Обе команды только читают Eva, поэтому восстановление после перезапуска выполняет их заново.** Журнал нужен только для досылки отчёта; `wiki_sync` не продолжается с середины. Хаб очищает стадию при повторной выдаче. Цена ошибки: лишняя полная перекачка после перезапуска. 10. **`check`:** - хаб: `GET /agent/settings` своим токеном; - Eva: `CmfDocument.list(filter=[], fields=["code"], slice=[0, 1])`; - вывод «хаб: ok», «eva: ok» или текст ошибки, код 0 или 3, как у jira-agent; - `selftest` — версия плюс `check`. 11. **Тесты:** - `unittest` на `/usr/bin/python3` (3.9); - фейки `tests/fake_hub.py` (как у jira-agent, плюс `POST /agent/wiki/pages`), `tests/fake_eva.py` (JSON-RPC на `http.server`), `tests/fake_relay.py`; - `tests/test_py39.py` — копия. - Тесты общих частей переносятся из jira-agent и адаптируются: журнал, транспорт, самообновление, запускатель. 12. **`ref` вложений и ссылок — значение атрибута после unescape** (так отдаёт `html.parser`). Обоснование: хаб (`convert.py`, BeautifulSoup `.get("src"/"href")`) ищет запись по тому же unescaped-значению. Цена ошибки: адреса с `&` не сопоставятся, вложение останется неразрешённым; правка одной строки. 13. **При расхождении плана с кодом хаба по протоколу прав код хаба.** Поручение владельца; расхождения фиксируются находками `O-n`, хаб не правится. Цена ошибки: агент подгонится под ошибочное поведение хаба; видно в e2e. 14. **Каркас `EvaClient` создан в задаче 1; `Runner(cfg, hub, eva, journal)`.** В `EvaClient` — только Bearer-заголовок, TLS-настройки, счётчики `calls`/`rate_limited` и время последнего вызова (`last_call_at`, `last_call_iso`). Обоснование: `Runner` и `Executor` из jira-agent опираются на эти поля клиента (пауза между командами, отчёт); без каркаса общие тесты цикла не перенести. JSON-RPC и повторы добавляют задачи 2–3. Цена ошибки: перенос полей в другой класс. 15. **`claim` шлёт `last_call_at`, а не `last_jira_call_at`.** Обоснование: хаб (`core/api.py`) читает `body.get("last_call_at", body.get("last_jira_call_at"))`; новое имя не привязано к Jira. Поля отчёта `jira_calls`/`rate_limited` остаются как есть: хаб читает только их. Цена ошибки: если хаб уберёт новое имя, время последнего вызова перестанет доходить (только статус). 16. **Jira-специфичные тесты jira-agent не переносились; `test_clients.py` написан заново на `MiniServer`.** Исключены: `JiraClient` (multipart, 429/503), `Executor`, `refresh`, история, вложения Jira. Сценарии `Http` (перенаправления, обрывы, не-JSON, предел `fetch_bytes`) сохранены на маленьком локальном сервере, фейка Eva в задаче 1 нет (появляется в задаче 2). Обоснование: брифом перенос ограничен общими частями; Jira в агенте нет. Цена ошибки: пропущенный сценарий `Http`; виден по сравнению со списком тестов jira-agent. 17. **Тесты цикла работают на заглушке `wiki_fetch` (`failed` «не реализовано»); паузу и время вызова Eva тесты задают вручную (`eva.last_call_at`, `eva._touch()`).** Результат `done` проверяется через подмену `Executor.run`/`do_wiki_fetch`. Обоснование: уточнение к задаче 1. Цена ошибки: тесты цикла придётся уточнить, когда команды заработают. 18. **`test_run_py_is_copy` ищет `jira-agent/src/run.py` на 1–3 уровня выше корня репозитория; нет файла — skip с причиной.** Путь из плана (`../../jira-agent`) верен для основного checkout, из worktree `.wt/<срез>` — на уровень глубже. Цена ошибки: тест тихо пропускается на нестандартной раскладке каталогов. 19. **`.gitignore` дополнен `agent.py.new`, `agent.py.prev`, `__pycache__/`, `*.pyc`** (рабочие файлы самообновления и кэш). Цена ошибки: нет. 20. **`EvaClient(cfg)` остаётся с сигнатурой каркаса (по всему `cfg`), а не `(cfg_eva, timeout, verify_ssl, ca_bundle)` из брифа.** Обоснование: `Runner`/`main` уже создают клиента как `EvaClient(cfg)` (решение 14). Цена ошибки: нет, внутренняя сигнатура. 21. **`EvaClient.call(method, **kwargs)` переопределяет `Http.call(method, path, ...)`.** Транспортный вызов — через `Http.call(self, ...)`; тесты общего `Http` в `test_clients.py` переведены на `agent.Http.call(client, ...)`. Обоснование: интерфейс клиента Eva задан брифом (метод JSON-RPC первым аргументом). Цена ошибки: путаница сигнатур; видна при чтении класса. 22. **Пауза между вызовами — перед вызовом (и перед повтором) по `last_call_at`; первый вызов после старта не задерживается. `Runner.load_settings` ставит `eva.pacing` из `wiki_pacing` (по умолчанию 0.2).** Обоснование: `last_call_at` общий с паузой между командами. Цена ошибки: лишняя или недостающая пауза, правится настройкой. 23. **Токен вырезается (`***`) из текстов `HttpError`/`ConnectionError`/`EvaError`, выходящих из `EvaClient`.** Обоснование: сервер может отразить заголовок в теле ошибки. Цена ошибки: токен в логе. 24. **`download` принимает только адрес под `base` (иначе `ValueError("чужой хост")`) и повторяет те же коды, что `call`.** Обоснование: токен нельзя отправлять на чужой хост (решение 8). Цена ошибки: нет. 25. **`classify_links`: уточнения разбора (задача 3).** - Миниатюра определяется по хвосту пути `.meta/thumbnail.jpg` (query отбрасывается), адрес вложения — путь без хвоста, без query и fragment, процентная кодировка пути сохраняется. - Имя: `alt`/`title` → текст `` (пробелы схлопнуты) → `unquote` последнего сегмента пути. - Порядок записей — порядок открывающих тегов (для `` сначала ссылка, потом картинка); повторы не склеиваются. - Код страницы — первое `DOC-<цифры>` в пути (регистр не важен, результат в верхнем регистре); `/files/…` приоритетнее кода. - `ref` — значение после unescape; сверено с хабом: `convert.py` ищет по `el.get("src"/"href")` BeautifulSoup, то есть тоже по unescaped-значению (решение 12 подтверждено, расхождений нет). - В `tests/test_py39.py` в белый список stdlib добавлен `html` (`html.parser`). Обоснование: форматы из Context design хаба. Цена ошибки: потеря/дубль вложения, ловится на живой синхронизации. 26. **Обход дерева `wiki_sync` ведётся по коду страницы, а не по `id` (уточнение решения 5).** - `children`, `seen` и `parent_code` ключуются кодом (`ref.code`): код уникален и есть в каждой ссылке `parent`/`tree_parent`; `id` берётся только для `eva_id`. - Соседи сортируются по `(orderno is None, orderno, name без регистра, code)` — как `collect_subtree` экспорта; решение 5 говорит «name», хвост по коду добавлен для детерминизма. - Дерево с проектом — один список документов проекта; без проекта — обход в ширину через `tree_parent.code` (защита от циклов тоже по коду). Обоснование: ссылка Eva в фейке и в экспорте гарантированно несёт `code`; поле `id` в ссылке может не совпасть с `id` документа. Цена ошибки: если коды в Eva неуникальны (не бывает), страницы склеятся. 27. **Страница без названия получает `title = code`; корень без названия — `root_title = code`.** Обоснование: `stage._check` хаба отклоняет пустой `title` (422 `<код>: title`), и одна такая страница завалила бы всю синхронизацию. Цена ошибки: в хабе страница названа кодом вместо пустого имени. 28. **Страница, исчезнувшая между списком и чтением (`get_doc` → `None`), — `failed` «страница <код> не найдена или нет прав»; частичная пропуск-синхронизация не делается.** Обоснование: отчёт `codes` обязан совпадать со стадией хаба; тихий пропуск скрыл бы потерю. Цена ошибки: из-за одной удалённой на лету страницы нужна повторная синхронизация. 29. **Размер пачки считается тем же кодированием, что у `Http.call` (`ensure_ascii=False`, UTF-8) + 2 байта на разделитель; страница больше 4 МБ уходит одна пачкой (предел хаба 16 МБ).** Обоснование: оценка совпадает с реальным телом запроса. Цена ошибки: пачка чуть больше 4 МБ; хаб примет до 16 МБ. 30. **`fake_hub.POST /agent/wiki/pages` проверяет пачку как `stage.accept`/`_check` хаба (≤ 50 страниц, `command_id` целое, команда `wiki_sync` в статусе `claimed`, типы и пределы полей, форма `attachments`/`links`) и отвечает `{error, detail}` с 422/404/409.** Тесты `test_folder.py` приведены к валидным страницам и выданной команде. Обоснование: поручение владельца — фейк не мягче хаба. Цена ошибки: фейк строже или мягче хаба в мелочах; ловится e2e. 31. **Решение 26 уточнено (T4.1): ключ дерева — код страницы, но ссылку `parent`/`tree_parent` в код разрешает `build_children`.** - Ссылка Eva приходит то объектом `{id, code, ...}`, то строкой-id, то объектом без `code` (так говорит `ref_id` эталонного экспорта). - `build_children` строит карту `str(id) → code` по документам проекта и корню. Ссылка с `code` даёт код сразу; иначе берётся `id` (из dict или сама строка) и ищется в карте. Неразрешённая ссылка (родитель в другом проекте) пропускается. - Без проекта (фильтр `tree_parent.code`) разрешение не нужно: родитель известен по запросу. - `FakeEva`: id документов отличны от кодов; ссылки по умолчанию `{"id": "", "code"}`, формы `ref_form`: `full`, `id_only`, `string`, `code_id`. Обоснование: прежний `_ref_code` на голой строке-id и dict без `code` молча давал `codes == [root]` при `done`. Цена ошибки: страница с ссылкой на родителя, не попавшего в список проекта, окажется оторванной от дерева (не будет обойдена). 32. **`wiki_fetch` (задача 5): уточнения.** - Проверка `Content-Length` вынесена в `Http.fetch_bytes`: заявленная длина больше предела — `TooBig` до чтения тела; без заголовка работает потоковое чтение limit + 1 байт. Предел включительно: ровно 10 МБ принимается. - Ошибки: чужой хост (`ValueError` из `download`) → «чужой хост»; `TooBig` → «файл больше 10 МБ — откройте в Eva» (как в спеке хаба); HTTP 404 → «файл не найден в Eva»; перенаправление на другой адрес → «Eva перенаправила скачивание на <хост>»; прочее — общие правила `Executor.run` (5xx и сеть повторяются). - Пустой файл (0 байт): хаб принимает его от агента (`POST /agent/files` → `files.put(allow_empty=True)`, решение 13 хаба; `check_result` сверяет размер 0 с `files.size`, `None` — только «нет файла»), поэтому агент отчитывается `done {file, size: 0}` без особых случаев. Расхождения с брифом нет; `fake_hub` повторяет это и добавил 413 для файла > 10 МБ, как `files.put`. - Результат `{attachment_id, file, size}`; `size` берётся из ответа хаба. Содержимое в лог не пишется (только размер); в памяти одна копия данных, base64 строится на время отправки. - Тесты каркаса (`test_runner`, `test_folder`), опиравшиеся на заглушку `wiki_fetch`, ждут теперь «чужой хост» (адрес фикстуры не на хосте Eva). Обоснование: сверка с кодом хаба (`attachments.check_result`, `api._put_file`) и спекой «Вложения вики по запросу». Цена ошибки: другой текст ошибки в карточке вложения; ловится живой проверкой. 33. **`check` (задача 6): код возврата 0 или 3, оба узла проверяются всегда.** - Хаб — `GET /agent/settings`, Eva — `CmfDocument.list(filter=[], fields=["code"], slice=[0, 1])`; вывод «хаб: доступен…», «eva: ok» или «eva: НЕДОСТУПНА / ошибка — <текст>». Недоступность хаба не мешает проверить Eva. - Код недоступности — 3 (design 10), а не 1, как у каркаса из jira-agent; `selftest` по-прежнему 0 или 3. Тесты каркаса приведены: `test_check_command` и `test_check_folder_silent_relay` ждут 3; `FolderIntegrationTest` и `SelfUpdateE2E` поднимают `FakeEva` (selftest = check, без доступной Eva самообновление откладывается). - У Eva в `check` одна попытка (`ATTEMPTS = 1`): проверка отвечает сразу, без повторов с паузами 1+2+4 с. Токен Eva в тексте ошибки заменяется на `***` (решение 23). Обоснование: design 10 и поручение владельца «коды как у jira-agent (0 или 3)». Цена ошибки: скрипт установки, ожидающий 1, увидит 3; на самообновление не влияет (там `selftest`). 34. **Флаг `agent.py run --once` для сквозного прогона.** Ставит `runner.idle_limit = 1`: агент выполняет выданное и выходит при первом пустом claim (код 0). Протокол с хабом не менялся, `Runner.idle_limit` уже был в каркасе (используют тесты). Обоснование: прогон делает две фазы (`wiki_sync`, затем `wiki_fetch`, который клиент ставит после чтения страницы), агенту нужен выход между ними. Цена ошибки: нулевая для боевого режима (без флага поведение прежнее). Пустой claim хаб держит ~30 с, поэтому каждая фаза e2e длится около 30 с. 35. **Сквозной прогон: `scripts/e2e-hub.sh` + `scripts/fake_eva_serve.py`.** - Хаб — `uv run --project --no-sync jira-hub serve --host 127.0.0.1 --port <свободный>` с `JIRA_HUB_HOME=$(mktemp -d)/hub-home`; порт 8790 отвергается явно. `--no-sync`, чтобы прогон не менял `.venv` соседнего worktree. - Токены берутся из `jira-hub tokens` (клиентский и «агент wiki»); транспорт агента — прямой HTTP, `auto_update=false`. - Фейковая Eva (`tests/fake_eva.py`) запускается отдельным процессом, 5 страниц в проекте `E2E`, картинка `/files/obj/e2e/scheme.png`; проект создаётся `POST /projects {label, name, wiki_root}`, затем `wiki_sync`, `GET /wiki/status|tree|pages/DOC-2`, `POST /wiki/attachments//fetch`, `wiki_fetch`, `GET …/content` и сверка sha256. - Завершение: `trap` останавливает дерево процессов (`uv run` порождает хаб дочерним процессом: простой `kill` оставлял хаб жить) с `kill -9` как запасным вариантом; временный каталог удаляется. Готовность хаба — опрос порта `nc -z` с шагом 0,1 с. - Хаб принял всё, что требует его спека (расхождений с хабом не найдено). Обоснование: поручение владельца (тестовый хаб только из worktree wiki, временный home, не живой). Цена ошибки: при переименовании модулей/маршрутов хаба прогон упадёт на `POST /projects` или `tokens` — это сигнал, а не порча данных. 36. **`classify_links` не падает на нестандартном HTML и битых адресах (I-1).** - На Python 3.9 `_markupbase` зовёт `error()` на ``, `` и подобном, а базовый `error()` бросает `NotImplementedError`. `_LinkParser.error()` бросает своё `_ParseError`, а `parse_marked_section` ловит его и пропускает конструкцию до `>` (как «мусорный комментарий»; нет `>` — ждёт данных, при `close()` её пропускает `goahead`). Известные секции (``, ``, ``) разбираются как раньше. - Простой no-op `error()` не годится: после него `parse_marked_section` падает на несвязанной переменной `match`, а `_scan_name` — на распаковке `None`. - Запасной путь: `feed`/`close` обёрнуты в `try` (`_ParseError`, `NotImplementedError`, `AssertionError`), собранное до сбоя сохраняется, в лог — строка. - Ссылка, на которой `urljoin`/`urlsplit` бросают `ValueError` (`http://[::1`), пропускается, остальные остаются. Обоснование: одна битая конструкция в HTML страницы роняла весь `wiki_sync` «внутренней ошибкой агента». Цена ошибки: ссылка внутри нераспознанной секции не попадёт в `attachments`/`links` (останется в HTML как есть). 37. **`wiki_fetch`: адрес кодируется перед запросом, отказ по хосту — `ForeignHost` (I-2).** - Путь и запрос после `base_url` кодируются `quote(…, safe="/%:@!$&'()*+,;=-._~")` (у запроса ещё `?`); готовые `%XX` не кодируются повторно, одиночный `%` без двух шестнадцатеричных цифр становится `%25`; фрагмент `#…` отбрасывается. - `download` бросает `ForeignHost("чужой хост")` (другие схема, хост, порт, userinfo, адрес не под `base_url`) или `ForeignHost("некорректный адрес")` (`urlsplit` не разбирает: `http://[::1`, порт не числом). `do_wiki_fetch` ловит только `ForeignHost`; прочие `ValueError` (в том числе `UnicodeEncodeError`) уходят в «внутреннюю ошибку агента», а не маскируются под «чужой хост». - `url` вложения в `classify_links` не перекодируется: это ключ склейки вложений в хабе, менять его форму — вне MVP. Обоснование: имена файлов в Eva с пробелами и кириллицей; без кодирования `http.client` отказывал (`InvalidURL` повторялся как обрыв связи, `UnicodeEncodeError` выдавался за «чужой хост»). Цена ошибки: если Eva ждёт путь в другой кодировке (не UTF-8), файл не найдётся (404 «файл не найден в Eva»). 38. **`EvaError` в команде — `failed` «Eva: <текст>» без повтора (M-1).** Текст уже очищен от токена (решение 23). Обоснование: ошибка JSON-RPC (нет прав, неверный фильтр) — ответ Eva, а не сбой агента; повтор не поможет. Цена ошибки: временная ошибка Eva, пришедшая в `error`, не повторится автоматически — повторяет пользователь. 39. **413 хаба на пачку страниц — ошибка с кодом страницы или границами пачки (M-3).** «хаб: HTTP 413: страница DOC-x больше 16 МБ — хаб не принял: <ответ хаба>» для пачки из одной страницы; «пачка страниц DOC-a…DOC-b (N шт.) больше 16 МБ …» иначе. Не повторяется. Пачка из нескольких страниц больше 16 МБ невозможна (пачка закрывается на 4 МБ), но текст на этот случай есть. Обоснование: по «HTTP 413» без кода не понять, какую страницу Eva чистить. Цена ошибки: нет. 40. **`list_docs` останавливается, если ответ страницы не приносит новых записей (M-4).** Записи склеиваются по JSON-представлению (`sort_keys`); полная страница без новых записей — останов с записью в лог, собранное возвращается. Так ловятся оба случая «Eva игнорирует `slice`»: весь список каждый раз и одна и та же первая страница. Обоснование: иначе бесконечный цикл вызовов Eva. Цена ошибки: если Eva игнорирует `slice` и отдаёт только первые N записей, дерево будет неполным без явного отказа (видно в логе). 41. **`Executor(eva, hub)`: удалены неиспользуемые `Executor.s` (настройки) и `Executor.sleep` (M-7).** Настройки, нужные исполнителю (`wiki_pacing`), ставит `Runner.load_settings` прямо в `EvaClient.pacing`. Цена ошибки: нет. 42. **Перенаправление Eva на SSO (M-5) не чинится до живой проверки.** Сейчас перенаправление на другой адрес отклоняется (`RedirectAway` → «перенаправление … отклонено»), страница входа с кодом 200 даёт «ответ не JSON». Как именно Eva банка отвечает на протухший токен, неизвестно; чинить вслепую — угадывать формат. В README и ARCHITECTURE строка «проверить при первой живой синхронизации». Цена ошибки: при протухшем токене текст ошибки менее понятен («ответ не JSON» вместо «токен Eva недействителен»). 43. **Документация (I-3, T6.1, T6.4, M-2, M-6).** - Выкатка — `jira-hub agent publish --module wiki src/agent.py` (без `--module` хаб публикует версию агенту `jira`, `registry.DEFAULT_AGENT`); порог — `wiki_min_agent_version` (ключ модуля wiki хаба, `agent_min_version_key`). - `eva.base_url` — только origin: к нему добавляются `/api/`, `/files/…`, `/project/Document/<код>`, и с ним же сравнивается адрес вложения. `agent.example.json` уже содержит origin без пути (`https://eva.example.ru`); JSON без комментариев, поэтому пояснение — в README. - Агент — только macOS (`fcntl` в `acquire_lock`): из README убраны `C:\…` и «планировщик задач», добавлен пример launchd с меткой `ru.aepif.eva-agent` по образцу jira-agent. Цена ошибки: нет. 44. **Финальная волна правок — один fixer, объём: I-1, I-2, I-3, M-1–M-4, M-6, M-7, T6.1, T6.4, T5.2; M-5 не чинится (R-14).** Обоснование: M-5 (отказ на `text/html`) мог бы ложно отвергнуть настоящие `.html`-вложения; поведение Eva при просроченном токене проверяется на первой живой синхронизации. Цена ошибки: при SSO-редиректе страница входа сохранится как вложение; правится одной проверкой Content-Type. 45. **T2.3 и T5.1 отклонены по итогам финального ревью (R-15).** `Http.__init__` делает `base.rstrip("/")`; размер из ответа хаба равен `len(decoded)`, и хаб сверяет его с `files.size`. Цена ошибки: нет (оба подтверждены ревьюером по коду).