Andrey Epifancev 4faf7884ad docs(openspec): wiki-module — решения Task 6 (#91)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:12:31 +04:00

559 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## 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 по адресу `<eva>/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`.
Картинки и файлы — ссылки `<img src>`/`<a href>` на тот же хост с путём `/(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<uuid>/<имя в 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/<label>/`), `url` (адрес страницы в Eva от агента), `size` (символов Markdown), `synced_at`.
Страница в двух проектах — две строки. Деревья проектов независимы.
- **`wiki_page_fts`** — FTS5 `(title, body)` с `content` по Markdown-тексту и токенизатором
`unicode61 remove_diacritics 2`. Используется только для поиска. Сам текст страниц живёт в файлах.
- **`wiki_attachment`**: `id` INTEGER PK, `label`, `page_code`, `url` (абсолютный адрес в Eva), `name`, `state`
(`none` | `fetching` | `ready` | `failed`), `file` (sha256 в `files/`), `size`, `error`, `fetched_at`. Одна строка
на пару «адрес + страница». Адрес, который встречается на нескольких страницах, даёт несколько строк, но файл в
`files/` хранится один (sha256).
- **`wiki_sync_stage`** — временные пачки страниц текущей синхронизации, ключ `(command_id, code)`. Очищается при
завершении команды в любом статусе.
`file_refs` модуля возвращает `file` всех `wiki_attachment` в состоянии `ready`, поэтому уборка `files/` их не
трогает, пока строка жива.
### 10. Синхронизация `wiki_sync`
**Постановка.** Команда `wiki_sync {label, root}` — тип чтения (`read_types`), пакет с `project = <label>`. Ставят её:
- MCP `sync` и кнопка «Обновить» в вебе (`POST /wiki/sync {project}`);
- привязка или смена корня (`on_project_created`/`on_project_updated`);
- фоновая работа модуля (`Module.tick`): по каждому проекту с корнем, если с `synced_at` (или с последней
неудачи) прошло больше `wiki_auto_sync_hours` (настройка модуля, по умолчанию 24, `0` — выключено) и у проекта нет
пакета `wiki_sync` в работе.
`POST /wiki/sync` и фоновая работа не ставят пакет, пока предыдущий `wiki_sync` проекта в работе: ответ 409 с id
пакета, MCP показывает его состояние.
**Смена и сброс корня** не упираются в это правило. `PATCH` отменяет ещё не выданные (`queued`) пакеты `wiki_sync`
проекта. При новом корне он ставит новый пакет; уже выданный агенту пакет доживает. Отчёт `done` по команде, у которой
`params.root` не равен текущему корню проекта (сменён или сброшен), отбрасывается: копия и данные не меняются, команда
`done` с пометкой в `result.discarded = "корень сменился"`. Типичный случай: корень проставили до установки eva-agent,
потом исправили опечатку.
**Агент** (контракт; решения внутри агента — его change):
1. Берёт корень `CmfDocument.get` и все страницы поддерева, как `collect_subtree` в `eva_wiki_export.py`.
2. По каждой странице берёт `text`.
3. Разбирает ссылки страницы. Это делает агент, а не хаб: только он знает `eva.base_url`.
Относительные адреса относятся к хосту Eva (`urljoin` с `eva.base_url`).
- **Вложение** — `<img src>`/`<a href>` на хост 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/.<label>.new/` с
предварительно выделенными `id` вложений. Затем одной короткой транзакцией (`CommandHooks.on_result`):
- каталог атомарно подменяется: переименование, старая копия удаляется после подмены;
- `wiki_page` и FTS пишутся заново;
- для `wiki_attachment` при той же паре «адрес + страница» сохраняются `id` и скачанный файл, новые пары
добавляются, пропавшие удаляются;
- заполняются `wiki_project.synced_at`, `pages_count` и `root_title`, `sync_error` обнуляется.
- Код из `codes`, для которого не пришла пачка, — команда `failed` «нет страницы <код>», копия прежняя.
- Отчёт `failed` пишет `sync_error`, копия прежняя.
- Повторная выдача чистит `wiki_sync_stage` команды.
**Раскладка копии** (по образцу `eva_wiki_export.py`):
- каталог страницы — `NN_<безопасное название>/` внутри каталога родителя; `NN` — позиция среди соседей, у корня
номера нет;
- в каталоге `index.md` с frontmatter `code`, `title`, `url`, `synced_at`, затем `# <title>` и текст;
- в корне `_tree.json` — плоский список `{code, title, depth, path}`;
- каталоги 0700, как остальные данные хаба.
Безопасное название — как `safe_name` скрипта; при совпадении у соседей хаб добавляет `_2`, `_3`.
**HTML → Markdown** делает хаб: зависимость `markdownify`, ATX-заголовки. Ссылки (классифицирует агент, см. выше):
- **картинка или файл Eva** заменяется на `[📎 <имя>](attachment:<id>)`:
по `id` инструмент `attachment` и веб скачивают файл;
- **ссылка на страницу Eva с кодом `DOC-<цифры>`** заменяется на `[<текст>](page:<код>)`: по коду её открывает
инструмент `page`, а в вебе она ведёт на страницу вики, если та есть в дереве проекта;
- прочие ссылки остаются как есть.
**Объём.** Перекачка всего дерева при каждой синхронизации — осознанно (история и частичная перекачка — позже). Темп —
настройка модуля для агента `wiki_pacing` (пауза между вызовами Eva, по умолчанию 0.2 с).
### 11. Вложения по запросу `wiki_fetch`
`POST /wiki/attachments/{id}/fetch` (веб и MCP) ставит пакет из одной команды `wiki_fetch {attachment_id, url}`. Это
тип чтения, по образцу `modules/jira/fetch.request_fetch`: одна транзакция, `state = fetching`. Пока скачивание идёт,
повторный запрос отвечает текущим состоянием, а не новым пакетом.
**Агент:**
- скачивает адрес с токеном Eva;
- больше `MAX_SIZE` ядра (10 МБ, по `Content-Length` или по факту чтения) — `failed` «файл больше 10 МБ — откройте в
Eva»;
- иначе `POST /agent/files` и отчёт `done {file, size}`.
**Хаб** проверяет файл в `files/` и ставит `state = ready`. Если файла нет или размер чужой — `failed`.
**Выход:**
- `GET /wiki/attachments/{id}` — состояние;
- `GET /wiki/attachments/{id}/content` — сам файл;
- MCP `attachment` кладёт копию в `downloads/<id>/<имя>` (общая папка ядра, уборка 30 дней). Имена каталогов jira и
wiki не пересекаются: каталог wiki — `downloads/wiki-<id>/`.
### 12. MCP вики
**Запуск.** `jira-hub mcp wiki [--project <метка>]`, имя сервера `jira-hub-wiki` (переименование в sdlc-hub — позже,
вместе со всем). Регистрация в awp-ca — новая запись `jira-hub-wiki` в `.mcp.json` с `--project AWPCA`; её делает
владелец. Хаб выключен — та же подсказка, что у jira.
**Инструменты** (короткие имена: в Claude они видны как `mcp__jira-hub-wiki__<имя>`):
| Инструмент | Что делает |
|---|---|
| `projects` | проекты: метка, название, корень, страниц, синхронизация |
| `project_create` | `label`, `name`, `wiki_root` (необязательно) |
| `root_set` | `root` — корень текущего проекта (`PATCH /projects/{label}`) |
| `tree` | `code` (необязательно, по умолчанию корень), `depth` (по умолчанию 2) — дерево с кодами и размерами |
| `page` | `code` или `title` (точное совпадение, иначе до 10 кандидатов), `offset` — Markdown страницы с шапкой (код, путь в дереве, адрес Eva, дата синхронизации), длинный текст — частями по 20 000 символов |
| `search` | `query`, `limit` (по умолчанию 10) — страницы с фрагментами, FTS5; слова запроса ищутся по префиксу |
| `attachment` | `id` — скачать вложение (как `attachment` у jira: ждёт до 60 с, затем путь к копии) |
| `sync` | синхронизировать сейчас, `wait` (по умолчанию нет) |
| `status` | корень, последняя синхронизация, ошибка, пакет в работе, агент `wiki` (онлайн, версия, публикация, обновление — как `agent_status` jira) |
Инструменты, кроме `projects` и `project_create`, требуют привязки к проекту, как у jira. Без корня они отвечают
«у проекта <метка> нет корня вики: root_set <DOC-…>». Корень есть, а синхронизаций ещё не было — «вики проекта ещё
не синхронизирована: sync».
### 13. Веб
Модуль `web/src/modules/wiki/`:
- **страница «Вики»** `/app/wiki?project=<метка>`, пункт боковой панели;
- **дерево** слева: свёрнутое, раскрывается по клику;
- **страница** справа: Markdown, без сырого HTML;
- **поиск** над деревом;
- **кнопка «Обновить»** и плашка хода синхронизации в боковой панели — как у jira;
- **ссылки:** `page:` ведёт на страницу вики, `attachment:` скачивает файл (запрос, опрос, `api.blob`, как вложения в
карточке задачи);
- **без корня** — форма «Корень вики (DOC-…)» (`PATCH /projects`);
- **окно «Новый проект»** получает поле «Корень вики (необязательно)» через `projectFields`.
Маршруты HTTP модуля (клиентский токен):
- `GET /wiki/tree?project=` — дерево с кодами;
- `GET /wiki/pages/{code}?project=` — `{code, title, path, url, synced_at, markdown, attachments}`;
- `GET /wiki/search?project=&q=&limit=`;
- `POST /wiki/sync`;
- `GET /wiki/status?project=`;
- вложения из решения 11.
Отрисовка Markdown — `react-markdown` с `remark-gfm` (таблицы), без `rehype-raw`.
### 14. eva-agent
Репозиторий `aep/eva-agent` (создан владельцем пустым 24.10.2026), каталог `jira-bridge/eva-agent/`. Обвязка
подключается `adopt-project.sh`. Решения агента — его design-only change. Хаб фиксирует только контракт:
- **Стек:** Python 3.9 на stdlib, одним файлом `src/agent.py` со строкой `AGENT_VERSION = <целое>`. Запускатель
`src/run.py` — копия запускателя jira-agent. Имена `agent.py`, `update.json` и протокол самообновления прежние, иначе
`run.py` не подходит.
- **Конфиг `agent.json`:** `hub_url` (`folder:<путь>` или http), `hub_token` — токен агента `wiki` из `jira-hub tokens`,
`eva: {base_url, token}`, `verify_ssl`, `ca_bundle`, `timeout`, `poll_seconds`, `auto_update`. Токен Eva — только
здесь.
- **Протокол с хабом** — как у jira-agent: `claim`, `GET /agent/settings`, `GET /agent/commands/{id}`, результат,
`GET /agent/release`, `POST /agent/files`; плюс `POST /agent/wiki/pages`.
- **Команды:** `wiki_sync`, `wiki_fetch` (решения 10, 11). Обе только читают Eva: журнал на диске нужен для повторного
отчёта, но «проверь, потом сделай» не нужно.
- **Из jira-agent копируются, без общей библиотеки:** HTTP-слой, `FolderTransport`, `HubClient` (без jira-маршрутов),
`Journal`, `Runner`, `Updater`, `main`. Решение владельца 23.10.2026: однофайловый агент на виртуалке.
- **Запуск** на той же виртуалке, отдельным каталогом и процессом со своей блокировкой. Первую установку делает
владелец руками, дальше агент обновляется публикацией `jira-hub agent publish --module wiki`.
### 15. Миграция v7 одним шагом
Один шаг `MIGRATIONS` 6 → 7 с копией базы перед ним (механизм прежний):
1. `tokens.agent` → `tokens.agents.jira`, токен `agents.wiki` создаётся.
2. `agent_status` пересоздаётся по агентам (решение 3).
3. `project` теряет `board_id`/`board_type`, появляется `jira_project` с перенесёнными значениями.
4. Создаются таблицы wiki (решение 9).
Перенос `release/` в `release/jira/` — не миграция базы, а шаг старта хаба (решение 5).
### 16. Порядок работ
1. Ядро: виды агентов (решения 1–7) — jira-agent v14 работает. Живой проверки посередине нет: launchd запускает хаб
из основного checkout, а частичная v7 на живой базе сломала бы последующие шаги миграции. Совместимость с v14
проверяется тестом: снимок схемы v6 с досками и токеном → миграция → `claim`, `GET /agent/settings`,
`/agent/state` с прежним токеном, формой и ответами v14. Живая проверка — после merge.
2. Ядро: поля проектов (решение 8) и снятие долга.
3. Модуль wiki в хабе: данные, синхронизация с имитацией агента в тестах, вложения, HTTP, MCP.
4. Веб.
5. eva-agent в своём репозитории: отдельный PR и отдельный план, после merge хаба.
Шаги 1–4 — этот change и один PR в jira-hub. Шаг 5 — PR в eva-agent и его design-only change.
## Risks / Trade-offs
- **Миграция v7 трогает живые данные:** токены, статус агента, доски. Защищают копия базы перед миграцией и тест
миграции на снимке v6 (`tests/fixtures/` из настоящей схемы v6 с досками AWPCA = 4216 scrum). Если jira-agent после
выкатки получает 401, это ошибка переноса токена; откат — копия базы.
- **jira-agent v14 видит другой `/agent/settings`** (решение 6). Это проверяется тестом на равенство ответа
эталону v6, кроме `board_id`.
- **FTS5 в Python от uv.** Если модуль `fts5` в SQLite не собран, поиск сломается. Тест на старте модуля проверяет
`CREATE VIRTUAL TABLE … USING fts5`; если не вышло — поиск идёт через `LIKE` по файлам и в логе одно
предупреждение.
- **Русская морфология.** FTS5 ищет по префиксу слова, «статусы» не найдёт по «статус…» в другой форме корня.
Принимаем; MCP подсказывает искать по корню слова.
- **Большое дерево.** Сотни страниц по несколько сотен КБ HTML — пачки по 50 страниц и 4 МБ. Стадия хранится в базе;
копия базы перед командой не делается (чтение).
- **Атомарность копии.** Подмена каталога переименованием. Если хаб упал между удалением старой и переименованием
новой, при старте модуль доводит подмену по наличию `wiki/.<label>.new/`.
- **Агент `wiki` есть в хабе до установки eva-agent.** `status` и `GET /agents` показывают его офлайн с подсказкой
«eva-agent не выходил на связь». Пакеты `wiki_sync` стоят в очереди и не мешают jira (решение 4). Фоновая
синхронизация не ставит новый пакет, пока предыдущий не выдан.
## Migration Plan
- Хаб: merge → `make web` → kickstart (владелец) → `/mcp` reconnect. jira-agent не обновляется.
- После kickstart:
- проверить `agent_status` jira (v14 онлайн);
- проставить корни проектам (`root_set` или веб);
- добавить `jira-hub-wiki` в `.mcp.json` awp-ca.
- eva-agent: отдельный PR и ручная установка на виртуалке владельцем; дальше публикация `--module wiki`.
- Откат хаба: предыдущий коммит и копия базы v6 из `backups/`.
## Decisions made without discussion
Принятые агентом в brainstorming 24.10.2026; владелец видит их при ревью документа.
1. **Вид агента = имя модуля** (`wiki`, а не `eva`). Обоснование: см. решение 1. Цена ошибки — переименование ключа в
`tokens.agents` и `agent_status`.
2. **Агенты работают параллельно, «одна в полёте» — на агента** (решение 4). Обоснование: синхронизация вики не должна
держать Jira. Цена ошибки — правка одного условия в `claim`.
3. **Реле общее, без подпапок** (решение 7). Обоснование: реле агентов не различает и не должно. Цена ошибки — подпапки
и вторая настройка, если папка станет узким местом.
4. **Корень вики меняется после создания, доска — нет** (решение 8). Обоснование: у живых проектов корня нет. Цена
ошибки — запрет в описании поля.
5. **Перекачка всего дерева каждый раз** (решение 10). Обоснование: дата изменения в Eva неизвестна, история отложена.
Цена ошибки — нагрузка на Eva при большом дереве; лечится `wiki_pacing` и частичной перекачкой позже.
6. **Текст страниц в файлах, в базе — индекс FTS** (решение 9). Обоснование: копия — главный артефакт, база — для
поиска. Цена ошибки — двойной источник; синхронизация пишет оба в одной транзакции и подмене.
7. **Ссылки `attachment:<id>` и `page:<код>` в Markdown** (решение 10). Обоснование: по ним работают инструменты и веб.
Цена ошибки — формат ссылок в файлах; меняется со следующей синхронизацией.
8. **Предел вложения 10 МБ — общий `MAX_SIZE` ядра** (решение 11). Обоснование: реле передаёт файл base64 в JSON.
Цена ошибки — большие файлы Eva только по ссылке в Eva.
9. **Имя MCP-сервера `jira-hub-wiki`, инструменты без префикса** (решение 12). Цена ошибки — правка `.mcp.json` в
awp-ca.
10. **Отрисовка Markdown — `react-markdown` + `remark-gfm`** (решение 13). Цена ошибки — замена библиотеки на странице
вики.
11. **Ежедневная синхронизация — фоновой работой хаба** (решение 10). Обоснование: прецедент — автообновление
зеркала jira. Цена ошибки — отключение настройкой `wiki_auto_sync_hours = 0`.
12. **Одна живая проверка — после merge, без промежуточной** (решение 16; builder check). Обоснование: частичная v7 на
живой базе ломает последующие шаги, а launchd смотрит в основной checkout. Цена ошибки — несовместимость с v14
всплывёт после выкатки; страхуют тест миграции на снимке v6 и откат на копию базы.
13. **HTML → Markdown при приёме пачки, вне транзакции** (решение 10; builder check). Обоснование: перевод десятков МБ
под `BEGIN IMMEDIATE` блокирует `claim` jira-agent и веб. Цена ошибки — стадия в базе крупнее (Markdown вместо
HTML — обычно меньше).
14. **Ссылки классифицирует агент, хаб заменяет по `ref`** (решение 10; builder check). Обоснование: адрес Eva знает
только агент, хабу он не нужен. Цена ошибки — контракт `pages` меняется в обоих репозиториях.
15. **`wiki_fetch` только на хост `eva.base_url`** (решение 10; builder check). Обоснование: токен Eva не уходит на
чужой адрес. Цена ошибки — вложение на другом хосте открывается только ссылкой.
16. **Смена корня отменяет невыданные `wiki_sync`, поздний `done` старого корня отбрасывается** (решение 10; builder
check). Обоснование: корень правят, пока eva-agent ещё не установлен; копия старого дерева не должна встать под
новый корень. Цена ошибки — лишняя синхронизация.
17. **Тестовые HTML-страницы — синтетические, по реальным форматам адресов из `awp-ca/docs/source/tz/`** (Context). Сам
текст ТЗ в фикстуры не копируется. Обоснование: сырого HTML Eva нет, а содержимое ТЗ банка не место в репозитории
хаба. Цена ошибки — разметку вокруг ссылок (особенно у прикреплённых файлов) уточнять по первой живой
синхронизации; агент и хаб правятся вместе.
Решения контроллера при исполнении плана (2026-10-24, SDD).
18. **R-1.** 2026-10-24. Строки `agent_status` для `registry.agent_modules()` вставляются `INSERT OR IGNORE` при каждом
`db.migrate`, а не только в `MIGRATIONS[6]`. Обоснование: wiki-агент появляется после схемы 7 (Task 5), иначе у него
нет строки статуса. Цена ошибки: падение `claim` wiki; правка одного места.
19. **R-2.** 2026-10-24. Ответ `POST /projects` — по спеке `hub-api`: `{project, batch_id}`, `batch_id` — пакет первого
модуля, вернувшего пакет; `batch_ids` в ответе POST не добавляем (исполнитель Task 3 добавил, ревью отклонило как
лишнее). Цена ошибки: клиент не видит второй пакет без `GET /batches`.
20. **R-3.** 2026-10-24. `test_core_has_no_module_terms` ищет `eva` по границе слова (`\beva\b`, `eva-agent`), а `wiki_`,
`wiki/`, `DOC-` — подстрокой. Обоснование: подстрока `eva` даёт ложные срабатывания (`evaluate`, `level`). Цена
ошибки: правка регулярки.
21. **R-4.** 2026-10-24. В Task 5 `on_project_updated(None)` чистит `wiki_page_fts` только если таблица существует
(создаёт её Task 7). Цена ошибки: FTS-строки сброшенного корня живут до Task 7.
Решения исполнителя Task 1 (2026-10-24):
22. Фикстура `tests/fixtures/settings_v6.json` — замороженные умолчания настроек v6 для `make_v6_db`, чтобы v6-база не
зависела от текущего кода. Цена ошибки: лишний файл фикстуры.
23. `queue.claim/agent_version/agent_view` получили необязательный `agent` (умолчание — первый агент реестра, в ядре нет
слова jira); Task 2 передаёт агента явно. Цена ошибки: до Task 2 поведение равно jira.
24. Служебный ключ `_agent` перезаписывается диспетчером для маршрутов scope `agent`, поэтому адрес запроса его не
подделает. Цена ошибки: нет.
25. `current_command_id` освобождается `WHERE current_command_id = ?` без имени агента (id команды уникален). Цена
ошибки: нет.
26. `h_claim` принимает `last_call_at` как алиас `last_jira_call_at` (решение 3). Цена ошибки: нет.
27. `_auth` для агента сравнивает токен со всеми агентами без раннего выхода (постоянное время). Цена ошибки: нет.
28. `_tokens_v7`: нет `tokens` или уже есть `agents` — шаг пропускается (идемпотентность). Цена ошибки: нет.
Решения исполнителя Task 2 (2026-10-24):
29. `module_dir` создаёт каталог модуля (0700) только если его нет; права существующих каталогов выставляет `Hub` при
старте (и `agent publish` для корня), а не `claim`. Цена ошибки: чужие права на каталогах агентов перезаписываются
при старте.
30. `Hub(release_dir=)` переименован в `release_root=`. Цена ошибки: нет (вызовов с kwarg не было).
31. `GET /agent?module=` отвечает 404 `not_found` и для модуля без команд. Цена ошибки: клиент не отличает «нет
модуля» от «нет агента».
32. `Hub.agent_update` строится по `registry.agent_modules()`; для прочих имён — пустой словарь (`_no_update`).
33. `queue.claim`: параметр `last_jira_call_at` переименован в `last_call_at`; тело HTTP принимает оба ключа.
34. `migrate_layout` переносит файлы покадрово (agent.py, затем manifest.json), повторяется, пока нет
`jira/manifest.json`; существующий приёмник не перезаписывается, корневой файл удаляется. Цена ошибки: если старый
код успел записать в корень более новый agent.py после частичной миграции, он потеряется (риск низкий).
35. `registry.DEFAULT_AGENT = "jira"` — единственное место с умолчанием агента в ядре; `release.py` импортирует его
оттуда. Цена ошибки: нет.
Решения исполнителя Task 3 (2026-10-24):
36. Миграция v7 убирает колонки досок `ALTER TABLE project DROP COLUMN`, а не пересозданием таблицы: при
`foreign_keys=ON` `DROP TABLE project` ломается из-за дочерней `jira_project`. Нужен SQLite не ниже 3.35 (в
окружении 3.51). Цена ошибки: на старом SQLite миграция падает; копия базы сделана до неё.
37. `jira_projects.create(conn, label, name, board_id)` — точка создания проекта с доской для init, миграции v3 и тестов;
доска проверяется раньше метки и названия. Цена ошибки: другое сообщение при одновременно плохих метке и доске.
38. `POST /projects`: проверка ядра (`label`, `name`), затем полей модулей (неизвестное — 422 `<поле>: неизвестное
поле`), затем 409 на дубль; модулям без своих полей в `on_project_created` передаётся пустой dict.
39. `PATCH /projects/{label}`: проект проверяется (404) раньше полей; пустое тело — 200 с `batch_ids: []`; `author`
необязателен (по умолчанию `api`).
40. `HubClient.patch` добавлен в ядро (нужен тестам и `root_set`). Метка в адресе PATCH раскодируется `unquote`.
41. `register_core_tools` создаёт инструмент `projects` внутри; функций `projects`/`project_create` в `core.mcp` больше
нет, тесты берут их из `mcp._tool_manager` и `jmcp.project_create`.
42. Проверка «изменяемое поле модуля без `on_project_updated`» выполняется при старте в `registry.check`, а не 422 при
запросе (исполнитель Task 3, fix). Цена ошибки: модуль с таким полем не запустится; правится в модуле.
43. Ключ `author` в `POST`/`PATCH /projects` — служебный и полем проекта не считается. Цена ошибки: модуль не может
назвать своё поле `author` (запрещено `registry.check`).
Решения исполнителя Task 4 (2026-10-24):
44. Правка `web/e2e/hub.ts` и `feed.spec.ts` (`tokens().agents.jira`) сделана в Task 4: после Task 1 формат токенов
изменился, e2e без правки не зелёные. Цена ошибки: нет (только тесты).
45. `Project` — `{label, name, created_at} & Record<string, unknown>`; типы jira (`JiraProject`) в
`modules/jira/projectFields.ts`. `useUpdateProject({label, fields})` сам добавляет `author: "viewer"`; сигнатура
может уточниться в Task 10. Цена ошибки: мелкая правка.
46. Поля окна — единый `TextInput` (number/text), вёрстка не менялась; `min=1` только для int.
Решения исполнителя Task 5 и рулинг R-5 (2026-10-24):
47. **R-5.** Имя каталога копии проекта — `paths.dir_name(label)`: `%`, `/`, `\` экранируются (`%25`, `%2F`, `%5C`), `.` и
`..` — `%2E`, `%2E%2E`. Обычные метки не меняются. Обоснование: метки ядра допускают `/` и `..`, без экранирования
сборка и `rmtree` выходили бы за каталог вики. Раскладка копии (Task 7) идёт только через `paths.project_dir`. Цена
ошибки: экзотические метки получают экранированное имя каталога.
48. `PATCH wiki_root` с тем же кодом — no-op (`batch_ids: []`): повторная синхронизация идёт через `POST /wiki/sync`.
Цена ошибки: одна строка в `on_updated`.
49. `on_updated` отменяет только пакеты `wiki_sync`, все команды которых `queued`; выданный (`claimed`) не трогает — его
поздний `done` по старому корню отбросит Task 7. Цена ошибки: лишняя синхронизация.
50. Автосинхронизация проекта, у которого нет ни `synced_at`, ни `failed_at` и нет активного пакета, ставится сразу, не
ожидая часов. Цена ошибки: лишний пакет; зависит от записи `failed_at`/`synced_at` в Task 7. Прочие решения
исполнителя (3, 5–9, 11–12 отчёта): `paths.root` на базе без файла бросает `RuntimeError`, `_drop_copy` его ловит;
очистка FTS по rowid страницы; строгая валидация настроек и команд (bool не целое); `wiki_project.failed_at` пока не
пишется. Цена ошибки: правка одной строки в соответствующем месте.
Решения исполнителя Task 6 (2026-10-24; HTML Eva недоверенный):
51. Вложения дедуплицируются по `url`: миниатюра и файл — одно вложение, если агент шлёт одинаковый `url`. Цена ошибки:
при разных `url` в eva-agent появятся две ссылки; проверяется по первой живой синхронизации.
52. Картинка внутри `<a>` на вложение выдаёт только ссылку `<a>`. Цена ошибки: при разных `ref` показана одна ссылка.
53. Небезопасные ссылки (`javascript:`, `data:`, `vbscript:` после удаления управляющих символов и пробелов) дают только
текст, у картинок пусто; ссылки и картинки автора со схемой `attachment:` — текст/пусто. Цена ошибки: видимый
текст без ссылки.
54. Ссылку вложения порождает только конвертер: приватный маркер U+E000/U+E001, авторские U+E000/U+E001 из HTML
удаляются; любое авторское `attachment:@` нейтрализуется вставкой U+200C между `:` и `@`; `fill_ids` заменяет
только `](attachment:@N)`, индекс вне списка — `ValueError` (вызывающий Task 7 превращает в `failed`). Цена
ошибки: невидимый символ в сыром `.md` при авторском «attachment:@»; лишние PUA-символы удаляются.
55. Текст `<` вне кода выводится `&lt;` (сырых тегов нет); внутри `<code>`/`<pre>` не меняется. Имя вложения
экранирует `\ [ ] <` и `_`/`*` на границе слова. `@` не экранируется. Цена ошибки: сырой `.md` с `&lt;`.
56. Известные края (отложены): вложение в `<a>` страницы/внешней ссылки, вложенный `<span>` между `<a>` и `<img>`,
хвостовой `\` в тексте ссылки, пробелы/скобки во внешнем href, дубликаты `ref` (побеждает последний).