## MODIFIED Requirements ### Requirement: Процесс и доступ `sdlc-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:` (по умолчанию 8790), база и настройки в `~/.local/share/sdlc-hub/` (переопределяется `SDLC_HUB_HOME`). Все маршруты API SHALL требовать `Authorization: Bearer `: токен агента модуля открывает только `/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/.json`: `{id, method, path, body, authorization, deadline}`, где `deadline` — момент UTC (ISO 8601), после которого ответ не нужен; файлы, имя которых начинается с точки, реле MUST NOT читать; - реле SHALL брать запрос переименованием в `work/.json` и обрабатывать каждый взятый запрос в отдельном потоке; - запрос с истёкшим `deadline` SHALL выбрасываться без ответа; `POST /agent/claim` SHALL пересылаться, только если до `deadline` осталось не меньше 35 с, иначе выбрасываться без ответа; - остальные запросы реле SHALL пересылать в свой HTTP на `:` процесса `serve` (`127.0.0.1`, если `serve` слушает `0.0.0.0`) тем же методом, путём и телом, с заголовком `Authorization` из файла, и записывать ответ `resp/.json` = `{id, status, body}` (тело — строка ответа HTTP) через временный файл с именем на точку и переименование; затем удалять `work/.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 <секунды>`. 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 записываться в `/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, sha256 <первые 12 символов>…, байт»; при ошибке — сообщение и код 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 на v: » (`rolled_back`), «v отвергнута: » (`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` и совпадает с `origin/main` (владелец сделал `git pull --ff-only` прямо перед запуском: скрипт приходит этим pull); в 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`, пересоздать `.venv` (`uv sync`), `make web`; 5. поставить и запустить launchd `ru.aepif.sdlc-hub` (`uv run --project 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 и каталоги не тронуты