54 KiB
Модуль wiki, виды агентов и поля проектов — план реализации
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: проект хаба связывается с корнем Eva Wiki; хаб держит Markdown-копию дерева, отдаёт её через MCP jira-hub-wiki и страницу «Вики»; ядро различает агентов (jira, wiki) и больше не знает про доски Jira.
Architecture: ядро получает агентов по модулям (токен → модуль, agent_status/релиз/claim по агенту) и поля проекта от модулей (ProjectField, project_view, on_project_created/updated). Модуль jira_hub/modules/wiki/ — по образцу модуля jira: схема, настройки, хуки команд wiki_sync/wiki_fetch, приём пачек страниц, перевод HTML → Markdown, сборка копии с атомарной подменой каталога, FTS5-поиск, маршруты, MCP, фоновая синхронизация. Веб — модуль web/src/modules/wiki/. Миграция схемы 6 → 7 одним шагом.
Tech Stack: Python 3.13 (uv), stdlib http.server, SQLite (FTS5), mcp<2 (FastMCP), markdownify; React 19 + TS + Vite, TanStack Query, react-markdown + remark-gfm, Vitest, Playwright.
Spec: openspec/changes/wiki-module/ — proposal.md, design.md (решения 1–16, «Decisions made without discussion» 1–17), specs/{modules,data-model,hub-api,wiki,mcp,web-app}/spec.md. Спека — источник истины; план — её аргумент.
Global Constraints
- jira-agent v14 работает без обновления: прежний агентский токен,
POST /agent/claim(тело сlast_jira_call_at), ответclaim,GET /agent/settings(те же ключи и значения, кроме удалённогоboard_id),/agent/state,/agent/history,/agent/known-keys,/agent/files*,/agent/release. tests/fixtures/baseline/mcp_tools.expected.json(серверjira-hub) НЕ меняется ни в одной задаче. Эталоныroutes,settings,schemaпереписываются (BASELINE_WRITE=1) только в задачах, где это сказано, и в отчёте задачи перечисляется каждая изменившаяся строка эталона.- Схема —
SCHEMA_VERSION = 7, один шагMIGRATIONS[6]; копия базы перед миграцией — прежний механизмdb.migrate. - Адреса веба, ключи
localStorage, адреса и ответы API модуля jira — прежние;GET /agentбез параметра — агентjira. - Новые имена в ядре — без «jira» и без «wiki»; ядро не импортирует модули, кроме
core/registry.pyи шаговMIGRATIONS(tests/test_registry.py). - Токен Eva в хабе не появляется нигде (ни в настройках, ни в логах, ни в тестах).
- Тестовые HTML-страницы Eva — синтетические, с форматами адресов из design.md (Context); текст ТЗ из awp-ca не копируется.
- Живой хаб не трогать: ни launchd, ни
~/.local/share/jira-hub; тесты — во временномJIRA_HUB_HOME/tmp_path. Любой запуск CLI в тестах и вручную — сJIRA_HUB_HOMEво временном каталоге. - Новые зависимости: Python —
markdownify(последняя стабильная, нижняя граница вpyproject.toml); веб —react-markdown,remark-gfm(точные версии, как остальные вpackage.json). - Коммиты — по-русски, последней строкой
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. Каждая задача заканчивается зелёнымuv run pytest -q(иmake check, если задача трогаетweb/). - Каждое решение исполнителя, которого нет в спеке и плане, — в отчёт задачи (контроллер переносит в design.md «Decisions made without discussion»).
Review Focus
- Хаб v6 с живыми данными (токен,
agent_statusid=1 v14, доски AWPCA 4216 scrum, публикацияrelease/agent.pyv14) после обновления: jira-agent с прежним токеном получает команды и релиз v14 — тестtest_v6_upgrade_keeps_jira_agent(Task 1, дополняется в Task 2 и Task 3). - Синхронизация вики идёт часами (агент не установлен) — jira-команды выдаются, автообновление jira не блокируется:
test_wiki_queue_does_not_block_jira(Task 2),test_auto_refresh_ignores_wiki_batches(Task 5). - HTML Eva с повторяющимися ссылками, миниатюрами файлов, значками
static/, относительнымиproject/Document/DOC-…, таблицами — копия без сырого HTML, одна ссылка на файл: тестыtests/test_wiki_convert.py(Task 6). - Сбой посреди подмены каталога (хаб упал после переименования старой копии) — при старте копия доводится, поиск и страницы сходятся:
test_finish_pending_swap(Task 7). - Два проекта с одним корнем или пересекающимися деревьями; название страницы с
/,.., эмодзи, 200 символов, совпадающие названия соседей — пути внутриwiki/<метка>/, без выхода наружу:test_layout_names_safe(Task 7).
Целевая раскладка
src/jira_hub/core/
modules.py + ProjectField; Module: project_fields, project_view, on_project_created(conn, project, fields, author),
on_project_updated, agent_min_version_key, mcp_project_columns
registry.py + agent_modules(); check(): поля проекта
settings.py tokens → {client, agents}; agent_token(), agent_keys(module), for_agent(conn, module); min_agent_version → jira
http.py + AGENT, agent_of(q)
db.py SCHEMA_VERSION 7, MIGRATIONS[6], agent_status по агенту
queue.py claim(conn, agent, …), agent_version(conn, agent), agent_view(conn, agent), has_active(conn, module=None)
release.py + module_dir(), migrate_layout()
projects.py только label/name/created_at
api.py Hub.agent_update по агентам, Hub.release_root, /agent?module, /agents, POST/PATCH /projects из полей модулей, do_PATCH
mcp.py register_core_tools(mcp, *, projects_doc, columns, project_create), create_project(), mcp_json(label, module)
cli.py tokens по агентам, agent publish --module
src/jira_hub/modules/jira/
schema.py + jira_project; projects.py: доски из jira_project, migrate_v6 (перенос)
project_fields.py поле board_id, project_view (board_id, board_type, issues_count)
src/jira_hub/modules/wiki/
__init__.py schema.py settings.py paths.py projects.py commands.py convert.py stage.py sync.py
search.py attachments.py api.py mcp.py tick.py
web/src/modules/wiki/ index.tsx, api/queries.ts, WikiPage.tsx, Tree.tsx, PageView.tsx, Search.tsx, RootForm.tsx, SyncStatus.tsx
web/src/projects/NewProjectDialog.tsx поля из MODULES[].projectFields
tests/fixtures/schema_v6.json копия baseline/schema.expected.json до Task 1
tests/v6db.py make_v6_db(path)
Task 1: Миграция v7 — токены и статус по агентам
Files:
- Create:
tests/fixtures/schema_v6.json(копияtests/fixtures/baseline/schema.expected.jsonна коммите до правок),tests/v6db.py,tests/test_agents.py - Modify:
src/jira_hub/core/{db.py,settings.py,registry.py,modules.py,http.py,api.py,cli.py,queue.py},src/jira_hub/modules/jira/{settings.py,api.py,__init__.py},tests/fixtures/baseline/{schema,settings,routes}.expected.json(переписать), затронутые тесты (test_settings.py,test_api.py,test_cli.py,test_db.py— обращения кtokens["agent"],agent_status id=1)
Interfaces:
-
Produces:
Module.agent_min_version_key: str | None = None(у jira —"min_agent_version").registry.agent_modules() -> list[Module]— модули сcommands, в порядке реестра.settings.tokens(conn) -> {"client": str, "agents": {module: str}};settings.agent_token(conn, module) -> str;settings.agent_keys(module: str) -> list[str](ключи модуля, затемCORE_AGENT_KEYS = ["settings_version"]);settings.for_agent(conn, module) -> dict.http.AGENT = "_agent";http.agent_of(q) -> str— имя модуля агента запроса (диспетчер кладёт вquery[AGENT]для scopeagent, перезаписывая значение из адреса).- Таблица
agent_status(agent TEXT PRIMARY KEY, last_seen_at, agent_version, settings_version, current_command_id, last_call_at); строки дляregistry.agent_modules()вставляетdb.migrate. tests/v6db.py: make_v6_db(path, *, agent_token="T-agent", client_token="T-client") -> None— база v6 изschema_v6.json:schema_version = 6,tokens = {agent, client},agent_statusid=1agent_version = "14", проектыAWPCA(4216,scrum) иsfm_kedr(NULL,NULL), умолчания настроек v6.
-
Step 1: Скопировать эталон схемы v6 в
tests/fixtures/schema_v6.json, написатьtests/v6db.py. -
Step 2: Фикстуры второго модуля в
tests/conftest.py.stub_module—Module(name="stub", commands=CommandHooks(types={"stub_cmd"}, read_types={"stub_cmd"}, …заглушки…), agent_keys=("stub_key",), settings_defaults={"stub_key": 1}, routes=(("POST", r"^/agent/stub$", h, "agent"),)), подключается подменойregistry._cache(monkeypatch). На её основе —conn_two_modulesиhub_server_two_modules. Ими пользуются Task 1–3. -
Step 2a: Падающие тесты в
tests/test_agents.py:
def test_v6_upgrade_keeps_jira_agent(tmp_path):
make_v6_db(tmp_path / "hub.sqlite")
conn = db.connect(tmp_path / "hub.sqlite")
assert db._schema_version(conn) == 7
tok = settings.tokens(conn)
assert tok["client"] == "T-client" and tok["agents"]["jira"] == "T-agent" and "agent" not in tok
row = conn.execute("SELECT * FROM agent_status WHERE agent = 'jira'").fetchone()
assert row["agent_version"] == "14"
assert list((tmp_path / "backups").glob("*schema-v6*")) # копия перед миграцией
def test_agent_token_routes(hub_server): # hub_server — фикстура теста API (порт 0), как в test_api.py
# токен jira: /agent/claim 200, /agent/state 200; клиентский на /agent/claim — 401
def test_module_agent_route_requires_own_module(hub_server_two_modules):
# заглушка второго модуля с commands (types={"stub_cmd"}) и маршрутом ("POST", r"^/agent/stub$", h, "agent"):
# его токеном POST /agent/state — 401; токеном jira POST /agent/stub — 401; своим — 200
def test_agent_settings_per_module(hub_server_two_modules):
# токен jira: ключи jira + settings_version, без ключей заглушки и без board_id; токен заглушки — её ключи + settings_version
def test_tokens_cli_lists_agents(tmp_path, monkeypatch, capsys):
# JIRA_HUB_HOME=tmp_path; `jira-hub tokens` → строки «клиентский токен: …», «агент jira: …»
-
Step 3: Запустить — FAIL (
uv run pytest tests/test_agents.py -q). -
Step 4: Реализовать.
db.py:SCHEMA_VERSION = 7; вCORE_SCHEMAноваяagent_status;MIGRATIONS[6] = [_tokens_v7, _agent_status_v7]:_tokens_v7:tokens {agent, client}→{client, agents: {jira: agent}};_agent_status_v7: пересоздание таблицы (CREATEagent_status_new, INSERT … SELECT'jira',last_jira_call_at→last_call_at, DROP, RENAME).- Шаги ядра не импортируют модули: имя
jiraв шаге миграции — данные истории (как_migrate_projects), допустимо по границе (исключениеcore/db.pyвtest_core_has_no_jira_termsуже есть). - В конце
migrateвместоINSERT … (id) VALUES (1)— по строке наregistry.agent_modules().
settings.py:ensure_defaultsсоздаётtokensсclientи токеном каждого агента и дописывает недостающие токены агентов при каждом вызове;min_agent_versionуходит изCORE_DEFAULTSиvalidateв модуль jira (DEFAULTS,validate_setting,AGENT_KEYS), ключ и значение прежние;board_idудалить изAGENT_KEYSjira.
api.py:_COMPILEDхранит модуль-владелец маршрута (Noneу ядра);_auth(scope, owner) -> str | None: дляagentищет модуль по токену (hmac.compare_digestс каждым), владелец неNoneи не совпал — 401; возвращает имя модуля;_dispatchкладёт его вquery[http.AGENT];h_agent_settings→settings.for_agent(conn, http.agent_of(q)).
queue.py:agent_status—WHERE agent = ?(пока везде'jira', Task 2 заменит на агента запроса).cli._print_tokens: «клиентский токен: …», затем «агент <модуль>: …» поagent_modules(), затем «база: …».
-
Step 5: Эталоны.
BASELINE_WRITE=1 uv run pytest tests/test_baseline.py. Допустимые изменения:schema— версия 7 иagent_status;settings—min_agent_versionпереехал в ключи модуля, нетboard_idвagent_keys.
mcp_toolsне меняется. Затемuv run pytest -q— PASS. -
Step 6: Commit «Миграция v7: токены и статус по агентам модулей (#91)».
Task 2: Очередь, релиз и статус по агенту
Files:
- Modify:
src/jira_hub/core/{queue.py,api.py,release.py,cli.py,paths.py},src/jira_hub/modules/jira/{fetch.py,refresh.py,mcp.py},tests/test_agents.py,tests/test_queue*.py,tests/test_release.py,tests/test_cli.py,tests/fixtures/baseline/routes.expected.json
Interfaces:
-
Consumes: Task 1 —
http.agent_of,registry.agent_modules,agent_statusпо агенту. -
Produces:
queue.claim(conn, agent: str, agent_version=None, settings_version=None, last_call_at=None, backup=None, files_dir=None)—_next/_redeliverберут только типыregistry.module(agent).commands.types;current_command_id,last_seen_at— строки агента.queue.agent_version(conn, agent: str) -> str | None;queue.agent_view(conn, agent: str) -> dict(полеmodule;warningпоModule.agent_min_version_key;redelivered,rate_limited_24h— только команды типов модуля).queue.has_active(conn, module: str | None = None) -> bool—Noneкак сейчас (любой модуль).release.module_dir(root, module) -> Path(root/<module>, 0700);release.migrate_layout(root) -> None— переносroot/agent.py,root/manifest.jsonвroot/jira/, еслиroot/jira/нет; ошибка —log.warning, без исключения.Hub.release_root: Path(бывшийHub.release_dir),Hub.agent_update: dict[str, dict],Hub.note_agent_update(agent, body).GET /agent?module=<имя>(по умолчаниюjira; неизвестный — 404not_found),GET /agents→{"agents": [view, …]}в порядкеagent_modules().- CLI
jira-hub agent publish [--module M] PATH(по умолчаниюjira; неизвестный или без команд — код 2, stderr «неизвестный модуль M; модули с агентами: jira, …»).
-
Step 1: Падающие тесты (
tests/test_agents.py, дописать):
def test_wiki_queue_does_not_block_jira(conn_two_modules):
# пакет 1 stub_cmd, пакет 2 ensure_issue; claim(conn, "stub") → stub_cmd; claim(conn, "jira") → ensure_issue,
# хотя stub_cmd claimed; agent_status: current_command_id своих строк
def test_redeliver_only_own(conn_two_modules): # claimed stub_cmd не довыдаётся агенту jira
def test_agent_version_per_agent(conn_two_modules) # claim(conn, "jira", agent_version=14) не трогает строку stub
def test_release_per_module(tmp_path) # publish в module_dir(root,"stub") v1 при jira v14: claim агентов — свои версии
def test_release_layout_migration(tmp_path) # root/agent.py+manifest v14 → после Hub(...) — root/jira/, claim jira v14
def test_publish_unknown_module(tmp_path, capsys) # код 2, stderr «неизвестный модуль nope»
def test_agents_endpoint(hub_server_two_modules) # GET /agents — два элемента с module; GET /agent — jira; GET /agent?module=nope — 404
def test_v6_upgrade_keeps_jira_agent(...) # дописать: release/agent.py v14 в tmp → claim токеном T-agent отдаёт agent_release v14
-
Step 2: FAIL.
-
Step 3: Реализовать.
h_claimберёт агента изq, читаетbody.get("last_call_at", body.get("last_jira_call_at")), релиз —release.module_dir(hub.release_root, agent).h_agent_release— релиз своего агента.Hub.__init__вызываетrelease.migrate_layout(self.release_root).- Модуль jira:
fetch.py—agent_version(conn, "jira");refresh.maybe_auto_refresh—has_active(conn, "jira");mcp.py:batch_wait/agent_status—GET /agent(как было).
- Реле не меняется.
-
Step 4: Эталон
routes(BASELINE_WRITE=1). Допустимое изменение — новый маршрутGET ^/agents$.uv run pytest -q— PASS. -
Step 5: Commit «Очередь, релиз и статус по агенту модуля (#91)».
Task 3: Поля проекта от модулей; доски — в модуль jira
Files:
- Modify:
src/jira_hub/core/{modules.py,registry.py,projects.py,api.py,mcp.py,http.py},src/jira_hub/modules/jira/{schema.py,projects.py,hooks.py,settings.py,api.py,mcp.py,__init__.py} - Create:
src/jira_hub/modules/jira/project_fields.py,tests/test_project_fields.py - Modify tests:
tests/test_projects.py,tests/test_api.py,tests/test_registry.py(снять исключение строкдолг hub-modulesвtest_core_has_no_jira_terms),tests/conftest.py::add_project(доска — вjira_project), эталоныschema,routes
Interfaces:
-
Produces:
@dataclass(frozen=True) class ProjectField: name: str # ключ в теле POST/PATCH и в GET /projects validate: Callable[[Any], Any] # ValueError(текст) → 422 с полем; возвращает нормализованное значение editable: bool = False # Module: project_fields: tuple[ProjectField, ...] = () project_view: Callable[[Connection, list[str]], dict[str, dict]] | None = None # label → {поле: значение} on_project_created: Callable[[Connection, dict, dict, str], dict | None] | None = None # project, свои поля, author → пакет on_project_updated: Callable[[Connection, dict, dict, str], list[dict]] | None = None # project, изменённые свои поля, author → пакеты- Ядро:
projects.create(conn, label, name) -> dict,projects.validate(label, name),projects.get;projects.list_projects(conn) -> list[dict]— толькоlabel,name,created_at;projects.ProjectError(field, reason),ProjectExists— прежние.
registry.check(mods, core_routes, core_settings)дополнительно падает на поле проекта в двух модулях или с именемlabel|name|created_at|author(«поле проекта <имя>: <модуль> и <модуль>»).api.project_rows(conn, labels=None) -> list[dict]— поля ядра плюсproject_viewвсех модулей.POST /projects— неизвестное поле 422bad_project«<поле>: неизвестное поле».PATCH /projects/{label}→{"project": row, "batch_ids": [...]}; неизменяемое поле — 422bad_project«<поле> после создания не меняется».core/mcp.py:register_core_tools(mcp, *, projects_doc: str, columns: list[tuple[str, Callable[[dict], str]]], project_create: Callable) -> None;create_project(label, name, fields: dict, module: str) -> str— тело запроса и текст ответа прежние;mcp_json(label, module="jira")— у jira прежний фрагмент, у прочих — серверjira-hub-<модуль>с аргументамиmcp <модуль> --project <метка>;fmt_projects(items, current, columns).
- Модуль jira:
- таблица
jira_project(label TEXT PRIMARY KEY REFERENCES project(label), board_id INTEGER, board_type TEXT); projects.boards,projects.record_board_types,light_listчитают и пишутjira_project;- шаг миграции
projects.migrate_v6(conn)— ленивый импорт вMIGRATIONS[6]: копирует доски, пересоздаётprojectбез колонок досок; project_fields.FIELDS = (ProjectField("board_id", validate_board, editable=False),)— сообщение прежнее «ожидается целое > 0 или null»;project_fields.view(conn, labels)→board_id,board_type,issues_count;- в
modules/jira/mcp.pyсвояproject_create(label, name, board_id=None)с прежними docstring и сигнатурой, она вызываетcore.create_project;register_core_tools(mcp, projects_doc=<прежний docstring projects>, columns=[("доска", …), ("тип доски", … «неизвестен»), ("задач", …)], project_create=project_create).
- таблица
- Ядро:
-
Step 1: Падающие тесты (
tests/test_project_fields.py):
def test_v6_boards_moved(tmp_path): # make_v6_db → connect: jira_project AWPCA 4216 scrum, sfm_kedr NULL NULL; в project нет board_id
def test_projects_get_shape(api) # GET /projects: AWPCA {label,name,created_at,board_id:4216,board_type:"scrum",issues_count:…}
def test_post_unknown_field(api) # {label:"X",name:"X",board:5} → 422, detail содержит "board"; проекта X нет
def test_patch_board_forbidden(api) # PATCH /projects/AWPCA {board_id:1} → 422 «board_id после создания не меняется»
def test_patch_unknown_project(api) # PATCH /projects/NOPE {} → 404
def test_field_conflict() # registry.check с двумя модулями-заглушками с полем "root" → RuntimeError, текст содержит "root" и оба имени
def test_mcp_jira_tools_unchanged() # список инструментов jira-сервера == baseline mcp_tools (уже в test_baseline — убедиться, что зелёный)
def test_mcp_json_per_module() # mcp_json("AWPCA") — прежний; mcp_json("AWPCA","wiki") → ключ "jira-hub-wiki", args [..., "mcp","wiki","--project","AWPCA"]
-
Step 2: FAIL.
-
Step 3: Реализовать.
Handler.do_PATCH→_dispatch("PATCH")(спекаhub-api: 501 — только методы кроме GET, POST, PUT, PATCH). Шагprojects.migrate_v6дописывается в конецMIGRATIONS[6]. Снять все меткидолг hub-modulesвsrc/; веб — Task 4.test_core_has_no_jira_termsбез исключения для меток.grep -rn "долг hub-modules" src— пусто. -
Step 4: Эталоны
schema(projectбез досок,jira_project) иroutes(PATCH ^/projects/([^/]+)$).mcp_toolsне меняется.uv run pytest -q— PASS. -
Step 5: Commit «Поля проекта от модулей, доски — в модуль jira (#91)».
Task 4: Веб — поля проекта модулей
Files:
- Modify:
web/src/modules/types.ts,web/src/modules/jira/index.tsx,web/src/projects/NewProjectDialog.tsx,web/src/api/{types.ts,queries.ts}, тесты рядом (*.test.tsx),web/e2e/(если создание проекта там проверяется)
Interfaces:
-
Produces:
HubModule.projectFields?: ProjectFieldDef[], гдеtype ProjectFieldDef = { name: string; label: string; hint: string; kind: "int" | "text" }.- Окно собирает поля из
MODULES.flatMap(m => m.projectFields ?? []). Пустое значение в тело не попадает,int—Number(...). - Тип
Project—{label, name, created_at} & Record<string, unknown>. Поля jira (board_id,board_type,issues_count) типизирует модуль jira. useUpdateProject()—PATCH /projects/{label}; понадобится в Task 10.- У jira:
{name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int"}.
-
Step 1: Падающий тест
NewProjectDialog.test.tsx: доска пусто → тело{label, name}безboard_id; доска «5120» →board_id: 5120. Поле с подписью «Номер доски Jira» есть. -
Step 2: FAIL (
cd web && npx vitest run src/projects). -
Step 3: Реализовать. Метку
долг hub-modulesиз диалога снять. Вёрстка окна — как сейчас. -
Step 4:
make checkиcd web && npm run e2e— PASS. -
Step 5: Commit «Веб: поля проекта от модулей (#91)».
Task 5: Модуль wiki — каркас, корень проекта, команды
Files:
- Create:
src/jira_hub/modules/wiki/{__init__.py,schema.py,settings.py,paths.py,projects.py,commands.py,tick.py};tests/test_wiki_projects.py,tests/test_wiki_commands.py.
- Modify:
src/jira_hub/core/registry.py(mods = [jira.MODULE, wiki.MODULE]);pyproject.toml(markdownify),uv.lock;- эталоны
schema,settings,routes; tests/test_agents.py(токенwikiпоявляется при миграции v6).
Interfaces:
-
Consumes: Task 1–3 (агенты,
ProjectField,on_project_*). -
Produces:
schema.SQL:wiki_project(label PK REFERENCES project(label), root_code TEXT NOT NULL, root_title TEXT, synced_at TEXT, failed_at TEXT, pages_count INTEGER NOT NULL DEFAULT 0, sync_error TEXT);wiki_page(label, code, eva_id, title, parent_code, orderno, depth, path, url, size, synced_at, PRIMARY KEY(label, code));wiki_attachment(id INTEGER PK AUTOINCREMENT, label, page_code, url, name, state TEXT NOT NULL DEFAULT 'none', file, size, error, fetched_at, UNIQUE(label, page_code, url));wiki_sync_stage(command_id, code, payload TEXT NOT NULL, PRIMARY KEY(command_id, code)).
settings.DEFAULTS = {"wiki_pacing": 0.2, "wiki_auto_sync_hours": 24, "wiki_min_agent_version": 1},AGENT_KEYS = ["wiki_pacing"]. Проверки:wiki_pacing— число 0–10;wiki_auto_sync_hours— целое ≥ 0;wiki_min_agent_version— целое ≥ 0.
paths.root(conn) -> Path—Path(<файл базы из PRAGMA database_list>).parent / "wiki";paths.project_dir(conn, label) -> Path.projects.parse_root(value) -> str | None—None/""→None; строка сDOC-\d+(без учёта регистра, в том числе внутри ссылки) →DOC-<цифры>в верхнем регистре; иначеValueError("ожидается код DOC-… или ссылка на документ Eva").projects.FIELDS = (ProjectField("wiki_root", parse_root, editable=True),).projects.view(conn, labels)→wiki_root,wiki_root_title,wiki_pages,wiki_synced_at,wiki_sync_error(None, если корня нет).projects.on_created(conn, project, fields, author) -> dict | None— корень задан: строкаwiki_projectи пакетwiki_sync.projects.on_updated(conn, project, fields, author) -> list[dict]:- отменяет
queuedwiki_syncпроекта (queue.cancel_batch); - новый корень — обновляет
root_code, ставит пакет; None— удаляет строки проекта (wiki_project,wiki_page,wiki_attachment, FTS) и каталогpaths.project_dir.
- отменяет
commands.TYPES = {"wiki_sync", "wiki_fetch"}, оба вread_types; параметрыwiki_sync {label: str, root: "DOC-…"},wiki_fetch {attachment_id: int, url: str};commands.HOOKS: CommandHooks(пауза — 0;check_batch— проект пакета равенlabel).commands.enqueue_sync(conn, label, root, author) -> dict— пакет «Вики <метка>» сproject = label;commands.active_sync(conn, label) -> dict | None— пакетwiki_syncпроекта не в терминальном статусе.tick.auto_sync_tick(hub, conn):- по каждому
wiki_project:wiki_auto_sync_hours > 0, сmax(synced_at, failed_at)прошло больше часов, нетactive_sync→enqueue_sync(…, "hub"); - атрибут
label = "синхронизация вики".
- по каждому
MODULE = Module(name="wiki", agent_min_version_key="wiki_min_agent_version", …).
-
Step 1: Падающие тесты:
def test_parse_root():
assert parse_root("https://eva.example.ru/project/Document/doc-001234") == "DOC-001234"
assert parse_root(" DOC-7 ") == "DOC-7" and parse_root(None) is None and parse_root("") is None
with pytest.raises(ValueError): parse_root("страница про статусы")
def test_post_project_with_root(api) # POST {label:"S",name:"S",wiki_root:"DOC-001234"} → 201; batch_id — refresh jira; в очереди wiki_sync {label:"S",root:"DOC-001234"}
def test_patch_root_existing(api) # PATCH /projects/AWPCA {wiki_root:"DOC-000777"} → 200, batch_ids из одного wiki_sync; GET /projects wiki_root
def test_patch_root_bad(api) # {wiki_root:"страница"} → 422, detail про wiki_root; корень прежний
def test_patch_root_cancels_queued(api) # корень DOC-12 + queued wiki_sync; PATCH DOC-21 → прежний пакет cancelled, новый {root:"DOC-21"}
def test_reset_root_removes_copy(api, tmp_path) # каталог wiki/AWPCA и строки есть; PATCH {wiki_root:None} → каталога и строк нет
def test_auto_sync_tick(conn) # synced_at 25 ч назад → пакет; 23 ч → нет; active_sync → нет; wiki_auto_sync_hours 0 → нет
def test_auto_refresh_ignores_wiki_batches(conn) # queued wiki_sync не мешает maybe_auto_refresh jira
def test_wiki_agent_token_after_upgrade(tmp_path) # make_v6_db → tokens.agents имеет jira=T-agent и новый wiki
- Step 2: FAIL.
- Step 3: Реализовать.
on_result,check_resultи прочие хуки результата пока пустые: Task 7 их заполнит. - Step 4: Эталоны
schema,settings,routes(новых маршрутов пока нет —routesне должен измениться).uv run pytest -q— PASS. - Step 5: Commit «Модуль wiki: корень проекта, команды, фоновая синхронизация (#91)».
Task 6: Перевод HTML Eva в Markdown
Files:
- Create:
src/jira_hub/modules/wiki/convert.py,tests/test_wiki_convert.py,tests/fixtures/wiki/*.html(синтетические страницы)
Interfaces:
-
Produces:
convert.to_markdown(html: str, attachments: list[dict], links: list[dict]) -> tuple[str, list[dict]]:- вход —
attachments [{ref, url, name}],links [{ref, code}], как в спеке; - выход — Markdown и список вложений страницы
[{url, name}]без повторов поurl, в порядке первого появления.
- вход —
- Правила замены:
refвложения →[📎 <name>](attachment:@<n>), гдеn— индекс в возвращённом списке;- повторная ссылка на тот же
url(миниатюра и файл) удаляется; refссылки →[<текст ссылки или код>](page:<code>);img/aс адресом, начинающимся наstatic/или/static/, удаляется;- прочие ссылки остаются как есть;
- сырого HTML (тегов) в результате нет;
- заголовки ATX.
convert.fill_ids(markdown, ids: list[int]) -> str—attachment:@<n>→attachment:<ids[n]>.
-
Step 1: Фикстуры и падающие тесты. Фикстуры — 3 синтетические страницы с реальными форматами адресов:
- картинки
/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/image2026-7-27_14-20-6.png?version=1; - файл: миниатюра
…/model.xlsx.meta/thumbnail.jpg?version=1, значокstatic/attach-file.pngи ссылка…/model.xlsx; <a href="project/Document/DOC-019834">Модель данных</a>;- таблица;
- вложенные списки;
- внешняя ссылка
https://example.org.
- картинки
def test_image_becomes_attachment() # одна строка "[📎 image2026-7-27_14-20-6.png](attachment:@0)"
def test_thumbnail_and_file_one_link() # список вложений — 1 (model.xlsx); в тексте одна ссылка; "attach-file" нет
def test_page_link() # "[Модель данных](page:DOC-019834)"
def test_external_link_kept() # "(https://example.org)" есть
def test_no_raw_html() # re.search(r"<[a-zA-Z/]", md) is None
def test_table_kept() # строка с "|" и разделитель "---"
def test_fill_ids() # fill_ids("[x](attachment:@1)", [7, 9]) == "[x](attachment:9)"
- Step 2: FAIL.
- Step 3: Реализовать на
markdownify.MarkdownConverter(подкласс сconvert_img/convert_a).refсравнивается со значением атрибута ровно, без нормализации.- Неизвестные теги разворачиваются в текст.
- Step 4: PASS.
- Step 5: Commit «wiki: перевод HTML Eva в Markdown (#91)».
Task 7: Синхронизация — приём пачек, сборка копии, поиск
Files:
- Create:
src/jira_hub/modules/wiki/{stage.py,sync.py,search.py},tests/test_wiki_sync.py - Modify:
src/jira_hub/modules/wiki/{commands.py,__init__.py,api.py (создать: маршрут агента)}, эталонroutes
Interfaces:
-
Consumes: Task 5 (
paths,commands, таблицы), Task 6 (to_markdown,fill_ids). -
Produces:
- Маршрут
POST /agent/wiki/pages(scopeagent, владелецwiki) →stage.accept(conn, command_id, pages) -> {"accepted": n}.- Перевод
to_markdown— до транзакции; в стадию кладётся JSON{code, eva_id, title, parent_code, orderno, url, markdown, attachments}. - Ошибки: 409
not_claimed(команда неclaimedили неwiki_sync); 422bad_pages«: <поле>» (обязательные строки:code,title,html;orderno— целое илиnull;parent_code— строка илиnull).
- Перевод
sync.build_copy(stage_pages: list[dict], codes: list[str], target: Path, ids: dict[tuple[str, str], int]) -> list[dict]:- строит дерево по
parent_code, корень —codes[0]; - соседи сортируются по
(orderno is None, orderno, code); - каталоги
NN_<safe_name>, у корня без номера; safe_name— как вeva_wiki_export.py:[<>:"/\\|?*\x00-\x1f]→_, обрезка 80 символов,.и пробелы по краям убираются, пустое →untitled;- совпадения у соседей —
_2,_3;..исключён; - каждый путь проверяется: он внутри
target; - пишет
index.md(frontmattercode,title,url,synced_at;# title; текст) и_tree.json; - права каталогов 0700;
- возвращает строки
wiki_page.
- строит дерево по
sync.apply(conn, cmd, result) -> None— вызывается изCommandHooks.on_resultприdone:- Проверки:
params.root≠ текущийroot_codeили корня нет →result.discarded = "корень сменился", ничего не меняется;- код из
codesбез стадии → статусfailed«нет страницы <код>». Эту проверку делаетcheck_result.
- Выделение
idвложений: существующие по(label, page_code, url)сохраняются, новые вставляются. Это делается в той же транзакции, что и запись; сборка копии идёт вwiki/.<label>.new/до коммита. Из-за этогоapplyразбит наprepare, вне транзакции, иcommit, вon_result. Точное разбиение — решение исполнителя, с условием:markdownifyи запись файлов не идут подBEGIN IMMEDIATE. - Подмена каталога:
wiki/<label>→wiki/.<label>.old,.new→<label>, удаление.old. wiki_pageи FTS пишутся заново; пропавшие вложения удаляются.- Запись
synced_at,pages_count,root_title;sync_errorиfailed_atобнуляются; стадия команды очищается.
- Проверки:
sync.on_failed_or_reported_failed(conn, cmd, error)—sync_error,failed_at, очистка стадии.- Повторная выдача (
on_claimсredelivery=True) очищает стадию команды. sync.finish_pending_swaps(root: Path) -> None— вызывается изtickмодуля при первом тике:- есть
.<label>.newи нет<label>→ переименовать; - есть
.<label>.old→ удалить.
- есть
search.ensure(conn) -> bool:CREATE VIRTUAL TABLE IF NOT EXISTS wiki_page_fts USING fts5(label UNINDEXED, code UNINDEXED, title, body, tokenize='unicode61 remove_diacritics 2');OperationalError→Falseи одноlog.warning.search.replace(conn, label, pages: list[(code, title, markdown)]).search.query(conn, label, q, limit) -> list[{code, title, path, snippet}]:- слова запроса превращаются в
"<слово>"*через AND, кавычки в слове экранируются; snippet(...)поbody;- без FTS —
LIKEпо файламindex.mdпроекта.
- слова запроса превращаются в
- Маршрут
-
Step 1: Падающие тесты (
tests/test_wiki_sync.py; агента имитирует тест через HTTP на порту 0):
def test_first_sync(api) # DOC-1 корень, DOC-2, DOC-3 дети; пачка + done → 3 index.md, GET /projects wiki_pages 3, root_title
def test_failed_keeps_copy(api) # вторая синхронизация failed "Eva: HTTP 503" → копия и поиск прежние, wiki_sync_error
def test_missing_page_fails(api) # codes [DOC-1, DOC-2], пачка только DOC-1 → failed «нет страницы DOC-2», копия прежняя
def test_page_removed(api) # было DOC-1..3, стало DOC-1, DOC-3 → каталога DOC-2 нет, поиск не находит, вложения удалены
def test_attachment_id_survives(api) # вложение 7 ready; та же страница и url → id 7, state ready
def test_stale_root_discarded(api) # wiki_sync {root DOC-12} выдан, корень → DOC-21, done → копия прежняя, result.discarded
def test_redelivery_clears_stage(api) # пачка, повторная выдача, done без новых пачек → failed «нет страницы …»
def test_pages_wrong_agent(api) # токеном jira POST /agent/wiki/pages → 401
def test_pages_not_claimed(api) # команда queued → 409 not_claimed
def test_bad_page(api) # страница без title → 422 bad_pages "DOC-2: title"
def test_layout_names_safe(tmp_path) # названия "../x", "a/b", "😀"*200, два "Статусы" → все пути внутри target; "Статусы", "Статусы_2"
def test_finish_pending_swap(tmp_path) # есть .AWPCA.new и нет AWPCA → после finish — AWPCA; .AWPCA.old удалён
def test_search_prefix(conn) # страница с «статус «На согласовании»» находится по «согласов»; кавычка в запросе не роняет
- Step 2: FAIL.
- Step 3: Реализовать.
- Step 4: Эталон
routes: допустимое изменение — POST ^/agent/wiki/pages$. uv run pytest -q — PASS.
- Step 5: Commit «wiki: синхронизация — пачки страниц, копия, поиск (#91)».
Task 8: Маршруты вики и вложения по запросу
Files:
- Create:
src/jira_hub/modules/wiki/attachments.py, tests/test_wiki_api.py
- Modify:
src/jira_hub/modules/wiki/{api.py,commands.py,__init__.py} (file_refs), эталон routes
Interfaces:
-
Consumes: Task 5, Task 7.
-
Produces (клиентский токен; проект — project=<метка>, неизвестный → 404, без корня → 409 no_root):
GET /wiki/status → {root, root_title, synced_at, pages, error, batch: {id, status} | null}.
GET /wiki/tree → {"pages": [{code, title, depth, parent_code, size}]}.
GET /wiki/pages/{code} → {code, title, path, url, synced_at, markdown, attachments: [{id, name, state, size}]}; страницы нет → 404.
GET /wiki/search с q и limit (1–50, по умолчанию 10; пустой q → 422 bad_query) → {"results": [...]}.
POST /wiki/sync {project, author?} → 201 {batch_id}; идёт синхронизация → 409 sync_in_progress с batch_id в detail.
- Вложения:
POST /wiki/attachments/{id}/fetch → состояние {id, name, page_code, url, state, size, error}, при новой постановке 202;
GET /wiki/attachments/{id};
GET /wiki/attachments/{id}/content → Raw с именем файла; не ready — 409 not_ready.
attachments.request_fetch(conn, attachment_id, author) -> dict — одна транзакция:
ready → как есть;
fetching → как есть;
- иначе
state = fetching и пакет wiki_fetch.
- Хуки результата
wiki_fetch:
check_result → файла result.file нет в files/ или размер не совпал → failed;
on_result → ready, file, size, fetched_at либо failed с error;
on_failed → failed.
file_refs(conn) → file вложений в ready.
-
Step 1: Падающие тесты:
def test_status_tree_page(api) # после синхронизации из Task 7: tree — 3 узла в порядке обхода; page DOC-3 — markdown и attachments
def test_no_root(api) # sfm_kedr: /wiki/tree → 409 no_root
def test_sync_in_progress(api) # второй POST /wiki/sync → 409 sync_in_progress, batch_id прежний
def test_fetch_flow(api) # fetch → 202 fetching; агент POST /agent/files + done {file,size} → ready; content → байты
def test_fetch_too_big(api) # агент failed «файл больше 10 МБ — откройте в Eva» → state failed, content → 409 not_ready
def test_fetch_size_mismatch(api) # done с size ≠ фактическому → failed
def test_file_refs_keep_ready(api) # files.sweep не удаляет файл ready-вложения
def test_search_empty_query(api) # q="" → 422
- Step 2: FAIL. Step 3: Реализовать.
- Step 4: Эталон
routes — только новые маршруты /wiki/*. PASS.
- Step 5: Commit «wiki: маршруты и вложения по запросу (#91)».
Task 9: MCP вики
Files:
- Create:
src/jira_hub/modules/wiki/mcp.py, tests/test_wiki_mcp.py, tests/fixtures/baseline/mcp_wiki_tools.expected.json (эталон через BASELINE_WRITE=1, тест в tests/test_baseline.py::test_mcp_wiki_tools)
- Modify:
src/jira_hub/modules/wiki/__init__.py (mcp_server)
Interfaces:
-
Consumes: core/mcp (guard, scoped, register_core_tools, create_project, client, PROJECT), маршруты Task 8.
-
Produces:
FastMCP("jira-hub-wiki").
- Инструменты по таблице из спеки
mcp, «MCP вики»: tree, page, search, attachment, sync, status, root_set, затем общие projects, project_create.
- Тексты отказов дословно:
- «у проекта <метка> нет корня вики: root_set DOC-…»;
- «вики проекта ещё не синхронизирована: sync».
page:
- порция 20 000 символов;
- последняя строка «продолжение: offset=», если текст не кончился;
- поиск по
title: точное совпадение без учёта регистра, иначе до 10 кандидатов «<код> <название>», поиск через GET /wiki/tree.
attachment:
- ждёт до 60 с, опрос раз в 2 с;
- готово — копия
downloads/wiki-<id>/<имя> (каталог 0700, как у jira) и путь;
- иначе — состояние и подсказка повторить.
sync(wait=False): при wait ждёт пакет до 300 с.
status дописывает агента wiki из GET /agent?module=wiki в формате fmt_agent модуля jira. fmt_agent переносится в core/mcp.py как общий, тексты не меняются: эталон mcp_tools jira не меняется.
-
Step 1: Падающие тесты (клиент хаба подменяется, как в tests/test_mcp.py):
def test_unbound_tools_refuse() # без --project: tree → NOT_BOUND
def test_no_root_message() # проект без корня: tree → «у проекта sfm_kedr нет корня вики: root_set DOC-…»; запроса /wiki/tree нет
def test_page_chunks() # markdown 45 000 → первые 20 000 + «продолжение: offset=20000»; offset 40000 → без строки продолжения
def test_page_by_title_candidates() # title «Стат» → кандидаты с кодами
def test_jira_server_unchanged() # baseline mcp_tools (jira) совпадает
def test_mcp_wiki_tools() # эталон mcp_wiki_tools.expected.json
def test_run_wiki_module() # core.mcp.run("wiki", "AWPCA") поднимает сервер jira-hub-wiki (без stdio: проверить registry.module("wiki").mcp_server().name)
- Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.
- Step 5: Commit «wiki: MCP-сервер jira-hub-wiki (#91)».
Task 10: Веб — страница «Вики»
Files:
- Create:
web/src/modules/wiki/{index.tsx,api/queries.ts,WikiPage.tsx,Tree.tsx,PageView.tsx,Search.tsx,RootForm.tsx,SyncStatus.tsx,wiki.css} и тесты рядом, web/e2e/wiki.spec.ts
- Modify:
web/src/modules/index.ts (MODULES = [jiraModule, wikiModule]), web/package.json (+react-markdown, remark-gfm), web/scripts/test-hub.sh/web/scripts/seed-hub.py (засеять копию вики для e2e: корень и 3 страницы через POST /agent/wiki/pages токеном агента wiki)
Interfaces:
-
Consumes: маршруты Task 8, useUpdateProject (Task 4), HubModule.
-
Produces:
wikiModule: HubModule:
- страница
/app/wiki, пункт панели «Вики»;
projectFields: [{name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text"}];
SidebarStatus — ход синхронизации текущего проекта (опрос GET /wiki/status раз в 3 с, пока batch не терминальный).
- Поведение — по требованию спеки
web-app, «Вики проекта»:
- страница выбирается параметром
page;
- ссылки
page: → ?project=…&page=<код>;
- ссылки
attachment: → загрузка как у вложений карточки задачи: POST …/fetch, опрос GET /wiki/attachments/{id}, затем api.blob на /content;
- Markdown —
react-markdown + remark-gfm, без rehype-raw.
- Дизайн — скилл
aura-design, общие компоненты web/src/ui/, цвета только токенами.
-
Step 1: Падающие тесты (Vitest):
test("ссылка page: открывает страницу") // клик по [Модель данных](page:DOC-2) → адрес ?project=AWPCA&page=DOC-2, справа DOC-2, узел выделен
test("без корня — форма привязки") // status 409 no_root → форма; ввод DOC-001234 → PATCH /projects/sfm_kedr {wiki_root}
test("поиск заменяет дерево") // ввод «согласов» → результаты GET /wiki/search; очистка → дерево
test("сырой HTML не исполняется") // markdown с "<script>" показан текстом, script в DOM нет
test("ошибка синхронизации видна") // status.error → плашка с текстом
test("поле корня в окне нового проекта") // NewProjectDialog: поле «Корень вики»; ввод → wiki_root в теле
e2e (web/e2e/wiki.spec.ts): открыть /app/wiki?project=AWPCA, увидеть дерево из 3 страниц, открыть страницу, найти по слову.
- Step 2: FAIL. Step 3: Реализовать. Step 4:
make check, cd web && npm run e2e — PASS.
- Step 5: Commit «Веб: страница «Вики» (#91)».
Task 11: Граница, документация, эталоны — финал
Files:
-
Modify: ARCHITECTURE.md, CLAUDE.md (репозитория хаба), README.md, tests/test_registry.py (если нужно), openspec/changes/wiki-module/design.md (только раздел «Decisions made without discussion» — дописывает контроллер)
-
Step 1: Проверки:
grep -rn "долг hub-modules" src web/src — пусто;
test_core_has_no_jira_terms — без исключений для меток долга;
- новый тест
test_core_has_no_module_terms: в src/jira_hub/core/ нет wiki_, wiki/, DOC-, eva, кроме core/registry.py.
-
Step 2: Документация:
ARCHITECTURE.md:
- агенты модулей;
- поля проекта;
- модуль
wiki — таблица файлов, как у jira, и потоки «Синхронизация вики», «Вложение вики»;
- раздел «Границы» без долга;
- открытые вопросы: запись в вики, история, переименование.
CLAUDE.md:
- данные
wiki/;
- команды
jira-hub mcp wiki, agent publish --module, tokens.
README.md — подключение jira-hub-wiki в .mcp.json и установка eva-agent («отдельный репозиторий aep/eva-agent»).
-
Step 3: make check и cd web && npm run e2e — PASS; openspec validate wiki-module --strict — valid.
-
Step 4: Commit «Документация: модуль wiki и агенты модулей (#91)».