Переименование jira-hub → sdlc-hub (#97) #99

Merged
aep merged 21 commits from feat/97-sdlc-rename into main 2026-10-24 14:35:47 +03:00
9 changed files with 908 additions and 0 deletions
Showing only changes of commit bfc1b3056b - Show all commits

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View File

@ -0,0 +1,103 @@
## Context
`main` bb54d0a: схема v7, модули `jira` и `wiki`, агенты jira v14 и wiki v1. Имя `jira-hub` живёт в:
- **коде:** пакет `src/jira_hub/` (≈30 файлов импортируют `jira_hub`), `pyproject.toml` (дистрибутив и скрипт
`jira-hub`), `core/paths.py` (`JIRA_HUB_HOME`, `~/.local/share/jira-hub`), `core/cli.py` (`prog`, модуль MCP по
умолчанию `jira`), `core/mcp.py` (`mcp_json`: серверы `jira-hub`/`jira-hub-<модуль>`), `FastMCP("jira-hub")` и
`FastMCP("jira-hub-wiki")`, подсказки «jira-hub serve», `api.py` (текст «make web в каталоге jira-hub», строка запуска);
- **тестах:** `JIRA_HUB_HOME` в фикстурах, эталон `tests/fixtures/baseline/mcp_wiki_tools.expected.json`, проверки имён
серверов и фрагментов `.mcp.json`, пути `src/jira_hub` в `test_layout`, `test_registry`, `test_web_app`,
`test_wiki_projects`;
- **вебе:** `<title>Хаб Jira</title>`, бренд в `shell/Sidebar.tsx`, ключи `jira-hub-token|sidebar|legend`,
`JIRA_HUB_URL` в `vite.config.ts`, `web/scripts/test-hub.sh`, `package.json` (`jira-hub-web`), подсказка входа;
- **документации:** README (launchd, MCP, выкатка), CLAUDE.md, ARCHITECTURE.md, Makefile, `openspec/specs/` (18
требований в 7 спеках);
- **проектах-потребителях** (не git): `awp-ca` — `.mcp.json`, `enabledMcpjsonServers` в `.claude/settings.local.json`,
`CLAUDE.md`, скилл `building-customer-error-report` (`mcp__jira-hub__*`); `stocks_drrk` — `.mcp.json`,
`enabledMcpjsonServers`;
- **живой установке:** launchd `ru.aepif.jira-hub` (`uv run --project …/jira-bridge/jira-hub jira-hub serve`, лог
`~/Library/Logs/jira-hub.log`), данные `~/.local/share/jira-hub/`, `.mcp.json` в `~/projects/awp-ca` и
`~/projects/stocks_drrk` (серверы `jira-hub`, `jira-hub-wiki`), user scope MCP `jira-hub`, репозиторий Gitea
`aep/jira-hub`, checkout `~/projects/jira-bridge/jira-hub` с worktree в `.wt/`;
- **соседях:** jira-agent и eva-agent упоминают `jira-hub` только в документации, `agent.example.json` и e2e-скрипте
eva-agent (`JIRA_HUB_HOME`, `jira-hub` CLI). Протокол агента имени хаба не содержит: агенты ходят через общую папку.
## Goals / Non-Goals
**Goals:** одно имя `sdlc-hub` в коде, CLI, данных, MCP, вебе и документации; живой хаб переключается одним скриптом
владельца с сухим прогоном; новый код не может молча работать с пустым каталогом данных рядом с живым.
**Non-Goals и Anti-Goals:** см. proposal.md.
## Decisions
1. **Имя — `sdlc-hub`** (решение владельца 23.10.2026). Бренд в вебе — «SDLC Hub».
2. **Псевдонима `jira-hub` нет.** Старые записи MCP и launchd после переключения должны падать явно, а не работать
«почти». Переключение делает скрипт сразу для всех известных потребителей.
3. **MCP-серверы `sdlc-<модуль>`**, модуль в `sdlc-hub mcp` обязателен. Модуль по умолчанию был нужен только ради
неизменных записей `.mcp.json` в hub-modules; при переименовании записи меняются всё равно. Имена инструментов в
Claude станут `mcp__sdlc-jira__*` и `mcp__sdlc-wiki__*`.
4. **Каталог данных не переносится кодом.** Хаб, MCP-серверы открытых сессий Claude и CLI открывают одну базу; перенос
«на лету» при старте любого из них рискует WAL и гонкой. Код только отказывает с подсказкой (требование «Каталог
данных прежнего имени»), перенос — шаг скрипта при остановленном хабе.
5. **Скрипт переключения в репозитории** (`scripts/cutover.sh`), сухой прогон по умолчанию. Шагов много, они касаются
launchd, двух чужих репозиториев и user scope Claude; руками их легко сделать не в том порядке (I-2 wiki-module).
Скрипт одноразовый, после переключения остаётся в истории как образец; тесты — на сухой прогон и проверки шага 1
(во временном `HOME`), без launchd.
6. **Переименование репозитория в Gitea — руками владельца** в интерфейсе (Settings → Repository name). Gitea оставляет
перенаправление со старого адреса; `tea` и `origin` после этого работают по новому адресу. Скрипт проверяет, что
переименование сделано, и меняет `origin`.
7. **Checkout переезжает в `jira-bridge/sdlc-hub`**, workspace `jira-bridge` остаётся: от его пути зависят каталог
памяти Claude, workspace `CLAUDE.md`, путь общей папки реле (`…/utm/rshb/jira-bridge`) и настройки агентов на
виртуалке. Переименование workspace — отдельно, если понадобится.
8. **Ключи `localStorage` — с разовым переносом** из `jira-hub-*`: браузер владельца не должен снова просить токен и
терять свёрнутость панели и легенды. Ключи доски `graph-*` имени хаба не содержат и не меняются.
9. **История не переписывается**: `docs/superpowers/plans/`, `openspec/changes/archive/` остаются со старыми именами.
Раздел `Purpose` спек `hub-api`, `mcp`, `web-app` (дельтами не меняется) правится в PR архива.
10. **Агенты без релиза.** `src/agent.py` и `src/run.py` jira-agent и eva-agent не трогаются (строки документации — со
следующим релизом); README, CLAUDE.md, ARCHITECTURE.md, `agent.example.json` и `scripts/e2e-hub.sh` eva-agent —
мелкими PR в своих репозиториях (bounded, без change).
11. **Порядок с #98 (веб под модули).** Переименование сливается первым; ветка #98 перебазируется на него. Бренд и
ключи хранилища меняет этот change, устройство боковой панели — #98.
## Переключение (для владельца)
1. Слить PR этого change; переименовать репозиторий в Gitea `aep/jira-hub` → `aep/sdlc-hub`.
2. Закрыть или не трогать сессии Claude с MCP хаба (после переключения — `/mcp` reconnect).
3. `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh` — сухой прогон, проверить вывод.
4. `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh --apply`.
5. Просмотреть правки в awp-ca и stocks_drrk (`.mcp.json`, `.claude/`, `CLAUDE.md` awp-ca; копии `*.pre-sdlc` рядом) и
удалить копии. Это не git-репозитории — коммитить нечего.
6. Проверить: `agent_status` через `sdlc-jira`, `status` через `sdlc-wiki`, веб `http://127.0.0.1:8790/app/` без
повторного входа.
Откат: остановить `ru.aepif.sdlc-hub`, перенести каталоги обратно, вернуть plist `ru.aepif.jira-hub` из README
предыдущей версии, `git checkout` прежнего коммита, вернуть записи MCP — порядок в README.
## Risks / Trade-offs
- **Сессии Claude с открытым MCP** держат старый путь к пакету: после переноса checkout их серверы падают до
reconnect. Принято: переключение — разовое событие владельца.
- **codegraph-индексы** в `.codegraph/` checkout и worktree содержат абсолютные пути: после переноса — `codegraph init`
заново (шаг в сводке скрипта).
- **Гонка с #98**: массовый перенос `src/jira_hub` → `src/sdlc_hub` конфликтует с любой веткой, трогающей Python;
#98 — только `web/`, поэтому конфликт ограничен брендом и ключами хранилища (решение 11).
## Decisions made without discussion
Владелец 24.10.2026 ответил «делай» на вопросы brainstorming — все решения ниже приняты агентом.
| # | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 24.10.2026 | Бренд «SDLC Hub» | имя из решения владельца, латиница как в имени репозитория | правка строки в вебе |
| 2 | 24.10.2026 | Без псевдонима `jira-hub` | явный отказ лучше тихой работы старых записей | вернуть точку входа в `pyproject.toml` |
| 3 | 24.10.2026 | MCP `sdlc-jira`/`sdlc-wiki`, модуль обязателен | симметрия модулей, ядро без «jira по умолчанию» | переименовать серверы ещё раз во всех `.mcp.json` |
| 4 | 24.10.2026 | Каталог данных переносит скрипт, код только отказывает | гонка открытых баз при переносе на лету | ручной `mv` при остановленном хабе |
| 5 | 24.10.2026 | Скрипт `scripts/cutover.sh` с сухим прогоном | много шагов в чужих конфигурациях, порядок важен | шаги руками по README |
| 6 | 24.10.2026 | Gitea переименовывает владелец | действие на сервере, вне репозитория | — |
| 7 | 24.10.2026 | Workspace `jira-bridge` не переименовывается | от пути зависят память Claude, реле, агенты | отдельный change позже |
| 8 | 24.10.2026 | Перенос ключей `localStorage` | не просить токен заново | повторный вход в веб |
| 9 | 24.10.2026 | История (планы, архив) не меняется | это запись прошлого | — |
| 10 | 24.10.2026 | Агенты без релиза, документация — мелкими PR | протокол не меняется | строки документации в `agent.py` со старым именем до следующего релиза |
| 11 | 24.10.2026 | Сначала переименование, потом #98 | #98 перебазируется на новые пути | перебазирование веток |

View File

@ -0,0 +1,42 @@
## Why
Хаб перестал быть только мостом Jira: с hub-modules (#87) он состоит из ядра и модулей, с wiki-module (#91) — второй
модуль (EVA Wiki) со своим агентом и своим MCP. Имя `jira-hub` теперь вводит в заблуждение: в вебе «Хаб Jira», MCP вики
называется `jira-hub-wiki`, данные вики лежат в `~/.local/share/jira-hub/`. Решение владельца 23.10.2026 — хаб
становится **sdlc-hub**; переименование было отложено до готовности модулей. 24.10.2026 владелец решил делать его сейчас,
вместе с пересборкой веба под модули (jira-hub#98), «раз мы причёсываем визуал».
## What Changes
- **Пакет и CLI.** Пакет `jira_hub` → `sdlc_hub`, дистрибутив `jira-hub` → `sdlc-hub`, команда `jira-hub` → `sdlc-hub`.
Псевдонима `jira-hub` нет.
- **MCP по модулю без модуля по умолчанию.** `sdlc-hub mcp <модуль> [--project <метка>]`; модуль обязателен. Серверы
называются `sdlc-<модуль>`: `sdlc-jira`, `sdlc-wiki`. Фрагмент `.mcp.json` из `project_create` — с новыми именами.
- **Каталог данных.** `~/.local/share/sdlc-hub/`, переменная `SDLC_HUB_HOME`. Если нового каталога нет, а прежний
`~/.local/share/jira-hub/` есть, любая команда хаба, открывающая базу, отказывает с текстом, как перенести каталог;
сама ничего не переносит.
- **Веб.** Бренд «SDLC Hub» (заголовок вкладки, боковая и верхняя панели), ключи `localStorage` `sdlc-hub-*` с разовым
переносом значений из `jira-hub-*`, подсказка входа `uv run sdlc-hub tokens`, `npm run dev` — `SDLC_HUB_URL`.
- **Переключение живого хаба.** Скрипт `scripts/cutover.sh` (сухой прогон по умолчанию, `--apply` — выполнить) для
владельца: останавливает launchd `ru.aepif.jira-hub`, переносит каталог данных, ставит `ru.aepif.sdlc-hub` с логом
`~/Library/Logs/sdlc-hub.log`, переписывает записи MCP в `.mcp.json` известных проектов и в user scope, переносит
checkout `jira-bridge/jira-hub` → `jira-bridge/sdlc-hub` и чинит worktree. Репозиторий в Gitea `aep/jira-hub` →
`aep/sdlc-hub` владелец переименовывает в интерфейсе Gitea, скрипт затем меняет `origin`.
- **Документация.** README, CLAUDE.md, ARCHITECTURE.md, текущие спеки (`openspec/specs/`) — на новые имена; workspace
`CLAUDE.md` и упоминания в jira-agent/eva-agent (README, CLAUDE.md, ARCHITECTURE.md, `agent.example.json`, e2e-скрипт
eva-agent) — отдельными мелкими PR в своих репозиториях.
## Non-Goals
- Пересборка веба под модули — jira-hub#98, отдельный change.
- Переименование workspace `jira-bridge`, общей папки реле (`relay_dir`), репозиториев агентов, launchd агентов на
виртуалке.
- Правка `src/agent.py` и `src/run.py` агентов: строки документации в них меняются со следующим релизом агента.
- Правка истории: `docs/superpowers/plans/`, `openspec/changes/archive/`.
- Изменения протокола агента, HTTP API (кроме текстов), схемы базы, токенов.
## Anti-Goals
- Живой хаб не должен оказаться в состоянии «код новый, данные/launchd старые» без явной ошибки: молчаливо создать пустой
`~/.local/share/sdlc-hub/` рядом с живыми данными — провал, даже если тесты зелёные.
- Агенты на виртуалке не требуют ни перенастройки, ни нового релиза.

View File

@ -0,0 +1,41 @@
## MODIFIED Requirements
### Requirement: Настройки
Таблица `setting` SHALL хранить парами ключ–JSON: `project_key`, `field_ids`, `issue_types`, `priority_map`, `link_types`,
`transitions_map`, `pacing`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой
правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`;
относительный путь и не-строка при записи — 422; агенту не передаётся), `closed_statuses` (список имён статусов Jira, считающихся закрытыми, по умолчанию
`["Готово", "Снято"]`; не-список при записи — 422; агенту не передаётся; на существующей базе появляется со значением
по умолчанию при открытии хаба). Базовая JQL вычисляется из проектов, доска — свойство проекта (требование
«Проекты»); на новой базе ключей `base_jql` и `board_id` нет. Оставшиеся от v3 ключи хаб SHALL NOT использовать, кроме
передачи `board_id` агенту как запасной доски для команд без доски в параметрах (поставленных до миграции);
`GET /settings` SHALL отдавать вычисленную `base_jql` только для чтения, а запись `base_jql` или `board_id` через
`PUT /settings` — 422 с пояснением. Спринты агент читает с досок проектов, отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json`
командой `sdlc-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. `issue_types` — объект
`{task, epic, bug}`, у каждого `{name, id}` (`id` может быть `null`); `init` берёт их из ключей конфига
`issue_type_<тип>` и `issue_type_<тип>_id`; `bug` попадает в `issue_types`, только если в конфиге есть хотя бы
один из ключей `issue_type_bug`, `issue_type_bug_id` (`task` и `epic` — как раньше, всегда).
#### Scenario: Правка настроек поднимает версию
- **WHEN** через API изменён `pacing`
- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию
#### Scenario: Конфиг без ключей ошибки
- **WHEN** `sdlc-hub init --config` с `issue_type_task`, `issue_type_epic`, без `issue_type_bug`
- **THEN** `issue_types` содержит `task` и `epic`, `bug` нет; постановка `ensure_issue` с `issue_type = bug` — 422
#### Scenario: Неверный relay_dir
- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"`
- **THEN** 422, значение не записано, `settings_version` не изменился
#### Scenario: Закрытые статусы по умолчанию
- **WHEN** хаб открыл базу, в которой нет ключа `closed_statuses`
- **THEN** `GET /settings` отдаёт `closed_statuses = ["Готово", "Снято"]`; `claim` агенту этого ключа не передаёт
#### Scenario: Запись базовой JQL
- **WHEN** `PUT /settings {base_jql: "project = AURORA"}`
- **THEN** 422 «базовая JQL собирается из проектов»; `settings_version` не изменился
#### Scenario: Создание проекта не трогает версию настроек
- **WHEN** создан проект `STOCKS_DRRK`
- **THEN** `settings_version` прежний; `GET /settings` отдаёт `base_jql` с обеими метками

View File

@ -0,0 +1,236 @@
## MODIFIED Requirements
### Requirement: Процесс и доступ
`sdlc-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:<port>` (по умолчанию 8790), база и настройки в
`~/.local/share/sdlc-hub/` (переопределяется `SDLC_HUB_HOME`). Все маршруты API SHALL требовать
`Authorization: Bearer <token>`: токен агента модуля открывает только `/agent/*` (маршруты ядра — от имени этого агента,
маршруты модуля со scope `agent` — только агенту своего модуля, см. `modules`, «Агенты модулей»), клиентский — всё
остальное. `sdlc-hub tokens` SHALL печатать клиентский токен и токены агентов с именами модулей. Исключение —
раздача веб-приложения (`GET /app`, `GET /app/…`) и перенаправления `GET /`, `GET /links`: это статические файлы без
данных, они отдаются без токена. Ответы API — JSON, ошибки — `{error, detail}` с кодами 400 (неверное тело или
`Content-Length`), 401, 404, 409, 413 (тело больше предела), 422, 500 (внутренняя ошибка); реле общей папки
дополнительно отвечает 502. Методы, кроме GET, POST, PUT и PATCH, отвечают 501 стандартной страницей `http.server`, не
JSON.
#### Scenario: Чужой токен
- **WHEN** токеном агента `jira` вызван `GET /issues`
- **THEN** 401
#### Scenario: Неподдерживаемый метод
- **WHEN** `DELETE /issues/AURORA-1`
- **THEN** 501, тело не JSON; база не меняется
#### Scenario: Страница без токена
- **WHEN** `GET /app/board` без заголовка `Authorization`, сборка приложения есть
- **THEN** 200, `Content-Type: text/html; charset=utf-8`, тело — `index.html` сборки; данных зеркала в нём нет
#### Scenario: Токены после миграции
- **WHEN** база v6 с агентским токеном T мигрировала в v7, запущен `sdlc-hub tokens`
- **THEN** вывод содержит клиентский токен, `jira: T` и новый токен `wiki`
### Requirement: Реле через общую папку
`sdlc-hub serve` SHALL запускать поток реле вместе с HTTP-сервером. Пока настройка `relay_dir` пуста (`null`), реле
бездействует; настройку оно SHALL перечитывать не реже раза в 5 с, включение и выключение не требуют перезапуска. Если
каталог `relay_dir` существует, реле SHALL создать в нём подкаталоги `req/`, `resp/`, `work/`; если не существует —
записать одно предупреждение в лог и проверять снова.
Обмен:
- запрос агента — файл `req/<id>.json`: `{id, method, path, body, authorization, deadline}`, где `deadline` — момент UTC
(ISO 8601), после которого ответ не нужен; файлы, имя которых начинается с точки, реле MUST NOT читать;
- реле SHALL брать запрос переименованием в `work/<id>.json` и обрабатывать каждый взятый запрос в отдельном потоке;
- запрос с истёкшим `deadline` SHALL выбрасываться без ответа; `POST /agent/claim` SHALL пересылаться, только если до
`deadline` осталось не меньше 35 с, иначе выбрасываться без ответа;
- остальные запросы реле SHALL пересылать в свой HTTP на `<host>:<port>` процесса `serve` (`127.0.0.1`, если `serve`
слушает `0.0.0.0`) тем же методом, путём и телом, с заголовком `Authorization` из файла, и записывать ответ
`resp/<id>.json` = `{id, status, body}` (тело — строка ответа HTTP) через временный файл с именем на точку и
переименование; затем удалять `work/<id>.json`;
- если соединение со своим HTTP не удалось или ответ нарушает протокол HTTP, ответ SHALL иметь `status = 502` и текст
ошибки в `body`;
- нечитаемый JSON запроса SHALL перечитываться до 2 с, затем запрос выбрасывается с записью в лог.
При первой подготовке каждой папки после старта реле (в том числе новой папки после смены `relay_dir`) реле SHALL
удалить файлы из `work/` с записью в лог. Повторная подготовка той же папки (пропал подкаталог, не было доступа) SHALL
только создавать недостающие подкаталоги, не трогая `work/`. Раз в минуту
реле SHALL удалять ответы в `resp/` старше 10 мин, запросы в `req/` с `deadline` старше 60 с и файлы с именем на точку
старше 10 мин. На каждый обработанный запрос реле SHALL писать в лог хаба строку `relay <METHOD> <path> <status>
<секунды>`. HTTP API, обработчики и проверка токенов при этом не меняются.
#### Scenario: Запрос через папку
- **WHEN** в `req/` появился `a1.json` с `GET /agent/settings`, агентским токеном и `deadline` через 60 с
- **THEN** в `resp/` появляется `a1.json` со `status = 200` и телом настроек; `work/a1.json` удалён
#### Scenario: Чужой токен через папку
- **WHEN** в файле запроса `GET /issues` с агентским токеном
- **THEN** ответ со `status = 401`, как по HTTP
#### Scenario: Просроченный claim
- **WHEN** реле взяло `POST /agent/claim`, до `deadline` которого осталось 20 с
- **THEN** запрос выброшен без ответа и без обращения к хабу; команда в очереди осталась `queued`
#### Scenario: Долгий claim не задерживает другие запросы
- **WHEN** реле пересылает `claim`, который ждёт команду 30 с, и в это время в `req/` появляется `GET /agent/settings`
- **THEN** ответ на `settings` пишется, не дожидаясь `claim`
#### Scenario: Папка не подключена
- **WHEN** `relay_dir` задан, но каталога нет; через минуту каталог появился
- **THEN** в логе одно предупреждение; после появления каталога реле создаёт подкаталоги и обрабатывает запросы
#### Scenario: Сбой протокола своего HTTP
- **WHEN** при пересылке запроса свой HTTP оборвал ответ (`http.client.HTTPException`)
- **THEN** в `resp/` ответ со `status = 502` и текстом ошибки; реле продолжает работу
#### Scenario: Повторная подготовка не трогает взятые запросы
- **WHEN** реле работает, в `work/` лежит взятый запрос, и пропал подкаталог `resp/`
- **THEN** реле создаёт `resp/` заново; файл в `work/` не удалён, в логе нет «удалён незавершённый запрос»
### Requirement: Публикация версии агента
`sdlc-hub agent publish [--module <модуль>] PATH` SHALL публиковать файл агента модуля (по умолчанию `jira`;
неизвестный модуль или модуль без команд — код 2 и список модулей с агентами в stderr): текст в UTF-8 не больше 1 МБ,
ровно одна строка `AGENT_VERSION = <целое>`, версия больше опубликованной для этого модуля. Файл SHALL записываться в
`<SDLC_HUB_HOME>/release/<модуль>/agent.py` (каталоги 0700), затем `manifest.json` `{version, sha256, size,
published_at}`; оба — через временный файл с `fsync` и переименование, манифест последним. Прежний `agent.py` на время
публикации SHALL переименовываться в `agent.py.prev` и удаляться после записи манифеста; если запись нового файла или
манифеста не удалась (`OSError`), хаб SHALL вернуть `agent.py.prev` на место `agent.py`, удалить временные файлы и
завершить команду сообщением «не удалось записать публикацию: <ошибка>» и кодом 1 — опубликованной остаётся прежняя
версия. Команда SHALL работать без запущенного `serve` и печатать «опубликована v<N>, sha256 <первые 12 символов>…,
<size> байт»; при ошибке — сообщение и код 1, опубликованное не меняется. При старте хаб SHALL один раз перенести
прежнюю публикацию `release/agent.py` и `release/manifest.json` в `release/jira/`, если `release/jira/` ещё нет; ошибка
переноса — предупреждение в лог, хаб стартует. Хаб SHALL читать манифест модуля агента при каждом `claim`,
`GET /agent/release` и `GET /agent`; манифест годен, только если это объект, `version` — целое число (не логическое) не
меньше 1, `sha256` — строка из 64 символов `[0-9a-f]`; негодный или нечитаемый манифест означает «публикации нет» и
предупреждение в лог — одно на каждый новый текст ошибки. Файл `agent.py`, не декодируемый как UTF-8 при совпавшем
sha256, означает «публикации нет» (`GET /agent/release` — 404 `no_release`). `GET /agent?module=<модуль>` (без
параметра — `jira`) и каждый элемент `GET /agents` SHALL дополняться полями `release` (`{version, sha256,
published_at}` или `null`), `update_state`, `launcher_version`, `agent_reported` (был ли `claim` этого агента с момента
запуска хаба) и `update_hint`: «агент запущен без run.py — обновится только вручную» (опубликована версия выше версии
агента, агент онлайн, `agent_reported = true`, `launcher_version = null`), «откат с v<to> на v<from>: <error>»
(`rolled_back`), «v<to> отвергнута: <error>» (`rejected`), иначе `null`.
#### Scenario: Публикация новой версии
- **WHEN** опубликована v5, `sdlc-hub agent publish jira-agent/src/agent.py` с `AGENT_VERSION = 6`
- **THEN** в `release/jira/` — новый `agent.py` и манифест с `version = 6`, `agent.py.prev` нет; вывод «опубликована v6,
sha256 …»; код 0
#### Scenario: Публикация eva-agent
- **WHEN** `sdlc-hub agent publish --module wiki eva-agent/src/agent.py` с `AGENT_VERSION = 1`, опубликована jira v14
- **THEN** в `release/wiki/` — v1; `claim` агента `wiki` получает `agent_release {version: 1, …}`, `claim` агента `jira` —
`{version: 14, …}`
#### Scenario: Неизвестный модуль
- **WHEN** `sdlc-hub agent publish --module nope agent.py`
- **THEN** код 2, в stderr «неизвестный модуль nope» и список модулей с агентами; `release/` не изменён
#### Scenario: Версия не выше опубликованной
- **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6`
- **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён
#### Scenario: Нет строки версии
- **WHEN** публикуется файл без строки `AGENT_VERSION = <целое>` или с двумя такими строками
- **THEN** сообщение об ошибке, код 1; `release/` не изменён
#### Scenario: Диск полон при записи манифеста
- **WHEN** опубликована v8, публикуется v9, запись `manifest.json` завершилась `OSError`
- **THEN** «не удалось записать публикацию: …», код 1, без трейсбека; `agent.py` и манифест — прежние v8,
`GET /agent/release` отдаёт v8; временных файлов и `agent.py.prev` в `release/jira/` нет
#### Scenario: Негодный манифест
- **WHEN** в `manifest.json` `version = 6.9` (или `true`, или `sha256` короче 64 символов), хаб получает 100 `claim`
- **THEN** в `claim` `agent_release = null`, `GET /agent/release` — 404; в логе одно предупреждение о манифесте
#### Scenario: Агент без запускателя
- **WHEN** опубликована v6, агент v5 онлайн, в последнем `claim` `launcher_version = null`
- **THEN** `GET /agent` → `agent_reported = true`, `update_hint` = «агент запущен без run.py — обновится только вручную»
#### Scenario: Хаб перезапущен, агент ещё не выходил на связь
- **WHEN** опубликована v10, агент v9 под запускателем, хаб перезапущен, `claim` после запуска ещё не было
- **THEN** `GET /agent` → `agent_reported = false`, `launcher_version = null`, `update_hint = null`; после первого
`claim` — `agent_reported = true`, `launcher_version` из `claim`
#### Scenario: Перенос прежней публикации
- **WHEN** хаб с `release/agent.py` v14 и манифестом без `release/jira/` запущен на новом коде
- **THEN** публикация v14 лежит в `release/jira/`, `claim` агента `jira` получает `agent_release {version: 14, …}`
### Requirement: Раздача веб-приложения
Хаб SHALL отдавать собранное веб-приложение (спека `web-app`) из каталога пакета `src/sdlc_hub/web/app/`, читая файлы
с диска на каждый запрос:
- `GET /app/assets/<путь>` — файл из `assets/` сборки; путь, который после разрешения выходит за `assets/`, не
существует или содержит нулевой байт, — 404; `Content-Type` по расширению (`.js` — `text/javascript; charset=utf-8`, `.css` —
`text/css; charset=utf-8`, `.svg` — `image/svg+xml`, `.png` — `image/png`, `.ico` — `image/x-icon`, `.woff2` —
`font/woff2`, `.map` — `application/json`, прочее — `application/octet-stream`);
`Cache-Control: public, max-age=31536000, immutable`;
- любой другой `GET /app` или `GET /app/…` — `index.html` сборки, `text/html; charset=utf-8`, `Cache-Control: no-cache`;
- если `index.html` сборки нет — на любой `GET /app…` 503 `text/html; charset=utf-8` с текстом, что приложение не
собрано и нужно выполнить `make web` в каталоге sdlc-hub;
- `GET /` и `GET /links` — 302 с `Location: /app/board`.
Сборка приложения не хранится в git; её создаёт `make web`.
#### Scenario: Файл сборки
- **WHEN** в сборке есть `assets/index-abc123.js`, запрошен `GET /app/assets/index-abc123.js`
- **THEN** 200, `Content-Type: text/javascript; charset=utf-8`, `Cache-Control` с `immutable`, тело — содержимое файла
#### Scenario: Путь приложения
- **WHEN** запрошен `GET /app/issues/AURORA-1620`
- **THEN** 200, тело — `index.html` сборки, `Cache-Control: no-cache`
#### Scenario: Выход за каталог сборки
- **WHEN** запрошен `GET /app/assets/../../api.py` (в том числе в виде `%2e%2e`)
- **THEN** 404, содержимое файлов вне `assets/` не отдаётся
#### Scenario: Приложение не собрано
- **WHEN** каталога сборки нет, запрошен `GET /app/board`
- **THEN** 503, тело — HTML с подсказкой `make web`
#### Scenario: Старый адрес доски
- **WHEN** запрошен `GET /links` или `GET /`
- **THEN** 302, `Location: /app/board`
#### Scenario: Нулевой байт в пути
- **WHEN** запрошен `GET /app/assets/%00` или `GET /app/assets/a%00.js`
- **THEN** 404 `not_found`, а не 500
## ADDED Requirements
### Requirement: Каталог данных прежнего имени
Если `SDLC_HUB_HOME` не задан, `~/.local/share/sdlc-hub/` не существует, а `~/.local/share/jira-hub/` существует, любая
команда `sdlc-hub`, которой нужен каталог данных (`serve`, `mcp`, `tokens`, `init`, `import`, `agent publish`), SHALL
завершиться с кодом 2 и текстом в stderr: каталог данных лежит под прежним именем, перенести его —
`scripts/cutover.sh` (или `mv ~/.local/share/jira-hub ~/.local/share/sdlc-hub` при остановленном хабе). Команда MUST NOT
создавать новый каталог, переносить или менять прежний. Если существуют оба каталога, используется новый, в stderr —
предупреждение, что прежний можно удалить после проверки.
#### Scenario: Код обновлён, каталог не перенесён
- **WHEN** есть только `~/.local/share/jira-hub/`, запущен `sdlc-hub serve`
- **THEN** код выхода 2, в stderr — подсказка о переносе; `~/.local/share/sdlc-hub/` не создан, прежний каталог не тронут
#### Scenario: Задан SDLC_HUB_HOME
- **WHEN** `SDLC_HUB_HOME=/tmp/x`, есть `~/.local/share/jira-hub/`
- **THEN** команда работает с `/tmp/x`, проверка прежнего каталога не выполняется
### Requirement: Переключение живого хаба
`scripts/cutover.sh` SHALL переводить живую установку Mac mini со старого имени на новое. Без `--apply` скрипт SHALL
только печатать шаги и найденные проблемы, ничего не меняя. С `--apply` — по порядку, с остановкой на первой ошибке:
1. проверки: основной checkout `jira-bridge/jira-hub` чистый и на `main`; в Gitea доступен `aep/sdlc-hub` (владелец
переименовал репозиторий); `~/.local/share/sdlc-hub/` и `jira-bridge/sdlc-hub` не существуют;
2. остановить launchd `ru.aepif.jira-hub` и удалить его plist;
3. перенести каталог данных `~/.local/share/jira-hub` → `~/.local/share/sdlc-hub`;
4. перенести checkout `jira-bridge/jira-hub` → `jira-bridge/sdlc-hub`, `git worktree repair`, `origin` →
`aep/sdlc-hub`, `git pull --ff-only`, пересоздать `.venv` (`uv sync`), `make web`;
5. поставить и запустить launchd `ru.aepif.sdlc-hub` (`uv run --project <checkout> sdlc-hub serve`, лог
`~/Library/Logs/sdlc-hub.log`) и дождаться ответа хаба на порту из настроек;
6. в проектах из списка (по умолчанию `~/projects/awp-ca`, `~/projects/stocks_drrk`): в `.mcp.json` заменить записи
`jira-hub` → `sdlc-jira` (`sdlc-hub mcp jira`) и `jira-hub-wiki` → `sdlc-wiki` (`sdlc-hub mcp wiki`) с новым путём; в
`.claude/settings*.json` — имена серверов (`enabledMcpjsonServers` и т. п.) и разрешения `mcp__jira-hub__` →
`mcp__sdlc-jira__`, `mcp__jira-hub-wiki__` → `mcp__sdlc-wiki__`; в `CLAUDE.md` и `.claude/skills/**` — те же имена
серверов и инструментов. Прочие файлы проектов (заметки, отчёты) не трогаются. Перед правкой файл копируется рядом
с суффиксом `.pre-sdlc`; коммиты скрипт не делает;
7. в user scope Claude Code заменить `jira-hub` на `sdlc-jira` (`claude mcp remove`/`add`).
Каждый выполненный шаг SHALL печататься; в конце — сводка, что осталось владельцу (`/mcp` reconnect в открытых сессиях,
`codegraph init` в checkout и worktree, удалить копии `.pre-sdlc` после проверки). Шаги откатываются в обратном порядке по инструкции из README.
#### Scenario: Сухой прогон
- **WHEN** `bash scripts/cutover.sh` без аргументов
- **THEN** напечатаны шаги и проверки, файловая система, launchd и конфигурации Claude не изменены
#### Scenario: Репозиторий в Gitea не переименован
- **WHEN** `bash scripts/cutover.sh --apply`, `aep/sdlc-hub` в Gitea не найден
- **THEN** скрипт останавливается на шаге 1 с подсказкой, launchd и каталоги не тронуты

View File

@ -0,0 +1,243 @@
## MODIFIED Requirements
### Requirement: Сервер и регистрация
`sdlc-hub mcp <модуль> [--project <метка>]` SHALL запускать MCP-сервер модуля хаба по stdio (пакет `mcp`), ходящий в
HTTP API хаба клиентским токеном из настроек; сервер называется `sdlc-<модуль>` (`sdlc-jira`, `sdlc-wiki`). Модуль
обязателен: без него — код выхода 2 и список модулей в stderr, сервер не запускается; неизвестный модуль — так же.
Общая регистрация — в user scope Claude Code как `sdlc-jira` (`uv run --project <путь> sdlc-hub mcp jira`), без
проекта. Репозиторий проекта SHALL регистрировать серверы модулей в своём `.mcp.json` (project scope) с
`--project <метка>`; эта запись перекрывает общую. Если хаб недоступен, каждый инструмент SHALL вернуть текст «хаб не
запущен: sdlc-hub serve», а не исключение.
#### Scenario: Хаб выключен
- **WHEN** вызван `issues` при остановленном хабе
- **THEN** ответ инструмента — одна строка с подсказкой, сервер MCP продолжает работать
#### Scenario: Сервер модуля jira
- **WHEN** запущен `sdlc-hub mcp jira --project AWPCA`
- **THEN** сервер называется `sdlc-jira`, список инструментов и их описания совпадают с прежним сервером `jira-hub`,
сессия привязана к `AWPCA`
#### Scenario: Модуль по умолчанию
- **WHEN** запущен `sdlc-hub mcp --project AWPCA`
- **THEN** модуля по умолчанию больше нет: код выхода 2, в stderr — подсказка указать модуль и список модулей (`jira`, `wiki`)
#### Scenario: Неизвестный модуль
- **WHEN** запущен `sdlc-hub mcp nope`
- **THEN** код выхода 2, в stderr — «неизвестный модуль nope» и список модулей (`jira`, `wiki`)
### Requirement: Инструменты
| Инструмент | Параметры | Результат |
|---|---|---|
| `projects` | — | таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач; в привязанной сессии текущий проект отмечен |
| `project_create` | `label`, `name`, `board_id` (необязательно) | `POST /projects` с автором `claude`: проект, id пакета обновления и фрагмент `.mcp.json` для репозитория проекта (см. ниже); 409/422 — текст ошибки |
| `issues` | фильтры как у `GET /issues` (кроме `project` — его задаёт привязка), `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту |
| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (номер «#<comment_id>», автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет»; у комментария со ссылками на вложения — строка «файлы: #<id> <имя>, …»; затем вложения (см. «Вложения в MCP») |
| `comment` | `key`, `comment_id` (номер из ответа `issue`, «#» в начале допускается) | один комментарий целиком по `GET /issues/{key}`: строка «<key> #<id> <автор>, создан <created>» (и «, изменён <updated>», если `updated` есть и отличается от `created`), под ней полный текст с переносами строк; нет такого номера — «у <key> нет комментария #<id>»; есть ссылки на вложения — после текста строка «вложения:» и по строке на каждое: «#<id> <имя> (<размер>)» |
| `attachment` | `key`, `attachment` (номер вложения «#<id>» или «<id>», иначе — точное имя файла), `wait_seconds` (по умолчанию 120, не больше 600) | скачивает вложение и отдаёт путь к локальной копии (см. «Вложения в MCP») |
| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки |
| `batch_create` | `title`, `commands` (список объектов по контракту `commands`; у `ensure_attachment` вместо `file` можно `path`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации |
| `batch_status` | `id` | статусы команд, ошибки, оценка остатка |
| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту |
| `batches` | `limit` | последние пакеты со статусом |
| `command_retry` | `id` | новый пакет |
| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» |
| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` |
| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v<N> от <дата>» или «публикации нет»), состояние обновления («обновление: <state> v<from> → v<to>»), запускатель («запускатель: v<N>», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть |
| `sync_log` | `limit` | последние `sync_run` |
| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` |
Разрез по проекту и поведение без привязки — требование «Привязка сессии к проекту».
Описание инструмента `batch_create` SHALL перечислять параметры каждого типа команды (обязательные, необязательные в
`[ ]`, формат значения) и собираться из описания параметров в коде хаба (см. `commands`), а не вручную.
`ensure_attachment` в `batch_create` SHALL принимать вместо `file` параметр `path` — абсолютный путь к локальному файлу на
машине MCP-сервера. До постановки пакета MCP SHALL прочитать файл, загрузить его `POST /files` и заменить `path` на
`file`; `filename` по умолчанию — имя файла из `path`. Нет файла, нет прав, не файл, больше 10 МБ — ответ инструмента
с причиной и путём, пакет не ставится. Передан и `path`, и `file` — ответ «у ensure_attachment либо path, либо file».
Описание инструмента `batch_create` SHALL сообщать, что метку проекта сессии хаб дописывает в `ensure_issue` сам, метки
других проектов запрещены, а команды над задачами вне проекта отклоняются; `board_id` для `ensure_sprint` выставляет хаб.
Ответ `project_create` SHALL содержать фрагмент `.mcp.json`
`{"mcpServers": {"sdlc-jira": {"command": "uv", "args": ["run", "--project", "<корень хаба>", "sdlc-hub", "mcp", "jira",
"--project", "<метка>"]}}}`, где корень хаба — корень репозитория, из которого загружен пакет текущего процесса MCP
(вычисляется от расположения пакета, без зашитого пути), и указание: положить файл в корень репозитория проекта (слить
с имеющимся `.mcp.json`) и переподключить MCP (`/mcp` → reconnect, одобрить сервер проекта).
Автор команд из MCP SHALL быть `claude`; из API без автора — `api`.
#### Scenario: Пакет из чата
- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}`
- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах
#### Scenario: Проект из чата
- **WHEN** сессия без привязки, `project_create {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120}`
- **THEN** проект создан, ответ содержит id пакета обновления и фрагмент `.mcp.json` с `"--project", "STOCKS_DRRK"`
#### Scenario: Ошибка с файлом из чата
- **WHEN** `batch_create {title: "Ошибка ОСВ 60", commands: [ensure_issue{temp_key: new:1, issue_type: bug, …},
ensure_attachment{key: new:1, path: "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}]}`
- **THEN** файл загружен в хаб; пакет создан с `ensure_attachment {key: new:1, file: <sha>, filename: "ОСВ сч. 60 за 2 кв.
2026.xlsx"}` и хвостовым `refresh`
#### Scenario: Файла нет
- **WHEN** тот же вызов, файла по `path` нет
- **THEN** ответ «файл не найден: <path>», пакет не создан, в хаб ничего не загружено
#### Scenario: Ожидание результата
- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут
- **THEN** ответ через 6 минут со статусами всех команд
#### Scenario: Статус агента с довыдачей
- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта
- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2
#### Scenario: Отмена пакета с командой у агента
- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed`
- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …»
#### Scenario: Статус агента после отката
- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7
- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и
«откат с v7 на v6: …»
#### Scenario: Статус агента сразу после перезапуска хаба
- **WHEN** хаб перезапущен, агент ещё не прислал `claim`
- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска
хаба)» и не содержит «без запускателя» и «агент запущен без run.py»
#### Scenario: Задача с семью комментариями
- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов
- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев с номерами «#<comment_id>» (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2»
#### Scenario: Комментарий целиком
- **WHEN** у задачи `AURORA-9001` комментарий `#101` длиной 800 символов с переносами строк, вызван `comment {key: "AURORA-9001", comment_id: "101"}`
- **THEN** ответ — «AURORA-9001 #101 <автор>, создан <created>» и полный текст комментария без обрезки, переносы строк сохранены
#### Scenario: Нет такого комментария
- **WHEN** `comment {key: "AURORA-9001", comment_id: "999"}`, у задачи нет комментария 999
- **THEN** ответ «у AURORA-9001 нет комментария #999»
### Requirement: Привязка сессии к проекту
`sdlc-hub mcp <модуль> --project <метка>` SHALL привязывать сервер MCP к проекту. Привязка SHALL проверяться при каждом вызове
инструмента по `GET /projects`: метки нет — ответ «проект <метка> не заведён в хабе: project_create или исправьте
--project в .mcp.json» (кроме `projects`, `project_create`).
В привязанной к проекту P сессии:
- `issues` — `GET /issues?project=P` с остальными фильтрами;
- `issue`, `comment` — любая задача по ключу;
- `notes_set` — только своя задача P (метка P; соседи — нет) или временный ключ `new:<n>` (его задача создаётся пакетом
проекта), иначе ответ «<key> не в проекте P»;
- `batch_create`, `command_retry` — с `project = P` (проверки пакета проекта, см. `commands`; повтор команды другого
проекта или пакета без проекта — отказ хаба);
- `batches` — `GET /batches?project=P`; `batch_status`, `batch_wait`, `batch_cancel` — по id, без ограничения;
- `refresh` — `full` — общий полный прогон всех проектов, `keys` — как раньше.
Без привязки инструменты `issues`, `issue`, `comment`, `notes_set`, `batch_create`, `command_retry`, `refresh` SHALL отвечать
«сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)» и в хаб не ходить; `projects`,
`project_create`, `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`,
`batch_cancel` работают.
#### Scenario: Сессия без проекта
- **WHEN** MCP запущен без `--project`, вызван `issues`
- **THEN** ответ «сессия не привязана к проекту: …», запросов к `GET /issues` нет
#### Scenario: Задачи своего проекта
- **WHEN** MCP запущен с `--project STOCKS_DRRK`, вызван `issues`
- **THEN** в таблице только свои задачи `STOCKS_DRRK` и их соседи
#### Scenario: Создание задачи без метки
- **WHEN** сессия `STOCKS_DRRK`, `batch_create {commands: [ensure_issue {temp_key: new:1, summary: "…"}]}` без `labels`
- **THEN** пакет поставлен с `project = STOCKS_DRRK`, у `ensure_issue` `labels = ["STOCKS_DRRK"]`
#### Scenario: Опечатка в проекте
- **WHEN** MCP запущен с `--project STOKS`, такого проекта нет; вызван `issues`
- **THEN** ответ «проект STOKS не заведён в хабе: …»
### Requirement: Вложения в MCP
`issue` SHALL после комментариев выводить «вложения (N):» и по строке на каждое вложение (новые сверху): «#<id> <имя>
(<размер>) — <автор>, <created>», дальше через запятую: «в описании», если на вложение ссылается описание, «в #<comment_id>»
для каждого ссылающегося комментария, «скачано», если `state = ready`, «больше 10 МБ — только в Jira» для таких файлов;
без вложений — «вложений нет». Размер — в байтах, КБ или МБ с одним знаком после запятой («512 Б», «234,4 КБ», «2,3 МБ»);
неизвестный — «размер неизвестен».
`attachment` SHALL:
1. найти вложение по `GET /issues/{key}`: по номеру, иначе по точному имени (несколько с одним именем — самое новое, в
ответ строка «у <key> N вложений «<имя>», взято самое новое #<id>»); не найдено — «у <key> нет вложения «<значение>»;
список — issue <key>», в хаб больше не ходить;
2. `POST /attachments/{id}/fetch` с автором `claude`; 413 — «файл больше 10 МБ (<размер>) — скачайте в Jira:
задача <key>, вложение «<имя>»»; 409 и прочие ошибки хаба — их текст;
3. пока `state = fetching` — опрашивать `GET /attachments/{id}` раз в 2 с, не дольше `wait_seconds`; не дождался —
«скачивание ещё идёт (команда #<command_id>, агент <онлайн/офлайн>); вызовите attachment ещё раз — вторая команда не
поставится»; `failed` — «не удалось скачать: <error>»;
4. при `ready` — `GET /attachments/{id}/content` и записать файл в `<каталог данных хаба>/downloads/<id>/<имя>`, где имя —
`filename` с заменой `/`, `\`, `:` и управляющих символов на `_` (пустое — `attachment-<id>`); каталог с правами
`0700`; запись через временный файл и переименование; если там уже лежит файл того же размера — не перезаписывать;
5. ответить «<абсолютный путь>» отдельной строкой и под ним «<имя> (<размер>), <mime_type>, задача <key>».
#### Scenario: Скриншот из комментария
- **WHEN** `comment {key: "AURORA-1650", comment_id: "101"}` показал «вложения: #31337 скрин 1.png (234,4 КБ)»; вызван
`attachment {key: "AURORA-1650", attachment: "#31337"}`, агент онлайн, файла у хаба нет
- **THEN** поставлена `fetch_attachment`; через несколько секунд ответ — путь
`…/sdlc-hub/downloads/31337/скрин 1.png` и строка «скрин 1.png (234,4 КБ), image/png, задача AURORA-1650»; файл по пути
читается
#### Scenario: Повторный вызов
- **WHEN** тот же вызов второй раз, файл у хаба (`ready`) и в `downloads/31337/`
- **THEN** ответ сразу, команд в очереди не прибавилось
#### Scenario: Агент не успел
- **WHEN** `attachment {…, wait_seconds: 30}`, агент занят долгим `refresh`
- **THEN** через 30 с ответ «скачивание ещё идёт (команда #N, агент онлайн); вызовите attachment ещё раз — вторая
команда не поставится»
#### Scenario: По имени
- **WHEN** `attachment {key: "AURORA-1650", attachment: "ОСВ 60.xlsx"}`, вложение с таким именем одно
- **THEN** скачивается оно; путь оканчивается на `/ОСВ 60.xlsx`
#### Scenario: Вложения в карточке
- **WHEN** `issue {key: "AURORA-1650"}`, у задачи вложения #31337 «скрин 1.png» (на него ссылается комментарий 101,
скачано) и #31338 «запись.mp4» на 25 МБ
- **THEN** в ответе «вложения (2):», строка #31338 с «больше 10 МБ — только в Jira», строка #31337 с «в #101, скачано»; у
комментария 101 строка «файлы: #31337 скрин 1.png»
### Requirement: MCP вики
`sdlc-hub mcp wiki [--project <метка>]` SHALL запускать по stdio MCP-сервер модуля `wiki` с именем `sdlc-wiki`,
ходящий в HTTP API хаба клиентским токеном; хаб недоступен — каждый инструмент отвечает «хаб не запущен: sdlc-hub serve».
Привязка к проекту проверяется так же, как у сервера jira («Привязка сессии к проекту»). Инструменты:
| Инструмент | Параметры | Ответ |
|---|---|---|
| `projects` | — | таблица: метка, название, корень вики, страниц, последняя синхронизация; текущий проект отмечен |
| `project_create` | `label`, `name`, `wiki_root` (необязательно) | `POST /projects` с автором `claude`; проект и фрагмент `.mcp.json` с записью `sdlc-wiki` |
| `root_set` | `root` | `PATCH /projects/<текущий> {wiki_root}`; новый корень и id пакета синхронизации |
| `tree` | `code` (необязательно), `depth` (по умолчанию 2) | дерево от корня или от `code`: отступ, код, название, размер |
| `page` | `code` или `title`, `offset` (по умолчанию 0) | шапка (код, путь в дереве, адрес в Eva, дата синхронизации, вложения с `id`) и Markdown с `offset` до 20 000 символов, в конце — «продолжение: offset=<N>», если текст длиннее; `title` ищется точно, иначе — до 10 кандидатов с кодами |
| `search` | `query`, `limit` (по умолчанию 10) | страницы: код, название, путь, фрагмент |
| `attachment` | `id` | ставит скачивание, ждёт до 60 с; готово — путь к копии `downloads/wiki-<id>/<имя>`; иначе состояние и подсказка повторить |
| `sync` | `wait` (по умолчанию `false`) | ставит синхронизацию (или сообщает о текущей) и id пакета; с `wait` — ждёт до 300 с и отдаёт итог |
| `status` | — | корень, последняя синхронизация, ошибка, пакет в работе, агент `wiki`: онлайн/офлайн, версия, публикация, обновление, запускатель (как `agent_status` сервера jira) |
Инструменты, кроме `projects` и `project_create`, SHALL требовать привязки к проекту. В привязанной сессии без корня
(кроме `root_set`, `status`) ответ SHALL быть «у проекта <метка> нет корня вики: root_set DOC-…»; с корнем, но без
синхронизаций — «вики проекта ещё не синхронизирована: sync». Набор инструментов и их описания у сервера `sdlc-jira`
(модуль jira) SHALL остаться прежним.
#### Scenario: Поиск из сессии awp-ca
- **WHEN** сервер запущен с `--project AWPCA`, копия синхронизирована, вызван `search {query: "статус согласован"}`
- **THEN** ответ — страницы с кодами и фрагментами; запрос в хаб — `GET /wiki/search?project=AWPCA&q=…`
#### Scenario: Длинная страница
- **WHEN** `page {code: DOC-3}`, Markdown 45 000 символов
- **THEN** первые 20 000 символов и «продолжение: offset=20000»; `page {code: DOC-3, offset: 40000}` — остаток без строки
продолжения
#### Scenario: Корня нет
- **WHEN** сессия `sfm_kedr` без корня, вызван `tree`
- **THEN** ответ «у проекта sfm_kedr нет корня вики: root_set DOC-…», запроса `GET /wiki/tree` нет
#### Scenario: Сервер jira не изменился
- **WHEN** запущен `sdlc-hub mcp jira --project AWPCA`
- **THEN** имя сервера `sdlc-jira`, список инструментов и описания совпадают с эталоном до change

View File

@ -0,0 +1,39 @@
## MODIFIED Requirements
### Requirement: Инициализация настроек
`sdlc-hub init --config <admin/jira-sync/config.example.json>` SHALL перенести в `setting`: `project_key`, типы задач,
`epic_name_field_id`, `fields`, `epic_link`, `priority_map`, `labels`, `status_labels`, `kind_labels`, `comment_marker`,
`pacing` (в новую форму по типам команд), `transitions.map`, `link_types`; сгенерировать два токена; завести проект
`AWPCA` «АРМ КА» с `board_id` из `sprints.board_id` конфига (если проекта `AWPCA` ещё нет). Поля `auth`, `base_url`,
`verify_ssl`, `ca_bundle` MUST NOT переноситься (они у агента).
#### Scenario: Повторный init
- **WHEN** `init` вызван на уже инициализированной базе
- **THEN** настройки не перезаписываются без `--force`, токены сохраняются; проект `AWPCA` не дублируется
#### Scenario: Первый init
- **WHEN** `init --config` на пустой базе, в конфиге `sprints.board_id = 4216`
- **THEN** есть проект `AWPCA` «АРМ КА» с `board_id = 4216`; в `setting` нет `base_jql` и `board_id`
### Requirement: Импорт CSV-пакета
`sdlc-hub import <папка admin/jira-sync>` SHALL прочитать `tasks.csv`, `new_tasks.csv`, `epics.csv` и заполнить
`issue_note` по ключу Jira: статус по факту, вид работы, что сделано, что осталось, внутренние колонки. Строки
`new_tasks.csv` без ключа SHALL получать временный ключ `new:<номер строки>`; строки `tasks.csv` и `epics.csv` без ключа
SHALL пропускаться. `links.csv` SHALL импортироваться как сверочный набор в отчёт импорта (ожидаемые связи против зеркала
после первого `refresh`), не как команды. Связь, которой в зеркале нет как `source→target` того же типа, но есть как
`target→source` того же типа, SHALL попадать в `reversed_links`, а не в `missing_links`. Импорт идемпотентен: повтор
обновляет заметки, не дублирует.
#### Scenario: Импорт и первая синхронизация
- **WHEN** выполнены `init`, `import`, запущены хаб и агент, `refresh full` завершён
- **THEN** число задач зеркала ≥ числа строк `tasks.csv` + созданных из `new_tasks.csv`; отчёт импорта перечисляет ключи
из CSV, которых нет в зеркале, связи из `links.csv`, которых нет в `issue_link`, и связи, которые есть в обратную сторону
#### Scenario: Связь в обратную сторону
- **WHEN** в `links.csv` строка «AURORA-1445 → AURORA-1446, связанные», а в зеркале есть только «ASUPR_Связанные»
1446→1445
- **THEN** связь в `reversed_links`, в `missing_links` её нет
#### Scenario: Эпик без ключа
- **WHEN** в `epics.csv` строка без значения в колонке «Ключ»
- **THEN** строка пропущена, заметка `new:*` для неё не создаётся

View File

@ -0,0 +1,169 @@
## MODIFIED Requirements
### Requirement: Вход по токену
Приложение SHALL работать с API хаба того же хоста под клиентским токеном: хранить его в `localStorage` под ключом
`sdlc-hub-token` (и в памяти, если хранилище закрыто) и передавать в заголовке `Authorization: Bearer` каждого запроса.
Если токена нет или любой запрос ответил 401, приложение SHALL показывать экран входа вместо страниц: поле токена с
подсказкой `uv run sdlc-hub tokens`, без панелей страниц и без фоновых запросов, которые могли бы изменить поле ввода.
Введённый токен SHALL проверяться запросом `GET /settings` и сохраняться только при успехе. Действие «Сменить токен»
SHALL очищать токен и показывать экран входа. Ответ 401 SHALL выводить на экран входа, только если запрос ушёл с текущим токеном: запоздавший 401 на
запрос с прежним токеном после входа с новым не учитывается. Если проверка токена получила ответ 4xx, кроме 401,
экран входа SHALL показывать текст ошибки хаба, а не «хаб не отвечает».
Ключи `localStorage` приложения с префиксом `sdlc-hub-` (`sdlc-hub-token`, `sdlc-hub-sidebar`, `sdlc-hub-legend`) SHALL
при первом чтении, если ключа нет, брать значение одноимённого ключа с префиксом `jira-hub-`, записывать его под новым
ключом и удалять прежний.
#### Scenario: Первый вход
- **WHEN** приложение открыто впервые, токена нет
- **THEN** показан экран входа; после ввода верного токена открывается запрошенная страница, токен сохранён
#### Scenario: Устаревший токен
- **WHEN** сохранённый токен не подходит и запрос ответил 401
- **THEN** показан экран входа с пояснением, что токен не подошёл; панели доски и задач не видны
#### Scenario: Токен от прежней доски
- **WHEN** в `localStorage` нет `sdlc-hub-token`, но есть `jira-hub-token` (сохранён до переименования хаба)
- **THEN** приложение входит с ним без запроса токена; значение перенесено в `sdlc-hub-token`, `jira-hub-token` удалён
#### Scenario: Запоздавший 401 от прежнего токена
- **WHEN** запрос ушёл с прежним токеном, пользователь вошёл с новым, и после этого запрос ответил 401
- **THEN** приложение остаётся на странице, новый токен сохранён
#### Scenario: Хаб отверг проверку токена
- **WHEN** проверка `GET /settings` ответила 400 с `detail`
- **THEN** экран входа показывает текст `detail`, а не «хаб не отвечает»
### Requirement: Оболочка и адреса
Приложение SHALL открываться под префиксом `/app/` и иметь страницы `/app/board` (доска связей), `/app/issues` (список
задач), `/app/feed` (лента изменений, требование «Лента изменений») и `/app/issues/{key}` (карточка задачи — окно поверх страницы, требование «Карточка задачи»); `/app` и `/app/` SHALL вести на `/app/board`, неизвестный путь —
на страницу «не найдено» со ссылками на разделы. Боковая панель SHALL быть закреплена у левого края окна на всю его
высоту и MUST NOT прокручиваться вместе со страницей; страница занимает остальную ширину. Сверху вниз панель SHALL
содержать: название «SDLC Hub» и кнопку «Свернуть панель»; выпадающий список проектов («<метка> — <название>», текущий
отмечен, в конце «+ Новый проект…»; требование «Проект в приложении»); разделы «Доска связей», «Задачи» и «Лента» — вертикальным
списком ссылок со значками (текущий отмечен `aria-current="page"`); внизу — блок данных Jira с действием «Обновить из
Jira» (требование «Обновление из Jira»), переключатель темы (требование «Тема») и меню с действием «Сменить токен».
Свёрнутая панель SHALL быть узкой полосой с теми же действиями значками: «Развернуть панель», метка текущего проекта,
разделы, «Обновить из Jira» с точкой состояния агента, тема, меню; названия — в подсказках и `aria-label`. Свёртка
SHALL храниться в `localStorage` под ключом `sdlc-hub-sidebar` (`collapsed`; без записи — развёрнута). На экранах уже
640 px панели в потоке нет: сверху SHALL быть закреплена строка с кнопкой «Меню», названием и кнопкой «Обновить из
Jira»; «Меню» открывает развёрнутую панель поверх страницы, панель закрывается выбором раздела, клавишей Escape или
щелчком мимо неё. Горизонтальной прокрутки страницы на любой ширине нет.
Неожиданная ошибка отрисовки страницы SHALL показываться сообщением с кнопкой «Перезагрузить», не пустым экраном.
#### Scenario: Переход между разделами
- **WHEN** на доске в боковой панели выбран раздел «Задачи»
- **THEN** адрес — `/app/issues` с текущим проектом, показан список задач; «назад» возвращает на доску
#### Scenario: Раздел «Лента»
- **WHEN** на доске в боковой панели выбран раздел «Лента»
- **THEN** адрес — `/app/feed` с текущим проектом, показана лента изменений за сегодня; раздел отмечен в панели
#### Scenario: Сменить токен
- **WHEN** в меню боковой панели выбрано «Сменить токен»
- **THEN** токен очищен, показан экран входа
#### Scenario: Панель на месте при прокрутке
- **WHEN** доска прокручена до самого низа
- **THEN** боковая панель видна целиком на прежнем месте: разделы и «Обновить из Jira» доступны без прокрутки вверх
#### Scenario: Свёрнутая панель
- **WHEN** нажата «Свернуть панель», страница перезагружена
- **THEN** панель свёрнута в полосу значков, доска шире; значок «Задачи» открывает список задач; «Развернуть панель»
возвращает полную панель
#### Scenario: Узкий экран
- **WHEN** ширина окна 390 px, нажата «Меню», в панели выбран раздел «Задачи»
- **THEN** панель открылась поверх страницы, после выбора закрыта, показан список задач; горизонтальной прокрутки нет
### Requirement: Доска связей
Страница `/app/board` SHALL воспроизводить поведение доски связей хаба (`links.html` ревизии 3931a88) без изменений,
кроме перечисленных в этом требовании и в требовании «Источник данных доски»: построение модели из `GET /board`, CSV
фильтра Jira и JSON доски (`arm-ka-links/1`); дорожки по эпикам со свёрткой и перестановкой, служебные дорожки внизу;
карточки с перетаскиванием, растяжкой, размером по содержимому и привязкой к сетке; стрелки связей с выравниванием;
фильтры по спринтам и исполнителям; легенда со скрытием видов работ и типов связей; «соседи по связям», «только
передачи», «скрыть без связей», «критический путь», «по датам» с колонками дат и их растяжкой; ящик «Проблемы»; таблица
связей; экспорт и импорт JSON; «Сбросить раскладку». Раскладка, дорожки и фильтры SHALL храниться в
`localStorage` под теми же ключами `graph-<имя>…`, что у `links.html`, с той же миграцией старых ключей.
Результат построения модели, видимой выборки, критического пути и проблем SHALL совпадать с результатом `links.html`
на одних и тех же данных и фильтрах.
Шапка доски (закреплена при прокрутке) SHALL содержать заголовок «Доска связей», счётчики (задачи из всех, соседи по
связям, связи из всех, скрытые без связей), пометку источника, если на доске снимок из файла (с действием вернуться к
данным хаба), событие доски, кнопку «Проблемы» со счётчиком и меню с действиями «Перечитать», «Загрузить файл…»,
«Экспорт JSON», «Таблица связей», «Привязка к сетке» и «Сбросить раскладку» (с подтверждением). Под шапкой — панель
управления: мультиселекты «Спринты» и «Исполнители», переключатели группы «Показать» («Соседи по связям», «Только
передачи», «Скрыть без связей») и группы «Режим» («Критический путь», «По датам»), каждый с `aria-pressed` и прежней
подсказкой. Под панелью — легенда, которая сворачивается кнопкой «Легенда»; свёрнутая показывает число скрытых видов
работ и типов связей; состояние свёртки хранится в `localStorage` под ключом `sdlc-hub-legend`. «Обновить из Jira»,
тема и «Сменить токен» — в боковой панели приложения, на доске их нет.
У карточки задачи (не у заглушки) SHALL быть значок «Открыть карточку»,
открывающий `/app/issues/{key}` окном поверх доски; ключ карточки по-прежнему открывает задачу в Jira. Пока окно открыто
и после его закрытия доска SHALL сохранять прокрутку, раскладку, фильтры и строку состояния. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку
карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке
захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет.
Карточка задачи, статус Jira которой входит в `closed_statuses`, SHALL быть приглушена (полупрозрачна), под
указателем и с фокусом клавиатуры внутри карточки — в полную яркость; стрелка связи, у которой хотя бы один конец — такая задача, SHALL быть приглушена так же.
Приглушение — только вид: модель, видимая выборка, критический путь, проблемы, раскладка и экспорт JSON от него не
зависят; переключателя нет; правило одно для данных хаба, CSV и JSON.
При возврате на `/app/board` с другой страницы приложения в той же вкладке доска SHALL восстанавливать прежнюю
прокрутку; после перезагрузки страницы доска открывается сверху.
Дорожки SHALL разделяться линией; фон развёрнутых дорожек один, без чередования, чтобы подсветка колонок «по датам»
(прошедшие даты, сегодня) была видна на всех дорожках; свёрнутая дорожка выделяется фоном. У карточки задачи, статус
Jira которой не входит в `closed_statuses`, с плановой датой окончания раньше сегодняшней дата «до …» SHALL быть цветом
ошибки, с подсказкой и текстом для экранного чтения «просрочено»; это только вид, модель и раскладка от него не зависят.
#### Scenario: Раскладка переживает переход
- **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными
- **THEN** карточки стоят на тех же местах, дорожки в том же порядке и свёртке, фильтры те же
#### Scenario: Совпадение со старой доской
- **WHEN** на одной фикстуре `GET /board` и одном наборе фильтров построены модель, видимая выборка, критический путь
и проблемы старой страницей и приложением
- **THEN** результаты совпадают
#### Scenario: Открыть карточку
- **WHEN** на карточке AURORA-1620 нажат значок «Открыть карточку»
- **THEN** адрес `/app/issues/AURORA-1620`, карточка задачи открыта окном, под ним доска
#### Scenario: Возврат с карточки
- **WHEN** доска прокручена вниз, нажат значок «Открыть карточку», затем окно закрыто
- **THEN** адрес `/app/board`, доска показана с той же прокруткой и раскладкой, без повторной загрузки `GET /board`
#### Scenario: Возврат из другого раздела
- **WHEN** доска прокручена вниз, выбран раздел «Задачи», затем раздел «Доска связей»
- **THEN** доска показана с той же прокруткой
#### Scenario: Закрытые на доске приглушены
- **WHEN** на доске AURORA-1620 «В работе», AURORA-1621 «Готово», AURORA-1622 «Снято», связь AURORA-1620 → AURORA-1621
- **THEN** карточки AURORA-1621 и AURORA-1622 и стрелка AURORA-1620 → AURORA-1621 приглушены, AURORA-1620 — нет;
под указателем AURORA-1621 — в полную яркость; критический путь и ящик «Проблемы» те же, что без приглушения
#### Scenario: Свёрнутая легенда
- **WHEN** в легенде скрыт вид работы «тестирование», легенда свёрнута, страница перезагружена
- **THEN** легенда свёрнута, рядом с кнопкой «Легенда» — «скрыто: 1»; карточек вида «тестирование» на доске нет
#### Scenario: Просрочка на доске
- **WHEN** сегодня 22.10, у AURORA-1620 статус «В работе» и срок 20.10, у AURORA-1621 «Готово» и срок 20.10, включено
«По датам»
- **THEN** дата AURORA-1620 — цветом ошибки с пометкой «просрочено», у AURORA-1621 — обычная; подсветка колонки 20.10
видна на всех дорожках, дорожки одного фона
### Requirement: Сборка и разработка
Приложение SHALL быть npm-проектом в `web/` (React, TypeScript, Vite) с точными версиями зависимостей и
`package-lock.json`. `make web` SHALL устанавливать зависимости (`npm ci`) и собирать приложение в
`src/sdlc_hub/web/app/`; каталог сборки не хранится в git. Шрифты (Mulish — текст интерфейса, IBM Plex Mono — ключи
задач и исходники), иконки и прочие ресурсы страницы SHALL входить в сборку: приложение не обращается к внешним
адресам. `make check` SHALL запускать `uv run pytest`, проверку типов и линтер фронта и тесты фронта; зелёным считается
только весь набор. `npm run dev` SHALL поднимать сервер разработки с прокси маршрутов API на хаб из `SDLC_HUB_URL` (по
умолчанию тестовый `http://127.0.0.1:8791`).
#### Scenario: Сборка с нуля
- **WHEN** в чистом checkout выполнен `make web`
- **THEN** в `src/sdlc_hub/web/app/` есть `index.html` и `assets/`; `git status` не показывает файлов сборки
#### Scenario: Без интернета
- **WHEN** приложение открыто на машине без доступа в интернет
- **THEN** страница не запрашивает ресурсов вне хаба, текст набран Mulish, ключи задач — IBM Plex Mono

View File

@ -0,0 +1,33 @@
## MODIFIED Requirements
### Requirement: Markdown-копия вики
Копия проекта SHALL лежать в `<SDLC_HUB_HOME>/wiki/<метка>/` (каталоги 0700): у каждой страницы — каталог
`NN_<название>` внутри каталога родителя (`NN` — двузначная позиция среди соседей по `orderno`, затем по коду; у корня
номера нет; название без символов `<>:"/\|?*` и управляющих, обрезано до 80 символов; совпадение у соседей —
суффикс `_2`, `_3`), в нём `index.md`: frontmatter `code`, `title`, `url`, `synced_at`, строка `# <title>` и текст. В
корне копии — `_tree.json`, список `{code, title, depth, path}` в порядке обхода. Копия SHALL собираться рядом
(`wiki/.<метка>.new/`) и подменять прежнюю переименованием; незавершённую подмену хаб SHALL доводить при старте.
HTML страницы SHALL переводиться в Markdown (заголовки `#`); ссылка или картинка на файл Eva (по списку `attachments` агента: адрес на хосте Eva
с путём `/files/…`, относительный адрес — тоже Eva; миниатюра `<адрес>.meta/thumbnail.jpg` означает файл `<адрес>`, а
ссылки на `static/…` — значки интерфейса — из текста удаляются) SHALL заменяться на
`[📎 <имя>](attachment:<id>)`, где `id` — строка вложения; ссылка на документ Eva с кодом `DOC-<цифры>` (в Eva — относительный
`project/Document/DOC-…`) — на `[<текст>](page:<код>)`; прочие ссылки остаются. Сырой HTML в копию не попадает.
#### Scenario: Раскладка
- **WHEN** корень DOC-1 «АРМ КА», дети DOC-3 «Статусы» (`orderno` 1) и DOC-2 «Роли/права» (`orderno` 2)
- **THEN** `wiki/AWPCA/АРМ КА/index.md`, `…/01_Статусы/index.md`, `…/02_Роли_права/index.md`; `_tree.json` в порядке
DOC-1, DOC-3, DOC-2
#### Scenario: Картинка на странице
- **WHEN** в HTML DOC-3 `<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/scheme.png?version=1">`
- **THEN** в `index.md` — `[📎 scheme.png](attachment:<id>)`, у вложения `<id>` `state = none`, файл не скачан
#### Scenario: Прикреплённый файл с миниатюрой
- **WHEN** на странице миниатюра `…/<uuid>/model.xlsx.meta/thumbnail.jpg?version=1`, значок `static/attach-file.png` и
ссылка на `…/<uuid>/model.xlsx`
- **THEN** одно вложение `model.xlsx` с адресом файла; в тексте одна ссылка `[📎 model.xlsx](attachment:<id>)`, значка нет
#### Scenario: Ссылка на другую страницу
- **WHEN** в HTML `<a href="project/Document/DOC-019834">Модель данных</a>`
- **THEN** в `index.md` — `[Модель данных](page:DOC-019834)`