## Context Хаб после #87 (main `f7add9b`) — ядро `jira_hub/core/` и модуль `jira_hub/modules/jira/`, схема v6. Контракт модуля — `core/modules.py` (`Module`, `CommandHooks`), реестр — `core/registry.py` (сейчас `[jira.MODULE]`). Что в ядре зашито под один агент и под Jira (карта кода 24.10.2026): - **Токены.** Один агентский и один клиентский токен в `setting.tokens = {agent, client}` (`core/settings.py:71-76`). `Handler._auth` (`core/api.py:406-415`) пускает на любой маршрут со scope `agent` любой агентский токен. - **`agent_status`** — одна строка `id = 1` (`core/db.py:52-59`). Её пишут claim, отчёт и отмена (`core/queue.py`, 7 мест), читают `agent_version()` и `agent_view()`. - **`claim`** (`core/queue.py:227-271`) берёт любую команду очереди: SQL `_next` и `_redeliver` не фильтруют ни по типу, ни по агенту. «Одна в полёте» тоже общая. - **Состояние самообновления** держится в памяти хаба одним объектом `Hub.agent_update` (`core/api.py:47, 91-104`). - **Релиз** один: `release/agent.py` и `manifest.json` (`core/release.py`, `paths.release_dir()`), команда `jira-hub agent publish PATH`. - **Настройки агента.** `/agent/settings` отдаёт ключи всех модулей и ключи ядра `min_agent_version`, `settings_version` (`core/settings.py:19, 36-41`). Ключ `board_id` в `AGENT_KEYS` jira мёртвый. - **Реле** (`core/relay.py`) про агентов не знает: оно пересылает метод, путь и `Authorization` в свой HTTP. Имена файлов запросов (`uuid4().hex`) задаёт агент. Два агента в одной папке `relay_dir` не мешают друг другу. - **Проекты.** Колонки `board_id`, `board_type` лежат в таблице ядра `project`. `issues_count` считается в ядре по таблице модуля `issue`. Поле доски жёстко вшито в `POST /projects`, MCP `project_create`/`projects` и окно «Новый проект». Модуль может только поставить пакет при создании проекта (`on_project_created`). Все такие места помечены `долг hub-modules` (решение 12 hub-modules). - **MCP `agent_status`** — инструмент модуля jira. `GET /agent` отдаёт один объект. - **Ориентир для модуля wiki** — модуль jira: `modules/jira/fetch.py` (скачивание вложения по запросу командой `fetch_attachment`), `refresh.maybe_auto_refresh` (фоновый пакет чтения), `core/files.py` (хранилище по sha256, `MAX_SIZE` 10 МБ, уборка по `file_refs`). **Eva.** evaTeam, JSON-RPC 2.2 по адресу `/api/?m=<метод>`, заголовок `Authorization: Bearer <токен>`, тело `{jsonrpc: "2.2", method, callid, kwargs}`. Используются методы: - `CmfDocument.get(filter=[["code","==",<код>]], fields=[…])`; - `CmfDocument.list(filter, fields, slice=[from,to])`, страницы по 200. Поля списка: `id`, `code`, `name`, `parent`, `tree_parent`, `project`, `orderno`. Текст страницы — HTML в поле `text`. Дерево строится локально по `parent`/`tree_parent` из списка всех документов проекта Eva. Фильтр `parent.code` API не принимает (-32001). Если у страницы нет `project`, дерево обходится в ширину через фильтр `tree_parent.code`. Картинки и файлы — ссылки ``/`` на тот же хост с путём `/(files?|attachments?|download|media|upload)`. Всё это обкатано в `eva_wiki_export.py` (корень workspace). Реальные форматы ссылок взяты из выгрузки ТЗ АРМ КА (`awp-ca/docs/source/tz/`, корень DOC-019855, 22 страницы, 388 КБ Markdown; сырого HTML в выгрузке нет): - **Ссылка на страницу** — относительный адрес без хоста и без ведущей `/`: `project/Document/DOC-019834`. - **Картинка** — `/files/obj/CmfDocument/CmfDocument%3A<3 символа>/CmfDocument%3A/<имя в URL-кодировке>?version=1`. - **Файл, прикреплённый к странице** (xlsx, dbml), выглядит как миниатюра `…/<имя файла>.meta/thumbnail.jpg?version=1`, значок `static/attach-file.png`, имя файла и дата текстом. Ссылка на сам файл в выгрузке не сохранилась: её, вероятно, съел markdownify. Адрес файла — адрес миниатюры без `.meta/thumbnail.jpg`. - **`static/…`** — ресурсы интерфейса Eva, не вложения. Объём настоящего дерева невелик (десятки страниц), но решения ниже рассчитаны и на сотни. Есть ли у документа дата изменения и история версий, неизвестно: публичной документации API нет, Eva доступна только из сети банка. Владелец (brainstorming 23–24.10.2026): см. proposal, «Why». ## Goals / Non-Goals **Goals:** - проект хаба получает корень Eva, дерево его страниц лежит у хаба Markdown-копией; - Claude ищет и читает вики проекта через отдельный MCP-сервер; - веб показывает дерево и страницы проекта; - eva-agent — второй агент; хаб различает агентов; - в ядре не остаётся Jira (метки `долг hub-modules` сняты). **Non-Goals:** см. proposal. Сверх них — правка уже скачанной копии руками: копия перезаписывается синхронизацией. ## Decisions ### 1. Вид агента = модуль с командами У каждого модуля, у которого есть `commands`, свой агент. Имя вида совпадает с именем модуля: агент `jira` — jira-agent, агент `wiki` — eva-agent. Отдельного справочника видов нет. Почему: модуль уже владеет своими типами команд, настройками для агента и маршрутами `/agent/<…>` своего модуля. Агент без модуля бессмыслен, модуль с командами без агента тоже. ### 2. Токен агента определяет его вид `setting.tokens` становится `{client, agents: {<модуль>: <токен>}}`. Миграция v7 переносит прежний `tokens.agent` в `tokens.agents.jira`, поэтому jira-agent v14 работает со своим прежним токеном. `ensure_defaults` создаёт недостающий токен для каждого модуля с командами. `jira-hub tokens` печатает клиентский токен и токены агентов с именем модуля. `Handler._auth("agent")` находит модуль по токену (`hmac.compare_digest` с каждым) и кладёт его в контекст запроса. Дальше: - маршруты ядра `/agent/*` работают от имени этого агента; - маршруты модуля со scope `agent` (у jira это `/agent/state`, `/agent/history`, `/agent/known-keys`) пускают только агента своего модуля, токен другого агента получает 401. Клиентский токен по-прежнему один. ### 3. `agent_status` по агентам Миграция v7 пересоздаёт таблицу с ключом `agent TEXT PRIMARY KEY` и переносит строку `id = 1` в `agent = 'jira'`. Колонку `last_jira_call_at` переименовываем в `last_call_at`. Протокол `claim` (поле `last_jira_call_at` в теле от jira-agent) не меняется: хаб читает и `last_call_at`, и `last_jira_call_at`. Строка агента появляется при старте хаба, по одной на модуль с командами. Все места `WHERE id = 1` становятся `WHERE agent = ?`. `queue.agent_version(conn)` превращается в `agent_version(conn, agent)`. Модуль jira в `fetch.py` спрашивает версию агента `jira`. ### 4. `claim` выдаёт только команды своего модуля `queue.claim(conn, agent, …)`. `_next` и `_redeliver` берут только команды, у которых `type` входит в `CommandHooks.types` модуля `agent`. Пакет и так однородный: «команды разных модулей в одном пакете» отвергаются с #87. «Одна команда в полёте» считается на агента. Агенты работают параллельно: долгая синхронизация вики не задерживает `ensure_*` в Jira. `current_command_id` хранится в строке своего агента. Копия базы перед первой пишущей командой пакета остаётся как есть: команды wiki в этом change только читают (`read_types`). ### 5. Самообновление по агентам - **Релиз:** `release/<модуль>/agent.py` и `manifest.json`. При старте хаб один раз переносит прежние `release/agent.py` и `release/manifest.json` в `release/jira/`. Переносит, только если `release/jira/` ещё нет; ошибка переноса пишется в лог, хаб стартует. - **Публикация:** `jira-hub agent publish [--module <модуль>] PATH`, по умолчанию `jira`. Неизвестный модуль или модуль без команд — код 2, как у `mcp`. Проверки и вывод прежние. - **Ответы агенту:** `claim` и `GET /agent/release` отдают релиз вида, к которому относится токен. - **Память хаба:** `Hub.agent_update` становится словарём по агентам. - **`GET /agent?module=<модуль>`**, без параметра — `jira`, как сейчас. Добавляется `GET /agents` — список всех агентов в том же формате с полем `module`. - **MCP:** `agent_status` остаётся у jira, у wiki есть свой (решение 12). ### 6. Настройки агента по модулю `GET /agent/settings` отдаёт ключи ядра и `agent_keys` только своего модуля: jira-agent не видит ключей вики и наоборот. `settings_version` остаётся общим счётчиком. Лишний перезапрос настроек у eva-agent после правки настроек jira безвреден. `min_agent_version` переезжает из ядра в модуль jira: имя ключа и значение прежние, для jira-agent ничего не меняется. Модуль задаёт ключ своей минимальной версии полем `Module.agent_min_version_key`; у wiki это `wiki_min_agent_version`, по умолчанию 1. `agent_view(conn, agent)` берёт минимальную версию по этому ключу. Мёртвый ключ `board_id` из `AGENT_KEYS` jira удаляется. Требование для jira-agent v14: ответ `/agent/settings` для токена jira совпадает с нынешним, кроме ключа `board_id`. Сейчас он всегда `null`, агент его не читает (проверить по `jira-agent/src/agent.py` при реализации; если читает — ключ оставить со значением `null`). ### 7. Реле не меняется Одна общая папка `relay_dir` на обоих агентов. Запросы различаются токеном, имена файлов — `uuid4().hex` агента, `/agent/claim` у обоих один и тот же путь. Уборка `work/` при старте хаба общая, как сейчас: потерянный запрос агент повторит. Подпапки на агента не вводим. ### 8. Проекты: ядро — метка, название, дата; остальное — модули - **Ядро.** Таблица `project` остаётся с `label`, `name`, `created_at`. Миграция v7 переносит `board_id` и `board_type` в таблицу модуля jira `jira_project(label PK → project, board_id, board_type)` и удаляет колонки из `project` (через пересоздание таблицы, как в прежних миграциях). - **Число задач `issues_count`** считает модуль jira. - **Контракт `Module` получает поля проекта:** - `project_fields` — описание полей модуля: имя, тип, проверка, можно ли менять после создания, подпись и подсказка для веба и MCP; - `project_view(conn, labels) -> {label: {поле: значение}}` — значения для списка проектов, включая вычисляемые (`issues_count`, `pages_count`, `synced_at`); - `on_project_created(conn, project, fields, author)` — запись своих полей и пакет, как сейчас; - `on_project_updated(conn, project, fields, author)` — правка своих изменяемых полей и пакет. - **`registry.check`** падает, если имя поля проекта есть у двух модулей или совпадает с полем ядра. - **`GET /projects`** отдаёт плоские объекты: поля ядра и поля всех модулей. Набор и значения полей jira прежние: `board_id`, `board_type`, `issues_count`. - **`POST /projects` `{label, name, author?, <поля модулей>}`.** Неизвестное поле — 422. Каждый модуль проверяет свои поля до записи; ошибка — 422 с именем поля, ничего не записано. `batch_id` в ответе прежний: пакет первого модуля, вернувшего пакет. Решение R-8 hub-modules остаётся. - **Новый `PATCH /projects/{label}` `{<изменяемые поля модулей>, author?}`.** Неизменяемое поле — 422 «<поле> после создания не меняется». Ответ — `{project, batch_ids: [...]}`. Доска jira остаётся неизменяемой, как сейчас. Корень вики изменяемый: у уже заведённых проектов (AWPCA и др.) его надо проставить потом. - **MCP.** `core/mcp.register_core_tools(mcp, module)` строит `projects` и `project_create` сервера модуля из его `project_fields`. Сервер jira отдаёт прежние названия, параметры и описания `project_create` и прежние колонки `projects` (эталоны `tests/fixtures/baseline/mcp_tools.expected.json` не меняются). У сервера wiki — свои колонки и параметр `wiki_root`. - **Веб.** `HubModule.projectFields` — поля модуля в окне «Новый проект». Поле доски переезжает в модуль jira, текст и подсказка прежние. Метки `долг hub-modules` в ядре снимаются все. Тест `test_core_has_no_jira_terms` больше не делает для них исключения. ### 9. Модуль wiki: данные Таблицы модуля (схема v7, рядом с миграцией решений 2, 3, 8): - **`wiki_project`**: `label` PK → project, `root_code` (`DOC-<цифры>`), `root_title`, `synced_at` (последняя успешная синхронизация), `pages_count`, `sync_error` (текст последней неудачи или `NULL`). - **`wiki_page`**: - ключ: `label`, `code`; PK `(label, code)`; - поля: `eva_id`, `title`, `parent_code`, `orderno`, `depth`, `path` (каталог страницы относительно `wiki/` на хост Eva с путём `/files/…`. - Миниатюра `<адрес>.meta/thumbnail.jpg` — это файл по `<адрес>`; одно вложение, а не два. - Адрес файла и его миниатюры в одной странице — одно вложение. - `static/…` (значки интерфейса) не вложение: ссылка удаляется из текста. - **Ссылка на страницу** — ссылка на хост Eva с кодом `DOC-<цифры>` в адресе (`project/Document/DOC-…`). - По каждой ссылке агент шлёт: - `ref` — значение атрибута ровно как в HTML, по нему хаб находит место замены; - `url` — абсолютный адрес (только у вложения); - `name` — `alt` или `title` картинки, текст ссылки, иначе имя файла из пути; - `code` — у ссылки на страницу. 4. Отправляет страницы пачками `POST /agent/wiki/pages {command_id, pages: [{code, eva_id, title, parent_code, orderno, url, html, attachments: [{ref, url, name}], links: [{ref, code}]}]}`. В пачке не больше 50 страниц и не больше 4 МБ тела. 5. Отчитывается `done` с `result = {root_title, codes: [<все коды дерева в порядке обхода>], eva_calls}`. 6. В `wiki_fetch` скачивает только адреса на хосте `eva.base_url`, иначе `failed` «чужой хост». Так токен Eva не уходит на чужой адрес. **Хаб.** - **Приём пачки.** HTML каждой страницы сразу переводится в Markdown, ссылки заменяются по `ref`. Вместо `id` вложения ставится заглушка, её заполняет применение. Результат кладётся в `wiki_sync_stage`, копия не меняется. Перевод идёт вне транзакции записи (markdownify на десятках МБ занимает секунды) и не держит блокировку базы, в которой ждут `claim` jira-agent и веб. - **Отчёт `done`.** Сначала, вне транзакции, Markdown-копия собирается из стадии в `wiki/.