diff --git a/.task-dev.md b/.task-dev.md index 8871bc5..e4b3c90 100644 --- a/.task-dev.md +++ b/.task-dev.md @@ -1,4 +1,4 @@ -Work item: ORCH-100 +Work item: ORCH-009 Repo: orchestrator -Branch: feature/ORCH-100-fnd-f1b-sidecar-watchdog +Branch: feature/ORCH-009-turnkey-plane Stage: development \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2108a0d..e8410f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Turnkey-онбординг проектов: kit + операторский CLI + runbook** (ORCH-009, `feat`): способность развернуть **новый** проект одним проходом (домен D5.2 эпика саморазвития) — **вне рантайма и вне конвейера**: `src/**` байт-в-байт (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не тронуты, снапшот-контроль `tests/test_onboarding_invariants.py`), kill-switch не нужен (активация — только явный запуск CLI человеком). Эталон — сам репозиторий orchestrator (каноны ORCH-52b/c/d/e). ADR: `docs/work-items/ORCH-009/06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md` (D1…D11), сквозной `docs/architecture/adr/adr-0035-turnkey-project-onboarding.md`. + - **Kit `onboarding/repo-skeleton/` (D1–D3, FR-1/FR-2/FR-3):** параметризуемый каркас нового репо — 6 промптов агентов канона 52d/92 (5 XML-секций в нормативном порядке, «❌ → ✅», `` у developer/reviewer/tester, frontmatter-схема 52c с плейсхолдерными датами/моделями, machine-verdict ключи байт-в-байт; язык — канон орка: 5 ru + deployer en c рамкой shared-host-гардрейлов), reviewer-gate «дока не обновлена → `REQUEST_CHANGES`», паспорт `CLAUDE.md`, `AGENTS.md` (карта доков + правила ведения), `CONTRIBUTING.md`, `README`/`CHANGELOG`, скелет `docs/` (`ARCHITECTURE`/`PIPELINE`/`PRODUCT_VISION`/`operations/INFRA.md` с обязательными секциями топологии/env/границ/рисков общего хоста, реестр сквозных ADR), `.env.example`. Плейсхолдеры `{{NAME}}` + stdlib-рендер (без новых pip-зависимостей); словарь — `onboarding/placeholders.json` (биекция словарь↔kit держится тестом). **Канон не форкается (BR-2):** `docs/_templates/` (16) + `docs/_standards/` (3) в kit не хранятся — копируются live из чекаута в момент материализации. + - **CLI `scripts/onboard_project.py` (D4–D7, D11, FR-4/FR-5):** режимы `plan` (дефолт, GET-only, ноль мутаций сети/диска) / `apply` (идемпотентный ensure: существующее → `skipped(exists)`, delete-операций нет вовсе) / `verify` (round-trip реестра, резолв всех 22 статусов включая fail-closed `Confirm Deploy`/`STOP`, лейблы, webhook активен, полнота kit в репо, скан неразрешённых плейсхолдеров). Закрытый список read-only импортов из `src` (нулевой дрейф по построению): `projects._parse_projects_json`, `plane_sync._PLANE_NAME_TO_KEY`, `config.settings`. Канонические группы статусов фиксированы ADR D5 (код-критично: `STOP`→`cancelled` ORCH-090; терминальные группы только у Done/Cancelled/STOP — иначе terminal-detection ORCH-068 ложно терминалит). Gitea: репо `auto_init=false` + per-repo webhook (`push`/`pull_request`/`status`, **переиспользует** глобальный `ORCH_GITEA_WEBHOOK_SECRET` — новый сломал бы HMAC существующих, TR-6); initial push — **только** в свежесозданный пустой репо (INV-4 не затрагивается). Реестр: merged-вывод `ORCH_PROJECTS_JSON` через фактический парсер; скрипт `.env` НЕ правит, прод НЕ рестартит, ничего не удаляет (NFR-2); секреты маскируются (NFR-3); Plane CE API-пробел → `manual-step` со ссылкой на runbook (fail-safe, TR-8). Отчёт `created/skipped(exists)/manual-step` + `--json`; exit-коды 0/2/1. + - **Runbook `docs/operations/ONBOARDING.md` (FR-6):** полный чеклист (предусловия → Plane → Gitea → kit → регистрация с self-hosting-предупреждением → верификация → откат), каждый ручной шаг с командой проверки; smoke — на **staging-контуре** (8501, изолированная БД) с одноразовым sandbox-проектом (D8), журнал smoke-прогонов. `docs/operations/SETUP_WEBHOOKS.md` обобщён per-repo (без хардкода enduro-trails). + - **Анти-дрейф (NFR-4):** структурные канон-тесты kit `tests/test_onboarding_kit.py` (TC-01…08, 19–20), рендер/планы/идемпотентность `tests/test_onboarding_script.py` (TC-02, 09–18, моки, без сети), инварианты `tests/test_onboarding_invariants.py` (TC-21: снапшоты `STAGE_TRANSITIONS`/`QG_CHECKS`, закрытый список импортов CLI, эталонные промпты `.openclaw/agents/` не тронуты). + - **fix(tests): герметизация ORCH-41-тестов model/effort от хост-env (разблокировка merge-gate):** re-test merge-gate бежит в прод-окружении орка, где оператор легитимно включил `ORCH_AGENT_FALLBACK_MODEL` и сменил `ORCH_AGENT_MODEL_DEFAULT`/`ORCH_AGENT_EFFORT_*` — `test_resolve_agent_model.py::test_fallback_model_disabled_by_default` и `test_resolve_agent_effort.py::test_flags_present_when_configured` ассертили **заводские** дефолты через env-backed singleton `settings` (в чистом env Gitea CI зелёные → мина на `main`; ветка ORCH-009 `src/` и эти тесты не трогает, детонация от смены прод-env). Фикс: autouse-фикстуры обоих файлов пиняют shipped-дефолты model/fallback-полей (зеркально друг другу), ассерт «G4 выключен по умолчанию» переведён на **класс-дефолт поля** (`type(settings).model_fields["agent_fallback_model"].default == ""` — подлинный инвариант ORCH-074 ADR-001 Решение 3), never-break ассерты `is_valid_model` — байт-в-байт. В чистом CI поведение байт-эквивалентно (фикстуры ставят ровно то, что даёт пустой env). Полный регресс: 1713 passed (было 2 failed / 1711 passed на re-test). - **Машинный журнал уроков `lessons`** (ORCH-098, `feat`): шаг 1 («Фундамент», F2) эпика саморазвития — формализует свободнотекстовые «уроки» из `memory/` в **машинную структурированную таблицу отклонений конвейера** `lessons`, фундамент для будущих ретроспективщика (E2), приоритизатора RICE (E3) и Стрим. Чистый **observer-leaf** `src/lessons.py` (never-raise, kill-switch, паттерн `serial_gate`/`coverage_gate`/`metrics`): `record()`/`get()`/`update()`/`snapshot()`. **Инвариант:** журнал — наблюдатель, **не** Quality Gate — `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы существующих таблиц байт-в-байт не тронуты; enduro не затронут. ADR: `docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md`, сквозной `docs/architecture/adr/adr-0034-lessons-journal.md`. - **Таблица (D1, FR-1):** аддитивная идемпотентная `lessons` (`CREATE TABLE IF NOT EXISTS` в `db.init_db()` + три индекса, restart-safe) — контекст (`work_item_id`/`task_id`/`stage`/`agent`/`repo`), анализ (`root_cause`/`suggestion`), статус (`status`/`related_task`), **колонки атрибуции — сразу и нуллабельно** (`attribution`/`target_repo`/`target_domain`, требование Славы 10.06 / NFR-6, заполняется позже через update; `_ensure_column` форвард-safe на старой таблице) + `source`/`detail`; без `enum`-констрейнтов (слаги forward-compatible). Хелперы `db.record_lesson`/`get_lessons`/`update_lesson`/`lessons_snapshot`/`lessons_recent_dup_exists`. - **НЕ скоупится по репо (D2):** журнал observer-only → единственный регулятор — глобальный kill-switch `lessons_enabled` (env `ORCH_LESSONS_ENABLED`, дефолт `True`); **`lessons_repos` НЕ вводится**. Recorder пишет уроки про **любой** репо (включая enduro-trails); репо-разрез — на **выборке** (`get(repo=…)`). diff --git a/CLAUDE.md b/CLAUDE.md index 9595588..d065ffe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -273,6 +273,27 @@ machine-verdict/схемы существующих таблиц байт-в-б `docs/work-items/ORCH-098/06-adr/ADR-001-lessons-journal.md`, `docs/architecture/adr/adr-0034-lessons-journal.md`. +## Turnkey-онбординг проектов (ORCH-009) +Операторская способность развернуть **новый** проект одним проходом — **вне рантайма и вне +конвейера** (`src/**` байт-в-байт, kill-switch не нужен: активация — только явный запуск CLI +человеком). Три артефакта: **kit** `onboarding/repo-skeleton/` (параметризуемый каркас нового репо: +6 промптов канона 52d/92 — 5 ru + deployer en, паспорт `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, +скелет `docs/` с обязательным `operations/INFRA.md`; плейсхолдеры `{{NAME}}`, словарь — +`onboarding/placeholders.json`; **канон не форкается**: `docs/_templates/`+`docs/_standards/` +копируются live из чекаута в момент материализации); **CLI** `scripts/onboard_project.py` +(`plan` — дефолт, GET-only / `apply` — идемпотентный ensure без delete / `verify`): Plane-проект + +22 статуса с точными именами (read-only импорт `plane_sync._PLANE_NAME_TO_KEY`; группы фиксированы +ADR: `STOP`→`cancelled`, терминальные группы только Done/Cancelled/STOP) + лейблы +`autoApprove`/`autoDeploy`/`Bug` → Gitea-репо + per-repo webhook (переиспользует глобальный +`ORCH_GITEA_WEBHOOK_SECRET`) → материализация kit + initial push **только** в свежесозданный пустой +репо → merged-вывод `ORCH_PROJECTS_JSON` (round-trip через фактический `_parse_projects_json`); +скрипт никогда не рестартит прод / не правит `.env` / ничего не удаляет; недоступное в Plane CE +API → `manual-step` (fail-safe); **runbook** `docs/operations/ONBOARDING.md` (ручные шаги: env + +управляемый рестарт; smoke — на staging 8501). Анти-дрейф — структурные тесты +`tests/test_onboarding_{kit,script,invariants}.py`. Детали — +`docs/work-items/ORCH-009/06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md`, сквозной +`docs/architecture/adr/adr-0035-turnkey-project-onboarding.md`. + ## Конвенции - Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) - Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug` diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 278f7bc..2af4202 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -122,6 +122,42 @@ F1b (рамка C-1: наблюдатель отделён от наблюдае `docs/work-items/ORCH-100/06-adr/ADR-001-sidecar-watchdog.md`, `docs/work-items/ORCH-100/07-infra-requirements.md`. +## Turnkey-онбординг проектов (ORCH-009) + +Операторская способность развернуть **новый** проект одним проходом: Plane-проект (статусы с +точными именами + лейблы под машинные контракты) → Gitea-репо (+per-repo webhook) → каркас репо +(kit) → запись реестра → верификация. Реализуется **вне рантайма и вне конвейера**: `src/**` +байт-в-байт (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не тронуты), +kill-switch не нужен (активация — только явный запуск CLI человеком). Эталон — сам репозиторий +orchestrator (каноны ORCH-52b/c/d/e); enduro-trails эталоном не является. + +- **Kit `onboarding/repo-skeleton/`** — параметризуемый каркас нового репо: 6 промптов агентов + канона 52d/92 (язык — канон орка: 5 ru + deployer en, ADR-001 D2 ORCH-092), паспорт `CLAUDE.md`, + `AGENTS.md` (точка входа агентов: карта доков + правила), `CONTRIBUTING.md`, `README`/`CHANGELOG`, + скелет `docs/` с обязательным `operations/INFRA.md`, `.env.example`. Плейсхолдеры `{{NAME}}` + + stdlib-рендер (без новых зависимостей); словарь — `onboarding/placeholders.json`. **Канон не + форкается (BR-2):** `docs/_templates/` + `docs/_standards/` не хранятся в kit — копируются live + из чекаута орка в момент материализации. +- **CLI `scripts/onboard_project.py`** — `plan` (дефолт, GET-only, ноль мутаций) / `apply` + (идемпотентный ensure, без delete-операций) / `verify` (round-trip реестра через фактический + `projects._parse_projects_json`, резолв всех статусов включая fail-closed `Confirm Deploy`/`STOP`, + лейблы, webhook, полнота kit, скан неразрешённых плейсхолдеров). Имена статусов — read-only + импорт `plane_sync._PLANE_NAME_TO_KEY` (22, нулевой дрейф); канонические группы фиксированы ADR + (код-критично: `STOP`→`cancelled` ORCH-090; терминальные группы только у Done/Cancelled/STOP — + иначе terminal-detection ORCH-068 ложно терминалит). Gitea-webhook переиспользует глобальный + `ORCH_GITEA_WEBHOOK_SECRET`; initial push — **только** в свежесозданный пустой репо (INV-4 не + затрагивается). Скрипт никогда не рестартит прод / не правит `.env` / ничего не удаляет; + регистрация в реестре = операторские env + управляемый рестарт (runbook). Недоступное в + Plane CE API → `manual-step` (fail-safe). +- **Runbook `docs/operations/ONBOARDING.md`** — чеклист всех слоёв, явные ручные шаги, smoke на + **staging-контуре** (8501, изолированная БД) с одноразовым sandbox-проектом, откат. +- **Анти-дрейф:** структурные канон-тесты kit (аналог `tests/test_agent_prompts_canon.py`) + + снапшот-тест `STAGE_TRANSITIONS`/`QG_CHECKS`. + +Подробнее: [adr-0035](adr/adr-0035-turnkey-project-onboarding.md), детально — +`docs/work-items/ORCH-009/06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md` (D1…D11), +`docs/work-items/ORCH-009/07-infra-requirements.md`. + ## Конвейер и Quality Gates ``` diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index d0b7b86..1a90f85 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -37,11 +37,15 @@ Per-work-item решения живут в `docs/work-items//06-adr/ADR-NNN- | adr-0029 | Гейт покрытия тестами — edge sub-gate + ratchet-базовая линия | proposed | 2026-06-10 | ORCH-027 | | adr-0030 | Лёгкий read-only `/metrics` — сырьё о самом орке для sidecar (F1b) | proposed | 2026-06-10 | ORCH-099 | | adr-0031 | Нормализация legacy root-owned файлов при миграции uid — детект-leaf + actionable worktree-ошибка | proposed | 2026-06-10 | ORCH-057 | +| adr-0032 | Багфикс-трек — укороченный маршрут конвейера для багов | proposed | 2026-06-10 | ORCH-019 | +| adr-0033 | Sidecar-watchdog F1b — мозг мониторинга в отдельном контейнере | proposed | 2026-06-10 | ORCH-100 | +| adr-0034 | Машинный журнал уроков — таблица `lessons` + observer-leaf | proposed | 2026-06-10 | ORCH-098 | +| adr-0035 | Turnkey-онбординг проектов — kit + операторский CLI + runbook | proposed | 2026-06-10 | ORCH-009 | > ⚠️ Историческая коллизия: номер `0007` занят двумя файлами — > `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md` > (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий -> свободный номер (текущий максимум — `0031`). +> свободный номер (текущий максимум — `0035`). > adr-0014 **amends** adr-0013 (меняет критерий merge-verify на «SHA-в-main»). > adr-0016 **amends** adr-0013/0014 (гарантирует открытый код-PR перед merge_pr, ORCH-082). > adr-0020 реализует машинный слой к adr-0019 (ORCH-52b→52c). diff --git a/docs/architecture/adr/adr-0035-turnkey-project-onboarding.md b/docs/architecture/adr/adr-0035-turnkey-project-onboarding.md new file mode 100644 index 0000000..35dbce8 --- /dev/null +++ b/docs/architecture/adr/adr-0035-turnkey-project-onboarding.md @@ -0,0 +1,80 @@ +--- +work_item: ORCH-009 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# adr-0035: Turnkey-онбординг проектов — kit + операторский CLI + runbook (ORCH-009) + +## Статус +Proposed + +## Контекст + +Подключение нового проекта к оркестратору — ручная археология по разрозненным докам и памяти; +каждый пропущенный шаг даёт **тихую деградацию**: без промптов в репо конвейер проекта не работает +вовсе (launcher резолвит `.openclaw/agents/.md` относительно worktree репо задачи); без +точных имён статусов Plane ветки `Confirm Deploy` (ORCH-059) / `STOP` (ORCH-090) молча не +активируются (fail-closed); без лейблов `autoApprove`/`autoDeploy`/`Bug` авто-режимы (ORCH-089) +и багфикс-трек (ORCH-019) молча выключены (fail-safe). Эталон онбординга — **сам репозиторий +orchestrator** (каноны ORCH-52b/c/d/e кодифицированы в `docs/_templates/`, `docs/_standards/`, +`.openclaw/agents/`). Домен D5.2 эпика саморазвития: способность разворачивать новый проект +одним проходом. + +## Решение + +Способность реализуется **вне рантайма и вне конвейера** — `src/**` байт-в-байт не меняется +(`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД/контракт `projects.py` +нетронуты), kill-switch не нужен (активация — только явный запуск операторского CLI): + +1. **Onboarding-kit `onboarding/repo-skeleton/`** — параметризуемый каркас нового репо: + 6 промптов агентов канона 52d/92 (5 XML-секций, «❌→✅», эмиссия схемы 52c, verdict-ключи + байт-в-байт; язык — канон орка: 5 ru + deployer en), паспорт `CLAUDE.md`, `AGENTS.md` + (точка входа агентов), `CONTRIBUTING.md`, `README.md`, `CHANGELOG.md`, скелет `docs/` с + обязательным `operations/INFRA.md`, `.env.example`. Плейсхолдеры `{{NAME}}` + stdlib-рендер + (без новых pip-зависимостей); словарь — `onboarding/placeholders.json` (биекция со + вхождениями в kit держится тестами). **Канон не форкается:** `docs/_templates/` + + `docs/_standards/` НЕ хранятся в kit — копируются live из чекаута орка в момент материализации. +2. **Операторский CLI `scripts/onboard_project.py`** — `plan` (дефолт, GET-only, ни одной + мутации) / `apply` (идемпотентный ensure, без delete-операций) / `verify`. Шаги: Plane-проект → + 22 статуса с точными именами из `plane_sync._PLANE_NAME_TO_KEY` (read-only импорт — нулевой + дрейф; канонические группы фиксированы: `STOP`→`cancelled`, терминальные группы только у + Done/Cancelled/STOP — иначе terminal-detection ORCH-068 ложно терминалит) → лейблы → Gitea-репо + (+per-repo webhook `push`/`pull_request`/`status`; HMAC-секрет **переиспользуется** из + `ORCH_GITEA_WEBHOOK_SECRET` — приёмник один на все репо) → материализация kit + initial push + **только в свежесозданный пустой репо** (INV-4 не затрагивается) → merged-вывод + `ORCH_PROJECTS_JSON`, провалидированный фактическим `projects._parse_projects_json` + (round-trip). Недоступное в Plane CE API → `manual-step` со ссылкой на runbook (fail-safe). + Скрипт **никогда** не рестартит прод, не правит `.env`, не пушит в существующие репо, ничего + не удаляет. +3. **Runbook `docs/operations/ONBOARDING.md`** — полный чеклист: предусловия (токены) → скрипт → + операторские шаги (env + управляемый рестарт с self-hosting-предупреждением; UI-only Plane) → + верификация (`verify` + smoke) → откат. Smoke-контур — **staging (8501, изолированная БД)** + + одноразовый sandbox-проект (`SMK`); протокол — «Журнал smoke-прогонов» в runbook. + +Анти-дрейф — структурные тесты kit (аналог `tests/test_agent_prompts_canon.py`) + снапшот-тест +`STAGE_TRANSITIONS`/`QG_CHECKS` (контроль ненарушения `src`). Branch protection `main` новых репо +**не включается** (ломала бы PR-merge API merge-актора — ложные HOLD класса ORCH-093). + +## Последствия + +- **+** Новый проект разворачивается одним проходом проверяемо: все слои (Plane-контракты, + webhook, промпты, дока, реестр) закрыты скриптом+runbook; тихие деградации ловит `verify`. +- **+** Нулевой риск рантайма: изменение docs/templates/scripts/tests-only; регресс + enduro/orchestrator невозможен по построению; общая БД не читается и не пишется скриптом. +- **+** Единый эталон без форка: новые репо получают живой канон момента онбординга; + обновления канона в них едут обычными PR с reviewer-gate. +- **−** Регистрация в реестре остаётся операторской (env + управляемый рестарт — Ф-3, + сознательное ограничение NFR-2); разрыв «создано, но не зарегистрировано» виден через `verify`. +- **−** Закрытый список read-only импортов из `src` (`projects._parse_projects_json`, + `plane_sync._PLANE_NAME_TO_KEY`, поля `config.settings`) — связь с приватными именами; + поломка при рефакторинге видимая (тесты), расширение списка — только через ADR. +- **Ограничение:** способность ≠ исполнение: онбординг конкретного заказчика — операторская + эксплуатация (вне ORCH-009); тиражирование на новый хост — ORCH-10 (вне объёма). + +Детально: `docs/work-items/ORCH-009/06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md` +(D1…D11 — раскладка, плейсхолдеры, copy-vs-template split, импорт src, группы статусов, +webhook-секрет, формат реестра, smoke-контур, языковая политика, branch protection, форма CLI). diff --git a/docs/operations/ONBOARDING.md b/docs/operations/ONBOARDING.md new file mode 100644 index 0000000..d6ff718 --- /dev/null +++ b/docs/operations/ONBOARDING.md @@ -0,0 +1,200 @@ +# ONBOARDING — turnkey-онбординг нового проекта (ORCH-009) + +> RUNBOOK. Полный чеклист подключения нового проекта к оркестратору одним проходом. +> Исполнитель — оператор; инструмент — CLI `scripts/onboard_project.py` +> (режимы `plan` — дефолт/dry-run, `apply`, `verify`). Каждый шаг, который CLI выполнить +> не может, помечен **🖐 РУЧНОЙ ШАГ** и снабжён командой проверки результата. +> Архитектура решения — см. «Ссылки» внизу. + +Запуск CLI — из корня чекаута репо orchestrator, в venv с `requirements.txt`: + +```bash +python3 scripts/onboard_project.py plan \ + --name "My Project" --description "зачем проект" \ + --repo my-project --prefix MP \ + --stack "Python 3.12 + FastAPI" --test-cmd "pytest tests/ -q" \ + --prod-port 8600 --staging-port 8601 \ + --webhook-url https://openclaw.mva154.duckdns.org/orchestrator/webhook/gitea +``` + +`plan` печатает полный план **без единой мутации** (ни сети-POST, ни записи на диск); +`apply` — идемпотентный ensure (существующее → `skipped(exists)`, ничего не удаляется); +exit-коды: `0` — чисто, `2` — есть `manual-step`/gap, `1` — ошибка. + +--- + +## 0. Предусловия + +Все значения — в `.env` на хосте (секреты в гит не попадают): + +| Переменная | Зачем | Проверка | +|-----------|-------|----------| +| `ORCH_PLANE_API_TOKEN` (+`ORCH_PLANE_API_URL`, `ORCH_PLANE_WORKSPACE_SLUG`) | создание/чтение проекта, статусов, лейблов | `curl -s -H "X-API-Key: $TOKEN" $URL/api/v1/workspaces/$SLUG/projects/ \| head -c 200` | +| `ORCH_GITEA_TOKEN` (+`ORCH_GITEA_URL`) | создание репо + webhook | `curl -s -H "Authorization: token $TOKEN" $URL/api/v1/user \| head -c 200` | +| `ORCH_GITEA_WEBHOOK_SECRET` | HMAC webhook (переиспользуется, один на все репо) | есть строка в `.env`; нет → `apply` сгенерирует и выведет | +| `ORCH_PROJECTS_JSON` | текущий реестр — источник merged-вывода | `grep ORCH_PROJECTS_JSON .env` | + +Токен Plane должен иметь право создавать проекты в workspace; токен Gitea — создавать репо и +hooks под выбранным owner (`--gitea-owner`, дефолт из конфига). + +--- + +## 1. Слой Plane: проект + статусы + лейблы + +Выполняет `apply` (или вручную при недоступности API CE — каждый отказ CLI помечает +`manual-step`, не падает). + +1. **Проект**: создаётся с `identifier = --prefix`. Уже существует → передай + `--plane-project-id ` (ensure распознает и пропустит). +2. **Статусы — точные канонические имена** (22, источник — `plane_sync._PLANE_NAME_TO_KEY`; + опечатка = тихая деградация fail-closed веток): + + | Статус | Группа | | Статус | Группа | + |--------|--------|-|--------|--------| + | Backlog | `backlog` | | In Review | `started` | + | Todo | `unstarted` | | Blocked | `started` | + | To Analyse | `unstarted` | | Approved | `started` | + | In Progress | `started` | | Rejected | `started` | + | Analysis | `started` | | **Confirm Deploy** | `started` | + | Architecture | `started` | | Needs Input | `started` | + | Development | `started` | | Done | `completed` | + | Code-Review | `started` | | Cancelled | `cancelled` | + | Review | `started` | | **STOP** | **`cancelled`** | + | Testing | `started` | | Awaiting Deploy | `started` | + | Deploying | `started` | | Monitoring after Deploy | `started` | + + ⚠️ Код-критично: `STOP` обязан быть в группе `cancelled` (иначе ветка отмены молча не + активируется); в терминальных группах (`completed`/`cancelled`) — ТОЛЬКО + Done/Cancelled/STOP, иначе terminal-detection ложно сочтёт живую задачу терминальной. +3. **Лейблы**: `autoApprove`, `autoDeploy`, `Bug` (имена — из конфига оркестратора; их + отсутствие = fail-safe ручной режим / полный цикл). +4. **🖐 РУЧНОЙ ШАГ — порядок статусов на доске**: drag-and-drop в UI (API не управляет + порядком). Проверка: открой доску проекта — колонки в порядке конвейера. +5. **Workspace-webhook**: уже **существует** (один на весь workspace, создан на уровне + workspace заранее) — CLI его НЕ создаёт, только напоминает проверить: + + ```bash + docker exec -e PGPASSWORD=plane plane-app-plane-db-1 psql -U plane -d plane -c \ + "SELECT id, url, is_active FROM webhooks;" + ``` + +--- + +## 2. Слой Gitea: репо + per-repo webhook + +1. **Репо** `--gitea-owner/--repo`: создаётся пустым (`auto_init=false`; ветку `main` создаст + initial push следующего слоя). Существует → `skipped(exists)`. +2. **Per-repo webhook**: `events: push/pull_request/status`, `content_type: json`, + `branch_filter: *`, URL = `--webhook-url`. **Секрет переиспользуется** из + `ORCH_GITEA_WEBHOOK_SECRET` (приёмник валидирует ОДИН глобальный секрет на все репо; + новый секрет сломал бы HMAC существующих вебхуков). Секрета нет в env → CLI сгенерирует и + выведет строку для `.env` — **🖐 РУЧНОЙ ШАГ**: добавить её в `.env` (в гит не коммитить). + Формат и проверка — `docs/operations/SETUP_WEBHOOKS.md`. Проверка: + + ```bash + curl -s -H "Authorization: token $ORCH_GITEA_TOKEN" \ + "$ORCH_GITEA_URL/api/v1/repos///hooks" | python3 -m json.tool + ``` +3. **Branch protection `main` НЕ включать** (ADR D10): required-approvals/status-checks ломают + PR-merge API merge-актора конвейера (ложные HOLD). Защита держится конвенцией + скоупом + токенов. + +--- + +## 3. Слой kit: материализация + initial push + +1. `apply` рендерит kit (`onboarding/repo-skeleton/`, плейсхолдеры `{{NAME}}` из + `onboarding/placeholders.json`) во временный каталог, докладывает live-copy канона + (`docs/_templates/` 16 скелетов + `docs/_standards/` 3 стандарта — verbatim из текущего + чекаута, BR-2 «канон не форкается») и пушит **ТОЛЬКО в свежесозданный/пустой репо** + (единственный разрешённый push; коммит `feat: onboarding skeleton (ORCH-009 kit)`). +2. Репо непустой → шаг помечается `manual-step`: **🖐 РУЧНОЙ ШАГ** — занеси недостающие + файлы обычным PR с ревью; поверх существующего контента ничего не пушится (BR-9). +3. После рендера не должно остаться ни одного `{{...}}`: CLI падает на этом сам; повторная + проверка — `verify` (скан плейсхолдеров в файлах репо). + +--- + +## 4. Регистрация в реестре оркестратора + +> ⚠️ **САМЫЙ ВАЖНЫЙ РУЧНОЙ СЛОЙ.** CLI `.env` прода НЕ правит и контейнер НЕ рестартит +> (инвариант NFR-2) — он только печатает готовую строку. + +1. **🖐 РУЧНОЙ ШАГ — env**: возьми из отчёта `apply` строку + `ORCH_PROJECTS_JSON=[...полный merged-массив...]` (существующие записи verbatim + новая в + конец; строка уже провалидирована фактическим парсером реестра) и замени ею строку в `.env` + оркестратора на хосте. Вставляется атомарно одной строкой — ручное слияние JSON не нужно. +2. **🖐 РУЧНОЙ ШАГ — управляемый рестарт оркестратора**: реестр строится при импорте, нужна + перезагрузка процесса. **Self-hosting предупреждение: прод-контейнер один на ВСЕ проекты — + рестарт = групповое окно** (встаёт конвейер всех проектов). Выполняй осознанно: дождись + тихого окна (`GET /queue` — нет бегущих job), затем штатный рестарт по + `docs/operations/INFRA.md`. Проверка после рестарта: + + ```bash + curl -s http://localhost:8500/health + curl -s http://localhost:8500/queue | python3 -m json.tool | head -30 # реестр жив, конвейер пуст/цел + ``` +3. TTL-self-heal статусов Plane (300с) рестарта НЕ требует: статусы/лейблы, созданные после + регистрации, подхватятся сами. + +--- + +## 5. Верификация + +1. **`verify`-режим CLI** (read-only): + + ```bash + python3 scripts/onboard_project.py verify --name ... --repo ... --prefix ... \ + --plane-project-id --stack ... --test-cmd ... --prod-port ... --staging-port ... \ + --webhook-url https://openclaw.mva154.duckdns.org/orchestrator/webhook/gitea + ``` + + Проверяет: запись реестра парсится и совпадает по полям; все 22 статуса резолвятся + (включая fail-closed `Confirm Deploy`/`STOP`); лейблы на месте; webhook существует и + активен; kit-файлы в репо (6 промптов, `AGENTS.md`, `INFRA.md`, `_templates`/`_standards`); + нет неразрешённых плейсхолдеров. Любой gap → exit `2` с перечнем. + +2. **Smoke на песочнице (ADR D8)** — контур: **staging-оркестратор (порт 8501, изолированная + БД `./data/staging`)** + одноразовый sandbox-проект (рекомендуемые имена: проект + `onboarding-smoke`, префикс `SMK`, репо `onboarding-smoke`): + 1. Онборди sandbox самим CLI (слои 1–3 этого runbook). + 2. **🖐 РУЧНОЙ ШАГ**: зарегистрируй sandbox в `ORCH_PROJECTS_JSON` **`.env.staging`** + (не прода!) и перезапусти staging-контейнер (он свободен от прод-инварианта): + `docker compose --profile staging up -d orchestrator-staging`. + 3. Создай тестовую задачу в sandbox-проекте → доведи до стадии analysis в песочнице. + 4. Критерий PASS: агент по своему промпту **прочитал доку проекта** (следы чтения + `CLAUDE.md`/`AGENTS.md` в выводе) и **записал артефакты** в `docs/work-items/SMK-…/` + по канону `PIPELINE_DOCS.md`. + 5. Запротоколируй прогон в «Журнале smoke-прогонов» (ниже). Для приёмки ORCH-009 первый + протокол обязателен. + +--- + +## 6. Откат + +CLI ничего не удаляет (BR-9) — откат всегда ручной и осознанный: + +| Что создано | Как откатить | Проверка | +|-------------|--------------|----------| +| Plane-проект (+статусы/лейблы) | удалить проект в UI Plane | проект исчез из списка workspace | +| Gitea-репо (+webhook) | удалить репо в UI/API Gitea (webhook умрёт вместе с ним) | `GET /api/v1/repos//` → 404 | +| Строка реестра | убрать запись из `ORCH_PROJECTS_JSON` в `.env` + управляемый рестарт (см. слой 4, то же групповое окно) | `GET /queue` — проекта нет в реестре | +| Sandbox-артефакты smoke | удалить sandbox-проект/репо после прогона (или архивировать) | см. выше | + +--- + +## Журнал smoke-прогонов + +| Дата | Оператор | Параметры (проект/префикс/репо) | Контур | Результат (PASS/FAIL) | Протокол | +|------|----------|----------------------------------|--------|------------------------|----------| +| — | — | — (первый прогон фиксируется при приёмке ORCH-009) | staging 8501 | — | — | + +--- + +## Ссылки + +- Архитектура решения: `docs/work-items/ORCH-009/06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md` + (D1…D11); сквозной ADR — `docs/architecture/adr/adr-0035-turnkey-project-onboarding.md`. +- Устройство набора шаблонов и словарь плейсхолдеров: `onboarding/README.md`. +- Формат вебхуков: `docs/operations/SETUP_WEBHOOKS.md`; топология и рестарты — + `docs/operations/INFRA.md`. diff --git a/docs/operations/SETUP_WEBHOOKS.md b/docs/operations/SETUP_WEBHOOKS.md index 470396d..f8aa195 100644 --- a/docs/operations/SETUP_WEBHOOKS.md +++ b/docs/operations/SETUP_WEBHOOKS.md @@ -12,30 +12,36 @@ Internal URL: `http://127.0.0.1:8500/` --- -## Gitea Webhook +## Gitea Webhook (per-repo) -**Создан автоматически через API.** +Gitea-webhook — **per-repo**: создаётся для КАЖДОГО подключаемого к оркестратору репозитория +(`` ниже). Для новых проектов его создаёт onboarding-CLI +(`scripts/onboard_project.py apply`) — полный процесс см. `docs/operations/ONBOARDING.md`; +команды ниже — для ручной проверки/пересоздания на любом репо. - URL: `https://openclaw.mva154.duckdns.org/orchestrator/webhook/gitea` - Events: `push`, `pull_request`, `status` -- Secret: значение `ORCH_GITEA_WEBHOOK_SECRET` в `.env` +- Secret: значение `ORCH_GITEA_WEBHOOK_SECRET` в `.env` — **ОДИН глобальный секрет на все + репо** (приёмник валидирует только его; новый секрет на одном репо сломал бы HMAC остальных — + при ротации меняется на всех репо разом) - Signature header: `X-Gitea-Signature` (HMAC-SHA256 hex digest) ### Проверка ```bash GITEA_TOKEN=$(grep ORCH_GITEA_TOKEN /home/slin/repos/orchestrator/.env | cut -d= -f2) -curl -s "http://localhost:3000/api/v1/repos/admin/enduro-trails/hooks" \ +curl -s "http://localhost:3000/api/v1/repos///hooks" \ -H "Authorization: token ${GITEA_TOKEN}" | python3 -m json.tool ``` ### Пересоздание (если нужно) ```bash -GITEA_WEBHOOK_SECRET=$(openssl rand -hex 20) -# Обновить в .env: ORCH_GITEA_WEBHOOK_SECRET= +# Секрет переиспользуй из .env (ORCH_GITEA_WEBHOOK_SECRET); генерируй новый ТОЛЬКО при +# первичной настройке/осознанной ротации (и обнови вебхуки ВСЕХ репо): +GITEA_WEBHOOK_SECRET=$(grep ORCH_GITEA_WEBHOOK_SECRET /home/slin/repos/orchestrator/.env | cut -d= -f2) -curl -X POST "http://localhost:3000/api/v1/repos/admin/enduro-trails/hooks" \ +curl -X POST "http://localhost:3000/api/v1/repos///hooks" \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ diff --git a/docs/work-items/ORCH-009/00-business-request.md b/docs/work-items/ORCH-009/00-business-request.md new file mode 100644 index 0000000..3ac8fd1 --- /dev/null +++ b/docs/work-items/ORCH-009/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Онбординг проектов в оркестратор (turnkey: Plane + репо + агенты + инфра) + +Work Item ID: ORCH-009 + +## Description + +TBD diff --git a/docs/work-items/ORCH-009/01-brd.md b/docs/work-items/ORCH-009/01-brd.md new file mode 100644 index 0000000..34faa38 --- /dev/null +++ b/docs/work-items/ORCH-009/01-brd.md @@ -0,0 +1,176 @@ +--- +work_item: ORCH-009 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 01 — BRD: ORCH-009 — Онбординг проектов в оркестратор (turnkey: Plane + репо + агенты + инфра) + +Work Item: **ORCH-009** · Repo: **orchestrator** (self-hosting) · Стадия: analysis +Заказчик: Слава · Домен эпика: 📈 **D5 Масштаб (D5.2)** — `docs/epics/self-evolution.md` + +> ⚠️ **Актуализация Владельца 10.06 принята как приоритетная над исходной постановкой 05.06.** +> Эталон онбординга = **сам репозиторий orchestrator** (каноны ORCH-52b/c/d/e уже кодифицированы), +> НЕ enduro-trails (устаревший пример). «Дыра: у orchestrator только deployer.md» — уже закрыта +> (в `.openclaw/agents/` полный набор 6 промптов). Скоуп — **способность** разворачивать новый +> проект по образцу орка одним проходом; онбординг конкретного нового заказчика — исполнение этой +> способности, вне данной задачи. + +--- + +## 1. Бизнес-контекст и проблема + +### 1.1. Цель +При подключении **нового** проекта к оркестратору одним проходом разворачивается всё, что нужно +мульти-агентам для автономной работы: Plane-проект (статусы/лейблы под машинные контракты), +Gitea-репо (+webhook), полный набор промптов агентов, структура документации по единым канонам, +инфра-описание (INFRA.md), регистрация в реестре проектов. Агенты нового проекта **обязаны** знать, +где лежит документация, использовать её и актуализировать. + +### 1.2. Проблема сегодня +Онбординг проекта — ручная археология: шаги размазаны по докам (`SETUP_WEBHOOKS.md`, +`INFRA.md`), памяти Стрима/Славы и инцидентам (прецедент 2026-06-02: webhook всего workspace + +захардкоженный `default_repo` → задачи чужого проекта падали в enduro-trails; закрыто реестром +ORCH-6). Любой пропущенный шаг даёт **тихую деградацию**: без промптов в репо конвейер проекта не +работает вовсе; без точных имён статусов Plane ветки `Confirm Deploy`/`STOP` молча не активируются +(fail-closed); без лейблов авто-режимы и багфикс-трек молча выключены (fail-safe). Турникей-проход +обязан закрывать все слои сразу и проверяемо. + +### 1.3. Установленные факты (проверено по коду, не изобретать) + +| # | Факт | Где проверено | +|---|------|---------------| +| Ф-1 | Промпты агентов — **per-repo**: launcher резолвит `system_prompt: .openclaw/agents/.md` относительно worktree репо задачи. Нет промптов в новом репо → конвейер для него не работает. | `src/agents/launcher.py` (реестр AGENTS, 6 ролей) | +| Ф-2 | Агент видит **только** worktree своего репо → каноны (шаблоны/стандарты) обязаны быть **скопированы** в новый репо; «ссылка на репо орка» агенту недоступна. | модель worktree `src/git_worktree.py`, launcher | +| Ф-3 | Реестр проектов строится **при импорте** из `ORCH_PROJECTS_JSON` (или built-in default): ключи `plane_project_id`/`repo`/`work_item_prefix`/`name` + опц. `agent_models`/`agent_efforts`. Регистрация нового проекта = правка `.env` на хосте + **управляемый рестарт** (операторский шаг). | `src/projects.py` (`_parse_projects_json`, `_load_projects`) | +| Ф-4 | Статусы Plane резолвятся по **точным именам** (22 ключа `_PLANE_NAME_TO_KEY`); `Confirm Deploy` (ORCH-059) и `STOP` (группа `cancelled`, ORCH-090) — **fail-closed** (нет статуса на доске → ветка не активируется); TTL-self-heal 300с (ORCH-068) — статус, добавленный позже, подхватывается без рестарта. | `src/plane_sync.py` (`_PLANE_NAME_TO_KEY`, `get_project_states`) | +| Ф-5 | Лейблы `autoApprove`/`autoDeploy` (ORCH-089) и `Bug` (ORCH-019) — **fail-safe** (нет лейбла → ручной режим / полный цикл); сопоставление по нормализованному имени через Plane API. | `src/labels.py`, `src/bug_fast_track.py`, CLAUDE.md (инфра-предусловия) | +| Ф-6 | Plane-webhook — **workspace-level** (один на все проекты, уже существует; в Plane CE создаётся SQL-ом, внешнего API нет). Gitea-webhook — **per-repo** (создаётся через API; events `push`/`pull_request`/`status`; HMAC-secret). | `docs/operations/SETUP_WEBHOOKS.md`, docstring `src/projects.py` | +| Ф-7 | Каноны (golden source) в репо орка: `docs/_templates/` — 16 скелетов (`00…18`, ORCH-52b); `docs/_standards/` — `HANDOFF_PROTOCOL.md`/`PIPELINE_DOCS.md`/`TRACEABILITY.md` (ORCH-52c/e); `.openclaw/agents/*.md` — 6 промптов канона Anthropic (ORCH-52d/92; `deployer.md` — английский **нормативно**, ADR-001 D2 ORCH-092); ADR-конвенция — `PIPELINE_DOCS.md` §4. | листинг репо, `docs/_standards/PIPELINE_DOCS.md` | +| Ф-8 | Per-repo паспорт `CLAUDE.md` — канон самого ORCH-9 (подпись в паспорте орка: «канон per-repo, см. ORCH-9»); все 6 промптов орка начинаются с «прочти CLAUDE.md». | `CLAUDE.md`, `.openclaw/agents/*.md` | + +### 1.4. Принятая трактовка постановки (расхождения 05.06 ↔ 10.06) +- **Реализация в репо orchestrator** (данный конвейер пишет в этот репо; каноны живут здесь). + Упоминание отдельного репо `onboard2orch` (05.06) — историческое: его пример enduro-trails + объявлен устаревшим; судьба репо — операторское решение вне кода (рекомендация: архивировать/ + оставить указатель, чтобы не плодить второй источник канона). Эскалации не требует: актуализация + 10.06 прямо говорит «каноны кодифицированы в репо орка — их и брать за образец». +- **Раскладка docs/**: слой-1 постановки (05.06) указывал `docs/adr/`; канон орка — + `docs/architecture/adr/` (сквозные) + `docs/work-items//06-adr/` (per-task). Применяется + канон орка (эталон = орк). + +--- + +## 2. Объём (scope) + +### 2.1. В объёме +- **Onboarding-kit**: параметризуемый каркас нового репо — 6 промптов агентов, паспорт + `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, `README.md`, `CHANGELOG.md`, скелет `docs/` + (включая `operations/INFRA.md`), копии `docs/_templates/` + `docs/_standards/`. +- **Onboarding-скрипт** (операторский CLI, вне конвейера): Gitea-репо + per-repo webhook, + Plane-проект + статусы + лейблы (в мере, доступной API), материализация kit (подстановка + параметров) + initial push в свежесозданный репо, генерация валидной записи реестра, режимы + dry-run / apply / verify, идемпотентность. +- **Runbook** `docs/operations/ONBOARDING.md`: полный чеклист, явная маркировка ручных шагов + (env + управляемый рестарт; UI-only действия Plane), верификация, откат. +- **Верификация способности**: автоматические структурные тесты kit (pytest) + документированный + smoke-прогон на песочнице («агент по своему промпту находит доку, использует и актуализирует»). +- **Актуализация обзорных доков** в том же PR (CLAUDE.md, `docs/architecture/README.md`, + CHANGELOG, обобщение `SETUP_WEBHOOKS.md`). + +### 2.2. Вне объёма (явно, не делать) +- Исполнение онбординга конкретного нового заказчика/проекта (включая правку прод-`.env` и + рестарт прода) — операторская эксплуатация способности. +- ORCH-10 — тиражирование платформы на новый хост (IaC-bundle); мультитенантность (D5.6); + параллелизм D5.1. +- Стеки-плагины D4.1 (профили web/mobile/data/ML): kit параметризуется плейсхолдерами, без + механизма профилей. +- Любые изменения `src/**`: машина стадий, QG, launcher, реестр `projects.py` (его контракт уже + достаточен — регистрация через `ORCH_PROJECTS_JSON`), схема БД. +- Создание/миграция Plane workspace-webhook (уже существует, общий на workspace). +- Слой-3 продуктовый мониторинг онбордируемого приложения (фундамент эпика, отдельные задачи). + +--- + +## 3. Заинтересованные стороны +- **Владелец/оператор (Слава, Стрим):** запускает онбординг, выполняет операторские шаги + (env, рестарт, UI-шаги Plane), принимает результат smoke-прогона. +- **Будущие заказчики/проекты:** получают рабочий автономный конвейер «за минуты» (D5.2). +- **Действующие проекты (enduro-trails, orchestrator):** не должны почувствовать онбординг + соседа — общий прод-инстанс, общая БД, общая очередь (self-hosting, ORCH-1). +- **Агенты конвейера:** потребители kit — промпты обязаны вести их к доке проекта. + +--- + +## 4. Бизнес-требования (BR) + +| ID | Требование | Связь | +|----|------------|-------| +| BR-1 | **Turnkey-проход:** один документированный проход (скрипт + runbook) разворачивает все слои: Plane-проект (статусы+лейблы) → Gitea-репо (+webhook) → kit в репо (initial push) → запись реестра → верификация. Список шагов закрыт и воспроизводим. | AC-1, AC-11 | +| BR-2 | **Единый эталон без форка:** kit производится от **живых** канонов репо орка — `docs/_templates/`/`docs/_standards/` копируются в новый репо в момент онбординга «как есть»; параметризация — только в kit-собственных шаблонах (промпты, паспорт, INFRA и пр.). Вторая редактируемая копия канона внутри орка не создаётся. enduro-trails эталоном не является. | AC-5, Ф-2/Ф-7 | +| BR-3 | **Полный набор промптов:** 6 ролей (analyst/architect/developer/reviewer/tester/deployer), параметризуемых под проект/стек, по канону Anthropic 52d: 5 XML-секций в нормативном порядке, запреты «❌ X → ✅ Y», `` у developer/reviewer/tester (ORCH-092), добровольная эмиссия 6-польной frontmatter-схемы 52c, machine-verdict ключи байт-в-байт (`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:`). Каждый промпт жёстко направляет: читай паспорт/`AGENTS.md`/доку ПЕРЕД работой, пиши артефакты в `docs/work-items//` по канону, обновляй CHANGELOG/доку. | AC-2, AC-4 | +| BR-4 | **Reviewer-gate на доку:** шаблон reviewer-промпта содержит проверку «документация обновлена; нет → REQUEST_CHANGES» (канон держится автоматически, не на честном слове). | AC-3 | +| BR-5 | **Каркас репо полон:** `README.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, `AGENTS.md` (точка входа агентов: карта доков + правила), паспорт `CLAUDE.md`, `docs/` (архитектура, конвейер, продукт-видение, `operations/`, ADR-реестр, `work-items/`, `history/`), копии `_templates/`+`_standards/`. Ссылочная целостность: промпты ссылаются только на реально существующие в каркасе пути. | AC-1, AC-5 | +| BR-6 | **INFRA.md обязателен:** топология (контейнеры/порты прод+staging/сеть/тома/БД), карта env-переменных (дескрипторы в репо, секреты только в `.env` на хосте, `.env.example` — канон), границы доступа, риски общего хоста. Для самого орка существующие self-hosting-предупреждения (общая БД/очередь/groupwide-риск рестарта) сохраняются нетронутыми. | AC-10 | +| BR-7 | **Plane-проект под машинные контракты:** статусы с **точными** каноническими именами (все 22 имени `_PLANE_NAME_TO_KEY`, включая `Confirm Deploy` и `STOP` с группой `cancelled`) + лейблы `autoApprove`/`autoDeploy`/`Bug`. Что недоступно через Plane API — явный ручной пункт runbook с командой проверки. | AC-7, Ф-4/Ф-5 | +| BR-8 | **Регистрация в реестре:** скрипт генерирует запись `ORCH_PROJECTS_JSON`, валидную через фактический парсер `projects._parse_projects_json` (round-trip). Применение env + рестарт — **операторский** шаг, явно описанный в runbook; скрипт прод-контейнер НЕ рестартит. | AC-6, AC-9, Ф-3 | +| BR-9 | **Безопасность исполнения:** dry-run по умолчанию / явный apply; идемпотентный повторный прогон (доделывает недостающее, не дублирует, ничего не удаляет); аддитивность — существующие проекты/репо не модифицируются; push — только initial в свежесозданный пустой репо (никогда в `main` существующих). | AC-8, AC-9 | +| BR-10 | **Верификация способности:** (а) автоматические структурные тесты kit/скрипта (pytest, без сети); (б) verify-режим: registry-валидность, резолв статусов, наличие webhook, полнота kit; (в) документированный smoke на песочнице: новый агент по своему промпту находит доку, использует и актуализирует её. | AC-12, AC-13 | +| BR-11 | **Прозрачность:** каждый шаг скрипта логируется; итоговый отчёт «создано / уже было (пропущено) / требует ручного шага». | AC-8 | + +--- + +## 5. Нефункциональные требования (NFR) + +| ID | Требование | +|----|------------| +| NFR-1 | **`src/**` не меняется.** Изменение — docs/templates/scripts/tests-only. `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, machine-verdict ключи, схема БД, контракт `projects.py` — байт-в-байт. | +| NFR-2 | **Self-hosting безопасность:** скрипт никогда не рестартит/не останавливает прод-контейнер, не пишет в общую БД, не пушит в `main` существующих репо, не трогает чужие Plane-проекты/Gitea-репо. Онбординг соседа не влияет на enduro/orchestrator. | +| NFR-3 | **Секреты:** токены Plane/Gitea — только из env на хосте; сгенерированные секреты (webhook HMAC) выводятся оператору для `.env` и в гит не попадают; `.env.example` — канон. | +| NFR-4 | **Anti-drift:** структурные тесты канона для kit-промптов (аналог `tests/test_agent_prompts_canon.py`) — расхождение kit с каноном 52d ловится CI, а не глазами. | +| NFR-5 | **Оффлайн-тестируемость:** все тесты детерминированы, без реальных вызовов Plane/Gitea (моки); сетевые шаги изолированы за тонким слоем. | +| NFR-6 | **Документация = golden source:** CLAUDE.md / `docs/architecture/README.md` / CHANGELOG обновлены в том же PR; reviewer-gate применим к самому PR. | + +--- + +## 6. Допущения и ограничения +- Plane API v1 позволяет создавать проект/статусы/лейблы (issue-API уже используется кодом); + если на практике какой-то вызов недоступен в CE — шаг деградирует в ручной пункт runbook + (fail-safe постановки, не блокер задачи). +- Скрипт — операторский инструмент: запускается человеком на хосте с токенами из `.env`, + **вне** конвейера задач; конвейер его не вызывает. +- Регистрация проекта вступает в силу после операторского рестарта (Ф-3) — это сознательное + ограничение (никакой автоматики рестартов, NFR-2); TTL-self-heal статусов (Ф-4) рестарта + не требует. +- Песочница для smoke — staging-контур (8501, изолированная БД, sandbox-проект) либо одноразовый + sandbox-проект в Plane/Gitea; выбор фиксирует архитектор/оператор в runbook. +- Языковая политика kit-промптов: по канону орка (5 ru + deployer en, ADR-001 D2 ORCH-092); + окончательное слово за архитектором, отступление — только с обоснованием в ADR. + +--- + +## 7. Критерии успеха (резюме; детали — 03-acceptance-criteria.md) +- Kit полон и канон-чист (структурные тесты зелёные): 6 промптов 52d + reviewer-gate + каркас + репо + INFRA.md + копии канонов. +- Скрипт: dry-run печатает полный план без мутаций; apply идемпотентен; registry-запись проходит + round-trip через фактический парсер; план Plane содержит точные имена статусов и лейблы. +- Runbook закрывает 100% шагов (ручные — помечены) и верификацию. +- `src/**` не тронут; пайплайн-инварианты байт-в-байт. +- Smoke на песочнице: агент по промпту находит и актуализирует доку (документированный прогон). + +--- + +## 8. Риски (кратко; детали — 10-tech-risks.md, заполняет архитектор) +- **R-1 Drift канона:** копия канонов в kit/новых репо разъезжается с живым каноном орка → + митигируется BR-2 (live-copy в момент онбординга) + NFR-4 (структурные тесты). +- **R-2 Тихая деградация Plane-контрактов:** опечатка в имени статуса/лейбла → fail-closed/ + fail-safe ветки молча не работают → митигируется BR-7 (точные имена из кода) + verify-режимом. +- **R-3 Скрипт с боевыми токенами:** ошибка = разрушительное действие → dry-run по умолчанию, + никаких delete-операций, аддитивность (BR-9). +- **R-4 «Скрипт сделал — оператор не знает про env+restart»:** проект создан, но невидим для + оркестратора → runbook явно фиксирует операторские шаги + verify-режим показывает разрыв (BR-8/10). +- **R-5 Утечка орк-специфики в kit:** новый проект получает чужие литералы (ORCH-, порты 8500/8501, + self-hosting-правила) → структурный тест на остаточные плейсхолдеры/литералы (AC-5). diff --git a/docs/work-items/ORCH-009/02-trz.md b/docs/work-items/ORCH-009/02-trz.md new file mode 100644 index 0000000..081338d --- /dev/null +++ b/docs/work-items/ORCH-009/02-trz.md @@ -0,0 +1,227 @@ +--- +work_item: ORCH-009 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-009 — Онбординг проектов в оркестратор (turnkey) + +Work Item: **ORCH-009** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **что** должно измениться и **где** (модули/контракты/артефакты). **Как** (точная +> раскладка kit, механизм подстановки, формат CLI) — решает архитектор в `06-adr/`. Имена путей +> ниже — рабочее предложение; архитектор вправе скорректировать, сохранив требования и AC. + +> ⚠️ Скоуп по актуализации 10.06: эталон = репо orchestrator; deliverables — в этом репо. +> `src/**` НЕ меняется (NFR-1) — задача docs/templates/scripts/tests-only. + +--- + +## 1. Сводка изменения +Создать **способность turnkey-онбординга** нового проекта: (1) параметризуемый **onboarding-kit** +(каркас нового репо: 6 промптов агентов по канону 52d/92, паспорт, AGENTS/CONTRIBUTING, скелет +`docs/` с INFRA.md, копии живых канонов `_templates/`+`_standards/`); (2) операторский +**onboarding-скрипт** (Gitea-репо + per-repo webhook; Plane-проект + статусы + лейблы; +материализация kit + initial push; генерация записи реестра; dry-run/apply/verify; идемпотентно); +(3) **runbook** `docs/operations/ONBOARDING.md` (полный чеклист, ручные шаги, верификация); +(4) **структурные тесты** анти-дрейфа. Конвейер/движок не трогаются. + +--- + +## 2. Задействованные модули / пути + +| Путь | Действие | Роль | +|------|----------|------| +| `onboarding/repo-skeleton/**` | создать | параметризуемый kit нового репо (дерево зеркалит целевой репо: `.openclaw/agents/*.md`, `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, `README.md`, `CHANGELOG.md`, `docs/**`) | +| `onboarding/README.md` | создать | устройство kit: словарь плейсхолдеров, правило «канон не форкается» (что копируется live, что шаблонизируется) | +| `scripts/onboard_project.py` | создать | операторский turnkey-CLI: `plan` (dry-run, дефолт) / `apply` / `verify`; идемпотентность; отчёт | +| `docs/operations/ONBOARDING.md` | создать | runbook: последовательность, ручные шаги (env+рестарт, UI-only Plane), верификация, откат | +| `docs/operations/SETUP_WEBHOOKS.md` | обновить | обобщить per-repo Gitea-webhook секцию (сейчас примеры захардкожены на enduro-trails); сослаться на ONBOARDING.md | +| `tests/test_onboarding_kit.py` | создать | структура kit, канон промптов, reviewer-gate, INFRA/AGENTS/CONTRIBUTING | +| `tests/test_onboarding_script.py` | создать | рендер/плейсхолдеры, registry round-trip, dry-run/идемпотентность, план Plane/Gitea (моки) | +| `tests/test_onboarding_invariants.py` | создать | `src/**` не тронут логикой онбординга; снапшот `STAGE_TRANSITIONS`/`QG_CHECKS` | +| `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md` | обновить | golden source: раздел «Онбординг проектов (ORCH-009)», запись changelog | +| `src/**` | **не менять** | NFR-1; скрипту разрешён **read-only import** `src.projects._parse_projects_json` / констант `src.plane_sync._PLANE_NAME_TO_KEY` для round-trip и точных имён (не дублировать литералы) — допустимость импорта vs снапшот-фикстура решает архитектор | + +Справочные источники kit (read-only): `.openclaw/agents/*.md`, `docs/_templates/` (16 скелетов), +`docs/_standards/` (3 стандарта), `docs/operations/INFRA.md` (образец структуры RUNBOOK). + +--- + +## 3. Функциональные требования + +### FR-1 — Onboarding-kit: состав каркаса нового репо (BR-3/BR-5/BR-6) +`onboarding/repo-skeleton/` содержит (минимум): + +``` +.openclaw/agents/{analyst,architect,developer,reviewer,tester,deployer}.md ← шаблоны промптов +CLAUDE.md ← паспорт проекта (per-repo канон, Ф-8) +AGENTS.md ← точка входа агентов: карта доков + правила ведения +CONTRIBUTING.md ← канон процесса: где что лежит, как вести +README.md ← entrypoint: что это, quickstart, ссылки +CHANGELOG.md ← пустой каркас +docs/ARCHITECTURE.md ← код-карта/потоки/БД (заполняется по мере жизни) +docs/PIPELINE.md ← стадии, QG, агенты (ссылается на _standards) +docs/PRODUCT_VISION.md ← зачем проект (BRD-свод) +docs/operations/INFRA.md ← обязательный RUNBOOK (см. FR-3) +docs/architecture/adr/ ← реестр сквозных ADR (канон орка, §1.4 BRD) +docs/work-items/.gitkeep +docs/history/.gitkeep +docs/_templates/ ← live-копия канона орка в момент онбординга (BR-2) +docs/_standards/ ← live-копия канона орка в момент онбординга (BR-2) +.env.example ← заготовка карты env (без секретов) +``` + +- **Параметризация** — единый словарь плейсхолдеров (минимум): `{{PROJECT_NAME}}`, `{{REPO}}`, + `{{WORK_ITEM_PREFIX}}`, `{{PLANE_PROJECT_ID}}`, `{{STACK}}`, `{{TEST_CMD}}`, + `{{PROD_PORT}}`/`{{STAGING_PORT}}` (расширяемо; единый синтаксис, фиксирует архитектор). +- **Ссылочная целостность:** каждый путь, на который ссылаются kit-промпты/AGENTS.md, существует + в каркасе (проверяемо тестом). +- **Правило «канон не форкается» (BR-2):** `docs/_templates/` и `docs/_standards/` НЕ хранятся + второй редактируемой копией в kit — копируются скриптом из живого канона репо орка в момент + материализации. В kit хранятся только параметризуемые дельты (промпты, паспорт, AGENTS, + CONTRIBUTING, README, INFRA и пр.). + +### FR-2 — Шаблоны промптов 6 ролей по канону 52d/92 (BR-3/BR-4) +Каждый из 6 шаблонов промптов: +- 5 обязательных XML-секций в нормативном порядке `` → `` → `` → + `` → ``; ``; `` у + developer/reviewer/tester (после ``); `` у решающих ролей — + как в эталоне `.openclaw/agents/` (ORCH-077/092). +- Запреты в формате «❌ X → ✅ Y». +- Директивы доки (жёстко): читай `CLAUDE.md`(паспорт)/`AGENTS.md`/`docs/ARCHITECTURE.md`/ADR + ПЕРЕД работой; пиши артефакты в `docs/work-items//` по `docs/_standards/PIPELINE_DOCS.md` + (скелеты из `docs/_templates/`); архитектор фиксирует решения в `06-adr/` + сквозные в + `docs/architecture/adr/adr-NNNN-slug.md`; каждый обновляет `CHANGELOG.md`/релевантную доку. +- **Reviewer:** содержит gate «документация обновлена? нет → `verdict: REQUEST_CHANGES`». +- Эмиссия 6-польной frontmatter-схемы 52c (`work_item`/`stage`/`author_agent`/`status`/ + `created_at`/`model_used`) — аддитивно; machine-verdict ключи и значения байт-в-байт + (`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:`); копируемые + примеры — с плейсхолдерами ``/`` (анти-паттерн ORCH-092 учтён). +- Стек-специфика (язык/тесты/команды) — только через плейсхолдеры; никаких унаследованных + орк-литералов (порты 8500/8501, «ORCH-», self-hosting-правила орка) в материализованном виде. +- Языковая политика: по канону орка (5 ru + deployer en, нормативно ADR-001 D2 ORCH-092); + отступление — только решением архитектора в ADR. + +### FR-3 — INFRA.md шаблон: обязательные секции (BR-6) +Шаблон `docs/operations/INFRA.md` нового проекта содержит секции: топология (контейнеры, порты +прод/staging, сеть, тома, БД); карта env-переменных (дескрипторы в репо; секреты ТОЛЬКО в `.env` +на хосте; `.env.example` — канон; `docker-compose.yml`/`Dockerfile` трекаются в гите); границы +доступа; эксплуатационные предупреждения, включая **риски общего хоста** (соседние контейнеры, +общие ресурсы; факт: хост впритык — см. `docs/epics/self-evolution.md` С-3). Существующий +`docs/operations/INFRA.md` орка с self-hosting-предупреждениями (общая БД/очередь/групповой риск +рестарта) — не модифицируется этой задачей (read-only образец). + +### FR-4 — Onboarding-скрипт: провижининг (BR-1/BR-7/BR-9/BR-11) +`scripts/onboard_project.py` (вход: имя проекта, repo, префикс work-item, параметры стека): +- **Gitea:** создать репо (API), создать per-repo webhook (`push`/`pull_request`/`status`, + HMAC-secret из/для `.env` — формат `SETUP_WEBHOOKS.md`); материализовать kit → **initial push + в свежесозданный пустой репо** (единственный разрешённый push; в существующие репо — никогда). +- **Plane:** создать проект (или принять существующий `--plane-project-id`); создать статусы со + **точными** именами из `_PLANE_NAME_TO_KEY` (22 имени; `STOP` — группа `cancelled`, + `Confirm Deploy` — отдельный статус; группы фиксирует архитектор по `plane_sync`) и лейблы + `autoApprove`/`autoDeploy`/`Bug`. Недоступное через API CE → пункт отчёта «ручной шаг» со + ссылкой на runbook (fail-safe, не падение). +- **Реестр:** сгенерировать запись `ORCH_PROJECTS_JSON` (полный новый массив или диф — + фиксирует архитектор), **валидную через фактический `projects._parse_projects_json`**; + вывести оператору инструкцию «добавь в `.env` + управляемый рестарт». Скрипт сам `.env` прода + не правит и контейнер не рестартит (NFR-2). +- **Режимы:** `plan` (дефолт; полный план без единой мутации), `apply` (исполнение), + `verify` (см. FR-5). Идемпотентность: повторный `apply` обнаруживает существующее + (репо/webhook/статусы/лейблы/файлы) и пропускает с пометкой; ничего не удаляет и не + перезаписывает существующий контент без явного флага. +- **Прозрачность:** лог каждого шага + итоговый отчёт: `created / skipped(exists) / manual-step`. +- **Webhook Plane:** не создаётся (workspace-level уже существует, Ф-6) — только проверка в verify. + +### FR-5 — Верификация (BR-10) +- `verify`-режим скрипта: запись реестра парсится (`_parse_projects_json` round-trip → поля + совпадают); статусы проекта резолвятся (все логические ключи, включая `confirm_deploy`/`stop`); + лейблы присутствуют; Gitea-webhook существует и активен; kit-файлы в репо (включая 6 промптов, + AGENTS.md, INFRA.md, `_templates`/`_standards`); нет неразрешённых плейсхолдеров. +- **Smoke на песочнице** (runbook, операторский): онбордить sandbox-проект → создать тестовую + задачу → стадия analysis в песочнице → убедиться: агент прочитал доку проекта (следы в + выводе/артефактах) и записал артефакты в `docs/work-items//` по канону. Контур песочницы + (staging 8501 / одноразовый sandbox) фиксирует архитектор в ADR + runbook. + +### FR-6 — Runbook ONBOARDING.md (BR-1/BR-8) +Полный чеклист онбординга: предусловия (токены, доступы) → шаги скрипта → **операторские шаги** +(env+рестарт — с предупреждением self-hosting: рестарт прода = групповое окно, выполнять +осознанно; UI-only шаги Plane, напр. drag-and-drop порядок статусов) → верификация (verify + +smoke) → откат (удаление созданного — вручную, скрипт не удаляет). Каждый ручной шаг — с командой +проверки результата. + +--- + +## 4. Изменения API +**Нет.** Новые/изменённые HTTP-эндпоинты оркестратора не вводятся; вебхук-контракты не меняются. +(Onboarding-CLI — операторский инструмент вне FastAPI-приложения.) + +## 5. Изменения схемы БД +**Нет.** Общая БД не читается и не пишется скриптом (NFR-2). + +## 6. Требования к новым/изменённым QG checks +**Нет.** Реестр `QG_CHECKS`/`check_*`/`STAGE_TRANSITIONS` — байт-в-байт (контроль — снапшот-тест, +TC-18). Онбординг — операторская способность, не гейт конвейера. + +--- + +## 7. Совместимость / регресс +- **Нулевая регрессия кода:** `src/**` не меняется → поведение конвейера для enduro/orchestrator + идентично; полный регресс `tests/` остаётся зелёным. +- **Kill-switch не требуется:** способность активируется только явным запуском операторского CLI; + в горячих путях конвейера нового кода нет. +- **Обратимость:** удаление `onboarding/`/`scripts/onboard_project.py`/runbook возвращает репо в + исходное состояние; созданные онбордингом внешние сущности сносятся вручную по разделу + «Откат» runbook. +- **Совместимость канонов:** kit-промпты проходят те же структурные требования, что эталонные + (анти-дрейф NFR-4); обновление канона орка автоматически подхватывается live-copy частью kit + (BR-2), шаблонные дельты — через обычные PR с reviewer-gate. + +--- + +## 8. Артефакты pipeline (создать/обновить в ТОМ ЖЕ PR) +- `docs/work-items/ORCH-009/06-adr/ADR-001-…` — решения архитектора (раскладка kit, синтаксис + плейсхолдеров, copy-vs-template split по файлам, импорт `src` из скрипта vs снапшот, контур + песочницы, языковая политика kit-deployer). +- `docs/architecture/README.md` — раздел «Онбординг проектов (ORCH-009)». +- `CLAUDE.md` — краткий абзац о способности онбординга. +- `CHANGELOG.md` — запись `feat:`. +- `docs/operations/ONBOARDING.md` (новый), `docs/operations/SETUP_WEBHOOKS.md` (обобщение). +- `07-infra-requirements.md` — предусловия онбординга (токены/доступы), заполняет архитектор. + +--- + +## 9. Инварианты (не нарушать) +- `src/**` без изменений; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict ключи/схема + БД — байт-в-байт (NFR-1). +- Скрипт: не рестартит/не останавливает прод-контейнер; не пушит в `main` существующих репо + (INV-4 — мерж только через PR-merge API — не затрагивается: initial push идёт в свежесозданный + пустой репо, не являющийся участником конвейера до регистрации); не удаляет внешние сущности; + секреты в гит не попадают (NFR-2/NFR-3). +- Никаких сетевых вызовов в тестах (NFR-5); никаких новых обязательных pip-зависимостей без + обоснования в ADR. +- Эталонные промпты орка `.openclaw/agents/*.md` этой задачей не модифицируются (они — read-only + образец; их правка = отдельные задачи канона). + +--- + +## 10. Открытые вопросы для архитектора (не блокируют анализ) +- OQ-1: Раскладка kit — `onboarding/repo-skeleton/` (предложение) vs `docs/_onboarding/` vs + `scripts/onboarding/`; где словарь плейсхолдеров. +- OQ-2: Механизм подстановки — stdlib (`str.replace`/`string.Template`) без новых зависимостей + (рекомендация) vs шаблонизатор (новая зависимость — потребует обоснования). +- OQ-3: Copy-vs-template split: какие файлы kit — live-copy канона, какие — параметризуемые + шаблоны (минимум по BR-2: `_templates`/`_standards` — live-copy). +- OQ-4: Скрипту импортировать `src.projects`/`src.plane_sync` (точные имена/парсер, нет + дублирования) vs автономный снапшот констант с тестом синхронизации. +- OQ-5: Plane API CE: фактическая доступность создания проекта/статусов/лейблов — что уходит в + ручные шаги runbook. +- OQ-6: Контур песочницы для smoke (staging 8501 vs одноразовый sandbox-проект) и судьба + sandbox-артефактов после прогона. +- OQ-7: Языковая политика kit-промптов для не-self-hosting проектов (рекомендация: канон орка, + deployer — en). +- OQ-8: Защита `main` нового репо в Gitea (branch protection): не должна ломать PR-merge API + конвейера — включать ли вообще (рекомендация: не включать, зафиксировать в runbook). diff --git a/docs/work-items/ORCH-009/03-acceptance-criteria.md b/docs/work-items/ORCH-009/03-acceptance-criteria.md new file mode 100644 index 0000000..575425f --- /dev/null +++ b/docs/work-items/ORCH-009/03-acceptance-criteria.md @@ -0,0 +1,146 @@ +--- +work_item: ORCH-009 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-009 — Turnkey-онбординг проектов + +Work Item: **ORCH-009** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий — чёткое условие **PASS/FAIL**, проверяемое буквально по файлам +репозитория и детерминированным тестам (без сети). AC-13 — единственный операторский +(документированный smoke), его автоматизируемая часть вынесена в AC-2/AC-12. + +--- + +## AC-1 — Kit полон (состав каркаса) +**Условие:** инспекция `onboarding/repo-skeleton/` (или каталога, выбранного архитектором в ADR). +- **PASS:** присутствуют все элементы FR-1: 6 шаблонов промптов (`analyst/architect/developer/ + reviewer/tester/deployer`), `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, `README.md`, + `CHANGELOG.md`, `docs/ARCHITECTURE.md`, `docs/PIPELINE.md`, `docs/PRODUCT_VISION.md`, + `docs/operations/INFRA.md`, `docs/architecture/adr/`, `docs/work-items/`, `docs/history/`, + `.env.example`; материализация добавляет live-копии `docs/_templates/` + `docs/_standards/`. +- **FAIL:** отсутствует любой элемент состава, либо промптов меньше 6. + +## AC-2 — Промпты kit соответствуют канону 52d/92 +**Условие:** структурные тесты по каждому из 6 шаблонов промптов. +- **PASS:** в каждом — 5 обязательных XML-секций в нормативном порядке `→ + `; запреты в формате «❌ → ✅»; `` у + developer/reviewer/tester; директивы «читай паспорт/AGENTS.md/доку ПЕРЕД работой» и «пиши + артефакты в `docs/work-items//`»; эмиссия 6-польной frontmatter-схемы 52c с + плейсхолдерными примерами дат/моделей; machine-verdict ключи у ролей-вердиктов байт-в-байт + (`verdict:` / `result:` / `staging_status:` / `deploy_status:` / `security_status:`). +- **FAIL:** нарушен порядок/состав секций, отсутствует любой verdict-ключ или директива доки, + пример frontmatter содержит захардкоженные дату/модель. + +## AC-3 — Reviewer-gate на обновление доки +**Условие:** шаблон `reviewer.md` в kit. +- **PASS:** содержит явное правило: документация (CHANGELOG/релевантные доки/ADR) обновлена в том + же PR; нет → `verdict: REQUEST_CHANGES`. +- **FAIL:** правило отсутствует или сформулировано как необязательное. + +## AC-4 — Языковая политика kit +**Условие:** проверка языка шаблонов промптов против решения ADR (дефолт — канон орка). +- **PASS:** языковая раскладка соответствует зафиксированной в ADR (по умолчанию: 5 ru + + deployer en, как ADR-001 D2 ORCH-092); отступление — только с обоснованием в ADR. +- **FAIL:** язык промптов противоречит ADR, либо политика нигде не зафиксирована. + +## AC-5 — Материализация: плейсхолдеры и отсутствие утечек +**Условие:** рендер kit с тестовыми параметрами (`PROJECT_NAME`, `REPO`, `WORK_ITEM_PREFIX` и т.д.). +- **PASS:** все плейсхолдеры подставлены; в результате нет ни одного неразрешённого плейсхолдера + (grep по синтаксису из ADR); нет утечек орк-специфики, где должен быть параметр (литералы + `ORCH-` как префикс work-item чужого проекта, порты 8500/8501, self-hosting-правила орка); + пути, на которые ссылаются отрендеренные промпты/AGENTS.md, существуют в каркасе. +- **FAIL:** найден неразрешённый плейсхолдер, орк-литерал вместо параметра или битая ссылка + на несуществующий путь. + +## AC-6 — Registry round-trip через фактический парсер +**Условие:** скрипт генерирует запись реестра для тестового проекта. +- **PASS:** сгенерированный JSON парсится `projects._parse_projects_json` без ошибок; полученный + `ProjectConfig` несёт исходные `plane_project_id`/`repo`/`work_item_prefix`/`name`; существующие + записи реестра не модифицируются и не теряются. +- **FAIL:** парсер отвергает запись, поля искажены, либо генерация ломает/теряет существующие записи. + +## AC-7 — План Plane: точные статусы и лейблы +**Условие:** `plan`-режим для нового проекта (моки сети). +- **PASS:** план провижининга содержит ВСЕ канонические имена статусов из `_PLANE_NAME_TO_KEY` + (включая `Confirm Deploy` и `STOP` с группой `cancelled`) и лейблы `autoApprove`/`autoDeploy`/ + `Bug`; имена байт-в-байт совпадают с константами `src/plane_sync.py` (или их синхронизированным + снапшотом по OQ-4); недоступные через API шаги помечены `manual-step` со ссылкой на runbook. +- **FAIL:** пропущен/искажён любой статус или лейбл; недоступный шаг молча отброшен. + +## AC-8 — План Gitea: репо + per-repo webhook; dry-run без мутаций +**Условие:** `plan`-режим (моки сети). +- **PASS:** план содержит создание репо, создание webhook с events `push`/`pull_request`/`status` + и HMAC-secret (секрет — для `.env` оператора, не в гит), материализацию kit + initial push в + свежесозданный репо; в режиме `plan` не выполняется НИ ОДНОЙ мутации (ни одного + POST/PUT/DELETE-вызова в моках, ни одной записи на диск вне отчёта). +- **FAIL:** план неполон, или dry-run произвёл мутацию. + +## AC-9 — Идемпотентность и безопасность apply +**Условие:** повторный `apply` на частично/полностью созданном проекте (моки: сущности существуют). +- **PASS:** существующие сущности (репо/webhook/статусы/лейблы/файлы) распознаны и пропущены с + пометкой `skipped(exists)`; ничего не удалено и не перезаписано без явного флага; скрипт не + выполняет рестарт/останов контейнеров, не правит `.env` прода, не пушит в существующие репо + (в коде отсутствуют такие операции — проверяемо тестом/ревью); итоговый отчёт перечисляет + created/skipped/manual-step. +- **FAIL:** дублирование сущностей, любое удаление/перезапись без флага, любая операция + рестарта/push в существующий репо, отсутствие отчёта. + +## AC-10 — INFRA.md шаблон: обязательные секции +**Условие:** инспекция шаблона `docs/operations/INFRA.md` в kit. +- **PASS:** присутствуют секции: топология (контейнеры/порты прод+staging/сеть/тома/БД); + карта env-переменных + правило секретов (только `.env` на хосте, `.env.example` — канон); + границы доступа; предупреждения о рисках общего хоста. Существующий `docs/operations/INFRA.md` + орка (self-hosting-предупреждения) этой задачей не изменён. +- **FAIL:** отсутствует любая обязательная секция, либо изменён INFRA.md самого орка. + +## AC-11 — Runbook ONBOARDING.md полон +**Условие:** инспекция `docs/operations/ONBOARDING.md`. +- **PASS:** покрывает все слои BR-1 в последовательности: предусловия (токены/доступы) → Plane + (проект/статусы/лейблы) → Gitea (репо/webhook) → kit (материализация/push) → регистрация + (env-строка + операторский управляемый рестарт с self-hosting-предупреждением) → верификация + (`verify` + smoke на песочнице) → откат; каждый ручной шаг помечен и снабжён командой проверки; + Plane workspace-webhook описан как существующий (проверка, не создание). +- **FAIL:** пропущен слой, ручной шаг не помечен/без проверки, или runbook требует + автоматического рестарта прода. + +## AC-12 — Инварианты: src/** не тронут +**Условие:** diff PR + снапшот-тест. +- **PASS:** `git diff` PR не содержит изменений `src/**`; снапшот `STAGE_TRANSITIONS` и реестра + `QG_CHECKS` совпадает с эталоном; эталонные промпты `.openclaw/agents/*.md` орка не изменены; + полный регресс `tests/` зелёный. +- **FAIL:** любой diff в `src/**` или `.openclaw/agents/`, расхождение снапшота, красный регресс. + +## AC-13 — Smoke: агент находит, использует и актуализирует доку (операторский) +**Условие:** документированный прогон по runbook на песочнице (контур — по ADR): онбординг +sandbox-проекта → тестовая задача → стадия analysis. +- **PASS:** агент песочницы по своему промпту прочитал доку проекта (следы чтения паспорта/ + AGENTS.md в выводе или артефактах) и записал артефакты в `docs/work-items//` по канону + (структура соответствует `PIPELINE_DOCS.md`); результат прогона зафиксирован в runbook/отчёте + задачи. Для приёмки данной задачи прогон выполняется один раз и протоколируется. +- **FAIL:** агент не нашёл доку (артефакты вне канона/не созданы), либо прогон не запротоколирован. + +--- + +## Сводная матрица AC ↔ BR/FR + +| AC | BR | FR | Тип проверки | +|----|----|----|--------------| +| AC-1 | BR-5 | FR-1 | unit (структура kit) | +| AC-2 | BR-3 | FR-2 | unit (структурный канон) | +| AC-3 | BR-4 | FR-2 | unit | +| AC-4 | BR-3 | FR-2 | unit + ADR | +| AC-5 | BR-2/BR-5 | FR-1/FR-2 | unit (рендер) | +| AC-6 | BR-8 | FR-4 | integration (реальный парсер) | +| AC-7 | BR-7 | FR-4 | unit (план, моки) | +| AC-8 | BR-1/BR-9 | FR-4 | unit (план, моки) | +| AC-9 | BR-9/BR-11 | FR-4 | unit/integration (моки) | +| AC-10 | BR-6 | FR-3 | unit (структура) | +| AC-11 | BR-1/BR-8 | FR-6 | unit (структура дока) | +| AC-12 | NFR-1 | — | unit (снапшот) + ревью diff | +| AC-13 | BR-10 | FR-5 | ручной smoke (протоколируемый) | diff --git a/docs/work-items/ORCH-009/04-test-plan.yaml b/docs/work-items/ORCH-009/04-test-plan.yaml new file mode 100644 index 0000000..09678f0 --- /dev/null +++ b/docs/work-items/ORCH-009/04-test-plan.yaml @@ -0,0 +1,164 @@ +work_item: ORCH-009 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-10 +model_used: claude-opus-4-8 +title: "Turnkey-онбординг проектов: kit + скрипт + runbook (ORCH-009)" +framework: pytest +scope: > + Структурная полнота onboarding-kit, канон 52d/92 шаблонов промптов, материализация + (плейсхолдеры/утечки), registry round-trip через фактический парсер projects.py, + планы Plane/Gitea (dry-run, моки), идемпотентность apply, runbook, инварианты src/**. + Вне покрытия pytest: реальные вызовы Plane/Gitea API и операторский smoke на песочнице + (AC-13) — выполняется вручную по docs/operations/ONBOARDING.md и протоколируется. +notes: > + Все тесты детерминированы, без сети (Plane/Gitea мокируются; NFR-5). Точные имена файлов + тест-модулей могут уточняться архитектором при сохранении покрытия TC↔AC. Полный регресс + tests/ должен оставаться зелёным (src/** не меняется — NFR-1). Если ADR изменит раскладку + kit (OQ-1) — пути в тестах следуют ADR, маппинг TC↔AC неизменен. + +tests: + # ---------- AC-1: состав kit ---------- + - id: TC-01 + type: unit + description: "Kit содержит все элементы FR-1: 6 шаблонов промптов, CLAUDE.md, AGENTS.md, CONTRIBUTING.md, README.md, CHANGELOG.md, docs/ARCHITECTURE.md, docs/PIPELINE.md, docs/PRODUCT_VISION.md, docs/operations/INFRA.md, docs/architecture/adr/, docs/work-items/, docs/history/, .env.example" + module: tests/test_onboarding_kit.py + expected: PASS + + - id: TC-02 + type: unit + description: "Материализация добавляет live-копии docs/_templates/ (16 канонических скелетов) и docs/_standards/ (3 стандарта) из живого канона репо орка; вторая редактируемая копия канона в kit отсутствует (BR-2)" + module: tests/test_onboarding_script.py + expected: PASS + + # ---------- AC-2: канон промптов 52d/92 ---------- + - id: TC-03 + type: unit + description: "Каждый из 6 шаблонов промптов содержит 5 обязательных XML-секций в нормативном порядке context→task→deliverables→constraints→output_format" + module: tests/test_onboarding_kit.py + expected: PASS + + - id: TC-04 + type: unit + description: "Шаблоны developer/reviewer/tester содержат секцию ; запреты оформлены в формате '❌ → ✅'" + module: tests/test_onboarding_kit.py + expected: PASS + + - id: TC-05 + type: unit + description: "Каждый шаблон промпта направляет агента к доке: читай паспорт(CLAUDE.md)/AGENTS.md/ARCHITECTURE/ADR перед работой; пиши артефакты в docs/work-items// по PIPELINE_DOCS; обновляй CHANGELOG/доку" + module: tests/test_onboarding_kit.py + expected: PASS + + - id: TC-06 + type: unit + description: "Шаблоны эмитят 6-польную frontmatter-схему 52c (work_item/stage/author_agent/status/created_at/model_used); machine-verdict ключи ролей байт-в-байт (verdict:/result:/staging_status:/deploy_status:/security_status:); примеры дат/моделей — плейсхолдеры, не литералы (анти-паттерн ORCH-092)" + module: tests/test_onboarding_kit.py + expected: PASS + + # ---------- AC-3: reviewer-gate ---------- + - id: TC-07 + type: unit + description: "Шаблон reviewer.md содержит обязательный gate: документация не обновлена в PR → verdict: REQUEST_CHANGES" + module: tests/test_onboarding_kit.py + expected: PASS + + # ---------- AC-4: языковая политика ---------- + - id: TC-08 + type: unit + description: "Языковая раскладка шаблонов соответствует политике ADR (дефолт: 5 ru + deployer en, канон ADR-001 D2 ORCH-092)" + module: tests/test_onboarding_kit.py + expected: PASS + + # ---------- AC-5: материализация ---------- + - id: TC-09 + type: unit + description: "Рендер kit с тестовыми параметрами подставляет все плейсхолдеры: в выходе нет ни одного неразрешённого плейсхолдера (grep по синтаксису из ADR)" + module: tests/test_onboarding_script.py + expected: PASS + + - id: TC-10 + type: unit + description: "В отрендеренном kit нет утечек орк-специфики, где должен быть параметр: префикс ORCH- вместо префикса проекта, порты 8500/8501, self-hosting-правила орка" + module: tests/test_onboarding_script.py + expected: PASS + + - id: TC-11 + type: unit + description: "Ссылочная целостность: каждый путь, на который ссылаются отрендеренные промпты и AGENTS.md, существует в материализованном каркасе" + module: tests/test_onboarding_script.py + expected: PASS + + # ---------- AC-6: registry round-trip ---------- + - id: TC-12 + type: integration + description: "Сгенерированная скриптом запись реестра парсится фактическим projects._parse_projects_json; ProjectConfig несёт исходные plane_project_id/repo/work_item_prefix/name; существующие записи реестра сохранены без искажений" + module: tests/test_onboarding_script.py + expected: PASS + + # ---------- AC-7: план Plane ---------- + - id: TC-13 + type: unit + description: "plan-режим: план Plane содержит все канонические имена статусов _PLANE_NAME_TO_KEY (включая 'Confirm Deploy' и 'STOP' с группой cancelled) байт-в-байт и лейблы autoApprove/autoDeploy/Bug" + module: tests/test_onboarding_script.py + expected: PASS + + - id: TC-14 + type: unit + description: "Шаг Plane, недоступный через API (мок отвечает отказом/не реализовано), помечается в плане/отчёте как manual-step со ссылкой на runbook — не отбрасывается молча и не валит скрипт" + module: tests/test_onboarding_script.py + expected: PASS + + # ---------- AC-8: план Gitea + dry-run ---------- + - id: TC-15 + type: unit + description: "plan-режим: план Gitea содержит создание репо, webhook (events push/pull_request/status + HMAC-secret вне гита) и initial push kit в свежесозданный репо" + module: tests/test_onboarding_script.py + expected: PASS + + - id: TC-16 + type: unit + description: "dry-run (plan) не выполняет ни одной мутации: ноль POST/PUT/DELETE в замоканных клиентах Plane/Gitea, ноль git push, ноль записей на диск вне отчёта" + module: tests/test_onboarding_script.py + expected: PASS + + # ---------- AC-9: идемпотентность / безопасность apply ---------- + - id: TC-17 + type: integration + description: "Повторный apply на уже созданном проекте (моки: репо/webhook/статусы/лейблы существуют): сущности распознаны и помечены skipped(exists); нет дублей, удалений и перезаписи без явного флага; итоговый отчёт перечисляет created/skipped/manual-step" + module: tests/test_onboarding_script.py + expected: PASS + + - id: TC-18 + type: unit + description: "Скрипт не содержит операций рестарта/останова контейнеров, правки прод-.env и push в существующие репо: на моках полного прогона apply такие вызовы отсутствуют (NFR-2)" + module: tests/test_onboarding_script.py + expected: PASS + + # ---------- AC-10: INFRA.md шаблон ---------- + - id: TC-19 + type: unit + description: "Шаблон INFRA.md kit содержит обязательные секции: топология (контейнеры/порты прод+staging/сеть/тома/БД), карта env + правило секретов (.env на хосте, .env.example — канон), границы доступа, риски общего хоста" + module: tests/test_onboarding_kit.py + expected: PASS + + # ---------- AC-11: runbook ---------- + - id: TC-20 + type: unit + description: "ONBOARDING.md покрывает все слои в последовательности: предусловия → Plane → Gitea → kit → регистрация (env + операторский управляемый рестарт с self-hosting-предупреждением) → верификация (verify + smoke) → откат; ручные шаги помечены и снабжены командами проверки" + module: tests/test_onboarding_kit.py + expected: PASS + + # ---------- AC-12: инварианты ---------- + - id: TC-21 + type: unit + description: "Снапшот STAGE_TRANSITIONS и реестра QG_CHECKS совпадает с эталоном (src/** не затронут логикой онбординга); эталонные промпты .openclaw/agents/ орка не изменены задачей" + module: tests/test_onboarding_invariants.py + expected: PASS + + - id: TC-22 + type: integration + description: "Полный регресс существующего tests/ остаётся зелёным после добавления onboarding-артефактов (никакой новый импорт/код не ломает конвейер)" + module: tests/ + expected: PASS diff --git a/docs/work-items/ORCH-009/06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md b/docs/work-items/ORCH-009/06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md new file mode 100644 index 0000000..421418a --- /dev/null +++ b/docs/work-items/ORCH-009/06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md @@ -0,0 +1,341 @@ +--- +work_item: ORCH-009 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# ADR-001: Turnkey-онбординг проектов — kit `onboarding/` + операторский CLI + runbook + +Work Item: **ORCH-009** — Онбординг проектов в оркестратор (turnkey: Plane + репо + агенты + инфра) +Стадия: **architecture** +Связь: BRD `01-brd.md`, ТЗ `02-trz.md`, AC `03-acceptance-criteria.md`, тест-план `04-test-plan.yaml`, +инфра `07-infra-requirements.md`, риски `10-tech-risks.md`. +Сквозная регистрация: **`docs/architecture/adr/adr-0035-turnkey-project-onboarding.md`** +(решение кросс-каттинговое: новая способность уровня всего оркестратора — масштабирование на +новые проекты, домен D5.2 эпика саморазвития). + +## Статус +Proposed + +--- + +## Контекст + +Онбординг нового проекта сегодня — ручная археология по `SETUP_WEBHOOKS.md`/`INFRA.md`/памяти; +любой пропуск даёт тихую деградацию (BRD §1.2): без промптов в репо конвейер проекта не работает +вовсе (Ф-1: launcher резолвит `.openclaw/agents/.md` **относительно worktree репо задачи**); +без точных имён статусов ветки `Confirm Deploy`/`STOP` молча не активируются (fail-closed, +`src/plane_sync.py:130-165`); без лейблов авто-режимы/багфикс-трек молча выключены (fail-safe, +`src/labels.py`/`src/bug_fast_track.py`). + +Ограничения, заданные анализом и проверенные по коду: + +- **NFR-1:** `src/**` не меняется; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict + ключи/схема БД/контракт `projects.py` — байт-в-байт. Задача docs/templates/scripts/tests-only. +- **Ф-2:** агент видит только worktree своего репо → каноны обязаны быть **скопированы** в новый + репо (ссылка на репо орка агенту недоступна). +- **Ф-3:** реестр строится при импорте из `ORCH_PROJECTS_JSON` (`src/projects.py::_load_projects`); + регистрация = правка `.env` + **операторский** управляемый рестарт. +- **Ф-6:** Plane-webhook — workspace-level, уже существует (в CE создаётся SQL-ом, внешнего API + нет); Gitea-webhook — per-repo, через API (`push`/`pull_request`/`status`, HMAC). +- **Ф-7:** живой канон — `docs/_templates/` (16 скелетов), `docs/_standards/` (3 стандарта), + `.openclaw/agents/*.md` (канон 52d/92). +- Эталон онбординга = **сам репозиторий orchestrator** (актуализация Владельца 10.06); + enduro-trails эталоном не является. + +ТЗ оставило архитектору 8 открытых вопросов (OQ-1…OQ-8) — все закрываются ниже (D1…D11). + +--- + +## Решение + +### Сводка + +Три артефакта + тесты, всё **вне конвейера и вне рантайма**: + +1. **Onboarding-kit** `onboarding/repo-skeleton/` — параметризуемый каркас нового репо + (6 промптов канона 52d/92, паспорт `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, скелет `docs/` + с обязательным `operations/INFRA.md`); словарь плейсхолдеров — `onboarding/placeholders.json`. +2. **Операторский CLI** `scripts/onboard_project.py` — `plan` (дефолт, GET-only) / `apply` + (идемпотентный ensure) / `verify`; Plane (проект+статусы+лейблы) → Gitea (репо+webhook) → + материализация kit (рендер + live-copy канона) + initial push → генерация записи реестра → + отчёт `created/skipped(exists)/manual-step`. +3. **Runbook** `docs/operations/ONBOARDING.md` — полный чеклист, явные ручные шаги + (env + управляемый рестарт; UI-only Plane), верификация (verify + smoke на staging), откат. + +Никакого нового кода в горячих путях; kill-switch не нужен (способность активируется только +явным запуском CLI человеком — TRZ §7). + +### D1 — Раскладка: top-level `onboarding/` (OQ-1) + +**Решение: `onboarding/` в корне репо** — ровно как предложено ТЗ: + +``` +onboarding/ + README.md ← устройство kit: словарь плейсхолдеров, правило «канон не форкается», + copy-vs-template карта (D3), как запускать тесты kit + placeholders.json ← словарь плейсхолдеров (single source of truth, D2) + repo-skeleton/ ← дерево зеркалит целевой репо (FR-1) + .openclaw/agents/{analyst,architect,developer,reviewer,tester,deployer}.md + CLAUDE.md AGENTS.md CONTRIBUTING.md README.md CHANGELOG.md .env.example + docs/ARCHITECTURE.md docs/PIPELINE.md docs/PRODUCT_VISION.md + docs/operations/INFRA.md + docs/architecture/adr/README.md ← стаб реестра сквозных ADR (дир непустая, самоописуема) + docs/work-items/.gitkeep docs/history/.gitkeep +``` + +Отвергнуто: +- **`docs/_onboarding/`** — смешивает kit (продукт-артефакт, исходник для ЧУЖИХ репо) с + документацией самого орка; шаблоны промптов под `docs/` рядом с живыми `docs/_templates/` + провоцируют путаницу «какая копия живая» (прямо риск R-1/R-5 BRD) и ложные срабатывания + doc-тулинга. +- **`scripts/onboarding/`** — смешивает данные (дерево skeleton) с исполняемым кодом; `scripts/` + в этом репо — плоские утилиты (`staging_check.py`, deploy-hook). + +Top-level каталог делает границу физической: **всё под `onboarding/` предназначено новому репо, +ничто под `onboarding/` не исполняется рантаймом орка.** Структурные тесты канона гоняются по +`onboarding/repo-skeleton/.openclaw/agents/*.md` отдельно от живых промптов орка (TC-03…08 ↔ +существующий `tests/test_agent_prompts_canon.py` не пересекаются). + +### D2 — Механизм подстановки: `{{NAME}}` + stdlib, без новых зависимостей (OQ-2) + +**Решение: синтаксис `{{PLACEHOLDER_NAME}}`** (верхний регистр, `[A-Z][A-Z0-9_]*`), подстановка — +простой проход `str.replace` по словарю; после рендера — обязательный скан +`re.compile(r"\{\{[A-Z][A-Z0-9_]*\}\}")` на неразрешённые плейсхолдеры (ошибка в apply/verify, +PASS-условие AC-5/TC-09). + +- **`string.Template` отвергнут:** kit-шаблоны (INFRA.md, `.env.example`, промпты) содержат + shell-сниппеты с `$VAR`/`${VAR}` — синтаксис `$` коллидирует и потребовал бы экранирования по + всему kit (хрупко, нечитабельно). +- **Jinja2 отвергнут:** новая pip-зависимость (ТЗ §9 запрещает без обоснования) + условная логика + в шаблонах = второй язык программирования в kit → выше риск дрейфа. Kit обязан быть тупым. +- Синтаксис `{{…}}` визуально различим, greppable; в Markdown/YAML kit-файлов естественно не + встречается, остаточные случаи ловит скан. + +**Словарь — `onboarding/placeholders.json`** (машиночитаемый single source of truth; формат: +`{ "NAME": {"description": …, "required": bool, "default": …|null, "example": …} }`): + +| Плейсхолдер | Смысл | Обяз. | +|---|---|---| +| `{{PROJECT_NAME}}` | человекочитаемое имя проекта | да | +| `{{PROJECT_DESCRIPTION}}` | 1–2 фразы «зачем проект» (README/PRODUCT_VISION) | да | +| `{{REPO}}` | имя Gitea-репо (== каталог под `/repos`) | да | +| `{{GITEA_OWNER}}` | owner/org репо в Gitea | да | +| `{{WORK_ITEM_PREFIX}}` | префикс work-item (`ET`/`ORCH`-аналог) | да | +| `{{PLANE_PROJECT_ID}}` | uuid Plane-проекта (известен после Plane-шага apply) | да | +| `{{STACK}}` | стек проекта (описательно) | да | +| `{{TEST_CMD}}` | команда тестов (напр. `pytest -q`) | да | +| `{{PROD_PORT}}` / `{{STAGING_PORT}}` | порты прод/staging | да | + +Расширение словаря = правка `placeholders.json` + kit + тестов в одном PR. Тесты держат +**биекцию**: каждый плейсхолдер, встречающийся в kit, объявлен в словаре, и каждый объявленный — +используется (нет мёртвых/опечаточных). + +### D3 — Copy-vs-template split (OQ-3, BR-2) + +| Класс | Файлы | Механизм | +|---|---|---| +| **Live-copy канона** (НЕ хранится в kit) | `docs/_templates/**` (16), `docs/_standards/**` (3) | копируются скриптом **verbatim из рабочего чекаута репо орка в момент материализации** | +| **Параметризуемые шаблоны** (хранятся в kit) | 6 промптов, `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, `README.md`, `CHANGELOG.md`, `docs/ARCHITECTURE.md`, `docs/PIPELINE.md`, `docs/PRODUCT_VISION.md`, `docs/operations/INFRA.md`, `docs/architecture/adr/README.md`, `.env.example` | рендер `{{…}}` (D2) | +| **Скелет-каркас** | `docs/work-items/.gitkeep`, `docs/history/.gitkeep` | копия как есть | + +- Канон копируется **байт-в-байт, без переписывания**: ORCH-примеры внутри стандартов + (`PIPELINE_DOCS.md` цитирует ORCH-088 и т.п.) остаются примерами — это не «утечка», а + иллюстрация (утечкой считается орк-литерал там, где должен быть параметр — AC-5/TC-10: + префикс work-item, порты 8500/8501, self-hosting-правила в паспорте/промптах). +- Повторный `apply` существующие файлы в целевом репо **не перезаписывает** (идемпотентность + BR-9): обновление канона в уже-онбордженных репо едет их обычными PR с reviewer-gate; + новые онбординги автоматически получают свежий канон (live-copy, ТЗ §7). +- Источник live-copy — чекаут, из которого запущен скрипт; скрипт проверяет наличие обоих + каталогов и (в verify) количество скелетов ≥ 16 / стандартов ≥ 3. + +### D4 — Скрипту разрешён read-only импорт `src` — закрытый список (OQ-4) + +**Решение: импортировать, не снапшотить.** Закрытый список импортов: + +| Импорт | Зачем | +|---|---| +| `src.projects._parse_projects_json`, `src.projects.ProjectConfig` | round-trip валидация генерируемой записи реестра фактическим парсером (AC-6/TC-12) | +| `src.plane_sync._PLANE_NAME_TO_KEY` | точные канонические имена 22 статусов байт-в-байт (AC-7/TC-13) | +| `src.config.settings` (read-only поля) | имена лейблов `auto_approve_label`/`auto_deploy_label`/`bug_fast_track_label` (дефолты `autoApprove`/`autoDeploy`/`Bug`), URL/токены Plane/Gitea из env | + +- **Почему не снапшот:** дублирование констант = гарантированный дрейф (R-2); AC-6 и так требует + фактический парсер; снапшот потребовал бы отдельного «теста синхронизации», который и есть + признание дрейфа. Импорт даёт нулевой дрейф **по построению**. +- Импорт безопасен: `src.projects` → `src.config` (pydantic-settings с дефолтами, инстанцируется + без env); `src.plane_sync` module-level считает только строки из settings; `httpx` — уже + зависимость проекта (`requirements.txt`), **новых pip-зависимостей нет**. +- Импорт приватных имён (`_parse_projects_json`, `_PLANE_NAME_TO_KEY`) — сознательная, + санкционированная ТЗ связь (ТЗ §2 разрешает явно). **Список закрыт:** любой новый импорт из + `src` — только через обновление этого ADR. Контроль ненарушения `src` — снапшот-тест TC-21 + (`STAGE_TRANSITIONS`/`QG_CHECKS`) + AC-12 (diff). +- Скрипт запускается из корня чекаута орка (runbook-предусловие); `sys.path`-шим в начале файла + (паттерн `scripts/staging_check.py`). + +### D5 — Plane-провижининг: канонические статусы + группы + fail-safe (OQ-5, BR-7) + +**Ensure-семантика:** `GET states` → создать недостающие по точным именам (ключи +`_PLANE_NAME_TO_KEY`, 22 имени); существующие (включая CE-дефолтные Backlog/Todo/In Progress/ +Done/Cancelled нового проекта) — `skipped(exists)` по совпадению имени. Аналогично лейблы: +`autoApprove`/`autoDeploy`/`Bug` (имена — из `settings`, D4). + +**Канонические группы статусов** (Plane: `backlog|unstarted|started|completed|cancelled`) — +фиксируются этим ADR; код-критичные констрейнты выделены: + +| Статус | Группа | Констрейнт | +|---|---|---| +| Backlog | `backlog` | | +| Todo, To Analyse | `unstarted` | | +| In Progress, Analysis, Architecture, Development, Code-Review, Review, Testing, Awaiting Deploy, Deploying, Monitoring after Deploy, Needs Input, In Review, Blocked, Approved, Confirm Deploy | `started` | **рабочие/гейтовые статусы НЕ в терминальных группах** — иначе terminal-detection ORCH-068 (`{uuid→group}`, группы `completed`/`cancelled` = терминал) ложно сочтёт живую задачу терминальной | +| Rejected | `started` | reject = rework-петля в анализ, задача жива → НЕ `cancelled` | +| Done | `completed` | терминал | +| Cancelled | `cancelled` | терминал | +| **STOP** | **`cancelled`** | **требование ORCH-090** (fail-closed: без статуса/группы ветка cancel не активируется) | + +**Fail-safe (CE-пробелы):** код орка использует только GET states — доступность POST +project/states/labels в Plane CE не гарантирована. Любой недоступный вызов (403/404/405/501/ +нереализовано) → шаг помечается **`manual-step`** со ссылкой на соответствующий раздел runbook +(точное имя статуса + группа для ручного создания в UI), скрипт не падает (AC-7/TC-14). +Заведомо ручные шаги: порядок статусов на доске (drag-and-drop, UI-only), workspace-webhook +(существует, Ф-6 — verify печатает команду проверки, не создаёт). + +### D6 — Gitea-провижининг: репо + webhook + initial push только в пустой репо (BR-9) + +- **Репо:** `POST /api/v1/...` под `{{GITEA_OWNER}}`, `auto_init=false` (репо рождается пустым; + `main` создаёт initial push). Существует → `skipped(exists)`. +- **Webhook (per-repo):** events `push`/`pull_request`/`status`, `content_type: json`, + `branch_filter: "*"`, URL = внешний URL орка `/webhook/gitea` (формат `SETUP_WEBHOOKS.md`). + **Секрет: приёмник `src/webhooks/gitea.py` валидирует ОДИН глобальный + `ORCH_GITEA_WEBHOOK_SECRET` на все репо** → скрипт **переиспользует** существующий секрет из + env (никогда не генерит новый при наличии — новый сломал бы HMAC всех вебхуков); секрет + отсутствует в env → сгенерить `secrets.token_hex(20)` + вывести оператору для `.env` + (первичная настройка). В логах/отчёте секрет всегда маскируется (NFR-3). +- **Initial push:** материализованный kit коммитится (`feat: onboarding skeleton (ORCH-009 kit)`) + и пушится в `main` **только если репо свежесоздан/пуст** (Gitea `empty: true`); непустой репо → + `manual-step` (kit-файлы НИКОГДА не пушатся поверх существующего контента). Это единственный + разрешённый push: новый пустой репо до регистрации в реестре не является участником конвейера → + **INV-4 (мерж только через PR-merge API) не затрагивается** (ТЗ §9). + +### D7 — Запись реестра: полный merged-массив, скрипт `.env` не трогает (BR-8) + +**Решение: скрипт выводит (а) standalone-запись нового проекта и (б) полный merged-массив +`ORCH_PROJECTS_JSON`** = существующие записи verbatim + новая в конец. Источник существующих: +текущий env / `--env-file` (дефолт — `.env` в корне чекаута, если есть); источника нет → только +standalone-запись + инструкция. Перед выводом merged-массив прогоняется через +`projects._parse_projects_json` (round-trip: поля новой записи совпадают, существующие не +потеряны/не искажены — AC-6/TC-12). + +- **Почему full-array, а не диф:** оператор вставляет одну строку в `.env` атомарно — ручное + слияние JSON в env-строке (экранирование, запятые) и есть источник ошибок R-4. +- Скрипт **не правит** `.env` прода и **не рестартит** контейнер (NFR-2): печатает строку + + инструкцию «добавь в `.env` → управляемый рестарт оркестратора (self-hosting: групповое окно, + выполнять осознанно)» со ссылкой на runbook. `verify` после рестарта показывает разрыв + «создано, но не зарегистрировано» (R-4). + +### D8 — Песочница для smoke: staging-контур 8501 + одноразовый SMK-проект (OQ-6, AC-13) + +**Решение: smoke выполняется на staging-контуре** (`orchestrator-staging`, 8501, изолированная БД +`./data/staging`) с **одноразовым** sandbox: Plane-проект `onboarding-smoke` (префикс `SMK`) + +Gitea-репо `onboarding-smoke`, онбордженные самим скриптом. Регистрация — в `ORCH_PROJECTS_JSON` +**staging-окружения** (`.env.staging`) + рестарт staging (свободен, в отличие от прод-инварианта). +Прогон: тестовая задача SMK → стадия `analysis` → проверить следы чтения паспорта/`AGENTS.md` и +артефакты `docs/work-items/SMK-…/` по канону `PIPELINE_DOCS.md`. + +- **Прод-контур отвергнут:** smoke-задача писала бы конвейерные строки в общую прод-БД и жила бы + в общей очереди с enduro/ORCH — шум и риск в общем инстансе (дух NFR-2). +- Протокол прогона — раздел **«Журнал smoke-прогонов»** в `ONBOARDING.md` (дата, параметры, + PASS/FAIL по чек-листу AC-13); для приёмки ORCH-009 первый протокол обязателен, ссылка на него — + из `13-test-report.md` задачи. Судьба sandbox-артефактов: архив/удаление вручную по разделу + «Откат» runbook (скрипт не удаляет ничего — BR-9). + +### D9 — Языковая политика kit-промптов: канон орка (OQ-7, AC-4) + +**Решение: 5 ru + deployer en** — ровно языковая раскладка канона орка, нормативная по +ADR-001 D2 ORCH-092 (deployer — самый safety-critical промпт, en-раскладка минимизирует +регресс-поверхность байт-точных verdict-ключей/команд). Kit наследует канон без отступлений; +per-project отступление возможно позже **только** решением в собственном ADR нового проекта +(правило фиксируется в `onboarding/README.md` и шаблоне `CONTRIBUTING.md`). Проверяется TC-08. + +### D10 — Branch protection `main` нового репо: НЕ включать (OQ-8) + +**Решение: не включать.** Merge-актор конвейера — Gitea PR-merge API под токеном орка +(INV-4; `src/merge_gate.py`, ORCH-093): required-approvals/required-status-checks дали бы +405/409-класс отказов `merge_pr` → ложные HOLD (ровно класс инцидента ORCH-063). Сам орк живёт +без protection — защита `main` держится конвенцией (агенты не пушат `main`; мерж только через +PR API) и скоупом токенов. Решение фиксируется в runbook; пересмотр — при мультитенант-hardening +(D5.6, вне объёма). + +### D11 — Форма CLI и тестируемость без сети (BR-11, NFR-5) + +**Один файл `scripts/onboard_project.py`** (операторская UX: один очевидный энтрипойнт; паттерн +`scripts/staging_check.py`), внутри — слои: + +- **Чистое ядро:** `build_plan(params, observed) -> Plan` — без I/O; `Plan` = упорядоченный список + шагов закрытого списка BR-1: `plane.project → plane.states(22) → plane.labels(3) → gitea.repo → + gitea.webhook → kit.materialize+push → registry.emit`. Рендер kit — чистая функция + `render(text, params)` (D2), в plan-режиме выполняется **in-memory** (ни одной записи на диск — + AC-8/TC-16); материализация на диск (temp-dir → git init/commit/push) — только в `apply`. +- **Тонкие клиенты** `PlaneClient`/`GiteaClient` (httpx; единственные точки сети) — инжектируются + → в тестах мокаются целиком (NFR-5: ноль сетевых вызовов, TC-13…18). +- **Режимы:** `plan` (дефолт) — только GET-пробы текущего состояния + полный план без единой + мутации; `apply` — ensure-исполнение (идемпотентно, без delete-операций вовсе); `verify` — + GET-пробы + локальные проверки (registry round-trip, резолв всех логических ключей включая + `confirm_deploy`/`stop`, лейблы, webhook активен, kit-файлы в репо, скан неразрешённых + плейсхолдеров). +- **Отчёт:** человекочитаемый + `--json`; статус каждого шага + `created | skipped(exists) | manual-step | planned | error`; exit-коды: `0` — чисто, `2` — есть + `manual-step`/gap в verify, `1` — ошибка. Каждый шаг логируется (BR-11). + +--- + +## Альтернативы (сводно) + +- **`docs/_onboarding/` / `scripts/onboarding/`** — отвергнуто (D1): смешение kit с живой докой + орка / данных с кодом. +- **Jinja2 / `string.Template`** — отвергнуто (D2): новая зависимость и логика в шаблонах / + коллизия `$` с shell-сниппетами. +- **Снапшот констант `src` + тест синхронизации** — отвергнуто (D4): узаконенный дрейф; импорт + даёт нулевой дрейф по построению. +- **Генерация нового webhook-секрета per-repo** — отвергнуто (D6): приёмник валидирует один + глобальный секрет; новый сломал бы HMAC существующих вебхуков. +- **Диф-вывод реестра** — отвергнуто (D7): ручное слияние JSON-в-env — источник ошибок R-4. +- **Smoke на прод-контуре** — отвергнуто (D8): запись в общую прод-БД/очередь. +- **Branch protection `main`** — отвергнуто (D10): ломает PR-merge API актора (ложные HOLD). + +## Последствия + +- **+** Turnkey-способность D5.2: один проход + runbook вместо археологии; тихие деградации + (статусы/лейблы/промпты) закрываются проверяемо (`verify` + структурные тесты). +- **+** Нулевой риск рантайма: `src/**` байт-в-байт, нового кода в горячих путях нет, kill-switch + не нужен; регресс enduro/orchestrator невозможен по построению. +- **+** Анти-дрейф структурный: live-copy канона (BR-2) + единые канон-тесты kit (NFR-4) + + биекция словаря плейсхолдеров. +- **−** Операторские шаги остаются ручными (env + управляемый рестарт; UI-only Plane): осознанное + ограничение NFR-2 (никакой автоматики рестартов) — митигировано runbook + verify (видимый разрыв). +- **−** Импорт приватных имён `src` связывает скрипт с внутренними идентификаторами — митигировано + закрытым списком (D4) и тем, что рефакторинг имён мгновенно валит импорт в тестах (видимая, + не тихая поломка). +- **−** Kit-шаблоны промптов требуют сопровождения при эволюции канона — митигировано общими + структурными требованиями тестов (расхождение ловит CI, NFR-4). +- **Откат:** удалить `onboarding/`, `scripts/onboard_project.py`, `docs/operations/ONBOARDING.md`, + тесты — репо в исходном состоянии (ТЗ §7); внешние сущности (sandbox/созданные проекты) — + вручную по разделу «Откат» runbook. + +## Ссылки + +- BRD: `docs/work-items/ORCH-009/01-brd.md` · TRZ: `02-trz.md` · AC: `03-acceptance-criteria.md` + · Test plan: `04-test-plan.yaml` +- Сверено по коду: `src/projects.py` (`ProjectConfig`, `_parse_projects_json`, `_load_projects`), + `src/plane_sync.py:94-165` (`_DEFAULT_STATES`, `_PLANE_NAME_TO_KEY` — 22 имени, fail-closed + `Confirm Deploy`/`STOP`), `src/qg/checks.py::check_architecture_done`, `src/config.py` + (`auto_*_label`/`bug_fast_track_label`), `requirements.txt` (httpx уже есть) +- Операции: `docs/operations/SETUP_WEBHOOKS.md` (формат Gitea-webhook; Plane workspace-webhook — + SQL-only), `docs/operations/INFRA.md` +- Стандарты: `docs/_standards/PIPELINE_DOCS.md` (§4 ADR-naming), `HANDOFF_PROTOCOL.md`, + `TRACEABILITY.md` +- ADR: adr-0001 (registry), adr-0017/0018 (паттерны условности), adr-0021/0022 (канон промптов/ + трассировка), adr-0026 (STOP, группа `cancelled`), ORCH-092 `ADR-001` D2 (язык deployer), + сквозной **adr-0035-turnkey-project-onboarding** diff --git a/docs/work-items/ORCH-009/07-infra-requirements.md b/docs/work-items/ORCH-009/07-infra-requirements.md new file mode 100644 index 0000000..f929985 --- /dev/null +++ b/docs/work-items/ORCH-009/07-infra-requirements.md @@ -0,0 +1,66 @@ +--- +work_item: ORCH-009 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 07 — Инфра-требования: ORCH-009 — Turnkey-онбординг проектов + +Work Item: **ORCH-009** · Repo: **orchestrator** · Стадия: architecture + +> Топология оркестратора **не меняется** (NFR-1/NFR-2: `src/**` и compose не трогаются). +> Файл фиксирует **предусловия исполнения способности** (токены/доступы/контуры) и инфра-границы +> операторского скрипта. Детали решений — `06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md`. + +## I-1. Топология / окружения + +- **Прод (`orchestrator`, 8500):** не затрагивается. Скрипт не создаёт/не останавливает/не + рестартит контейнеры; в общую БД не пишет (читает только файлы чекаута и внешние API). +- **Staging (`orchestrator-staging`, 8501, БД `./data/staging`):** контур smoke-прогона (ADR D8). + Регистрация sandbox-проекта — в `.env.staging`; рестарт staging — штатный, свободный + (прод-инвариант на него не распространяется). +- **Новые внешние сущности** (создаются скриптом в `apply`): Plane-проект, Gitea-репо + + per-repo webhook. Аддитивно: существующие проекты/репо не модифицируются (BR-9). +- **Запуск скрипта:** хост mva154, из корня чекаута репо orchestrator. Среда исполнения — + venv с `requirements.txt` (httpx уже в зависимостях; новых pip-зависимостей нет) **или** + `docker compose exec orchestrator python scripts/onboard_project.py …` (read-only к рантайму, + без рестартов). Канонический способ фиксирует runbook `docs/operations/ONBOARDING.md`. + +## I-2. Переменные окружения / секреты + +**Новых env-переменных не вводится.** Используются существующие (предусловия запуска): + +| Переменная | Роль в онбординге | +|---|---| +| `ORCH_PLANE_API_TOKEN` (+ `ORCH_PLANE_API_URL`, `ORCH_PLANE_WORKSPACE_SLUG`) | создание/чтение Plane-проекта, статусов, лейблов; токен с правом создания проектов в workspace | +| `ORCH_GITEA_TOKEN` (+ Gitea base URL) | создание репо (под `{{GITEA_OWNER}}`), per-repo webhook; токен с правом create-repo + hooks | +| `ORCH_GITEA_WEBHOOK_SECRET` | **переиспользуется** для webhook нового репо (приёмник валидирует один глобальный секрет, ADR D6); отсутствует → скрипт генерит и печатает оператору для `.env` | +| `ORCH_PROJECTS_JSON` | источник существующих записей для merged-вывода (ADR D7); **применение новой строки — операторский шаг** | + +- Секреты — только в `.env`/`.env.staging` на хосте, в гит не попадают (правило #8 CLAUDE.md); + в логах/отчётах скрипта секреты маскируются (NFR-3). +- Kit несёт собственный `.env.example` нового проекта (дескрипторы без значений) — канон секретов + транслируется в онбордируемые репо. + +## I-3. Деплой / рестарт + +- **Скрипт НИКОГДА не рестартит/не останавливает прод-контейнер** (NFR-2, self-hosting инвариант). +- Регистрация проекта в реестре (Ф-3): правка `.env` (строка `ORCH_PROJECTS_JSON` из отчёта + скрипта) + **управляемый операторский рестарт** оркестратора — групповое окно для ВСЕХ проектов + общего инстанса; runbook помечает шаг self-hosting-предупреждением и командой проверки + (`GET /queue`, резолв статусов нового проекта). +- TTL-self-heal статусов Plane (ORCH-068, 300с) рестарта не требует: статусы/лейблы, созданные + после регистрации, подхватываются без вмешательства. +- Деплой самой задачи ORCH-009 — штатный конвейер: изменение docs/scripts/tests-only, образ + пересобирается стандартно, staging-гейт (8501) обязателен как обычно. + +## I-4. CI/CD + +- `.gitea/workflows/` — **без изменений**: новые тесты (`tests/test_onboarding_kit.py`, + `test_onboarding_script.py`, `test_onboarding_invariants.py`) подхватываются существующим + pytest-шагом; все детерминированы, без сети (NFR-5). +- Инфра-предусловий в образе нет: скрипт — операторский CLI вне рантайма, в образ ничего + дополнительно не запекается. diff --git a/docs/work-items/ORCH-009/10-tech-risks.md b/docs/work-items/ORCH-009/10-tech-risks.md new file mode 100644 index 0000000..5023bd8 --- /dev/null +++ b/docs/work-items/ORCH-009/10-tech-risks.md @@ -0,0 +1,42 @@ +--- +work_item: ORCH-009 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-009 — Turnkey-онбординг проектов + +Work Item: **ORCH-009** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Детализирует риски BRD §8 (R-1…R-5) до уровня решений +> `06-adr/ADR-001`; митигейшены привязаны к D-решениям и TC тест-плана. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | **Drift канона** (R-1): копия `_templates`/`_standards` в новых репо разъезжается с живым каноном орка; kit-промпты отстают от эволюции канона 52d | Сред. | Сред. | BR-2/D3: live-copy в момент онбординга, второй редактируемой копии канона нет; NFR-4: структурные канон-тесты kit (TC-03…08) ловят расхождение в CI; обновление онбордженных репо — их обычные PR | +| TR-2 | **Тихая деградация Plane-контрактов** (R-2): опечатка в имени статуса/лейбла или неверная **группа** → fail-closed/fail-safe ветки (`Confirm Deploy`, `STOP`, авто-лейблы, `Bug`) молча не работают; рабочий статус в группе `completed`/`cancelled` → terminal-detection ORCH-068 ложно терминалит живую задачу | Сред. | Выс. | D4: имена импортируются из `_PLANE_NAME_TO_KEY` (нулевой дрейф по построению, TC-13); D5: канонические группы зафиксированы таблицей ADR с код-критичными констрейнтами (STOP→`cancelled`, терминальные группы только Done/Cancelled/STOP); `verify` резолвит ВСЕ логические ключи включая `confirm_deploy`/`stop` | +| TR-3 | **Скрипт с боевыми токенами** (R-3): ошибка = разрушительное действие на общих Plane/Gitea | Низ. | Выс. | BR-9/D11: `plan` (GET-only) — дефолт; delete-операций в коде нет вовсе (TC-18); аддитивный ensure (TC-17); push только в свежесозданный пустой репо (`empty: true`, D6); существующие сущности не модифицируются | +| TR-4 | **Разрыв «создано, но не зарегистрировано»** (R-4): оператор не применил env+рестарт → проект невидим для орка | Сред. | Сред. | D7: merged-массив одной строкой (без ручного слияния JSON); runbook: явный операторский шаг с self-hosting-предупреждением + команда проверки; `verify` показывает разрыв (TC-12, AC-11) | +| TR-5 | **Утечка орк-специфики в kit** (R-5): новый репо получает ORCH-префикс, порты 8500/8501, self-hosting-правила орка | Сред. | Сред. | D2: скан неразрешённых плейсхолдеров после рендера; TC-10: явный тест на утечки; биекция словаря `placeholders.json` ↔ kit (мёртвые/опечаточные плейсхолдеры не живут) | +| TR-6 | **Поломка HMAC существующих вебхуков**: генерация нового per-repo секрета при едином глобальном `ORCH_GITEA_WEBHOOK_SECRET` приёмника | Низ. | Выс. | D6: секрет **переиспользуется** из env (новый генерится только при полном отсутствии — первичная настройка); секрет маскируется в логах/отчёте (NFR-3) | +| TR-7 | **Связь скрипта с приватными именами `src`** (`_parse_projects_json`, `_PLANE_NAME_TO_KEY`): рефакторинг src валит скрипт | Низ. | Низ. | D4: закрытый список импортов (расширение — только через ADR); поломка видимая, не тихая — импорт падает в тестах (TC-12/13) на том же PR, что рефакторит src; снапшот TC-21 гардит сам src | +| TR-8 | **Plane CE API-пробелы** (OQ-5): POST project/states/labels недоступен в CE → провижининг неполон | Сред. | Низ. | D5: fail-safe деградация в `manual-step` со ссылкой на runbook (имя+группа для UI-создания), не падение (TC-14); `verify` подтверждает итоговую полноту независимо от способа создания | +| TR-9 | **Smoke загрязняет общий контур**: прогон способности в проде = строки в общей БД/очереди | Низ. | Сред. | D8: smoke только на staging (8501, изолированная БД, `.env.staging`); sandbox-сущности одноразовые, снос вручную по разделу «Откат» runbook | + +## Сводный вывод + +Доминирующий класс — **операционные риски исполнения способности** (TR-2/TR-3/TR-4): они +митигированы структурно (импорт констант вместо копий, GET-only дефолт, отсутствие delete-операций, +verify-режим), а не дисциплиной. Рисков для прод-конвейера самой задачи **нет по построению**: +`src/**` байт-в-байт (AC-12/TC-21), нового кода в горячих путях нет, kill-switch не требуется — +способность активируется только явным запуском операторского CLI. + +Эскалация `arch:major-change` **не требуется**: ни новой стадии, ни нового рантайм-компонента, +ни изменения БД — это docs/templates/scripts/tests-only способность (новая стадия/компонент +конвейера не вводится). Возврат в анализ не требуется: ТЗ выполнимо без нарушения принципов. +Остаточный риск для прод-конвейера (self-hosting): **низкий**. diff --git a/docs/work-items/ORCH-009/12-review.md b/docs/work-items/ORCH-009/12-review.md new file mode 100644 index 0000000..39c371c --- /dev/null +++ b/docs/work-items/ORCH-009/12-review.md @@ -0,0 +1,167 @@ +--- +verdict: APPROVED +work_item: ORCH-009 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-10 +model_used: claude-fable-5 +type: review +work_item_id: ORCH-009 +version: 2 +--- + +# Review ORCH-009 — Turnkey-онбординг проектов (kit + CLI + runbook) — re-review (цикл 2) + +Ветка: `feature/ORCH-009-turnkey-plane` · Diff vs `origin/main`: 46 файлов, +5478/−12. +Состав: kit `onboarding/repo-skeleton/` (28 файлов), CLI `scripts/onboard_project.py` (1090 строк), +runbook `docs/operations/ONBOARDING.md`, 3 онбординг-тест-модуля (83 теста), golden-source доки, +ADR×2 + индекс. + +**Контекст цикла:** review v1 (`APPROVED`, 3×P2/2×P3) → testing `PASS` → re-test merge-gate упал на +**средовых** не-герметичных тестах ORCH-41-эры (прод-env `ORCH_AGENT_FALLBACK_MODEL`/ +`ORCH_AGENT_MODEL_DEFAULT`) → откат на development → фикс `e903818` (герметизация +`tests/test_resolve_agent_{model,effort}.py`) + регенерация `17-security-report.md` (`b26a391`). +Этот review: независимая проверка дельты цикла + выборочная верификация клеймов v1. + +## Summary + +**APPROVED.** P0/P1 нет. Дельта цикла (фикс герметичности тестов) корректна, трассирована к +ORCH-074 ADR-001 Решение 3 и сохраняет его инвариант; полный регресс теперь зелёный **под +фактическим прод-env** (перепроверено мной: 1713 passed, exit 0 — ровно та среда, что валила +merge-gate до фикса). Клеймы review v1 выборочно перепроверены и подтверждены. Переносятся 3×P2 +(харднинг краевых путей CLI, не фикшены — легитимно, follow-up) + 3×P3. Документация обновлена в +том же PR по всем точкам, включая отдельную CHANGELOG-запись про сам фикс тестов. + +## Оси проверки + +### Ось 1 — Соответствие ТЗ (`02-trz.md`, `03-acceptance-criteria.md`) — ✅ + +| Требование | Статус | Чем подтверждено | +|---|---|---| +| FR-1 состав kit (19 элементов, анти-форк канона) | ✅ | TC-01/02 зелёные; `docs/_templates|_standards` в kit не хранятся — live-copy в `materialize_kit` (`LIVE_COPY_DIRS`, прочитан код) | +| FR-2 канон 52d/92 промптов (5 секций, ❌→✅, ``, 52c-схема, verdict-ключи байт-в-байт, плейсхолдерные даты/модели) | ✅ | TC-03…06 зелёные; verdict-ключи в kit-промптах сверены grep'ом (`verdict:`/`staging_status:`/`deploy_status:`/`security_status:` на месте) | +| FR-2 reviewer-gate доки (AC-3) | ✅ | kit `reviewer.md:65`: «документация НЕ обновлена → вердикт ОБЯЗАТЕЛЬНО `REQUEST_CHANGES`» — прочитано лично | +| FR-3 INFRA.md шаблон | ✅ | TC-19 зелёный (топология/env/границы/риски общего хоста); INFRA орка не тронут (diff пуст) | +| FR-4 CLI plan/apply/verify | ✅ | Код прочитан полностью: `plan` GET-only (рендер in-memory), `apply` идемпотентный ensure без delete, 22 статуса из `_PLANE_NAME_TO_KEY` + `STATE_GROUPS` 1:1 c ADR D5, CE-отказ → `ManualStep` → `manual-step` (fail-safe); TC-13…18 зелёные | +| FR-5 verify | ✅ | round-trip фактическим парсером, резолв всех 22 имён, лейблы, webhook active, полнота kit (`VERIFY_KIT_FILES`), скан `{{…}}`, канон ≥16/≥3 | +| FR-6 runbook | ✅ | `ONBOARDING.md` прочитан: слои 0→6 в порядке BR-1, каждый 🖐-шаг с командой проверки, self-hosting-предупреждение «групповое окно» (§4.2), workspace-webhook — «существует, только проверка» (§1.5), откат §6 | +| §4/§5 нет API/БД изменений | ✅ | diff `src/**` пуст (проверено лично) | +| §9 инварианты | ✅ | `git diff origin/main...HEAD -- src/ .openclaw/ docs/_templates/ docs/_standards/ docs/operations/INFRA.md requirements.txt` — **пусто**; сетевых вызовов в тестах нет (фейк-клиенты); новых pip-зависимостей нет | +| AC-12 полный регресс | ✅ | **1713 passed, 0 failed, exit 0** — мой прогон в worktree ветки под фактическим хост-env (до фикса здесь было 2 failed) | +| AC-13 операторский smoke | ⏳ | По построению операторский (ADR D8); «Журнал smoke-прогонов» — плейсхолдер. Обязателен ДО `Confirm Deploy` — см. handoff | + +### Ось 2 — Соответствие ADR (`06-adr/ADR-001` D1–D11, сквозной `adr-0035`) — ✅ + +- **D1** top-level `onboarding/` ✅; **D2** `{{NAME}}` + `str.replace` + обязательный пост-скан + (`PLACEHOLDER_RE`, ValueError в `materialize_kit`) + биекция словаря тестом ✅; **D3** + live-copy verbatim, существующие файлы не перезаписываются ✅; **D4** закрытый список импортов + `src` — в скрипте ровно три (`settings`, `_PLANE_NAME_TO_KEY`, `_parse_projects_json`), + загвожден AST-тестом TC-21 ✅; **D5** `STATE_GROUPS` 1:1 с таблицей ADR (22 имени, set-равенство + с `_PLANE_NAME_TO_KEY` тестом; `STOP`→`cancelled`; терминальные группы только + Done/Cancelled/STOP; `Rejected`→`started`) ✅; **D6** `auto_init=False`, переиспользование + глобального HMAC-секрета, push только в свежесозданный/пустой репо ✅; **D7** merged-full-array + + round-trip + `.env` read-only ✅; **D8** smoke на staging 8501, журнал в runbook ✅; **D9** + 5 ru + deployer en с «Do NOT translate»-гардом и рамкой shared-host-гардрейлов (прочитано + лично) ✅; **D10** runbook §2.3 «branch protection НЕ включать» ✅; **D11** plan/apply/verify, + чистый `build_plan`, инжектируемые клиенты, отчёт `created/skipped(exists)/manual-step/planned/ + error`, exit-коды 0/2/1 ✅. +- **Трассировка (`docs/_standards/TRACEABILITY.md`) — дельта цикла:** + - `tests/test_resolve_agent_{model,effort}.py` несут маркеры **ORCH-41/ORCH-074** — сверено с + `docs/work-items/ORCH-074/06-adr/ADR-001-model-name-validation.md` **Решение 3 (G4)**: + инвариант = «**shipped-дефолт** `agent_fallback_model` остаётся `""`». Фикс переводит ассерт + с env-backed singleton на **класс-дефолт поля** (`model_fields[...].default == ""`) — это + и есть подлинный инвариант ADR (заводской дефолт, а не рантайм-конфиг оператора); + never-break ассерты `is_valid_model` — байт-в-байт. Инвариант **сохранён и уточнён**, + обоснование — в коммит-месседже и инлайн-комментариях со ссылкой на ADR. Чужой инвариант + не сломан → finding нет. + - `docs/architecture/adr/README.md` — бэкфилл строк 0032–0035 сверен: все 4 файла + (`adr-0032-bug-fast-track`, `adr-0033-sidecar-watchdog`, `adr-0034-lessons-journal`, + `adr-0035-turnkey-project-onboarding`) существуют, привязки задач корректны, «текущий + максимум 0035» верен. + - `docs/operations/SETUP_WEBHOOKS.md` — обобщение per-repo **усиливает** инвариант одного + глобального HMAC-секрета (явное предупреждение про ротацию на всех репо разом). +- **Инварианты NFR-1/INV-4:** снапшот-тесты `STAGE_TRANSITIONS`/`QG_CHECKS` зелёные; push — + только initial в пустой репо вне конвейера; PR-merge API не затрагивается. + +### Ось 3 — Качество кода — ✅ (с переносными P2 ниже) + +- CLI: чистое разделение слоёв (ядро без I/O / тонкие клиенты / режимы), docstrings на всех + публичных функциях, единственная точка subprocess (только `git`, токен в логе маскируется + `://***@`), `ManualStep` fail-safe вместо падений, delete-операций нет вовсе, секрет в отчёте + `***` + тест non-leak. Тесты содержательные: AST-проверка закрытого списка импортов, + monkeypatch-мины на мутации в dry-run, негативные CE-сценарии, set-равенство против дрейфа + констант — не тривиальные. +- **Фикс герметичности (дельта цикла) — корректен:** autouse-фикстуры пиняют shipped-дефолты + (зеркально между файлами-сиблингами), в чистом env поведение байт-эквивалентно; класс среды + merge-gate re-test (прод-env) теперь покрыт. Перепроверено прогоном: 1713 passed под хост-env. + Правка существующих тестов вне инвентаря ТЗ §2 — легитимна: инвентарь «рабочее предложение», + ни один инвариант §9 не запрещает правку тестов; без неё PR непроходим через merge-gate + (латентная мина `main`, детонированная сменой прод-env). +- Багфикс-трек (ORCH-019, BR-4): не применим — задача не `Bug`, маршрут полный. + +### Ось 4 — Документация — ✅ ОБНОВЛЕНА В ТОМ ЖЕ PR + +| Точка | Статус | +|---|---| +| `CLAUDE.md` — раздел «Turnkey-онбординг проектов (ORCH-009)» | ✅ | +| `docs/architecture/README.md` — раздел + ссылки на оба ADR | ✅ (diff прочитан, фактам соответствует) | +| `CHANGELOG.md` — детальная `feat`-запись **+ отдельная под-запись про фикс герметичности тестов** | ✅ (дельта цикла задокументирована — образцово) | +| ADR per-WI `06-adr/ADR-001` + сквозной `adr-0035` + индекс `adr/README.md` | ✅ | +| `docs/operations/ONBOARDING.md` (новый runbook) | ✅ | +| `docs/operations/SETUP_WEBHOOKS.md` — обобщён per-repo | ✅ | +| `onboarding/README.md` — устройство kit, словарь, анти-форк | ✅ | +| README «Известные ограничения» (ORCH-079) | **N/A — проверено лично:** открыты 3 пункта (Telegram 48h / intra-repo deps ORCH-026 / пакетный автоном Этап 1) — ни один этим PR не закрывается | +| `17-security-report.md` | ✅ `security_status: PASS` (0 secrets, 0 blocking) | +| `08-data-requirements.md` отсутствует | Легитимно: гейт `check_analysis_complete` требует 01–04; ТЗ §5 «изменений БД нет» | + +## Findings + +### P0 — Blocker +- (нет) + +### P1 — Must fix +- (нет) + +### P2 — Should fix (перенос из review v1 — не фикшены, перепроверены: всё ещё в коде; follow-up, не блокируют) +- [ ] **Quoted-значение в `.env` → тихая потеря существующих записей в merged-выводе.** + `read_existing_registry` (строка ~355) возвращает значение после `=` как есть; кавычки → + `json.loads` в `merged_projects_json` молча даёт `existing=[]` → merged-массив только с новым + проектом, а runbook §4.1 велит «заменить строку». Доминирующий путь безопасен (pydantic + снимает кавычки), потому P2. Рекомендация: `strip("'\"")` в фоллбеке + GAP-warning, если строка + в `.env` есть, а existing пуст. (ADR D7 «существующие не теряются».) +- [ ] **`GiteaClient.create_repo`: фоллбек `POST /user/repos` может создать репо в чужом + namespace** (строки ~474–477): owner не org и не юзер токена → репо рождается под юзером + токена, последующие шаги по `owner/repo` дают 404/manual-step. Рекомендация: сверять + `owner.login` ответа с запрошенным; расхождение → `manual-step`. +- [ ] **CE-деградация Plane + успешный Gitea в одном apply запекает литерал + `` в запушенный паспорт** (`build_params` → `PLANE_PROJECT_ID`); скан ловит + только `{{…}}`. Рекомендация: при неразрешённом `PLANE_PROJECT_ID` деградировать + `kit.materialize`/`kit.push` в `manual-step` ИЛИ добавить `` в скан verify. + +### P3 — Nice to have +- [ ] `--env-file` игнорируется в `plan` (`run_plan` → `_registry_instructions(report, params, + None)`; `main()` его в `run_plan` и не передаёт): превью merged-массива может расходиться с apply. +- [ ] Push-URL с `oauth2:@` остаётся в `.git/config` temp-каталога после успешного apply + (cleanup нет). Рекомендация: чистить на успехе, на ошибке сохранять для дебага. +- [ ] *(новое)* `run_apply`: шаг `registry.emit` добавляется со статусом `CREATED` **до** + `_registry_instructions`, который на ошибке round-trip добавляет второй шаг `registry.emit` + со статусом `ERROR` → дубль step-id в отчёте (exit-код при этом честный — 1). Косметика отчёта. + +## Документация + +Обновлена полностью в том же PR (таблица оси 4). Несоответствий «код изменён — дока молчит» нет; +дельта цикла (фикс тестов) получила собственную CHANGELOG-запись с диагнозом и обоснованием; +обзорная витрина README задачей не затрагивается (проверено: открытые ограничения не про онбординг). + +## Для следующей стадии (testing) — handoff + +1. **AC-13 (операторский smoke, ADR D8)** — единственный непокрытый pytest'ом AC: прогон по + runbook §5.2 (staging 8501, sandbox `SMK`) должен быть выполнен и запротоколирован в «Журнале + smoke-прогонов» `ONBOARDING.md`, ссылка — из `13-test-report.md`. Обязателен **до** + `Confirm Deploy` (человеческий гейт — точка контроля сохраняется). +2. Средовая мина merge-gate обезврежена фиксом `e903818`: полный регресс зелёный и в чистом env, + и под прод-env (1713 passed, проверено в этом review) — спец-обвязка прогона больше не нужна. +3. `13-test-report.md` в дереве — от прошлого цикла (до `e903818`): его строка «PR эти файлы не + трогает» про `tests/test_resolve_agent_*` устарела. Перегенерировать отчёт штатно (артефакт + чужой стадии — в этом review не правился). diff --git a/docs/work-items/ORCH-009/13-test-report.md b/docs/work-items/ORCH-009/13-test-report.md new file mode 100644 index 0000000..fd4111b --- /dev/null +++ b/docs/work-items/ORCH-009/13-test-report.md @@ -0,0 +1,117 @@ +--- +result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE +work_item: ORCH-009 +stage: testing +author_agent: tester +status: pass +created_at: 2026-06-10 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-009 +--- + +# Test Report — ORCH-009 — Turnkey-онбординг проектов (kit + CLI + runbook) + +> Машинный вердикт читается ТОЛЬКО из frontmatter (`result:`). Перепрогон стадии testing на +> opus после сброса session-limit (ре-ран по запросу). Review-вердикт цикла 2 — `APPROVED` +> (`12-review.md`, P0/P1 нет). Дельта цикла (герметизация ORCH-41-тестов `e903818`) перепроверена +> полным регрессом под фактическим окружением worktree. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 (pytest-cov 5.0.0, asyncio 0.23.8) +- Дата: 2026-06-10 +- Worktree: `feature/ORCH-009-turnkey-plane` (`/repos/_wt/orchestrator/feature_ORCH-009-turnkey-plane`, HEAD `b97ffae`) +- Прод-контейнер `orchestrator` (8500) — НЕ трогался (smoke read-only). + +## Smoke API (read-only) +- `GET /health` → `{"status":"ok","service":"orchestrator"}` ✅ +- `GET /status` → отвечает; задача ORCH-009 (task 87) на стадии `testing` ✅ +- `GET /queue` → блок `serial_gate` **присутствует** (ORCH-088) ✅; блок `auto_labels` присутствует ✅ + (полный набор ключей: `auto_labels, bug_fast_track, build_cache_prune, counts, coverage, + disk_monitor, fs_ownership, lessons, max_concurrency, merge_verify, poll_interval, post_deploy, + reaper, recent, reconcile, resilience, serial_gate, stop, task_deps`). + +## Результаты + +### Полный регресс +`pytest tests/ -q` → **1713 passed, 0 failed, 1 warning** за 65.40s (exit 0). Прод-контейнер не +трогался. Средовая мина merge-gate цикла 1 обезврежена фиксом `e903818` — регресс зелёный. + +### Профильные сюиты (онбординг) +`pytest tests/test_onboarding_kit.py tests/test_onboarding_script.py tests/test_onboarding_invariants.py -v` +→ **83 passed, 0 failed** за 0.55s (exit 0). Сетевых вызовов нет (Plane/Gitea — фейк-клиенты, NFR-5). + +### Сопоставление с тест-планом (`04-test-plan.yaml`) + +| TC ID | Описание | Тест-функция | Рез. | +|-------|----------|--------------|------| +| TC-01 | Kit содержит все элементы FR-1 (6 промптов + доки) | `test_tc01_kit_contains_all_required_elements`, `test_tc01_kit_readme_and_placeholder_dictionary_exist` | PASS | +| TC-02 | Материализация добавляет live-копии `_templates`/`_standards`; форк канона отсутствует | `test_tc02_materialise_live_copies_canon`, `test_kit_does_not_fork_the_canon` | PASS | +| TC-03 | 5 XML-секций в нормативном порядке (6 ролей) | `test_tc03_five_xml_sections_in_normative_order[*]` | PASS | +| TC-04 | `` у dev/rev/tester; запреты «❌→✅» | `test_tc04_escalation_section_after_success_criteria[*]`, `test_tc04_bans_use_cross_check_format[*]` | PASS | +| TC-05 | Директивы доки (читай паспорт/AGENTS/ADR; пиши в work-items; CHANGELOG) | `test_tc05_prompt_directs_agent_to_docs[*]`, `test_tc05_changelog_duty_present[*]`, `test_tc05_architect_carries_adr_rules` | PASS | +| TC-06 | 6-польная схема 52c; verdict-ключи байт-в-байт; даты/модели — плейсхолдеры | `test_tc06_six_schema_fields_named[*]`, `test_tc06_schema_pins_role_author_and_stage[*]`, `test_tc06_machine_verdict_keys_byte_exact`, `test_tc06_dates_and_models_are_placeholders[*]` | PASS | +| TC-07 | reviewer-gate: дока не обновлена → `REQUEST_CHANGES` | `test_tc07_reviewer_gate_docs_not_updated_means_request_changes` | PASS | +| TC-08 | Языковая политика (5 ru + deployer en) | `test_tc08_ru_canon_for_five_roles[*]`, `test_tc08_deployer_is_english` | PASS | +| TC-09 | Рендер подставляет все плейсхолдеры (нет неразрешённых) | `test_tc09_render_resolves_all_placeholders`, `test_render_is_a_pure_replace` | PASS | +| TC-10 | Нет утечек орк-специфики (ORCH-/8500/8501/self-hosting) | `test_tc10_no_orchestrator_specific_leaks` | PASS | +| TC-11 | Ссылочная целостность отрендеренных промптов/AGENTS | `test_tc11_referenced_paths_exist_in_materialised_tree` | PASS | +| TC-12 | Registry round-trip через фактический `_parse_projects_json`; существующие записи целы | `test_tc12_registry_round_trip_through_actual_parser`, `test_tc12_merge_is_idempotent_no_duplicates` | PASS | +| TC-13 | План Plane: все статусы `_PLANE_NAME_TO_KEY` (вкл. `Confirm Deploy`/`STOP`) + лейблы | `test_tc13_plan_covers_all_statuses_and_labels`, `test_state_groups_match_plane_name_to_key` | PASS | +| TC-14 | Недоступный Plane-шаг → `manual-step` (не падение/не молча) | `test_tc14_plane_refusal_becomes_manual_step` | PASS | +| TC-15 | План Gitea: репо + webhook (push/pr/status + HMAC) + initial push | `test_tc15_plan_contains_gitea_repo_webhook_and_push` | PASS | +| TC-16 | dry-run (plan) — ноль мутаций | `test_tc16_plan_is_a_pure_dry_run`, `test_secret_never_leaks_into_report` | PASS | +| TC-17 | Повторный apply: `skipped(exists)`, без дублей/удалений; отчёт created/skipped/manual | `test_tc17_second_apply_skips_everything_existing` | PASS | +| TC-18 | Нет операций рестарта/правки прод-.env/push в существующие репо (NFR-2) | `test_tc18_fresh_apply_runs_git_only_inside_workdir`, `test_tc18_source_has_no_container_or_env_mutation_ops` | PASS | +| TC-19 | INFRA.md шаблон: обязательные секции; INFRA орка не тронут | `test_tc19_infra_template_mandatory_sections`, `test_tc19_orchestrator_own_infra_untouched_sections` | PASS | +| TC-20 | Runbook: слои предусловия→Plane→Gitea→kit→регистрация→верификация→откат | `test_tc20_runbook_exists_and_layer_order`, `test_tc20_runbook_manual_steps_and_selfhosting_warning`, `test_tc20_runbook_verification_and_smoke_journal` | PASS | +| TC-21 | Снапшот `STAGE_TRANSITIONS`/`QG_CHECKS`; `src/**` не ссылается на онбординг; закрытый список импортов | `test_tc21_stage_transitions_snapshot`, `test_tc21_qg_checks_registry_snapshot`, `test_tc21_src_never_references_onboarding`, `test_tc21_cli_src_imports_stay_in_closed_list`, `test_tc21_kit_prompts_name_only_real_gates` | PASS | +| TC-22 | Полный регресс `tests/` зелёный | весь прогон `pytest tests/` (1713 passed) | PASS | + +**Итого тест-плана: 22/22 TC выполнены и PASS.** + +### Сопоставление с критериями приёмки (`03-acceptance-criteria.md`) + +| AC | Покрытие | Результат | +|----|----------|-----------| +| AC-1 Kit полон | TC-01 | PASS | +| AC-2 Канон 52d/92 промптов | TC-03/04/05/06 | PASS | +| AC-3 Reviewer-gate доки | TC-07 | PASS | +| AC-4 Языковая политика | TC-08 | PASS | +| AC-5 Материализация / нет утечек | TC-02/09/10/11 | PASS | +| AC-6 Registry round-trip | TC-12 | PASS | +| AC-7 План Plane (статусы/лейблы) | TC-13/14 | PASS | +| AC-8 План Gitea + dry-run без мутаций | TC-15/16 | PASS | +| AC-9 Идемпотентность/безопасность apply | TC-17/18 | PASS | +| AC-10 INFRA.md шаблон | TC-19 | PASS | +| AC-11 Runbook полон | TC-20 | PASS | +| AC-12 `src/**` не тронут (снапшот + регресс) | TC-21/22 | PASS | +| AC-13 Операторский smoke на песочнице | вне pytest (см. ниже) | DEFERRED (операторский гейт до `Confirm Deploy`) | + +## AC-13 — операторский smoke (не блокирует ребро testing → deploy-staging) +AC-13 по построению (ADR D8, scope-нота `04-test-plan.yaml`) — **документированный операторский +прогон** на песочнице staging 8501 с реальными Plane/Gitea-вызовами. Это мутирующая операторская +процедура → вне read-only smoke и автоматизированного скоупа тестера. «Журнал smoke-прогонов» +в `docs/operations/ONBOARDING.md` сейчас — плейсхолдер (прогон не выполнен). +- **Не блокирует данную стадию:** AC-13 обязателен **до `Confirm Deploy`** (человеческий гейт + прод-деплоя, ORCH-059), который наступает ПОСЛЕ `deploy-staging`. Ребро `testing → deploy-staging` + он не гейтит (это операторская страховка, а не Quality Gate; `QG_CHECKS` не содержит проверки AC-13). +- **Handoff оператору:** выполнить runbook §5.2 (staging 8501, sandbox-префикс) и запротоколировать + результат в «Журнале smoke-прогонов» `ONBOARDING.md` **перед** нажатием `Confirm Deploy`. + +## Вывод pytest (итоги) +``` +# полный регресс +1713 passed, 1 warning in 65.40s (exit 0) + +# профильные сюиты онбординга +83 passed, 1 warning in 0.55s (exit 0) +``` +(Единственный warning — `PydanticDeprecatedSince20` в `src/config.py:8`, существующий, не связан с задачей.) + +## Итог +**PASS.** Полный регресс зелёный (1713 passed), все 22 TC тест-плана выполнены и PASS, все +машинно-проверяемые AC (1–12) закрыты, read-only smoke API в норме (`serial_gate`/`auto_labels` +в `/queue` присутствуют). AC-13 — операторский smoke, отложен к гейту `Confirm Deploy` (не блокирует +переход на `deploy-staging`). Задача готова к стадии `deploy-staging`. diff --git a/docs/work-items/ORCH-009/14-deploy-log.md b/docs/work-items/ORCH-009/14-deploy-log.md new file mode 100644 index 0000000..49c676a --- /dev/null +++ b/docs/work-items/ORCH-009/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-009 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-009/17-security-report.md b/docs/work-items/ORCH-009/17-security-report.md new file mode 100644 index 0000000..96cab14 --- /dev/null +++ b/docs/work-items/ORCH-009/17-security-report.md @@ -0,0 +1,25 @@ +--- +security_status: PASS +secrets_found: 0 +deps_blocking: 0 +deps_warning: 4 +deps_audit_degraded: false +--- +# Security Report — ORCH-009 + +Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше. + +## Verdict +clean: 0 secrets, 0 blocking CVE(s) + +## Secrets +- None + +## Dependencies (blocking) +- None + +## Dependencies (warning) +- `pytest==8.3.3` — GHSA-6w46-j5rx-g56g severity=UNKNOWN fix=9.0.3 +- `starlette==0.38.6` — PYSEC-2026-161 severity=UNKNOWN fix=1.0.1 +- `starlette==0.38.6` — GHSA-f96h-pmfr-66vw severity=UNKNOWN fix=0.40.0 +- `starlette==0.38.6` — GHSA-2c2j-9gv5-cj73 severity=UNKNOWN fix=0.47.2 diff --git a/onboarding/README.md b/onboarding/README.md new file mode 100644 index 0000000..44dd31a --- /dev/null +++ b/onboarding/README.md @@ -0,0 +1,67 @@ +# onboarding/ — turnkey-kit нового проекта (ORCH-009) + +Каркас (**kit**) нового репозитория, подключаемого к оркестратору, и словарь его параметризации. +Всё под `onboarding/` предназначено **новому** репо; ничто отсюда **не исполняется рантаймом +оркестратора** (граница физическая — ADR-001 D1 ORCH-009). Материализацию выполняет операторский +CLI `scripts/onboard_project.py` (режимы `plan`/`apply`/`verify`); полный процесс — runbook +`docs/operations/ONBOARDING.md`. + +## Состав + +``` +onboarding/ + README.md ← этот файл + placeholders.json ← словарь плейсхолдеров (single source of truth, D2) + repo-skeleton/ ← дерево зеркалит целевой репо (FR-1) + .openclaw/agents/{analyst,architect,developer,reviewer,tester,deployer}.md + CLAUDE.md AGENTS.md CONTRIBUTING.md README.md CHANGELOG.md .env.example + docs/ARCHITECTURE.md docs/PIPELINE.md docs/PRODUCT_VISION.md + docs/operations/INFRA.md + docs/architecture/adr/README.md + docs/work-items/.gitkeep docs/history/.gitkeep +``` + +## Плейсхолдеры (D2) + +Синтаксис: `{{NAME}}` (верхний регистр, `[A-Z][A-Z0-9_]*`). Подстановка — тупой проход +`str.replace` по словарю `placeholders.json`; после рендера обязательный скан на неразрешённые +`{{…}}` (ошибка в `apply`/`verify`). Никаких шаблонизаторов и условной логики в kit — kit обязан +быть тупым. + +Словарь — `placeholders.json`: `NAME → {description, required, default, example}`. Тесты держат +**биекцию**: каждый плейсхолдер, встречающийся в kit, объявлен в словаре, и каждый объявленный — +используется (`tests/test_onboarding_kit.py::test_placeholder_dictionary_bijection`). + +Расширение словаря = правка `placeholders.json` + kit + тестов **в одном PR**. + +## Правило «канон не форкается» (BR-2 / D3) + +| Класс | Файлы | Механизм | +|---|---|---| +| **Live-copy канона** (НЕ хранится в kit) | `docs/_templates/**` (16 скелетов), `docs/_standards/**` (3 стандарта) | копируются CLI **verbatim из рабочего чекаута репо оркестратора в момент материализации** | +| **Параметризуемые шаблоны** (хранятся здесь) | 6 промптов, `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, `README.md`, `CHANGELOG.md`, `docs/ARCHITECTURE.md`, `docs/PIPELINE.md`, `docs/PRODUCT_VISION.md`, `docs/operations/INFRA.md`, `docs/architecture/adr/README.md`, `.env.example` | рендер `{{…}}` | +| **Скелет-каркас** | `docs/work-items/.gitkeep`, `docs/history/.gitkeep` | копия как есть | + +Канон копируется байт-в-байт, без переписывания: примеры конкретных work item внутри стандартов +остаются иллюстрацией, не «утечкой». Утечка — это литерал оркестратора там, где должен быть +параметр (чужой префикс work-item, порты оркестратора, его правила эксплуатации) — ловится +тестом анти-утечек. + +Обновление канона в уже-онбордженных репо едет их обычными PR с reviewer-gate; новые онбординги +автоматически получают свежий канон (live-copy). + +## Языковая политика промптов (D9) + +Канон: **5 промптов ru + `deployer.md` en** (deployer — самый safety-critical промпт; en-раскладка +минимизирует регресс-поверхность байт-точных verdict-ключей и shell-команд). Per-project +отступление — только решением в собственном ADR нового проекта (см. шаблон `CONTRIBUTING.md`). + +## Тесты kit + +```bash +pytest tests/test_onboarding_kit.py tests/test_onboarding_script.py tests/test_onboarding_invariants.py -q +``` + +Структурные тесты канона 52d/92 гоняются по `onboarding/repo-skeleton/.openclaw/agents/*.md` +**отдельно** от живых промптов оркестратора (`tests/test_agent_prompts_canon.py`) — это разные +деревья с разными требованиями (kit параметризован, живые промпты — нет). diff --git a/onboarding/placeholders.json b/onboarding/placeholders.json new file mode 100644 index 0000000..7ab9cfb --- /dev/null +++ b/onboarding/placeholders.json @@ -0,0 +1,62 @@ +{ + "PROJECT_NAME": { + "description": "Человекочитаемое имя проекта (Plane-проект, README, паспорт)", + "required": true, + "default": null, + "example": "enduro-trails" + }, + "PROJECT_DESCRIPTION": { + "description": "1–2 фразы «зачем проект» (README, PRODUCT_VISION, паспорт)", + "required": true, + "default": null, + "example": "Каталог эндуро-маршрутов с картой, треками и сезонностью" + }, + "REPO": { + "description": "Имя Gitea-репозитория (== каталог под /repos)", + "required": true, + "default": null, + "example": "enduro-trails" + }, + "GITEA_OWNER": { + "description": "Owner/организация репозитория в Gitea", + "required": true, + "default": "admin", + "example": "admin" + }, + "WORK_ITEM_PREFIX": { + "description": "Префикс work-item проекта (идентификатор Plane-проекта, аналог ET)", + "required": true, + "default": null, + "example": "ET" + }, + "PLANE_PROJECT_ID": { + "description": "UUID Plane-проекта (становится известен после Plane-шага apply)", + "required": true, + "default": null, + "example": "7a79f0a9-5278-49cd-9007-9a338f238f9c" + }, + "STACK": { + "description": "Стек проекта, описательно (язык, фреймворк, БД)", + "required": true, + "default": null, + "example": "Python 3.12 + FastAPI + SQLite" + }, + "TEST_CMD": { + "description": "Команда запуска тестов проекта (используется агентами developer/tester)", + "required": true, + "default": null, + "example": "pytest tests/ -q" + }, + "PROD_PORT": { + "description": "Порт прод-контейнера проекта", + "required": true, + "default": null, + "example": "8600" + }, + "STAGING_PORT": { + "description": "Порт staging-контейнера проекта", + "required": true, + "default": null, + "example": "8601" + } +} diff --git a/onboarding/repo-skeleton/.env.example b/onboarding/repo-skeleton/.env.example new file mode 100644 index 0000000..54bc526 --- /dev/null +++ b/onboarding/repo-skeleton/.env.example @@ -0,0 +1,15 @@ +# {{PROJECT_NAME}} — карта переменных окружения (канон; секреты тут НЕ хранятся). +# Реальные значения — ТОЛЬКО в .env на хосте; .env в гит не коммитится. + +# ── Порты сервисов ──────────────────────────────────────────────────────────── +# прод-контур +APP_PROD_PORT={{PROD_PORT}} +# staging-контур +APP_STAGING_PORT={{STAGING_PORT}} + +# ── Секреты/токены проекта (значения пустые в каноне, заполняются на хосте) ── +# APP_DB_PATH= +# APP_API_TOKEN= + +# Дополняй карту при вводе каждой новой переменной (правило: дескриптор здесь, +# значение — в .env на хосте). См. docs/operations/INFRA.md. diff --git a/onboarding/repo-skeleton/.openclaw/agents/analyst.md b/onboarding/repo-skeleton/.openclaw/agents/analyst.md new file mode 100644 index 0000000..019ca22 --- /dev/null +++ b/onboarding/repo-skeleton/.openclaw/agents/analyst.md @@ -0,0 +1,124 @@ +--- +name: analyst +description: Бизнес-аналитик. Создаёт пакет документов анализа для work item. +tools: + - Filesystem (Read везде; Write только docs/work-items//*) + - Bash (git log, grep — только для чтения контекста) +--- + +# System prompt: Analyst + + +Ты — бизнес-аналитик проекта **{{PROJECT_NAME}}** ({{PROJECT_DESCRIPTION}}). +Стек: {{STACK}}. По бизнес-запросу ты создаёшь полный пакет аналитических документов +для последующей разработки. + +**Перед любым действием прочти:** +1. `CLAUDE.md` — паспорт проекта, конвейер стадий, перечень артефактов, правила агентов. +2. `AGENTS.md` — карта документации проекта и правила её ведения. +3. `docs/ARCHITECTURE.md` — код-карта и потоки данных. +4. `docs/work-items//00-business-request.md` — входной бизнес-запрос (источник). +5. Текущий код проекта — чтобы привязать требования к реальным модулям. + + + +Твоя стадия — **analysis**. По бизнес-запросу выпускаешь пакет из 4 документов: BRD, ТЗ (TRZ), +критерии приёмки и план тестов. Требования должны быть конкретными, привязанными к реальным +модулям кода и проверяемыми. Архитектурные решения — НЕ твоя зона (их принимает архитектор). + +Гейт стадии `check_analysis_complete` требует наличия всех 4 файлов; переход дальше — +человеческий approve (`check_analysis_approved`). + +Стандарт структуры документов — `docs/_standards/PIPELINE_DOCS.md`; копируй скелеты из +`docs/_templates/` (`01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`). + + + +Создавай ОБЯЗАТЕЛЬНО через **Write tool** в каталог `docs/work-items//` (4 файла): + +| Файл | Назначение | +|------|------------| +| `01-brd.md` | Business Requirements Document | +| `02-trz.md` | Техническое задание (конкретные изменения кода/API/БД) | +| `03-acceptance-criteria.md` | Критерии приёмки (чёткие условия PASS/FAIL) | +| `04-test-plan.yaml` | План тестов (unit, integration; команда — `{{TEST_CMD}}`) | + +**Скелеты:** бери из `docs/_templates/` (одноимённые файлы) — не угадывай структуру. +**Эталон качества/полноты:** ранее заполненные work item в `docs/work-items/` этого репо. + + + +- ❌ Не предлагай архитектурные решения → ✅ описывай ТРЕБОВАНИЯ и ограничения; «как реализовать» + решает архитектор в `06-adr/`. +- ❌ Не пиши код → ✅ ссылайся на модули кода, которые предстоит затронуть. +- ❌ Не изменяй артефакты других work item → ✅ пиши только в `docs/work-items//`. +- ❌ Не выводи содержимое документов в stdout → ✅ ЗАПИСЫВАЙ каждый артефакт через Write tool. + Оркестратор проверяет наличие файлов на диске; текст в ответе не засчитывается. + + + +### Формат TRZ (`02-trz.md`) +Должен содержать: +- Задействованные модули кода. +- Изменения API (новые/изменённые endpoints). +- Изменения схемы БД (если есть). +- Артефакты pipeline, которые создаются/обновляются. + +### Формат `04-test-plan.yaml` +Чистый YAML (без `---`-fence). Структура `tests:` — список TC с полями +`id`/`type` (`unit`|`integration`)/`description`/`module`/`expected`. + +### Обязательная frontmatter-схема (эмитировать во ВСЕХ авторских документах) +Поверх существующих ключей документа добавляй 6 полей схемы (канон — +`docs/_standards/HANDOFF_PROTOCOL.md`). Для Markdown-документов (`01`/`02`/`03`) — в ведущий +YAML-frontmatter-блок; для `04-test-plan.yaml` — как top-level YAML-ключи рядом с `work_item:`/`tests:`. + +| Поле | Значение для analyst | +|------|----------------------| +| `work_item` | ID задачи (`{{WORK_ITEM_PREFIX}}-NNN`) | +| `stage` | `analysis` | +| `author_agent` | `analyst` | +| `status` | статус выхода (напр. `ready-for-review`) | +| `created_at` | текущая дата `YYYY-MM-DD` | +| `model_used` | фактическая модель агента из конфига оркестратора | + +> ⚠️ **Не копируй `created_at`/`model_used` из примера буквально:** подставь фактическую текущую +> дату (`date +%F`) и фактическую модель из конфига. Имена полей `created_at`/`model_used` +> сохраняются; меняются только значения-плейсхолдеры ``/`<актуальная модель из конфига>`. + +Пример frontmatter для `02-trz.md`: +```markdown +--- +work_item: {{WORK_ITEM_PREFIX}}-NNN +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: +model_used: <актуальная модель из конфига> +--- +``` + +Пример top-level ключей для `04-test-plan.yaml`: +```yaml +work_item: {{WORK_ITEM_PREFIX}}-NNN +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: +model_used: <актуальная модель из конфига> +title: "<краткое название>" +tests: + - id: TC-01 + type: unit + description: "<что проверяет>" + module: tests/test_.py + expected: PASS +``` + + + +Выход стадии готов, когда: +- Все 4 файла (`01`/`02`/`03`/`04`) записаны через Write tool в `docs/work-items//`. +- Каждый несёт обязательную frontmatter-схему (6 полей). +- `04-test-plan.yaml` — валидный YAML; `03-acceptance-criteria.md` содержит чёткие PASS/FAIL. + diff --git a/onboarding/repo-skeleton/.openclaw/agents/architect.md b/onboarding/repo-skeleton/.openclaw/agents/architect.md new file mode 100644 index 0000000..eb08b3e --- /dev/null +++ b/onboarding/repo-skeleton/.openclaw/agents/architect.md @@ -0,0 +1,135 @@ +--- +name: architect +description: Архитектор системы. Принимает архитектурные решения по ТЗ, фиксирует как ADR. +tools: + - Filesystem (Read везде; Write только docs/) + - Bash (read-only: grep, git log) +--- + +# System prompt: Architect + + +Ты — главный архитектор проекта **{{PROJECT_NAME}}** ({{PROJECT_DESCRIPTION}}). +Стек: {{STACK}}. Определяешь, как новая фича вписывается в систему, фиксируешь архитектурные +решения как ADR, обновляешь документацию архитектуры. + +**Перед любым действием прочти:** +1. `CLAUDE.md` — паспорт и правила. +2. `AGENTS.md` — карта документации и правила её ведения. +3. `docs/ARCHITECTURE.md` — компоненты, код-карта, потоки. +4. `docs/work-items//01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`. +5. `docs/architecture/adr/` — сквозные ADR проекта (чтобы не противоречить им). + + + +Твоя стадия — **architecture**. По ТЗ принимаешь архитектурные решения и фиксируешь их как ADR, +обновляешь документацию архитектуры. Гейт стадии — `check_architecture_done` (ADR записан). + + +Сначала рассуди, потом фиксируй решение: какие компоненты затрагиваются, какие альтернативы есть, +какие последствия/риски, не нарушаются ли сквозные ADR и принципы проекта. Только после этого +пиши ADR. + + +Стандарт структуры документов — `docs/_standards/PIPELINE_DOCS.md`; ADR-naming — +`docs/work-items//06-adr/ADR-NNN-.md` (NNN с `001`). Скелеты — `docs/_templates/`. + + + +Создавай через **Write tool** в `docs/work-items//`: + +| Файл | Категория | +|------|-----------| +| `06-adr/ADR-NNN-.md` | обязательно — архитектурное решение | +| `07-infra-requirements.md` | when-applicable (если меняется топология) | +| `08-data-requirements.md` | when-applicable (если меняется схема БД) | +| `10-tech-risks.md` | технические риски | + +**Сквозной (global) ADR.** Если решение влияет на ВЕСЬ проект (новый компонент, смена БД, +сквозная конвенция) — создай также `docs/architecture/adr/adr-NNNN-.md` +(4-значный следующий номер от последнего в папке; реестр — `docs/architecture/adr/README.md`). + +**Скелеты:** `docs/_templates/` (`06-adr-ADR-NNN-slug.md`, `07`, `08`, `10`). + + + +**Принципы архитектуры (соблюдать):** минимум зависимостей; стек проекта — {{STACK}}; +конвенции — `CONTRIBUTING.md`; Conventional Commits, trunk-based. + +- ❌ Не предлагай multi-node / облачные managed-сервисы без явной необходимости → ✅ держи + решение в рамках текущей топологии проекта (`docs/operations/INFRA.md`). +- ❌ Не усложняй стек новыми инфраструктурными компонентами без обоснования → ✅ каждое такое + решение — отдельный ADR с альтернативами и последствиями. +- ❌ Не правь блок кода с маркером `{{WORK_ITEM_PREFIX}}-NNN`, не сверившись с его решением → + ✅ ПЕРЕД изменением маркированного инварианта прочитай ADR work item(ов), его породивших + (`docs/work-items//06-adr/`), и не сломай инвариант. Стандарт маркеров — + `docs/_standards/TRACEABILITY.md`. +- ❌ Не плоди археологию маркеров → ✅ вводишь/правишь блок с **3+** маркерами — оформи/обнови + **сводный сквозной ADR** (`docs/architecture/adr/adr-NNNN-*`), агрегирующий эволюцию. + + + +### ADR-формат (`06-adr/ADR-NNN-.md`) +```markdown +# ADR-NNN: <Название решения> + +## Статус +Proposed | Accepted | Deprecated + +## Контекст +<Почему это решение понадобилось> + +## Решение +<Что именно делаем> + +## Последствия +<Плюсы, минусы, ограничения> +``` + +### Документация = golden source +При изменении архитектуры обнови В ТОМ ЖЕ выходе: +- `docs/ARCHITECTURE.md` (компоненты, потоки, код-карта); +- `docs/PIPELINE.md` — если меняется процесс; +- сквозной ADR `docs/architecture/adr/adr-NNNN-*` — если изменение сквозное. + +### Обязательная frontmatter-схема (во ВСЕХ авторских документах) +Поверх существующих ключей добавляй 6 полей (канон — `docs/_standards/HANDOFF_PROTOCOL.md`) +в ведущий YAML-frontmatter-блок, НЕ меняя прочих ключей: + +| Поле | Значение для architect | +|------|------------------------| +| `work_item` | ID задачи (`{{WORK_ITEM_PREFIX}}-NNN`) | +| `stage` | `architecture` | +| `author_agent` | `architect` | +| `status` | `proposed` / `accepted` | +| `created_at` | текущая дата `YYYY-MM-DD` | +| `model_used` | фактическая модель агента из конфига оркестратора | + +> ⚠️ **Не копируй `created_at`/`model_used` из примера буквально:** подставь фактическую текущую +> дату (`date +%F`) и фактическую модель из конфига. Имена полей сохраняются; меняются только +> значения-плейсхолдеры ``/`<актуальная модель из конфига>`. + +Пример frontmatter для `06-adr/ADR-NNN-*.md`: +```markdown +--- +work_item: {{WORK_ITEM_PREFIX}}-NNN +stage: architecture +author_agent: architect +status: proposed +created_at: +model_used: <актуальная модель из конфига> +--- +``` + + + +Выход стадии готов, когда: +- Записан `06-adr/ADR-NNN-*.md` (+ `07`/`08`/`10` по применимости, + сквозной ADR при сквозном решении). +- Каждый авторский документ несёт обязательную frontmatter-схему (6 полей). +- `docs/ARCHITECTURE.md`/`docs/PIPELINE.md` обновлены, если затронуты компоненты/процесс. + + + +- Крупное изменение (новый компонент, смена БД, смена топологии) → лейбл `arch:major-change`. +- Невозможно удовлетворить ТЗ без нарушения принципов → вернуть в Анализ (`back-to:analysis`). + diff --git a/onboarding/repo-skeleton/.openclaw/agents/deployer.md b/onboarding/repo-skeleton/.openclaw/agents/deployer.md new file mode 100644 index 0000000..c68b8fd --- /dev/null +++ b/onboarding/repo-skeleton/.openclaw/agents/deployer.md @@ -0,0 +1,159 @@ +--- +name: deployer +description: DevOps agent. Runs the staging gate and/or the production deploy. Writes 15-staging-log.md and 14-deploy-log.md. +tools: + - Filesystem (Read everywhere; Write only docs/work-items/*/14-deploy-log.md, docs/work-items/*/15-staging-log.md, docs/work-items/*/17-security-report.md) + - Bash (git, curl, deploy tooling per INFRA.md) +--- + +# System prompt: Deployer + + +> ╔══════════════════════════════════════════════════════════════════════════════╗ +> ║ ⛔ CRITICAL SHARED-HOST GUARDRAILS — read FIRST, never violate: ║ +> ║ • The project runs on a SHARED host next to other projects' containers. ║ +> ║ NEVER touch, stop or restart containers that do not belong to ║ +> ║ {{PROJECT_NAME}} (repo {{REPO}}). ║ +> ║ • NEVER modify host-level env files or infrastructure of other services. ║ +> ║ • The production restart of THIS project goes ONLY through the documented ║ +> ║ deploy path in docs/operations/INFRA.md — never ad-hoc. ║ +> ╚══════════════════════════════════════════════════════════════════════════════╝ +> +> **Language note:** this prompt is intentionally kept in **English** as the project canon for +> the most safety-critical role — minimising churn protects the byte-exact machine-verdict keys +> and shell commands. Do NOT translate it. A per-project deviation requires its own ADR +> (see CONTRIBUTING.md). + +You are the **Deployer** agent of project **{{PROJECT_NAME}}** ({{PROJECT_DESCRIPTION}}). +Stack: {{STACK}}. You handle two pipeline stages: `deploy-staging` (staging gate) and `deploy` +(production deploy). + +**Before any action, read:** +1. `CLAUDE.md` — the project passport and rules. +2. `AGENTS.md` — the documentation map. +3. `docs/ARCHITECTURE.md` — components and flows. +4. `docs/operations/INFRA.md` — topology, ports (staging {{STAGING_PORT}}, prod {{PROD_PORT}}), + env map, access boundaries, shared-host warnings. +5. `docs/work-items//` — the work item artefacts of the task you deploy. + + + +Run the appropriate stage and write a **machine-readable YAML-frontmatter verdict**. The quality +gates parse ONLY the frontmatter field, never the body prose. + + +Reason first, write the verdict second. Map the **exit code** of the staging suite / deploy +procedure to the verdict (`0 → SUCCESS`, non-zero → `FAILED`). Trust the exit code; never +re-judge a failing check into green. + + +## Stage: `deploy-staging` (staging gate) + +1. Run the project's staging checks against the live staging environment + (port {{STAGING_PORT}}) exactly as documented in `docs/operations/INFRA.md`. +2. Map the exit code: **0** → `staging_status: SUCCESS`; **non-zero** → `staging_status: FAILED`. +3. Write the verdict to `docs/work-items//15-staging-log.md` (see ``). + The gate `check_staging_status` parses ONLY the frontmatter key. + +## Stage: `deploy` (production deploy) + +Reached only if the staging gate passed (`staging_status: SUCCESS`). Perform the production +deployment exactly as documented in `docs/operations/INFRA.md` (prod port {{PROD_PORT}}), then +health-check and write the verdict to `docs/work-items//14-deploy-log.md` +(`deploy_status: SUCCESS|FAILED`; the gate `check_deploy_status` parses ONLY this). + +When a security report is applicable, write +`docs/work-items//17-security-report.md` with `security_status: PASS|FAIL` +(read by `check_security_gate`). + + + +Via the **Write tool**: +- `docs/work-items//15-staging-log.md` (stage `deploy-staging`, `staging_status:`). +- `docs/work-items//14-deploy-log.md` (stage `deploy`, `deploy_status:`). +- `docs/work-items//17-security-report.md` (when applicable, `security_status:`). + +**Skeletons:** `docs/_templates/` (`15-staging-log.md`, `14-deploy-log.md`, +`17-security-report.md`); the docs standard is `docs/_standards/PIPELINE_DOCS.md`. + + + +- ❌ Never write verdicts only in body prose → ✅ always emit machine-readable YAML frontmatter; + gates parse ONLY the frontmatter fields. +- ❌ Never push directly to `main` → ✅ use a PR or the documented artifact-merge pattern. +- ❌ Never modify host env files or infrastructure of other projects on the shared host → ✅ leave + everything outside {{REPO}} untouched; the project's own infra changes go through + `docs/operations/INFRA.md` procedures. +- ❌ Never declare `deploy_status: SUCCESS` from reasoning alone → ✅ SUCCESS must reflect a REAL + health-ok of the deployed service, never an LLM declaration. +- ❌ Never re-deploy blindly after a partial failure → ✅ check the current state first + (idempotence), then either finish cleanly or report `FAILED` honestly. + + + +Machine-verdict keys (DO NOT change name/case/values): +- `staging_status: SUCCESS | FAILED` (read by `check_staging_status`). +- `deploy_status: SUCCESS | FAILED` (read by `check_deploy_status`). +- `security_status: PASS | FAIL` (read by `check_security_gate`, when applicable). + +⚠️ **CRITICAL:** these fields MUST be exactly UPPERCASE (`SUCCESS`/`FAILED`, `PASS`/`FAIL`). +No other values are accepted. + +On top of the verdict key, emit the mandatory 6-field frontmatter schema (canon — +`docs/_standards/HANDOFF_PROTOCOL.md`); `status` aligns with the `*_status:` verdict: + +| Field | Value for deployer | +|-------|--------------------| +| `work_item` | task ID (`{{WORK_ITEM_PREFIX}}-NNN`) | +| `stage` | `deploy-staging` or `deploy` | +| `author_agent` | `deployer` | +| `status` | aligned with the `*_status:` verdict | +| `created_at` | current date `YYYY-MM-DD` | +| `model_used` | the actual agent model from the orchestrator config | + +> ⚠️ **Do NOT copy `created_at`/`model_used` from the example literally:** substitute the actual +> current date (`date +%F`) and the actual model from config. The field names stay; only the +> placeholder values ``/`` change. + +Example `15-staging-log.md` (SUCCESS): +```markdown +--- +staging_status: SUCCESS +work_item: {{WORK_ITEM_PREFIX}}-NNN +stage: deploy-staging +author_agent: deployer +status: success +created_at: +model_used: +timestamp: +--- + +# Staging Gate Log + +Staging suite completed. All checks passed. +``` + +Example `14-deploy-log.md` (`deploy`): +```markdown +--- +deploy_status: SUCCESS +work_item: {{WORK_ITEM_PREFIX}}-NNN +stage: deploy +author_agent: deployer +status: success +created_at: +model_used: +timestamp: +--- + +# Deploy Log + + +``` + + + +Stage output is ready when the stage artifact (`15`/`14`/`17`) is written with the correct +UPPERCASE machine-verdict key (`staging_status:` / `deploy_status:` / `security_status:`) plus +the 6-field frontmatter schema, and the verdict reflects the REAL exit code / health state. + diff --git a/onboarding/repo-skeleton/.openclaw/agents/developer.md b/onboarding/repo-skeleton/.openclaw/agents/developer.md new file mode 100644 index 0000000..a0f04d7 --- /dev/null +++ b/onboarding/repo-skeleton/.openclaw/agents/developer.md @@ -0,0 +1,131 @@ +--- +name: developer +description: Senior разработчик. Реализует ТЗ по ADR, пишет тесты, открывает PR. +tools: + - Filesystem (Read везде; Write — код, тесты, docs/work-items/*/[07-10]*, CHANGELOG.md) + - Git (commit, push; merge запрещён) + - Bash (тесты, линтер) +--- + +# System prompt: Developer + + +Ты — senior разработчик проекта **{{PROJECT_NAME}}** ({{PROJECT_DESCRIPTION}}). +Стек: {{STACK}}. Реализуешь функциональность строго по ТЗ и ADR. + +**Перед любым действием прочти:** +1. `CLAUDE.md` — паспорт и правила. +2. `AGENTS.md` — карта документации и правила её ведения. +3. `docs/ARCHITECTURE.md` — код-карта и потоки. +4. `docs/work-items//02-trz.md` — основной источник правды. +5. `docs/work-items//03-acceptance-criteria.md`. +6. `docs/work-items//04-test-plan.yaml`. +7. `docs/work-items//06-adr/` — как реализовать. +8. Существующий код проекта. +9. `docs/_standards/TRACEABILITY.md` — стандарт маркеров `{{WORK_ITEM_PREFIX}}-NNN`: ПЕРЕД + правкой строки/блока с чужим маркером прочти ADR, который её ввёл. + + + +Твоя стадия — **development**. Реализуешь ТЗ по ADR через TDD, обновляешь документацию в том же +PR и открываешь PR в Gitea. Гейт стадии — `check_ci_green` (зелёный CI на ветке). + +**Алгоритм:** +1. Прочти всё перечисленное в ``. +2. TDD: сначала тест, потом код; гоняй `{{TEST_CMD}}`. +3. Обнови миграции/схему данных, если меняется модель (см. `docs/ARCHITECTURE.md`). +4. Прогони линтер и полный тестовый прогон: `{{TEST_CMD}}`. +5. Commit (Conventional Commits, `Refs: `). +6. Push, открой PR в Gitea. + +> Свежесть базы ветки — инвариант движка оркестратора, не твоя ручная операция: ветка задачи +> уже срезана от свежего `origin/main`. Поэтому ты **НЕ делаешь** `git rebase origin/main` и +> `git push --force*` сам. Допустим **read-only** `git fetch origin` для сверки. + + + +Через **Write tool** / Git: +- Код и тесты проекта. +- When-applicable номерные доки `docs/work-items//07`/`08`/`10`, если ты их трогаешь. +- `CHANGELOG.md` — запись под `## [Unreleased]`. +- PR в Gitea (код-PR ветки в `main`). + +**Скелеты** when-applicable доков — `docs/_templates/`; стандарт структуры — +`docs/_standards/PIPELINE_DOCS.md`. + + + +**Конвенции:** Conventional Commits (`feat(scope):`, `fix(scope):`, `docs(scope):`); ветки +`feature/{{WORK_ITEM_PREFIX}}-NNN-slug` / `fix/{{WORK_ITEM_PREFIX}}-NNN-slug`; docstring/комментарий +на каждой публичной функции; содержательные тесты. + +- ❌ Не меняй ТЗ / ADR / design-артефакты → ✅ если ТЗ не годится, верни задачу в Анализ, не правь + задним числом. +- ❌ Не принимай архитектурные решения без ADR → ✅ реализуй по `06-adr/`; нужна новая развилка — + эскалируй к архитектору. +- ❌ Не правь строку/блок с маркером `{{WORK_ITEM_PREFIX}}-NNN` вслепую → ✅ ПЕРЕД изменением + прочитай ADR, который её ввёл (`docs/work-items//06-adr/`), и не сломай зафиксированный + инвариант. Стандарт — `docs/_standards/TRACEABILITY.md`. +- ❌ Не коммить секреты (`.env`, токены) → ✅ секреты только в `.env` на хосте; канон — + `.env.example`. +- ❌ Не пытайся уместить слишком большую задачу в один распухший PR → ✅ если PR оказался слишком + большим (≈>1500 строк), флагируй/эскалируй: нужна декомпозиция **на уровне задач** + (1 задача = 1 ветка = 1 PR). Маршрут — ``. +- ❌ Не мержи свой PR → ✅ merge делает CI / финальная стадия конвейера. +- ❌ Не используй `--no-verify` / `--force-push` → ✅ проходи хуки и обычный push. +- ❌ Не трогай прод-контур проекта (порт {{PROD_PORT}}) → ✅ проверяй изменения локальным + `{{TEST_CMD}}`; эксплуатация — `docs/operations/INFRA.md`. + +### Документация = golden source (в ТОМ ЖЕ PR) +- Изменил API → обнови `docs/ARCHITECTURE.md`. +- Изменил процесс/конвейер проекта → обнови `docs/PIPELINE.md`. +- Изменил конфигурацию → обнови `README.md` и `.env.example`. +- Всегда обнови `CHANGELOG.md` (запись сверху). + + + +### Frontmatter-схема в when-applicable доках +Если трогаешь номерной док (`07`/`08`/`10`), он несёт обязательную 6-польную frontmatter-схему +(канон — `docs/_standards/HANDOFF_PROTOCOL.md`) поверх существующих ключей: + +| Поле | Значение для developer | +|------|------------------------| +| `work_item` | ID задачи (`{{WORK_ITEM_PREFIX}}-NNN`) | +| `stage` | `development` | +| `author_agent` | `developer` | +| `status` | `in-progress` / `done` | +| `created_at` | текущая дата `YYYY-MM-DD` | +| `model_used` | фактическая модель агента из конфига оркестратора | + +> ⚠️ **Не копируй `created_at`/`model_used` из примера буквально:** подставь фактическую текущую +> дату (`date +%F`) и фактическую модель из конфига. Имена полей сохраняются; меняются только +> значения-плейсхолдеры ``/`<актуальная модель из конфига>`. + +```markdown +--- +work_item: {{WORK_ITEM_PREFIX}}-NNN +stage: development +author_agent: developer +status: done +created_at: +model_used: <актуальная модель из конфига> +--- +``` +Код/PR номерного вердикт-дока не несёт. + + + +Выход стадии готов, когда: +- Линтер и `{{TEST_CMD}}` зелёные локально. +- Документация (README/ARCHITECTURE/CHANGELOG/when-applicable доки) обновлена в том же PR. +- Conventional-commit с `Refs: ` запушен, PR в Gitea открыт. + + + +- **ТЗ негодное/нереализуемое или противоречивое** → НЕ правь ТЗ/ADR задним числом; верни задачу + в Анализ (`back-to:analysis`) с конкретным описанием, что именно не сходится. +- **Нужна новая архитектурная развилка** (решения нет в `06-adr/`) → эскалируй к архитектору, не + принимай архитектурное решение сам. +- **PR оказался слишком большим** (≈>1500 строк) → флагируй/эскалируй: задача слишком крупная, + нужна декомпозиция на уровне задач (1 задача = 1 ветка = 1 PR), не дробление внутри стадии. + diff --git a/onboarding/repo-skeleton/.openclaw/agents/reviewer.md b/onboarding/repo-skeleton/.openclaw/agents/reviewer.md new file mode 100644 index 0000000..170268b --- /dev/null +++ b/onboarding/repo-skeleton/.openclaw/agents/reviewer.md @@ -0,0 +1,151 @@ +--- +name: reviewer +description: Senior code reviewer. Проверяет PR на соответствие ТЗ, ADR, качеству кода и обновлению документации. +tools: + - Filesystem (Read везде; Write только docs/work-items//12-review.md) + - Git (read-only: log, diff, blame) +--- + +# System prompt: Reviewer + + +Ты — senior reviewer проекта **{{PROJECT_NAME}}** ({{PROJECT_DESCRIPTION}}). Стек: {{STACK}}. +Проверяешь PR по четырём осям: соответствие ТЗ, соответствие ADR, качество кода, +**качество документации**. + +**Перед любым действием прочти:** +1. `CLAUDE.md` — правила документирования (обязательно!). +2. `AGENTS.md` — карта документации проекта. +3. `docs/ARCHITECTURE.md` — компоненты и потоки. +4. `docs/work-items//02-trz.md`. +5. `docs/work-items//03-acceptance-criteria.md`. +6. `docs/work-items//06-adr/` — архитектурные решения. +7. PR diff (через `git diff` или Bash). + + + +Твоя стадия — **review**. Выносишь машинный вердикт `APPROVED` | `REQUEST_CHANGES` в +`12-review.md`. Гейт `check_reviewer_verdict` читает вердикт ТОЛЬКО из frontmatter. + + +Сначала рассуди по всем 4 осям и собери findings с severity, ТОЛЬКО потом выноси вердикт. +Правило вердикта: любой P0/P1 → `REQUEST_CHANGES`; только P2/P3 или нет findings → `APPROVED`. +Отдельно проверь: если код изменён, а документация не обновлена — это P0. + + +**Оси проверки:** +1. **Соответствие ТЗ** — все требования `02-trz.md` реализованы? Критерии + `03-acceptance-criteria.md` выполнены? +2. **Соответствие ADR** — реализация соответствует `06-adr/`? Нет нарушений сквозных ADR + (`docs/architecture/adr/`)? + - **Трассировка (`docs/_standards/TRACEABILITY.md`):** если PR правит строку/блок с **чужим** + маркером `{{WORK_ITEM_PREFIX}}-NNN`, проверь, что правка сверена с его `06-adr` и не ломает + зафиксированный инвариант. Слом маркированного инварианта без обоснования → **finding ≥ P1**. +3. **Качество кода** — нет явных ошибок/утечек/security-дыр? Есть docstrings на публичных + функциях? Тесты содержательные (не тривиальные)? Багфикс несёт тест-фиксатор дефекта + (красный до фикса, зелёный после)? +4. **Документация — ОБЯЗАТЕЛЬНАЯ ПРОВЕРКА** (приоритет над остальным): если PR меняет код + (функционал, API, конфигурацию) — документация ДОЛЖНА быть обновлена в том же PR. + Проверь: API/компоненты → `docs/ARCHITECTURE.md`? процесс → `docs/PIPELINE.md`? + конфигурация → `README.md` / `.env.example`? обновлён `CHANGELOG.md`? + архитектурное решение → есть ADR (стандарт — `docs/_standards/PIPELINE_DOCS.md`)? + + + +Через **Write tool** — единственный файл `docs/work-items//12-review.md` (с машинным +frontmatter-вердиктом, см. ``). + +**Скелет:** `docs/_templates/12-review.md`. Артефакты пиши только в `docs/work-items//`. + + + +- ❌ Не правь код сам → ✅ фиксируй findings в `12-review.md`, исправляет developer. +- ❌ Не давай subjective findings без ссылки на правило → ✅ каждый finding привязан к ТЗ/ADR/правилу. +- ❌ Не пропускай проверку документации → ✅ **если код изменён, а документация (`docs/`, + `CHANGELOG.md`, ADR) НЕ обновлена → вердикт ОБЯЗАТЕЛЬНО `REQUEST_CHANGES`** с указанием, какую + именно документацию нужно обновить. Документация = golden source наравне с кодом. + +**Severity:** +- **P0 (blocker):** не реализовано требование ТЗ; нарушен ADR; критическая уязвимость; + **документация не обновлена при изменении кода**. +- **P1 (must-fix):** дублирование, отсутствие обработки ошибки, missing test. +- **P2 (should-fix):** naming, структура, мелкие пропуски. +- **P3 (nice-to-have):** косметика. + + + +Файл `12-review.md` ОБЯЗАН начинаться с YAML-frontmatter. Оркестратор читает вердикт ТОЛЬКО из +`verdict:` (UPPERCASE, строго `APPROVED` | `REQUEST_CHANGES`). Упоминания в прозе НЕ учитываются; +без frontmatter → трактуется как not-approved. + +**Машинный ключ (НЕ менять имя/регистр/значения):** `verdict: APPROVED | REQUEST_CHANGES`. + +Поверх него — обязательная 6-польная frontmatter-схема (канон — +`docs/_standards/HANDOFF_PROTOCOL.md`), `status` согласован с `verdict:`: + +| Поле | Значение для reviewer | +|------|-----------------------| +| `work_item` | ID задачи (`{{WORK_ITEM_PREFIX}}-NNN`) | +| `stage` | `review` | +| `author_agent` | `reviewer` | +| `status` | согласован с `verdict:` (напр. `approved` / `changes-requested`) | +| `created_at` | текущая дата `YYYY-MM-DD` | +| `model_used` | фактическая модель агента из конфига оркестратора | + +> ⚠️ **Не копируй `created_at`/`model_used` из примера буквально:** подставь фактическую текущую +> дату (`date +%F`) и фактическую модель из конфига. Имена полей сохраняются; меняются только +> значения-плейсхолдеры ``/`<актуальная модель из конфига>`. + +```markdown +--- +verdict: APPROVED # APPROVED | REQUEST_CHANGES — строго одно из двух, UPPERCASE +work_item: {{WORK_ITEM_PREFIX}}-NNN +stage: review +author_agent: reviewer +status: approved +created_at: +model_used: <актуальная модель из конфига> +type: review +version: 1 +--- + +# Review {{WORK_ITEM_PREFIX}}-NNN + +## Summary +<краткий итог> + +## Findings + +### P0 — Blocker +- [ ] <описание> (если есть) + +### P1 — Must fix +- [ ] <описание> (если есть) + +### P2 — Should fix +- [ ] <описание> (если есть) + +## Документация +<статус обновления документации: что обновлено / что нужно обновить> +``` + +**Правила вердикта:** +- `verdict: APPROVED` — только если нет P0/P1. +- `verdict: REQUEST_CHANGES` — при ЛЮБОМ P0/P1, включая необновлённую документацию. +- Никаких других значений; без frontmatter QG не пройдёт. + + + +Выход стадии готов, когда `12-review.md` записан, несёт корректный машинный `verdict:` +(`APPROVED`|`REQUEST_CHANGES`, UPPERCASE) + 6-польную frontmatter-схему, а проверка документации +выполнена явно. + + + +- **Любой finding P0/P1** (не реализовано требование ТЗ, нарушен ADR, критическая уязвимость, + необновлённая документация при изменении кода, слом маркированного инварианта) → единая точка: + вердикт `REQUEST_CHANGES` с перечнем findings и ссылками на ТЗ/ADR/правило. +- **Неоднозначность/противоречивость требований** (не ясно, что считать корректным) → finding со + ссылкой на конкретное место `02-trz.md`/`03-acceptance-criteria.md`/`06-adr/`, а не + subjective-оценка. + diff --git a/onboarding/repo-skeleton/.openclaw/agents/tester.md b/onboarding/repo-skeleton/.openclaw/agents/tester.md new file mode 100644 index 0000000..c9ddcfa --- /dev/null +++ b/onboarding/repo-skeleton/.openclaw/agents/tester.md @@ -0,0 +1,128 @@ +--- +name: tester +description: QA-инженер. Прогоняет тесты, оформляет отчёт. +tools: + - Filesystem (Read везде; Write только docs/work-items//13-test-report.md) + - Bash (тесты, curl) +--- + +# System prompt: Tester + + +Ты — QA-инженер проекта **{{PROJECT_NAME}}** ({{PROJECT_DESCRIPTION}}). Стек: {{STACK}}. +Прогоняешь полный регресс и оформляешь отчёт. + +**Перед любым действием прочти:** +1. `CLAUDE.md` — паспорт и правила. +2. `AGENTS.md` — карта документации проекта. +3. `docs/ARCHITECTURE.md` — компоненты и потоки. +4. `docs/work-items//02-trz.md`. +5. `docs/work-items//03-acceptance-criteria.md`. +6. `docs/work-items//04-test-plan.yaml`. +7. `docs/work-items//12-review.md` — убедись, что вердикт `APPROVED`. +8. `docs/operations/INFRA.md` — окружения и smoke-endpoints проекта. + + + +Твоя стадия — **testing**. Прогоняешь регресс и smoke, выносишь машинный вердикт `result:` +(`PASS`|`FAIL`) в `13-test-report.md`. Гейт `check_tests_passed` читает вердикт из frontmatter. + + +Сначала прогони тесты и собери факты (полный регресс, smoke, покрытие ТЗ), классифицируй каждый +TC, и ТОЛЬКО потом выноси вердикт. Любой FAIL/смок-сбой → `result: FAIL`; всё зелёное → +`result: PASS`. + + +**Алгоритм:** +1. **Тесты — в worktree ветки задачи, НЕ в общем чекауте репо.** Прогоняй тесты из рабочего + дерева именно этой задачи, где лежит код ветки (общий чекаут могут параллельно переключать + другие задачи — гонка checkout). Команда: `{{TEST_CMD}}`. +2. **Smoke (read-only):** проверь живость окружения по smoke-endpoints из + `docs/operations/INFRA.md` (staging-порт {{STAGING_PORT}}); только чтение. +3. **Покрытие ТЗ:** для **каждого** TC из `04-test-plan.yaml` — выполнен? PASS/FAIL? Сопоставь с + критериями `03-acceptance-criteria.md`. Готовность = каждый TC сопоставлен, а не «файл записан». + + + +Через **Write tool** — единственный файл `docs/work-items//13-test-report.md` +(с машинным frontmatter-вердиктом, см. ``). + +**Скелет:** `docs/_templates/13-test-report.md`; стандарт — `docs/_standards/PIPELINE_DOCS.md`. + + + +- ❌ Не пиши продакшн-код → ✅ только прогоняй тесты и фиксируй результаты. +- ❌ Не подгоняй тесты под код → ✅ если тест падает обоснованно, фиксируй `result: FAIL`. +- ❌ Не запускай деструктивные операции на прод-контуре (порт {{PROD_PORT}}) → ✅ smoke только + read-only endpoints. + + + +Файл `13-test-report.md` ОБЯЗАН начинаться с YAML-frontmatter. Машинный ключ (НЕ менять +имя/регистр/значения): `result: PASS | FAIL`. + +Поверх него — обязательная 6-польная frontmatter-схема (канон — +`docs/_standards/HANDOFF_PROTOCOL.md`), `status` согласован с `result:`: + +| Поле | Значение для tester | +|------|---------------------| +| `work_item` | ID задачи (`{{WORK_ITEM_PREFIX}}-NNN`) | +| `stage` | `testing` | +| `author_agent` | `tester` | +| `status` | согласован с `result:` (`pass` / `fail`) | +| `created_at` | текущая дата `YYYY-MM-DD` | +| `model_used` | фактическая модель агента из конфига оркестратора | + +> ⚠️ **Не копируй `created_at`/`model_used` из примера буквально:** подставь фактическую текущую +> дату (`date +%F`) и фактическую модель из конфига. Имена полей сохраняются; меняются только +> значения-плейсхолдеры ``/`<актуальная модель из конфига>`. + +```markdown +--- +result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE +work_item: {{WORK_ITEM_PREFIX}}-NNN +stage: testing +author_agent: tester +status: pass +created_at: +model_used: <актуальная модель из конфига> +type: test-report +--- + +# Test Report — {{WORK_ITEM_PREFIX}}-NNN + +## Окружение +- Версии инструментов: <версии> +- Дата: + +## Результаты + +| TC ID | Описание | Результат | +|-------|----------|-----------| +| TC-01 | ... | PASS | + +## Вывод тестового прогона +<вставь вывод> + +## Итог +PASS / FAIL +``` + +**Вердикт:** +- Все тесты PASS + smoke OK → `result: PASS` → задача переходит на `deploy-staging`. +- Любой FAIL → `result: FAIL` → откат на `development` (`back-to:dev`). + + + +Выход стадии готов, когда `13-test-report.md` записан, несёт корректный машинный `result:` +(`PASS`|`FAIL`, UPPERCASE) + 6-польную frontmatter-схему, таблицу TC и вывод тестов, И **каждый +TC из `04-test-plan.yaml` выполнен и сопоставлен** с `03-acceptance-criteria.md` (а не только +«файл записан»). + + + +- **Обоснованный FAIL** (тест/смок падает по делу) → `result: FAIL` → откат на development + (`back-to:dev`); НЕ подгоняй тесты под код. +- **Смок-сбой инфраструктуры** (окружение недоступно) → зафиксируй как `result: FAIL` с + диагностикой (что именно недоступно), а не «зелено по умолчанию». + diff --git a/onboarding/repo-skeleton/AGENTS.md b/onboarding/repo-skeleton/AGENTS.md new file mode 100644 index 0000000..f6fa815 --- /dev/null +++ b/onboarding/repo-skeleton/AGENTS.md @@ -0,0 +1,37 @@ +# AGENTS.md — точка входа агентов проекта {{PROJECT_NAME}} + +Карта документации и правила её ведения. Любой агент читает этот файл **сразу после** +`CLAUDE.md` (паспорта) и **до** начала работы. + +## Карта документации + +| Документ | Что в нём | Когда читать | Когда обновлять | +|----------|-----------|--------------|-----------------| +| `CLAUDE.md` | паспорт: стек, команды, среды, правила | ВСЕГДА, первым | при изменении стека/команд/правил | +| `AGENTS.md` | этот файл: карта доков | ВСЕГДА, вторым | при изменении состава доков | +| `README.md` | витрина: что это, quickstart | при онбординге в задачу | при изменении quickstart/обзора | +| `docs/ARCHITECTURE.md` | код-карта, потоки, БД | перед изменением кода | при изменении компонентов/API/БД | +| `docs/PIPELINE.md` | стадии, Quality Gates, агенты | при вопросах процесса | при изменении процесса | +| `docs/PRODUCT_VISION.md` | зачем проект, ценность | при продуктовых решениях | при смене видения | +| `docs/operations/INFRA.md` | топология, env, границы, риски общего хоста | перед deploy/инфра-работой | при изменении топологии/env | +| `docs/architecture/adr/` | сквозные ADR | перед архитектурным решением | новый сквозной ADR | +| `docs/work-items//` | артефакты конкретной задачи | свою задачу — всегда | по своей стадии | +| `docs/_templates/` | скелеты номерных доков (канон) | перед записью номерного дока | НЕ править локально | +| `docs/_standards/` | PIPELINE_DOCS / HANDOFF_PROTOCOL / TRACEABILITY (канон) | по ссылкам из промптов | НЕ править локально | +| `CHANGELOG.md` | история изменений | — | каждый PR с изменением функционала | + +## Правила ведения + +1. **Артефакты задач** пиши ТОЛЬКО в `docs/work-items//` по стандарту + `docs/_standards/PIPELINE_DOCS.md`; скелеты бери из `docs/_templates/` (не угадывай структуру). +2. **Машинные вердикты** — строго YAML-frontmatter; имена/регистр ключей не менять + (`docs/_standards/HANDOFF_PROTOCOL.md`). +3. **Документация = golden source.** Изменил код → обнови `docs/ARCHITECTURE.md` / + `README.md` / `CHANGELOG.md` в том же PR. Reviewer обязан вернуть PR без обновлённой доки. +4. **ADR.** Архитектурные решения фиксируются в `docs/work-items//06-adr/`; сквозные — в + `docs/architecture/adr/adr-NNNN-slug.md` (реестр — `docs/architecture/adr/README.md`). +5. **Трассировка.** Нетривиальный инвариант в коде помечается маркером + `{{WORK_ITEM_PREFIX}}-NNN`; правка чужого маркера — только после чтения его ADR + (`docs/_standards/TRACEABILITY.md`). +6. **Канон не форкается.** `docs/_templates/` и `docs/_standards/` — копия живого канона + оркестратора на момент онбординга; их обновление приходит отдельными PR, локально не править. diff --git a/onboarding/repo-skeleton/CHANGELOG.md b/onboarding/repo-skeleton/CHANGELOG.md new file mode 100644 index 0000000..1dfd93c --- /dev/null +++ b/onboarding/repo-skeleton/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу, +свежие сверху. Каждая запись ссылается на work item (`{{WORK_ITEM_PREFIX}}-NNN`). + +## [Unreleased] +- Каркас репозитория {{PROJECT_NAME}} создан онбордингом оркестратора (kit). diff --git a/onboarding/repo-skeleton/CLAUDE.md b/onboarding/repo-skeleton/CLAUDE.md new file mode 100644 index 0000000..c6f123f --- /dev/null +++ b/onboarding/repo-skeleton/CLAUDE.md @@ -0,0 +1,82 @@ +# CLAUDE.md — паспорт проекта {{PROJECT_NAME}} + +## TL;DR +{{PROJECT_DESCRIPTION}} + +Проект ведётся мульти-агентным оркестратором: задачи из Plane идут по конвейеру стадий через +Quality Gates; на каждой стадии работает свой агент (analyst → architect → developer → reviewer → +tester → deployer). Промпты агентов — в `.openclaw/agents/` этого репо. + +## Стек +{{STACK}} + +## Команды +- `{{TEST_CMD}}` — все тесты + +## Среды +- **prod** — порт `{{PROD_PORT}}` +- **staging** — порт `{{STAGING_PORT}}` + +Детали топологии, env-карта и границы доступа — `docs/operations/INFRA.md`. + +## Привязка к оркестратору +- Gitea-репо: `{{GITEA_OWNER}}/{{REPO}}` +- Plane-проект: `{{PLANE_PROJECT_ID}}` +- Префикс work-item: `{{WORK_ITEM_PREFIX}}` + +## Структура +- `docs/ARCHITECTURE.md` — код-карта, потоки, БД. +- `docs/PIPELINE.md` — конвейер стадий, Quality Gates, агенты. +- `docs/PRODUCT_VISION.md` — зачем проект. +- `docs/operations/INFRA.md` — RUNBOOK: топология, env, границы. +- `docs/architecture/adr/` — реестр сквозных ADR. +- `docs/work-items//` — артефакты задач (по `docs/_standards/PIPELINE_DOCS.md`). +- `docs/_templates/` — скелеты номерных документов (канон, не править локально). +- `docs/_standards/` — стандарты документов/handoff/трассировки (канон, не править локально). +- `docs/history/` — исторические записи. + +## Конвейер (кратко; детали — docs/PIPELINE.md) +``` +created → analysis → architecture → development → review → testing → deploy-staging → deploy → done +``` + +## Конвенции +- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) +- Ветки: `feature/{{WORK_ITEM_PREFIX}}-NNN-slug`, `fix/{{WORK_ITEM_PREFIX}}-NNN-slug` +- ADR per work-item: `docs/work-items//06-adr/ADR-NNN-slug.md` +- Сквозные ADR: `docs/architecture/adr/adr-NNNN-slug.md` +- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `result:`, + `staging_status:`, `deploy_status:`, `security_status:`), никогда проза. Спека «стадия → + обязательный выход» — `docs/_standards/HANDOFF_PROTOCOL.md`. + +## Артефакты задачи (`docs/work-items//`) +`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, +`04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, +`08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, +`14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md`, `17-security-report.md`, +`18-coverage-report.md`. + +Перед написанием номерного дока бери скелет из `docs/_templates/` и не меняй имя machine-key +frontmatter (регистр чувствителен — иначе гейт упадёт ложно). + +## Правила для агентов +1. Перед любым действием прочесть этот файл и `AGENTS.md`. +2. **Документация = golden source наравне с кодом.** Изменил функционал → обнови доку В ТОМ ЖЕ + PR. Архитектурное решение → заведи ADR. Обнови `CHANGELOG.md`. +3. Никогда не править артефакты других этапов. +4. Никогда не комментировать ТЗ задним числом — если ТЗ не годится, возвращай в Анализ. +5. Никогда не закрывать задачу самостоятельно — это делает CI / финальная стадия. +6. **Reviewer проверяет: обновлена ли документация. Нет → REQUEST_CHANGES.** +7. Не использовать `--no-verify` без явного одобрения Owner. +8. Секреты — только в `.env` на хосте, в гит НЕ коммитятся (канон — `.env.example`). +9. **Трассировка маркеров:** правишь строку/блок с маркером `{{WORK_ITEM_PREFIX}}-NNN` → + ПЕРЕД изменением прочитай его `docs/work-items//06-adr/` и не сломай зафиксированный + инвариант. Стандарт — `docs/_standards/TRACEABILITY.md`. + +## ⚠️ Общий хост +Проект живёт на общем хосте рядом с контейнерами других проектов. Не трогать чужие контейнеры, +тома и env; рестарт прод-контура — только по процедуре `docs/operations/INFRA.md`. + +--- +*Паспорт проекта {{PROJECT_NAME}}. Поддерживается агентами при каждой доработке. Изолирован: +описывает только этот проект (канон per-repo).* diff --git a/onboarding/repo-skeleton/CONTRIBUTING.md b/onboarding/repo-skeleton/CONTRIBUTING.md new file mode 100644 index 0000000..fd64781 --- /dev/null +++ b/onboarding/repo-skeleton/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# CONTRIBUTING — канон процесса проекта {{PROJECT_NAME}} + +Как ведётся этот репозиторий: где что лежит, как оформлять изменения, как вести документацию. +Канон обязателен и для агентов конвейера, и для людей. + +## Где что лежит + +| Путь | Содержимое | +|------|-----------| +| код проекта | по код-карте `docs/ARCHITECTURE.md` | +| тесты | прогон: `{{TEST_CMD}}` | +| `.openclaw/agents/` | промпты 6 агентов конвейера (канон структуры — см. ниже) | +| `docs/` | документация (карта — `AGENTS.md`) | +| `docs/work-items//` | артефакты задач конвейера | +| `.env.example` | карта env-переменных (без секретов) | + +## Процесс изменения + +1. Задача рождается в Plane (проект `{{PROJECT_NAME}}`, префикс `{{WORK_ITEM_PREFIX}}`). +2. Конвейер ведёт её по стадиям (`docs/PIPELINE.md`); артефакты каждой стадии — в + `docs/work-items//` по `docs/_standards/PIPELINE_DOCS.md`. +3. Код едет веткой `feature/{{WORK_ITEM_PREFIX}}-NNN-slug` → PR в `main` → merge только через + PR-merge (никогда push в `main`). +4. Conventional Commits: `feat(scope):`, `fix(scope):`, `docs(scope):`, `refactor:`, `test:`; + футер `Refs: {{WORK_ITEM_PREFIX}}-NNN`. +5. Документация обновляется **в том же PR**, что и код (reviewer-gate вернёт PR без неё). +6. `CHANGELOG.md` — запись под `## [Unreleased]` на каждый смысловой PR. + +## Промпты агентов (`.openclaw/agents/`) + +- Структурный канон: 5 XML-секций в порядке `` → `` → `` → + `` → ``; запреты в формате «❌ X → ✅ Y»; `` у + developer/reviewer/tester; машинные verdict-ключи байт-в-байт. +- **Языковая политика:** 5 промптов на русском + `deployer.md` на английском (самый + safety-critical промпт; en-раскладка минимизирует регресс-поверхность verdict-ключей). + Отступление от политики — только отдельным ADR этого проекта в `docs/architecture/adr/`. +- Правка промптов = обычный PR с ревью; машинные ключи (`verdict:`, `result:`, + `staging_status:`, `deploy_status:`, `security_status:`) не переименовывать. + +## Документация + +- Стандарты (`docs/_standards/`) и скелеты (`docs/_templates/`) — копия живого канона + оркестратора на момент онбординга; **локально не править** (обновления приходят отдельными PR). +- Сквозные решения — `docs/architecture/adr/adr-NNNN-slug.md`; per-task — + `docs/work-items//06-adr/ADR-NNN-slug.md`. +- Маркеры трассировки `{{WORK_ITEM_PREFIX}}-NNN` в коде — по `docs/_standards/TRACEABILITY.md`. + +## Секреты + +Секреты живут ТОЛЬКО в `.env` на хосте; в гит не коммитятся. Карта переменных — `.env.example` +(дескрипторы без значений). Утечка секрета в коммит = инцидент: ротация ключа обязательна. diff --git a/onboarding/repo-skeleton/README.md b/onboarding/repo-skeleton/README.md new file mode 100644 index 0000000..c17444d --- /dev/null +++ b/onboarding/repo-skeleton/README.md @@ -0,0 +1,39 @@ +# {{PROJECT_NAME}} + +{{PROJECT_DESCRIPTION}} + +Репозиторий: `{{GITEA_OWNER}}/{{REPO}}` · Стек: {{STACK}} + +## Quickstart + +```bash +# тесты +{{TEST_CMD}} +``` + +Среды: prod — порт `{{PROD_PORT}}`, staging — порт `{{STAGING_PORT}}` +(топология и env — `docs/operations/INFRA.md`). + +## Документация + +| Документ | Что в нём | +|----------|-----------| +| `CLAUDE.md` | паспорт проекта: стек, команды, правила агентов | +| `AGENTS.md` | карта документации и правила её ведения | +| `CONTRIBUTING.md` | канон процесса: ветки, коммиты, PR, доки | +| `docs/ARCHITECTURE.md` | код-карта, потоки, БД | +| `docs/PIPELINE.md` | конвейер стадий, Quality Gates, агенты | +| `docs/PRODUCT_VISION.md` | зачем проект | +| `docs/operations/INFRA.md` | топология, env-карта, границы доступа | +| `CHANGELOG.md` | история изменений | + +## Как ведётся проект + +Проект ведёт мульти-агентный конвейер (Plane → стадии → Quality Gates → PR в Gitea); правила и +артефакты — `docs/PIPELINE.md` и `docs/_standards/PIPELINE_DOCS.md`. Изменения едут ветками +`feature/{{WORK_ITEM_PREFIX}}-NNN-slug` с Conventional Commits; документация обновляется в том же +PR, что и код. + +## Известные ограничения + +- (заполняется по мере жизни проекта; пункт снимается PR-ом, который его закрыл) diff --git a/onboarding/repo-skeleton/docs/ARCHITECTURE.md b/onboarding/repo-skeleton/docs/ARCHITECTURE.md new file mode 100644 index 0000000..c43a5da --- /dev/null +++ b/onboarding/repo-skeleton/docs/ARCHITECTURE.md @@ -0,0 +1,36 @@ +# ARCHITECTURE — {{PROJECT_NAME}} + +> Код-карта, потоки данных и хранилища проекта. Заполняется и поддерживается агентами по мере +> жизни проекта: **изменил компонент/API/БД → обнови этот файл в том же PR** (reviewer-gate). + +Стек: {{STACK}} + +## Компоненты + +| Компонент | Путь | Назначение | +|-----------|------|-----------| +| (заполнить при первом изменении кода) | | | + +## Потоки данных + +``` +(диаграмма потоков: источники → обработка → хранилища → потребители) +``` + +## API + +| Метод | Путь | Назначение | +|-------|------|-----------| +| (заполняется при появлении API) | | | + +## База данных / хранилища + +| Хранилище | Схема/путь | Назначение | +|-----------|-----------|-----------| +| (заполняется при появлении хранилищ) | | | + +## Сквозные решения + +Реестр сквозных ADR — `docs/architecture/adr/README.md`; per-task решения — +`docs/work-items//06-adr/`. Перед изменением блока с маркером `{{WORK_ITEM_PREFIX}}-NNN` +прочти его ADR (`docs/_standards/TRACEABILITY.md`). diff --git a/onboarding/repo-skeleton/docs/PIPELINE.md b/onboarding/repo-skeleton/docs/PIPELINE.md new file mode 100644 index 0000000..d0b8f75 --- /dev/null +++ b/onboarding/repo-skeleton/docs/PIPELINE.md @@ -0,0 +1,37 @@ +# PIPELINE — конвейер проекта {{PROJECT_NAME}} + +> Как задача проходит от идеи до прода. Управляет конвейером оркестратор; этот файл — карта +> процесса для агентов и людей проекта. + +## Стадии + +``` +created → analysis → architecture → development → review → testing → deploy-staging → deploy → done + ↑ │ + └──── REQUEST_CHANGES ──────┘ (откат на development) +``` + +| Стадия | Агент | Выходной артефакт | Гейт выхода | +|--------|-------|-------------------|-------------| +| analysis | analyst | `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml` | полнота пакета + человеческий approve | +| architecture | architect | `06-adr/ADR-NNN-slug.md` (+ `07`/`08`/`10`) | ADR записан | +| development | developer | код + тесты + PR + `CHANGELOG.md` | зелёный CI на ветке | +| review | reviewer | `12-review.md` (`verdict: APPROVED\|REQUEST_CHANGES`) | машинный вердикт | +| testing | tester | `13-test-report.md` (`result: PASS\|FAIL`) | машинный вердикт | +| deploy-staging | deployer | `15-staging-log.md` (`staging_status: SUCCESS\|FAILED`) | машинный вердикт | +| deploy | deployer | `14-deploy-log.md` (`deploy_status: SUCCESS\|FAILED`) | машинный вердикт | + +Машинные вердикты — строго YAML-frontmatter; имена/регистр ключей не менять. Полная спека +«стадия → обязательный выход» — `docs/_standards/HANDOFF_PROTOCOL.md`; структура каждого +документа — `docs/_standards/PIPELINE_DOCS.md`; скелеты — `docs/_templates/`. + +## Агенты + +Промпты 6 ролей — `.openclaw/agents/{analyst,architect,developer,reviewer,tester,deployer}.md`. +Каждый промпт направляет агента: прочитай `CLAUDE.md` (паспорт) и `AGENTS.md` (карта доков) +ПЕРЕД работой; пиши артефакты в `docs/work-items//`; обновляй документацию в том же PR. + +## Артефакты задачи + +Полный перечень номерных документов — паспорт `CLAUDE.md`, раздел «Артефакты задачи»; +канонический реестр и структура — `docs/_standards/PIPELINE_DOCS.md`. diff --git a/onboarding/repo-skeleton/docs/PRODUCT_VISION.md b/onboarding/repo-skeleton/docs/PRODUCT_VISION.md new file mode 100644 index 0000000..9d5afaa --- /dev/null +++ b/onboarding/repo-skeleton/docs/PRODUCT_VISION.md @@ -0,0 +1,24 @@ +# PRODUCT VISION — {{PROJECT_NAME}} + +> Зачем существует проект, какую ценность несёт и куда движется. Свод бизнес-требований уровня +> проекта (BRD конкретных задач — в `docs/work-items//01-brd.md`). + +## Назначение + +{{PROJECT_DESCRIPTION}} + +## Целевая аудитория + +(кто пользователи и заказчики; заполняется владельцем/аналитиком) + +## Ценность + +(какую проблему решает проект и почему это важно) + +## Границы + +(что проект сознательно НЕ делает) + +## Направление + +(крупные этапы/вехи; детализация — в Plane-проекте `{{PROJECT_NAME}}`) diff --git a/onboarding/repo-skeleton/docs/architecture/adr/README.md b/onboarding/repo-skeleton/docs/architecture/adr/README.md new file mode 100644 index 0000000..8851508 --- /dev/null +++ b/onboarding/repo-skeleton/docs/architecture/adr/README.md @@ -0,0 +1,19 @@ +# Реестр сквозных ADR — {{PROJECT_NAME}} + +Сквозные (cross-cutting) архитектурные решения проекта: затрагивают несколько компонентов или +весь проект. Per-task решения живут в `docs/work-items//06-adr/`; сюда выносится то, что +переживает отдельную задачу. + +## Конвенция + +- Имя файла: `adr-NNNN-.md` (NNNN — 4-значный, следующий от последнего в папке). +- Структура: `## Статус` (Proposed | Accepted | Deprecated) → `## Контекст` → `## Решение` → + `## Последствия` (скелет — `docs/_templates/06-adr-ADR-NNN-slug.md`, без per-task шапки). +- Новый сквозной ADR создаёт архитектор, когда решение влияет на весь проект (новый компонент, + смена БД, сквозная конвенция); правило — `.openclaw/agents/architect.md`. + +## Реестр + +| ADR | Решение | Статус | +|-----|---------|--------| +| (пока пусто) | | | diff --git a/onboarding/repo-skeleton/docs/history/.gitkeep b/onboarding/repo-skeleton/docs/history/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/onboarding/repo-skeleton/docs/operations/INFRA.md b/onboarding/repo-skeleton/docs/operations/INFRA.md new file mode 100644 index 0000000..5947439 --- /dev/null +++ b/onboarding/repo-skeleton/docs/operations/INFRA.md @@ -0,0 +1,60 @@ +# INFRA.md — инфраструктура и эксплуатация {{PROJECT_NAME}} + +> RUNBOOK. Топология, контейнеры, порты, переменные окружения, границы. +> **Секреты тут НЕ хранятся** — только дескрипторы. Реальные значения — в `.env` на хосте. + +## Топология + +``` + общий хост (рядом живут контейнеры ДРУГИХ проектов) + ┌──────────────────────────────────────────────────────────────────────┐ + │ {{REPO}} (PROD) :{{PROD_PORT}} env_file .env │ + │ {{REPO}}-staging (STAGING) :{{STAGING_PORT}} изолированные данные │ + └──────────────────────────────────────────────────────────────────────┘ +``` + +(уточни диаграмму под фактическую топологию: сеть, тома, БД, внешние зависимости) + +## Контейнеры + +| Контейнер | Роль | Порт | env_file | Данные (хост) | Старт | +|-----------|------|------|----------|---------------|-------| +| `{{REPO}}` | прод | {{PROD_PORT}} | `.env` | (указать тома/БД) | (команда старта) | +| `{{REPO}}-staging` | staging | {{STAGING_PORT}} | (staging env) | (изолированные) | (команда старта) | + +## Карта env-переменных + +Канон карты — `.env.example` в корне репо (дескрипторы без значений). Правило секретов: +**секреты ТОЛЬКО в `.env` на хосте**, в гит не коммитятся; `docker-compose.yml`/`Dockerfile` +(если есть) трекаются в гите. + +| Переменная | Назначение | +|-----------|-----------| +| `APP_PROD_PORT` | порт прод-контура ({{PROD_PORT}}) | +| `APP_STAGING_PORT` | порт staging-контура ({{STAGING_PORT}}) | +| (дополнять при вводе переменных) | | + +## Границы доступа + +- Кто имеет доступ к хосту/контейнерам/данным проекта — перечислить явно. +- Токены/ключи проекта: где живут (только `.env` на хосте), кем используются, как ротируются. +- Агенты конвейера работают в worktree репо и НЕ имеют доступа к чужим проектам. + +## Smoke-endpoints + +| Endpoint | Контур | Назначение | +|----------|--------|-----------| +| (например `/health`) | staging {{STAGING_PORT}} / prod {{PROD_PORT}} | живость сервиса (read-only) | + +## ⚠️ Эксплуатационные предупреждения — риски общего хоста + +- Хост ОБЩИЙ: рядом работают контейнеры других проектов и ресурсы (CPU/RAM/диск) делятся. + Дисковое место на хосте впритык — следи за объёмом образов/томов/логов. +- НИКОГДА не останавливать/не рестартить чужие контейнеры и не менять чужие env/тома. +- Рестарт прод-контура {{REPO}} — только по процедуре деплоя (см. ниже), не ad-hoc. +- Перед прод-деплоем обязателен staging-контур ({{STAGING_PORT}}). + +## Деплой + +(описать фактическую процедуру деплоя проекта: staging-проверка → прод-выкатка → health-check → +откат. Заполняется при настройке CI/CD проекта; deployer-агент исполняет ровно эту процедуру.) diff --git a/onboarding/repo-skeleton/docs/work-items/.gitkeep b/onboarding/repo-skeleton/docs/work-items/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/onboard_project.py b/scripts/onboard_project.py new file mode 100644 index 0000000..e7ae11a --- /dev/null +++ b/scripts/onboard_project.py @@ -0,0 +1,1090 @@ +#!/usr/bin/env python3 +"""onboard_project.py — операторский turnkey-CLI онбординга нового проекта (ORCH-009). + +Разворачивает все слои подключения нового проекта к оркестратору одним проходом: +Plane-проект (22 статуса с точными каноническими именами + лейблы) → Gitea-репо +(+per-repo webhook) → материализация onboarding-kit (рендер плейсхолдеров + +live-copy канона docs/_templates|_standards) + initial push ТОЛЬКО в свежесозданный +пустой репо → merged-вывод ``ORCH_PROJECTS_JSON`` (round-trip через фактический +парсер реестра). Полный процесс — runbook ``docs/operations/ONBOARDING.md``. + +Режимы (ADR-001 D11 ORCH-009): + plan — дефолт; только GET-пробы + полный план; НИ ОДНОЙ мутации (ни сети, ни диска). + apply — идемпотентный ensure: существующее → ``skipped(exists)``; delete-операций НЕТ. + verify — GET-пробы + локальные проверки (реестр, статусы, лейблы, webhook, полнота kit). + +Гарантии безопасности (NFR-2/NFR-3): + * прод-контейнер оркестратора НИКОГДА не трогается (ни рестартов, ни остановок); + * ``.env`` НИКОГДА не правится (читается read-only); регистрация = операторский шаг; + * push возможен ТОЛЬКО в свежесозданный/пустой репо — в существующие никогда; + * ничего не удаляется; секреты в отчёте маскируются. + +Запуск — из корня чекаута репо orchestrator (см. runbook): + python3 scripts/onboard_project.py plan --name "My Project" --repo my-project \\ + --prefix MP --stack "Python 3.12 + FastAPI" --test-cmd "pytest -q" \\ + --prod-port 8600 --staging-port 8601 --webhook-url https:///webhook/gitea + +Exit-коды: 0 — чисто; 2 — есть manual-step / gap в verify; 1 — ошибка. +""" + +import argparse +import dataclasses +import json +import logging +import os +import re +import secrets as _secrets +import subprocess +import sys +import tempfile +import urllib.parse + +# Запуск из корня чекаута (паттерн scripts/staging_check.py): добавляем корень в +# sys.path, чтобы работали read-only импорты из src. +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +# ── Закрытый список read-only импортов из src (ORCH-009 ADR-001 D4) ────────── +# Любой новый импорт из src — ТОЛЬКО через обновление ADR (контроль — +# tests/test_onboarding_invariants.py::test_tc21_cli_src_imports_stay_in_closed_list). +from src.config import settings # noqa: E402 # имена лейблов, URL/токены, реестр +from src.plane_sync import _PLANE_NAME_TO_KEY # noqa: E402 # точные имена 22 статусов (нулевой дрейф) +from src.projects import _parse_projects_json # noqa: E402 # round-trip валидация реестра + +try: # httpx — существующая зависимость проекта; нужен только реальным клиентам + import httpx +except Exception: # pragma: no cover - тесты инжектируют фейки, httpx не обязателен + httpx = None + +logger = logging.getLogger("onboard_project") + +ONBOARDING_DIR = os.path.join(_REPO_ROOT, "onboarding") +SKELETON_DIR = os.path.join(ONBOARDING_DIR, "repo-skeleton") +PLACEHOLDERS_PATH = os.path.join(ONBOARDING_DIR, "placeholders.json") +RUNBOOK = "docs/operations/ONBOARDING.md" + +# Live-copy канона (BR-2/D3): копируются из чекаута орка, в kit НЕ хранятся. +LIVE_COPY_DIRS = ("docs/_templates", "docs/_standards") + +# Per-repo Gitea-webhook (формат docs/operations/SETUP_WEBHOOKS.md). +WEBHOOK_EVENTS = ["push", "pull_request", "status"] + +# Синтаксис плейсхолдеров (D2): {{NAME}}, верхний регистр. +PLACEHOLDER_RE = re.compile(r"\{\{[A-Z][A-Z0-9_]*\}\}") + +# Канонические группы статусов Plane (ADR-001 D5 ORCH-009). Код-критичные +# констрейнты: STOP — группа `cancelled` (fail-closed ветка отмены, ORCH-090); +# в терминальных группах (`completed`/`cancelled`) ТОЛЬКО Done/Cancelled/STOP — +# иначе terminal-detection (ORCH-068, {uuid→group}) ложно сочтёт живую задачу +# терминальной. Имена ключей — байт-в-байт из plane_sync._PLANE_NAME_TO_KEY. +STATE_GROUPS: dict[str, str] = { + "Backlog": "backlog", + "Todo": "unstarted", + "To Analyse": "unstarted", + "In Progress": "started", + "Analysis": "started", + "Architecture": "started", + "Development": "started", + "Code-Review": "started", + "Review": "started", + "Testing": "started", + "Awaiting Deploy": "started", + "Deploying": "started", + "Monitoring after Deploy": "started", + "Needs Input": "started", + "In Review": "started", + "Blocked": "started", + "Approved": "started", + "Confirm Deploy": "started", + "Rejected": "started", # reject = rework-петля, задача жива → НЕ cancelled + "Done": "completed", + "Cancelled": "cancelled", + "STOP": "cancelled", +} + +# Статусы шагов отчёта (D11). +PLANNED = "planned" +CREATED = "created" +SKIPPED = "skipped(exists)" +MANUAL = "manual-step" +ERROR = "error" +OK = "ok" +GAP = "gap" + + +class ManualStep(Exception): + """API-шаг недоступен (403/404/405/501/не реализовано в CE) → ручной пункт runbook.""" + + +def label_names() -> list[str]: + """Имена лейблов авто-режимов/багфикс-трека — из конфига (D4), не литералы.""" + return [ + settings.auto_approve_label, + settings.auto_deploy_label, + settings.bug_fast_track_label, + ] + + +# --------------------------------------------------------------------------- # +# Отчёт +# --------------------------------------------------------------------------- # + +@dataclasses.dataclass +class Step: + """Один шаг плана/исполнения/верификации.""" + + id: str + description: str + status: str + detail: str = "" + + +@dataclasses.dataclass +class Report: + """Итоговый отчёт прогона: шаги + операторские инструкции + exit-код.""" + + mode: str + steps: list = dataclasses.field(default_factory=list) + instructions: list = dataclasses.field(default_factory=list) + + def add(self, step_id: str, description: str, status: str, detail: str = "") -> Step: + step = Step(step_id, description, status, detail) + self.steps.append(step) + logger.info("[%s] %s — %s%s", self.mode, step_id, status, f" ({detail})" if detail else "") + return step + + @property + def exit_code(self) -> int: + statuses = {s.status for s in self.steps} + if ERROR in statuses: + return 1 + if MANUAL in statuses or GAP in statuses: + return 2 + return 0 + + def to_dict(self) -> dict: + totals = {"created": 0, "skipped": 0, "manual": 0, "planned": 0, "error": 0, "ok": 0, "gap": 0} + key_by_status = { + CREATED: "created", SKIPPED: "skipped", MANUAL: "manual", + PLANNED: "planned", ERROR: "error", OK: "ok", GAP: "gap", + } + for s in self.steps: + totals[key_by_status.get(s.status, "error")] += 1 + return { + "mode": self.mode, + "steps": [dataclasses.asdict(s) for s in self.steps], + "totals": totals, + "instructions": list(self.instructions), + "exit_code": self.exit_code, + } + + def render_text(self) -> str: + lines = [f"== onboarding report ({self.mode}) =="] + for s in self.steps: + lines.append(f" [{s.status:>15}] {s.id:<28} {s.description}") + if s.detail: + lines.append(f" {'':>17} ↳ {s.detail}") + t = self.to_dict()["totals"] + lines.append( + f"-- totals: created={t['created']} skipped={t['skipped']} " + f"manual={t['manual']} planned={t['planned']} ok={t['ok']} " + f"gap={t['gap']} error={t['error']}" + ) + if self.instructions: + lines.append("-- операторские шаги:") + for i, instr in enumerate(self.instructions, 1): + lines.append(f" {i}. {instr}") + lines.append(f"-- exit code: {self.exit_code}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- # +# Чистое ядро: словарь, рендер, kit +# --------------------------------------------------------------------------- # + +def load_placeholders() -> dict: + """Словарь плейсхолдеров onboarding/placeholders.json (single source of truth).""" + with open(PLACEHOLDERS_PATH, encoding="utf-8") as f: + return json.load(f) + + +def render(text: str, params: dict) -> str: + """Тупой проход str.replace по словарю (D2) — без шаблонизаторов и логики.""" + for name, value in params.items(): + text = text.replace("{{" + name + "}}", str(value)) + return text + + +def find_unresolved(text: str) -> list[str]: + """Все неразрешённые {{PLACEHOLDER}} в тексте (обязательный пост-скан, D2).""" + return sorted(set(PLACEHOLDER_RE.findall(text))) + + +def iter_skeleton_files() -> list[str]: + """Относительные пути всех файлов repo-skeleton (отсортированы, детерминизм).""" + out: list[str] = [] + for root, _dirs, files in os.walk(SKELETON_DIR): + for name in files: + full = os.path.join(root, name) + out.append(os.path.relpath(full, SKELETON_DIR)) + return sorted(out) + + +def render_kit_in_memory(params: dict) -> dict: + """Рендер всех файлов kit В ПАМЯТИ (plan-режим: ноль записей на диск, AC-8).""" + rendered: dict[str, str] = {} + for rel in iter_skeleton_files(): + with open(os.path.join(SKELETON_DIR, rel), encoding="utf-8") as f: + rendered[rel.replace(os.sep, "/")] = render(f.read(), params) + return rendered + + +def materialize_kit(params: dict, dest_dir: str) -> list[str]: + """Материализовать kit на диск: рендер скелета + live-copy канона (apply-only). + + Возвращает список записанных относительных путей. Неразрешённые плейсхолдеры + после рендера → ValueError (PASS-условие AC-5). Существующие файлы в dest_dir + не перезаписываются (идемпотентность BR-9 при повторной материализации). + """ + written: list[str] = [] + rendered = render_kit_in_memory(params) + problems = {rel: bad for rel, content in rendered.items() if (bad := find_unresolved(content))} + if problems: + raise ValueError(f"unresolved placeholders after render: {problems}") + + for rel, content in rendered.items(): + target = os.path.join(dest_dir, *rel.split("/")) + if os.path.exists(target): + continue # не перезаписываем существующее (BR-9) + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "w", encoding="utf-8") as f: + f.write(content) + written.append(rel) + + # Live-copy канона байт-в-байт из чекаута орка (BR-2/D3). + for rel_dir in LIVE_COPY_DIRS: + src_dir = os.path.join(_REPO_ROOT, *rel_dir.split("/")) + if not os.path.isdir(src_dir): + raise FileNotFoundError(f"live canon dir missing in checkout: {rel_dir}") + for root, _dirs, files in os.walk(src_dir): + for name in files: + src_file = os.path.join(root, name) + rel = os.path.join(rel_dir, os.path.relpath(src_file, src_dir)).replace(os.sep, "/") + target = os.path.join(dest_dir, *rel.split("/")) + if os.path.exists(target): + continue + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(src_file, encoding="utf-8") as fsrc, open(target, "w", encoding="utf-8") as fdst: + fdst.write(fsrc.read()) + written.append(rel) + return written + + +# --------------------------------------------------------------------------- # +# Реестр (D7) +# --------------------------------------------------------------------------- # + +def build_registry_entry(params: dict) -> dict: + """Запись реестра нового проекта (контракт src/projects.py::ProjectConfig).""" + return { + "plane_project_id": str(params["PLANE_PROJECT_ID"]), + "repo": str(params["REPO"]), + "work_item_prefix": str(params["WORK_ITEM_PREFIX"]), + "name": str(params["PROJECT_NAME"]), + } + + +def merged_projects_json(entry: dict, existing_raw: str) -> tuple[str, str]: + """(standalone-запись, полный merged-массив) с round-trip через фактический парсер. + + D7: существующие записи verbatim + новая в конец; уже зарегистрированный проект + (тот же plane_project_id или repo) не дублируется. Несоответствие round-trip → + RuntimeError (никогда не отдаём оператору строку, которую парсер отвергнет). + """ + try: + existing = json.loads(existing_raw) if existing_raw and existing_raw.strip() else [] + except (ValueError, TypeError): + existing = [] + if not isinstance(existing, list): + existing = [] + + already = any( + isinstance(item, dict) + and ( + item.get("plane_project_id") == entry["plane_project_id"] + or item.get("repo") == entry["repo"] + ) + for item in existing + ) + merged_list = list(existing) if already else list(existing) + [entry] + merged = json.dumps(merged_list, ensure_ascii=False) + + parsed = _parse_projects_json(merged) + if parsed is None: + raise RuntimeError("merged ORCH_PROJECTS_JSON failed the registry parser round-trip") + wanted = [p for p in parsed if p.plane_project_id == entry["plane_project_id"] or p.repo == entry["repo"]] + if not already: + if not wanted: + raise RuntimeError("new registry entry lost in the round-trip") + got = wanted[0] + if ( + got.repo != entry["repo"] + or got.work_item_prefix != entry["work_item_prefix"] + or got.name != entry["name"] + ): + raise RuntimeError("registry round-trip distorted the new entry fields") + if len(parsed) < len([i for i in existing if isinstance(i, dict)]): + raise RuntimeError("registry round-trip lost existing entries") + + return json.dumps(entry, ensure_ascii=False), merged + + +def read_existing_registry(env_file: str | None = None) -> str: + """Существующие записи реестра: env (settings) > --env-file/.env (read-only).""" + raw = getattr(settings, "projects_json", "") or "" + if raw.strip(): + return raw + path = env_file or os.path.join(_REPO_ROOT, ".env") + if os.path.isfile(path): + try: + with open(path, encoding="utf-8") as f: # read-only: .env НИКОГДА не пишем (NFR-2) + for line in f: + line = line.strip() + if line.startswith("ORCH_PROJECTS_JSON="): + return line.split("=", 1)[1] + except OSError as e: + logger.warning("cannot read %s: %s", path, e) + return "" + + +# --------------------------------------------------------------------------- # +# Тонкие клиенты — единственные точки сети (инжектируются, в тестах фейки) +# --------------------------------------------------------------------------- # + +class PlaneClient: + """Тонкий клиент Plane API (паттерн URL — src/plane_sync.py).""" + + def __init__(self, base_url: str, token: str, workspace: str): + self.base = f"{base_url.rstrip('/')}/api/v1" + self.headers = {"X-API-Key": token} + self.ws = workspace + + def _get(self, url: str): + resp = httpx.get(url, headers=self.headers, timeout=15) + if resp.status_code == 200: + return resp.json() + if resp.status_code in (403, 404, 405, 501): + return None + resp.raise_for_status() + return None + + def _post(self, url: str, payload: dict) -> dict: + resp = httpx.post(url, headers=self.headers, json=payload, timeout=20) + if resp.status_code in (200, 201): + return resp.json() + if resp.status_code in (401, 403, 404, 405, 501): + raise ManualStep(f"Plane API refused POST ({resp.status_code}): {url}") + resp.raise_for_status() + raise ManualStep(f"Plane API unexpected status {resp.status_code}") + + def get_project(self, project_id: str): + if not project_id: + return None + return self._get(f"{self.base}/workspaces/{self.ws}/projects/{project_id}/") + + def find_project_by_identifier(self, identifier: str): + data = self._get(f"{self.base}/workspaces/{self.ws}/projects/") + results = data.get("results", data) if isinstance(data, dict) else data + if not isinstance(results, list): + return None + for item in results: + if isinstance(item, dict) and item.get("identifier") == identifier: + return item + return None + + def list_states(self, project_id: str): + data = self._get(f"{self.base}/workspaces/{self.ws}/projects/{project_id}/states/") + if data is None: + return None + return data.get("results", data) if isinstance(data, dict) else data + + def list_labels(self, project_id: str): + data = self._get(f"{self.base}/workspaces/{self.ws}/projects/{project_id}/labels/") + if data is None: + return None + return data.get("results", data) if isinstance(data, dict) else data + + def create_project(self, name: str, identifier: str) -> dict: + return self._post( + f"{self.base}/workspaces/{self.ws}/projects/", + {"name": name, "identifier": identifier}, + ) + + def create_state(self, project_id: str, name: str, group: str) -> dict: + return self._post( + f"{self.base}/workspaces/{self.ws}/projects/{project_id}/states/", + {"name": name, "group": group}, + ) + + def create_label(self, project_id: str, name: str) -> dict: + return self._post( + f"{self.base}/workspaces/{self.ws}/projects/{project_id}/labels/", + {"name": name}, + ) + + +class GiteaClient: + """Тонкий клиент Gitea API (паттерн URL — src/gitea.py, src/merge_gate.py).""" + + def __init__(self, base_url: str, token: str): + self.base = f"{base_url.rstrip('/')}/api/v1" + self.headers = {"Authorization": f"token {token}"} + + def _get(self, url: str): + resp = httpx.get(url, headers=self.headers, timeout=15) + if resp.status_code == 200: + return resp.json() + if resp.status_code == 404: + return None + if resp.status_code in (401, 403, 405, 501): + return None + resp.raise_for_status() + return None + + def _post(self, url: str, payload: dict) -> dict: + resp = httpx.post(url, headers=self.headers, json=payload, timeout=20) + if resp.status_code in (200, 201): + return resp.json() + if resp.status_code in (401, 403, 404, 405, 409, 501): + raise ManualStep(f"Gitea API refused POST ({resp.status_code}): {url}") + resp.raise_for_status() + raise ManualStep(f"Gitea API unexpected status {resp.status_code}") + + def get_repo(self, owner: str, repo: str): + return self._get(f"{self.base}/repos/{owner}/{repo}") + + def list_hooks(self, owner: str, repo: str): + return self._get(f"{self.base}/repos/{owner}/{repo}/hooks") or [] + + def create_repo(self, owner: str, name: str, description: str = "") -> dict: + # auto_init=False: репо рождается ПУСТЫМ; `main` создаёт initial push (D6). + payload = {"name": name, "description": description, "auto_init": False, "private": False} + try: + return self._post(f"{self.base}/orgs/{owner}/repos", payload) + except ManualStep: + # owner — не организация → личный namespace токена / admin-маршрут. + return self._post(f"{self.base}/user/repos", payload) + + def create_hook(self, owner: str, repo: str, url: str, secret: str, events: list) -> dict: + return self._post( + f"{self.base}/repos/{owner}/{repo}/hooks", + { + "type": "gitea", + "active": True, + "branch_filter": "*", + "events": list(events), + "config": {"url": url, "content_type": "json", "secret": secret}, + }, + ) + + def get_file_text(self, owner: str, repo: str, path: str): + data = self._get( + f"{self.base}/repos/{owner}/{repo}/raw/{urllib.parse.quote(path)}?ref=main" + ) + return data if isinstance(data, str) else None + + def list_dir(self, owner: str, repo: str, path: str): + data = self._get( + f"{self.base}/repos/{owner}/{repo}/contents/{urllib.parse.quote(path)}?ref=main" + ) + if not isinstance(data, list): + return None + return sorted(item.get("name", "") for item in data if isinstance(item, dict)) + + +# --------------------------------------------------------------------------- # +# git: initial push ТОЛЬКО в свежесозданный пустой репо (D6) +# --------------------------------------------------------------------------- # + +def default_git_runner(cmd: list, cwd: str) -> int: + """Единственная точка subprocess: git в каталоге материализации kit.""" + masked = [re.sub(r"://[^@/]+@", "://***@", part) for part in cmd] + logger.info("git: %s (cwd=%s)", " ".join(masked), cwd) + return subprocess.run(cmd, cwd=cwd, check=False).returncode # noqa: S603 + + +def _push_url(gitea_url: str, token: str, owner: str, repo: str) -> str: + parts = urllib.parse.urlsplit(gitea_url) + netloc = f"oauth2:{token}@{parts.netloc}" if token else parts.netloc + return urllib.parse.urlunsplit( + (parts.scheme, netloc, f"{parts.path.rstrip('/')}/{owner}/{repo}.git", "", "") + ) + + +def initial_push(workdir: str, owner: str, repo: str, git_runner) -> bool: + """git init/commit/push материализованного kit в пустой репо. True = успех.""" + url = _push_url(settings.gitea_url, settings.gitea_token, owner, repo) + commands = [ + ["git", "init", "-q", "-b", "main"], + ["git", "add", "-A"], + [ + "git", + "-c", "user.email=onboarding@orchestrator.local", + "-c", "user.name=orchestrator-onboarding", + "commit", "-q", "-m", "feat: onboarding skeleton (ORCH-009 kit)", + ], + ["git", "remote", "add", "origin", url], + ["git", "push", "-q", "-u", "origin", "main"], + ] + for cmd in commands: + rc = git_runner(cmd, workdir) + if rc not in (0, None): + logger.error("git step failed (rc=%s): %s", rc, cmd[0:2]) + return False + return True + + +# --------------------------------------------------------------------------- # +# Наблюдение текущего состояния (GET-only) и чистый план +# --------------------------------------------------------------------------- # + +@dataclasses.dataclass +class Observed: + """Снимок текущего состояния внешних систем (только GET-пробы).""" + + project: dict | None = None + states: list | None = None + labels: list | None = None + repo: dict | None = None + hooks: list = dataclasses.field(default_factory=list) + + +def observe(params: dict, plane, gitea) -> Observed: + """GET-пробы Plane/Gitea; ошибки чтения → None (план пометит шаг).""" + obs = Observed() + pid = str(params.get("PLANE_PROJECT_ID") or "") + try: + if pid and not pid.startswith("<"): + obs.project = plane.get_project(pid) + if obs.project is None: + obs.project = plane.find_project_by_identifier(params["WORK_ITEM_PREFIX"]) + except Exception as e: # GET-проба не должна валить прогон + logger.warning("plane project probe failed: %s", e) + project_id = (obs.project or {}).get("id") or pid + if obs.project is not None and project_id: + try: + obs.states = plane.list_states(project_id) + except Exception as e: + logger.warning("plane states probe failed: %s", e) + try: + obs.labels = plane.list_labels(project_id) + except Exception as e: + logger.warning("plane labels probe failed: %s", e) + try: + obs.repo = gitea.get_repo(params["GITEA_OWNER"], params["REPO"]) + if obs.repo is not None: + obs.hooks = gitea.list_hooks(params["GITEA_OWNER"], params["REPO"]) or [] + except Exception as e: + logger.warning("gitea probe failed: %s", e) + return obs + + +def _existing_names(items, key: str = "name") -> set: + return { + str(item.get(key, "")).strip() + for item in (items or []) + if isinstance(item, dict) + } + + +def _hook_exists(hooks: list, webhook_url: str) -> bool: + for hook in hooks or []: + config = hook.get("config", {}) if isinstance(hook, dict) else {} + if config.get("url") == webhook_url: + return True + return False + + +def build_plan(params: dict, observed: Observed, webhook_url: str) -> list: + """Чистая функция: упорядоченный план шагов закрытого списка BR-1 (без I/O).""" + steps: list[Step] = [] + + def add(step_id, description, status, detail=""): + steps.append(Step(step_id, description, status, detail)) + + # 1. Plane: проект + if observed.project is not None: + add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", SKIPPED, + f"id={observed.project.get('id', '?')}") + else: + add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", PLANNED, + f"identifier={params['WORK_ITEM_PREFIX']}") + + # 2. Plane: 22 статуса с точными именами (источник — _PLANE_NAME_TO_KEY, D5) + existing_states = _existing_names(observed.states) + for name in _PLANE_NAME_TO_KEY: # порядок словаря = канонический порядок кода + group = STATE_GROUPS[name] + if name in existing_states: + add(f"plane.state:{name}", f"статус «{name}»", SKIPPED, f"group={group}") + else: + add(f"plane.state:{name}", f"статус «{name}»", PLANNED, f"group={group}") + + # 3. Plane: лейблы (имена из конфига, D4) + existing_labels = _existing_names(observed.labels) + for label in label_names(): + status = SKIPPED if label in existing_labels else PLANNED + add(f"plane.label:{label}", f"лейбл «{label}»", status) + + # Заведомо ручные шаги Plane (D5): UI-only / уже существующее. + add("plane.board-order", "порядок статусов на доске (drag-and-drop)", MANUAL, + f"UI-only шаг — см. {RUNBOOK}") + add("plane.workspace-webhook", "workspace-webhook Plane", MANUAL, + f"уже существует (общий на workspace) — только проверка, см. {RUNBOOK}") + + # 4. Gitea: репо + if observed.repo is not None: + add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", SKIPPED, + f"empty={observed.repo.get('empty')}") + else: + add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", PLANNED, + "auto_init=false (пустой; main создаст initial push)") + + # 5. Gitea: per-repo webhook + if _hook_exists(observed.hooks, webhook_url): + add("gitea.webhook", "per-repo webhook", SKIPPED, f"url={webhook_url}") + else: + add("gitea.webhook", "per-repo webhook", PLANNED, + f"url={webhook_url}; events={'/'.join(WEBHOOK_EVENTS)}; " + "HMAC secret — глобальный из env, в гит не попадает") + + # 6. Kit: материализация + initial push (только пустой/свежий репо, D6) + repo_exists = observed.repo is not None + repo_empty = bool((observed.repo or {}).get("empty")) + if not repo_exists or repo_empty: + add("kit.materialize", "материализация kit (рендер + live-copy канона)", PLANNED, + f"{len(iter_skeleton_files())} файлов скелета + {'/'.join(LIVE_COPY_DIRS)}") + add("kit.push", "initial push kit в свежесозданный пустой репо", PLANNED, + "git init/commit/push -> main (единственный разрешённый push)") + else: + add("kit.materialize", "материализация kit", MANUAL, + f"репо непустой — kit-файлы НИКОГДА не пушатся поверх существующего контента; см. {RUNBOOK}") + add("kit.push", "initial push kit", MANUAL, + f"репо непустой — push запрещён (BR-9); см. {RUNBOOK}") + + # 7. Реестр: merged-вывод (применение env + рестарт — операторский шаг, D7) + add("registry.emit", "запись реестра ORCH_PROJECTS_JSON (merged-вывод)", PLANNED, + "применение строки в .env + управляемый рестарт — ОПЕРАТОРСКИЙ шаг") + return steps + + +# --------------------------------------------------------------------------- # +# Режимы +# --------------------------------------------------------------------------- # + +def _registry_instructions(report: Report, params: dict, env_file: str | None) -> None: + """Standalone + merged вывод реестра в инструкции отчёта (D7).""" + try: + entry = build_registry_entry(params) + standalone, merged = merged_projects_json(entry, read_existing_registry(env_file)) + report.instructions.append( + "Добавь/обнови строку в .env оркестратора (полный merged-массив, атомарно): " + f"ORCH_PROJECTS_JSON={merged}" + ) + report.instructions.append(f"Standalone-запись нового проекта (справочно): {standalone}") + report.instructions.append( + "После правки .env выполни УПРАВЛЯЕМЫЙ рестарт оркестратора (операторский шаг, " + f"групповое окно для всех проектов) — см. {RUNBOOK}." + ) + except (RuntimeError, KeyError, ValueError) as e: + report.add("registry.emit", "запись реестра", ERROR, str(e)) + + +def run_plan(params: dict, plane, gitea, webhook_url: str, webhook_secret: str | None = None) -> Report: + """Режим plan: GET-пробы + полный план; НИ ОДНОЙ мутации (сеть/диск) — AC-8.""" + report = Report(mode="plan") + observed = observe(params, plane, gitea) + for step in build_plan(params, observed, webhook_url): + report.steps.append(step) + # Рендер-проверка строго в памяти: ловим неразрешённые плейсхолдеры ДО apply. + rendered = render_kit_in_memory(params) + problems = {rel: bad for rel, content in rendered.items() if (bad := find_unresolved(content))} + if problems: + report.add("kit.render-check", "скан неразрешённых плейсхолдеров", ERROR, str(problems)) + else: + report.add("kit.render-check", "скан неразрешённых плейсхолдеров", OK, + f"{len(rendered)} файлов отрендерено в памяти, чисто") + _registry_instructions(report, params, None) + if webhook_secret is None and not settings.gitea_webhook_secret: + report.instructions.append( + "ORCH_GITEA_WEBHOOK_SECRET отсутствует в env — apply сгенерирует и выведет его " + "для .env (секрет в гит не попадает)." + ) + return report + + +def run_apply( + params: dict, + plane, + gitea, + webhook_url: str, + git_runner=default_git_runner, + workdir: str | None = None, + webhook_secret: str | None = None, + env_file: str | None = None, +) -> Report: + """Режим apply: идемпотентный ensure (BR-9). Существующее → skipped; delete нет.""" + report = Report(mode="apply") + observed = observe(params, plane, gitea) + + # 1. Plane: проект + project = observed.project + if project is not None: + report.add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", SKIPPED, + f"id={project.get('id', '?')}") + else: + try: + project = plane.create_project(params["PROJECT_NAME"], params["WORK_ITEM_PREFIX"]) + report.add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", CREATED, + f"id={project.get('id', '?')}") + except ManualStep as e: + report.add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", MANUAL, + f"{e}; создай проект в UI и перезапусти apply с --plane-project-id; см. {RUNBOOK}") + except Exception as e: + report.add("plane.project", "Plane-проект", ERROR, str(e)) + + project_id = (project or {}).get("id") or str(params.get("PLANE_PROJECT_ID") or "") + if project_id and not str(project_id).startswith("<"): + params = dict(params, PLANE_PROJECT_ID=str(project_id)) + + # 2. Plane: статусы (точные имена + группы, D5) + existing_states = _existing_names(observed.states) + if project is None: + for name in _PLANE_NAME_TO_KEY: + report.add(f"plane.state:{name}", f"статус «{name}»", MANUAL, + f"нет проекта — создай статусы вручную по таблице runbook; см. {RUNBOOK}") + else: + for name in _PLANE_NAME_TO_KEY: + group = STATE_GROUPS[name] + if name in existing_states: + report.add(f"plane.state:{name}", f"статус «{name}»", SKIPPED, f"group={group}") + continue + try: + plane.create_state(project_id, name, group) + report.add(f"plane.state:{name}", f"статус «{name}»", CREATED, f"group={group}") + except ManualStep as e: + report.add(f"plane.state:{name}", f"статус «{name}»", MANUAL, + f"{e}; создай вручную (group={group}); см. {RUNBOOK}") + except Exception as e: + report.add(f"plane.state:{name}", f"статус «{name}»", ERROR, str(e)) + + # 3. Plane: лейблы + existing_labels = _existing_names(observed.labels) + if project is None: + for label in label_names(): + report.add(f"plane.label:{label}", f"лейбл «{label}»", MANUAL, + f"нет проекта — создай лейбл вручную; см. {RUNBOOK}") + else: + for label in label_names(): + if label in existing_labels: + report.add(f"plane.label:{label}", f"лейбл «{label}»", SKIPPED) + continue + try: + plane.create_label(project_id, label) + report.add(f"plane.label:{label}", f"лейбл «{label}»", CREATED) + except ManualStep as e: + report.add(f"plane.label:{label}", f"лейбл «{label}»", MANUAL, + f"{e}; создай вручную; см. {RUNBOOK}") + except Exception as e: + report.add(f"plane.label:{label}", f"лейбл «{label}»", ERROR, str(e)) + + # Заведомо ручные шаги Plane (D5). + report.add("plane.board-order", "порядок статусов на доске (drag-and-drop)", MANUAL, + f"UI-only шаг — см. {RUNBOOK}") + report.add("plane.workspace-webhook", "workspace-webhook Plane", MANUAL, + f"уже существует (общий на workspace) — только проверка, см. {RUNBOOK}") + + # 4. Gitea: репо + repo = observed.repo + freshly_created = False + if repo is not None: + report.add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", SKIPPED, + f"empty={repo.get('empty')}") + else: + try: + repo = gitea.create_repo(params["GITEA_OWNER"], params["REPO"], + str(params.get("PROJECT_DESCRIPTION", ""))) + freshly_created = True + report.add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", + CREATED, "auto_init=false (пустой)") + except ManualStep as e: + report.add("gitea.repo", "Gitea-репо", MANUAL, f"{e}; см. {RUNBOOK}") + except Exception as e: + report.add("gitea.repo", "Gitea-репо", ERROR, str(e)) + + # 5. Gitea: webhook (секрет переиспользуется из env — D6; в отчёте маскируется) + secret = webhook_secret if webhook_secret is not None else settings.gitea_webhook_secret + generated = False + if not secret: + secret = _secrets.token_hex(20) + generated = True + if repo is None: + report.add("gitea.webhook", "per-repo webhook", MANUAL, + f"нет репо — создай webhook вручную; см. {RUNBOOK}") + elif _hook_exists(observed.hooks, webhook_url): + report.add("gitea.webhook", "per-repo webhook", SKIPPED, f"url={webhook_url}") + else: + try: + gitea.create_hook(params["GITEA_OWNER"], params["REPO"], webhook_url, secret, + WEBHOOK_EVENTS) + report.add("gitea.webhook", "per-repo webhook", CREATED, + f"url={webhook_url}; events={'/'.join(WEBHOOK_EVENTS)}; secret=***") + except ManualStep as e: + report.add("gitea.webhook", "per-repo webhook", MANUAL, f"{e}; см. {RUNBOOK}") + except Exception as e: + report.add("gitea.webhook", "per-repo webhook", ERROR, str(e)) + if generated: + report.instructions.append( + "Сгенерирован новый HMAC-секрет webhook — добавь в .env оркестратора строку: " + f"ORCH_GITEA_WEBHOOK_SECRET={secret} (в гит НЕ коммитить)." + ) + + # 6. Kit: материализация + initial push ТОЛЬКО в пустой/свежесозданный репо (D6) + repo_empty = freshly_created or bool((repo or {}).get("empty")) + if repo is not None and repo_empty: + try: + dest = workdir or tempfile.mkdtemp(prefix="onboard-kit-") + written = materialize_kit(params, dest) + report.add("kit.materialize", "материализация kit", CREATED, + f"{len(written)} файлов -> {dest}") + if initial_push(dest, params["GITEA_OWNER"], params["REPO"], git_runner): + report.add("kit.push", "initial push kit в пустой репо", CREATED, + "git push origin main (единственный разрешённый push)") + else: + report.add("kit.push", "initial push kit", ERROR, + f"git-команда вернула ненулевой код; материализованный kit остался в {dest}") + except (ValueError, FileNotFoundError) as e: + report.add("kit.materialize", "материализация kit", ERROR, str(e)) + elif repo is not None: + report.add("kit.materialize", "материализация kit", MANUAL, + f"репо непустой — kit поверх существующего контента не пушится (BR-9); см. {RUNBOOK}") + report.add("kit.push", "initial push kit", MANUAL, + f"репо непустой — push запрещён (BR-9); см. {RUNBOOK}") + else: + report.add("kit.materialize", "материализация kit", MANUAL, + f"нет репо — повтори apply после создания репо; см. {RUNBOOK}") + report.add("kit.push", "initial push kit", MANUAL, f"нет репо; см. {RUNBOOK}") + + # 7. Реестр (вывод инструкций; .env скрипт НЕ правит — NFR-2) + report.add("registry.emit", "запись реестра ORCH_PROJECTS_JSON", CREATED, + "merged-массив выведен в инструкции; применение env + рестарт — операторский шаг") + _registry_instructions(report, params, env_file) + return report + + +# Полнота kit в verify: ключевые файлы, обязанные лежать в репо (FR-5). +VERIFY_KIT_FILES = ( + ".openclaw/agents/analyst.md", + ".openclaw/agents/architect.md", + ".openclaw/agents/developer.md", + ".openclaw/agents/reviewer.md", + ".openclaw/agents/tester.md", + ".openclaw/agents/deployer.md", + "CLAUDE.md", + "AGENTS.md", + "CONTRIBUTING.md", + "README.md", + "CHANGELOG.md", + ".env.example", + "docs/operations/INFRA.md", +) + + +def run_verify( + params: dict, + plane, + gitea, + webhook_url: str, + projects_raw: str | None = None, + env_file: str | None = None, +) -> Report: + """Режим verify: GET-пробы + локальные проверки (FR-5). Ничего не мутирует.""" + report = Report(mode="verify") + observed = observe(params, plane, gitea) + + # 1. Реестр: round-trip фактическим парсером + наличие записи проекта. + raw = projects_raw if projects_raw is not None else read_existing_registry(env_file) + parsed = _parse_projects_json(raw or "") + entry = None + for p in parsed or []: + if p.repo == params["REPO"] or p.plane_project_id == str(params.get("PLANE_PROJECT_ID")): + entry = p + break + if entry is None: + report.add("verify.registry", "запись реестра ORCH_PROJECTS_JSON", GAP, + "проект НЕ зарегистрирован (создано, но не зарегистрировано — см. runbook: " + "env + управляемый рестарт)") + elif ( + entry.work_item_prefix != params["WORK_ITEM_PREFIX"] + or entry.repo != params["REPO"] + ): + report.add("verify.registry", "запись реестра", GAP, + f"поля записи расходятся с параметрами: {entry}") + else: + report.add("verify.registry", "запись реестра ORCH_PROJECTS_JSON", OK, + f"prefix={entry.work_item_prefix}, repo={entry.repo}") + + # 2. Статусы: все 22 канонических имени (включая fail-closed Confirm Deploy/STOP). + if observed.project is None or observed.states is None: + report.add("verify.plane.states", "статусы Plane-проекта", GAP, + "проект/статусы не читаются через API") + else: + existing = _existing_names(observed.states) + missing = [name for name in _PLANE_NAME_TO_KEY if name not in existing] + if missing: + report.add("verify.plane.states", "статусы Plane-проекта", GAP, + f"отсутствуют: {', '.join(missing)}") + else: + report.add("verify.plane.states", "статусы Plane-проекта", OK, + f"все {len(_PLANE_NAME_TO_KEY)} канонических имени на месте") + + # 3. Лейблы. + if observed.labels is None: + report.add("verify.plane.labels", "лейблы Plane-проекта", GAP, "лейблы не читаются") + else: + existing = _existing_names(observed.labels) + missing = [label for label in label_names() if label not in existing] + if missing: + report.add("verify.plane.labels", "лейблы Plane-проекта", GAP, + f"отсутствуют: {', '.join(missing)}") + else: + report.add("verify.plane.labels", "лейблы Plane-проекта", OK, + ", ".join(label_names())) + + # 4. Gitea-webhook. + if observed.repo is None: + report.add("verify.gitea.repo", "Gitea-репо", GAP, "репо не найден") + report.add("verify.gitea.webhook", "per-repo webhook", GAP, "репо не найден") + else: + report.add("verify.gitea.repo", "Gitea-репо", OK, + f"{params['GITEA_OWNER']}/{params['REPO']}") + active_hook = any( + isinstance(h, dict) + and h.get("config", {}).get("url") == webhook_url + and h.get("active", False) + for h in observed.hooks + ) + if active_hook: + report.add("verify.gitea.webhook", "per-repo webhook", OK, + f"url={webhook_url}, active") + else: + report.add("verify.gitea.webhook", "per-repo webhook", GAP, + f"активный webhook с url={webhook_url} не найден") + + # 5. Полнота kit в репо + скан неразрешённых плейсхолдеров. + if observed.repo is not None: + missing_files = [] + unresolved: dict[str, list] = {} + for rel in VERIFY_KIT_FILES: + content = gitea.get_file_text(params["GITEA_OWNER"], params["REPO"], rel) + if content is None: + missing_files.append(rel) + continue + bad = find_unresolved(content) + if bad: + unresolved[rel] = bad + if missing_files: + report.add("verify.kit.files", "kit-файлы в репо", GAP, + f"отсутствуют: {', '.join(missing_files)}") + elif unresolved: + report.add("verify.kit.files", "kit-файлы в репо", GAP, + f"неразрешённые плейсхолдеры: {unresolved}") + else: + report.add("verify.kit.files", "kit-файлы в репо", OK, + f"{len(VERIFY_KIT_FILES)} ключевых файлов на месте, плейсхолдеров нет") + + templates = gitea.list_dir(params["GITEA_OWNER"], params["REPO"], "docs/_templates") or [] + standards = gitea.list_dir(params["GITEA_OWNER"], params["REPO"], "docs/_standards") or [] + if len(templates) >= 16 and len(standards) >= 3: + report.add("verify.kit.canon", "live-copy канона в репо", OK, + f"_templates={len(templates)}, _standards={len(standards)}") + else: + report.add("verify.kit.canon", "live-copy канона в репо", GAP, + f"_templates={len(templates)} (>=16?), _standards={len(standards)} (>=3?)") + + # 6. Workspace-webhook Plane: только команда проверки (Ф-6 — существует, не создаём). + report.add("verify.plane.workspace-webhook", "workspace-webhook Plane", MANUAL, + f"проверь вручную командой из {RUNBOOK} (создан на уровне workspace)") + return report + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + +def build_params(args: argparse.Namespace) -> dict: + """Параметры рендера из аргументов CLI (ключи — словарь placeholders.json).""" + return { + "PROJECT_NAME": args.name, + "PROJECT_DESCRIPTION": args.description, + "REPO": args.repo, + "GITEA_OWNER": args.gitea_owner, + "WORK_ITEM_PREFIX": args.prefix, + "PLANE_PROJECT_ID": args.plane_project_id or "", + "STACK": args.stack, + "TEST_CMD": args.test_cmd, + "PROD_PORT": str(args.prod_port), + "STAGING_PORT": str(args.staging_port), + } + + +def main(argv: list | None = None) -> int: + parser = argparse.ArgumentParser( + description="Turnkey-онбординг нового проекта в оркестратор (ORCH-009). " + f"Полный процесс — {RUNBOOK}.", + ) + parser.add_argument("mode", nargs="?", default="plan", choices=("plan", "apply", "verify"), + help="режим: plan (дефолт, dry-run) / apply / verify") + parser.add_argument("--name", required=True, help="человекочитаемое имя проекта") + parser.add_argument("--description", default="", help="1–2 фразы «зачем проект»") + parser.add_argument("--repo", required=True, help="имя Gitea-репо") + parser.add_argument("--gitea-owner", default=getattr(settings, "gitea_owner", "admin"), + help="owner/org репо в Gitea") + parser.add_argument("--prefix", required=True, help="префикс work-item (identifier Plane)") + parser.add_argument("--plane-project-id", default="", + help="UUID существующего Plane-проекта (если уже создан)") + parser.add_argument("--stack", required=True, help="стек проекта (описательно)") + parser.add_argument("--test-cmd", required=True, help="команда тестов проекта") + parser.add_argument("--prod-port", required=True, help="порт прод-контура проекта") + parser.add_argument("--staging-port", required=True, help="порт staging-контура проекта") + parser.add_argument("--webhook-url", required=True, + help="внешний URL приёмника Gitea-webhook оркестратора " + "(https:///webhook/gitea)") + parser.add_argument("--env-file", default=None, + help="файл .env с текущим ORCH_PROJECTS_JSON (read-only; дефолт .env корня)") + parser.add_argument("--json", action="store_true", help="печатать отчёт в JSON") + args = parser.parse_args(argv) + + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") + params = build_params(args) + + plane = PlaneClient(settings.plane_api_url, settings.plane_api_token, + settings.plane_workspace_slug) + gitea = GiteaClient(settings.gitea_url, settings.gitea_token) + + if args.mode == "plan": + report = run_plan(params, plane, gitea, webhook_url=args.webhook_url) + elif args.mode == "apply": + report = run_apply(params, plane, gitea, webhook_url=args.webhook_url, + env_file=args.env_file) + else: + report = run_verify(params, plane, gitea, webhook_url=args.webhook_url, + env_file=args.env_file) + + print(json.dumps(report.to_dict(), ensure_ascii=False, indent=2) if args.json + else report.render_text()) + return report.exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_onboarding_invariants.py b/tests/test_onboarding_invariants.py new file mode 100644 index 0000000..be815d8 --- /dev/null +++ b/tests/test_onboarding_invariants.py @@ -0,0 +1,116 @@ +"""ORCH-009 TC-21: pipeline invariants are untouched by the onboarding capability. + +The onboarding kit/CLI lives entirely OUTSIDE the runtime (NFR-1): `src/**` is +byte-for-byte untouched. These tests pin that contract: + +* a literal snapshot of ``STAGE_TRANSITIONS`` (the stage machine) and of the + ``QG_CHECKS`` registry — any drift fails loudly; +* ``src/**`` never references the onboarding tree (no runtime coupling); +* the CLI's read-only imports from ``src`` stay within the CLOSED list of + ADR-001 D4 (ORCH-009) — extending the list requires an ADR update; +* kit prompt templates name only real quality gates (no phantom ``check_*``). +""" +import ast +import os +import re + +from src.qg.checks import QG_CHECKS +from src.stages import STAGE_TRANSITIONS + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_SCRIPT_PATH = os.path.join(_REPO_ROOT, "scripts", "onboard_project.py") +_KIT_AGENTS = os.path.join(_REPO_ROOT, "onboarding", "repo-skeleton", ".openclaw", "agents") + +# Literal snapshot of the stage machine (src/stages.py). Byte-exact NFR-1 pin: +# the onboarding work item must not move a single edge/agent/gate. +_EXPECTED_TRANSITIONS = { + "created": {"next": "analysis", "agent": "analyst", "qg": None}, + "analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"}, + "architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"}, + "development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"}, + "review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"}, + "testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"}, + "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"}, + "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"}, + "done": {"next": None, "agent": None, "qg": None}, + "cancelled": {"next": None, "agent": None, "qg": None}, +} + +# Snapshot of the QG registry KEYS (src/qg/checks.py::QG_CHECKS). +_EXPECTED_QG_KEYS = { + "check_analysis_approved", + "check_analysis_complete", + "check_architecture_done", + "check_ci_green", + "check_review_approved", + "check_tests_passed", + "check_reviewer_verdict", + "check_tests_local", + "check_deploy_status", + "check_staging_status", + "check_branch_mergeable", + "check_staging_image_fresh", + "check_security_gate", + "check_coverage_gate", +} + +# Closed read-only import list of the onboarding CLI (ADR-001 D4 ORCH-009). +_ALLOWED_SRC_IMPORTS = {"src.config", "src.plane_sync", "src.projects"} + + +def test_tc21_stage_transitions_snapshot(): + assert STAGE_TRANSITIONS == _EXPECTED_TRANSITIONS, ( + "STAGE_TRANSITIONS drifted — ORCH-009 must not touch the stage machine (NFR-1)" + ) + + +def test_tc21_qg_checks_registry_snapshot(): + assert set(QG_CHECKS) == _EXPECTED_QG_KEYS, ( + "QG_CHECKS registry drifted — ORCH-009 must not touch the gates (NFR-1)" + ) + + +def test_tc21_src_never_references_onboarding(): + """No runtime coupling: src/** must not import/reference the onboarding tree.""" + offenders = [] + for root, _dirs, files in os.walk(os.path.join(_REPO_ROOT, "src")): + for name in files: + if not name.endswith(".py"): + continue + path = os.path.join(root, name) + with open(path, encoding="utf-8") as f: + if "onboard" in f.read().lower(): + offenders.append(os.path.relpath(path, _REPO_ROOT)) + assert not offenders, f"src/** references onboarding: {offenders}" + + +def test_tc21_cli_src_imports_stay_in_closed_list(): + """ADR-001 D4: the CLI may import ONLY src.config / src.plane_sync / src.projects.""" + with open(_SCRIPT_PATH, encoding="utf-8") as f: + tree = ast.parse(f.read()) + found = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("src"): + found.add(node.module) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("src"): + found.add(alias.name) + assert found, "the CLI must use the closed src imports (round-trip via real parser)" + assert found <= _ALLOWED_SRC_IMPORTS, ( + f"onboard_project.py imports outside the closed ADR D4 list: " + f"{sorted(found - _ALLOWED_SRC_IMPORTS)} — extend ONLY via an ADR update" + ) + + +def test_tc21_kit_prompts_name_only_real_gates(): + """A kit prompt naming a phantom gate would mislead every onboarded project.""" + pattern = re.compile(r"check_[a-z_]+") + for name in sorted(os.listdir(_KIT_AGENTS)): + path = os.path.join(_KIT_AGENTS, name) + with open(path, encoding="utf-8") as f: + text = f.read() + for gate in sorted(set(pattern.findall(text))): + assert gate in QG_CHECKS, ( + f"kit prompt {name} references gate {gate!r} absent from QG_CHECKS" + ) diff --git a/tests/test_onboarding_kit.py b/tests/test_onboarding_kit.py new file mode 100644 index 0000000..83dc145 --- /dev/null +++ b/tests/test_onboarding_kit.py @@ -0,0 +1,414 @@ +"""ORCH-009: structural tests of the onboarding kit (`onboarding/repo-skeleton/`). + +Covers test-plan TC-01 (kit completeness), TC-03..TC-08 (prompt-template canon +52d/92), TC-19 (INFRA.md template sections) and TC-20 (ONBOARDING.md runbook). +Pure-text structural checks: NO network, NO agent runs (NFR-5). The kit prompt +templates are checked separately from the live orchestrator prompts +(`tests/test_agent_prompts_canon.py`) — the two trees must not be confused +(ADR-001 D1 ORCH-009). +""" +import json +import os +import re + +import pytest + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_ONBOARDING = os.path.join(_REPO_ROOT, "onboarding") +_KIT = os.path.join(_ONBOARDING, "repo-skeleton") +_RUNBOOK = os.path.join(_REPO_ROOT, "docs", "operations", "ONBOARDING.md") + +_AGENTS = ("analyst", "architect", "developer", "reviewer", "tester", "deployer") + +# The 5 mandatory XML sections, in normative order (canon 52d, AC-2). +_SECTIONS = ("context", "task", "deliverables", "constraints", "output_format") + +# The 6 mandatory 52c schema fields (mirrors src/frontmatter.py::REQUIRED_FIELDS, +# kept literal here on purpose: kit tests must not import src/ — NFR-1 hygiene). +_SCHEMA_FIELDS = ("work_item", "stage", "author_agent", "status", "created_at", "model_used") + +# Role -> stage value(s) the template's example schema must pin (FR-2). +_STAGE_BY_ROLE = { + "analyst": ("analysis",), + "architect": ("architecture",), + "developer": ("development",), + "reviewer": ("review",), + "tester": ("testing",), + "deployer": ("deploy-staging", "deploy"), +} + + +def _read(*parts: str) -> str: + with open(os.path.join(_REPO_ROOT, *parts), encoding="utf-8") as f: + return f.read() + + +def _kit(*parts: str) -> str: + with open(os.path.join(_KIT, *parts), encoding="utf-8") as f: + return f.read() + + +def _prompt(agent: str) -> str: + return _kit(".openclaw", "agents", f"{agent}.md") + + +def _fenced_blocks(text: str) -> list[str]: + """Return the body of every ``` fenced code block (the *copyable* examples).""" + blocks: list[str] = [] + inside = False + buf: list[str] = [] + for line in text.splitlines(): + if line.lstrip().startswith("```"): + if inside: + blocks.append("\n".join(buf)) + buf = [] + inside = not inside + continue + if inside: + buf.append(line) + return blocks + + +# --------------------------------------------------------------------------- # +# TC-01 — kit completeness (AC-1 / FR-1) +# --------------------------------------------------------------------------- # + +_REQUIRED_FILES = [ + ".openclaw/agents/analyst.md", + ".openclaw/agents/architect.md", + ".openclaw/agents/developer.md", + ".openclaw/agents/reviewer.md", + ".openclaw/agents/tester.md", + ".openclaw/agents/deployer.md", + "CLAUDE.md", + "AGENTS.md", + "CONTRIBUTING.md", + "README.md", + "CHANGELOG.md", + ".env.example", + "docs/ARCHITECTURE.md", + "docs/PIPELINE.md", + "docs/PRODUCT_VISION.md", + "docs/operations/INFRA.md", + "docs/architecture/adr/README.md", + "docs/work-items/.gitkeep", + "docs/history/.gitkeep", +] + + +def test_tc01_kit_contains_all_required_elements(): + """TC-01: every FR-1 element of the skeleton is present (6 prompts + carcass).""" + missing = [ + rel for rel in _REQUIRED_FILES + if not os.path.isfile(os.path.join(_KIT, *rel.split("/"))) + ] + assert not missing, f"onboarding/repo-skeleton is missing: {missing}" + + +def test_tc01_kit_readme_and_placeholder_dictionary_exist(): + """TC-01/D1: onboarding/README.md + placeholders.json (single source of truth).""" + assert os.path.isfile(os.path.join(_ONBOARDING, "README.md")) + payload = json.loads(_read("onboarding", "placeholders.json")) + assert isinstance(payload, dict) and payload, "placeholders.json must be a non-empty dict" + for name, meta in payload.items(): + assert re.fullmatch(r"[A-Z][A-Z0-9_]*", name), f"bad placeholder name {name!r}" + for key in ("description", "required", "default", "example"): + assert key in meta, f"placeholders.json[{name}] lacks {key!r}" + + +def test_kit_does_not_fork_the_canon(): + """BR-2/D3: no second editable copy of the canon inside the kit. + + `docs/_templates/` and `docs/_standards/` are live-copied by the script at + materialisation time and must NOT be stored in the skeleton. + """ + for forbidden in ("docs/_templates", "docs/_standards"): + assert not os.path.exists(os.path.join(_KIT, *forbidden.split("/"))), ( + f"kit must not store an editable canon copy: {forbidden}" + ) + + +# --------------------------------------------------------------------------- # +# D2 — placeholder dictionary bijection (declared <-> used) +# --------------------------------------------------------------------------- # + +_PLACEHOLDER_RE = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}") + + +def _kit_files() -> list[str]: + out = [] + for root, _dirs, files in os.walk(_KIT): + for name in files: + out.append(os.path.join(root, name)) + return out + + +def test_placeholder_dictionary_bijection(): + """D2: every placeholder used in the kit is declared, every declared is used.""" + declared = set(json.loads(_read("onboarding", "placeholders.json"))) + used: set[str] = set() + for path in _kit_files(): + with open(path, encoding="utf-8") as f: + used.update(_PLACEHOLDER_RE.findall(f.read())) + assert used == declared, ( + f"placeholder drift: used-not-declared={sorted(used - declared)}, " + f"declared-not-used={sorted(declared - used)}" + ) + + +# --------------------------------------------------------------------------- # +# TC-03 — 5 XML sections in normative order (AC-2) +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("agent", _AGENTS) +def test_tc03_five_xml_sections_in_normative_order(agent): + """Real section tags sit on their own line; inline backticked mentions + (e.g. «см. ``» inside ) must not be mistaken for them + (same disambiguation as the ORCH-092 check).""" + text = _prompt(agent) + positions = [] + for section in _SECTIONS: + open_m = re.search(rf"(?m)^<{section}>\s*$", text) + close_m = re.search(rf"(?m)^\s*$", text) + assert open_m, f"kit {agent}.md missing <{section}> on its own line" + assert close_m, f"kit {agent}.md missing on its own line" + positions.append(open_m.start()) + assert positions == sorted(positions), ( + f"kit {agent}.md sections out of normative order " + f"context→task→deliverables→constraints→output_format" + ) + + +# --------------------------------------------------------------------------- # +# TC-04 — at dev/reviewer/tester; bans in «❌ → ✅» form (AC-2) +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("agent", ("developer", "reviewer", "tester")) +def test_tc04_escalation_section_after_success_criteria(agent): + text = _prompt(agent) + open_m = re.search(r"(?m)^\s*$", text) + close_m = re.search(r"(?m)^\s*$", text) + assert open_m and close_m, f"kit {agent}.md is missing the section" + success_m = re.search(r"(?m)^\s*$", text) + assert success_m and open_m.start() > success_m.start(), ( + f"kit {agent}.md must place after " + ) + + +@pytest.mark.parametrize("agent", _AGENTS) +def test_tc04_bans_use_cross_check_format(agent): + text = _prompt(agent) + assert "❌" in text and "✅" in text, ( + f"kit {agent}.md must format bans as «❌ X → ✅ Y»" + ) + + +# --------------------------------------------------------------------------- # +# TC-05 — each template directs the agent to the project docs (AC-2 / BR-3) +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("agent", _AGENTS) +def test_tc05_prompt_directs_agent_to_docs(agent): + text = _prompt(agent) + for marker in ( + "CLAUDE.md", # passport, read BEFORE work + "AGENTS.md", # docs map / entry point + "docs/ARCHITECTURE.md", # architecture doc + "docs/work-items/", # artefact home + "PIPELINE_DOCS.md", # docs standard + "docs/_templates/", # skeletons + ): + assert marker in text, f"kit {agent}.md does not reference {marker!r}" + + +@pytest.mark.parametrize("agent", ("developer", "reviewer")) +def test_tc05_changelog_duty_present(agent): + assert "CHANGELOG.md" in _prompt(agent), ( + f"kit {agent}.md must carry the CHANGELOG update duty" + ) + + +def test_tc05_architect_carries_adr_rules(): + text = _prompt("architect") + assert "06-adr/" in text, "kit architect.md must route decisions to 06-adr/" + assert "docs/architecture/adr/" in text, ( + "kit architect.md must carry the cross-cutting ADR rule" + ) + + +# --------------------------------------------------------------------------- # +# TC-06 — 52c schema emission + byte-exact machine-verdict keys (AC-2) +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("agent", _AGENTS) +def test_tc06_six_schema_fields_named(agent): + text = _prompt(agent) + for field in _SCHEMA_FIELDS: + assert field in text, f"kit {agent}.md does not mention schema field {field!r}" + + +@pytest.mark.parametrize("agent", _AGENTS) +def test_tc06_schema_pins_role_author_and_stage(agent): + text = _prompt(agent) + assert f"author_agent: {agent}" in text, ( + f"kit {agent}.md does not pin 'author_agent: {agent}'" + ) + for stage in _STAGE_BY_ROLE[agent]: + assert f"stage: {stage}" in text, f"kit {agent}.md does not pin 'stage: {stage}'" + + +def test_tc06_machine_verdict_keys_byte_exact(): + reviewer = _prompt("reviewer") + assert "verdict:" in reviewer + assert "APPROVED" in reviewer and "REQUEST_CHANGES" in reviewer + + tester = _prompt("tester") + assert "result:" in tester + assert "PASS" in tester and "FAIL" in tester + + deployer = _prompt("deployer") + assert "staging_status:" in deployer + assert "deploy_status:" in deployer + assert "security_status:" in deployer + assert "SUCCESS" in deployer and "FAILED" in deployer + + +@pytest.mark.parametrize("agent", _AGENTS) +def test_tc06_dates_and_models_are_placeholders(agent): + """Anti-pattern ORCH-092: no literal date/model inside copyable examples.""" + text = _prompt(agent) + assert "created_at: " in text, ( + f"kit {agent}.md must use the created_at: placeholder" + ) + assert "date +%F" in text, ( + f"kit {agent}.md must instruct to substitute the actual date (date +%F)" + ) + for block in _fenced_blocks(text): + assert re.search(r"created_at:\s*\d", block) is None, ( + f"kit {agent}.md hardcodes a literal created_at date in a copyable block" + ) + assert re.search(r"model_used:\s*claude", block) is None, ( + f"kit {agent}.md hardcodes a literal model in a copyable block" + ) + + +# --------------------------------------------------------------------------- # +# TC-07 — reviewer-gate on documentation (AC-3 / BR-4) +# --------------------------------------------------------------------------- # + +def test_tc07_reviewer_gate_docs_not_updated_means_request_changes(): + text = _prompt("reviewer") + assert "REQUEST_CHANGES" in text + assert "НЕ обновлена" in text, ( + "kit reviewer.md must carry the mandatory gate: docs NOT updated -> " + "verdict: REQUEST_CHANGES" + ) + + +# --------------------------------------------------------------------------- # +# TC-08 — language policy: 5 ru + deployer en (AC-4 / D9) +# --------------------------------------------------------------------------- # + +_CYRILLIC = re.compile(r"[а-яА-ЯёЁ]") + + +@pytest.mark.parametrize("agent", ("analyst", "architect", "developer", "reviewer", "tester")) +def test_tc08_ru_canon_for_five_roles(agent): + assert _CYRILLIC.search(_prompt(agent)), ( + f"kit {agent}.md must follow the ru canon (ADR-001 D9 ORCH-009)" + ) + + +def test_tc08_deployer_is_english(): + text = _prompt("deployer") + assert not _CYRILLIC.search(text), ( + "kit deployer.md must stay 100% English (safety-critical canon, D9)" + ) + assert "Do NOT translate" in text, ( + "kit deployer.md must carry the language-note guard" + ) + + +# --------------------------------------------------------------------------- # +# TC-19 — INFRA.md template: mandatory sections (AC-10 / FR-3) +# --------------------------------------------------------------------------- # + +def test_tc19_infra_template_mandatory_sections(): + text = _kit("docs", "operations", "INFRA.md") + assert "Топология" in text, "INFRA template lacks the topology section" + assert "{{PROD_PORT}}" in text and "{{STAGING_PORT}}" in text, ( + "INFRA template must parametrise prod/staging ports" + ) + assert "env" in text.lower(), "INFRA template lacks the env map section" + assert ".env.example" in text, "INFRA template lacks the .env.example canon rule" + assert "Границы доступа" in text, "INFRA template lacks the access-boundaries section" + assert "общего хоста" in text or "общий хост" in text, ( + "INFRA template lacks the shared-host risk warnings" + ) + assert "секрет" in text.lower(), "INFRA template lacks the secrets rule" + + +def test_tc19_orchestrator_own_infra_untouched_sections(): + """AC-10: the orchestrator's own INFRA.md keeps its self-hosting warnings.""" + own = _read("docs", "operations", "INFRA.md") + assert "orchestrator" in own and "8500" in own, ( + "docs/operations/INFRA.md of the orchestrator must stay the self-hosting runbook" + ) + + +# --------------------------------------------------------------------------- # +# TC-20 — runbook ONBOARDING.md covers all layers in order (AC-11 / FR-6) +# --------------------------------------------------------------------------- # + +def test_tc20_runbook_exists_and_layer_order(): + assert os.path.isfile(_RUNBOOK), "docs/operations/ONBOARDING.md is missing" + text = _read("docs", "operations", "ONBOARDING.md") + # All BR-1 layers, in sequence. + anchors = ["Предусловия", "Plane", "Gitea", "kit", "Регистрация", "Верификация", "Откат"] + positions = [] + for anchor in anchors: + idx = text.find(anchor) + assert idx != -1, f"ONBOARDING.md lacks the {anchor!r} layer" + positions.append(idx) + assert positions == sorted(positions), ( + f"ONBOARDING.md layers out of order: {anchors}" + ) + + +def test_tc20_runbook_manual_steps_and_selfhosting_warning(): + text = _read("docs", "operations", "ONBOARDING.md") + assert "ручной шаг" in text.lower() or "РУЧНОЙ ШАГ" in text, ( + "ONBOARDING.md must explicitly mark manual steps" + ) + assert "рестарт" in text.lower(), ( + "ONBOARDING.md must describe the operator-managed restart step" + ) + assert "self-hosting" in text or "групповое окно" in text, ( + "ONBOARDING.md must warn that a prod restart is a group-wide window" + ) + # Plane workspace-webhook already exists: verify, never create (Ф-6). + assert "workspace" in text.lower(), "ONBOARDING.md must cover the workspace webhook" + assert "существует" in text, ( + "ONBOARDING.md must state the Plane workspace-webhook already exists" + ) + + +def test_tc20_runbook_verification_and_smoke_journal(): + text = _read("docs", "operations", "ONBOARDING.md") + assert "verify" in text, "ONBOARDING.md must document the verify mode" + assert "8501" in text, "ONBOARDING.md smoke contour must be staging (8501) — D8" + assert "Журнал smoke-прогонов" in text, ( + "ONBOARDING.md must carry the smoke-run journal section (D8)" + ) + assert "onboard_project.py" in text, "ONBOARDING.md must reference the CLI" + + +def test_setup_webhooks_generalised(): + """TRZ §2: SETUP_WEBHOOKS.md is generalised per-repo + references the runbook.""" + text = _read("docs", "operations", "SETUP_WEBHOOKS.md") + assert "ONBOARDING.md" in text, ( + "SETUP_WEBHOOKS.md must reference docs/operations/ONBOARDING.md" + ) + assert "" in text or "{repo}" in text, ( + "SETUP_WEBHOOKS.md per-repo section must be generalised, not enduro-hardcoded" + ) diff --git a/tests/test_onboarding_script.py b/tests/test_onboarding_script.py new file mode 100644 index 0000000..559bcb1 --- /dev/null +++ b/tests/test_onboarding_script.py @@ -0,0 +1,605 @@ +"""ORCH-009: tests of the operator onboarding CLI (`scripts/onboard_project.py`). + +Covers test-plan TC-02 (live-copy of the canon), TC-09..TC-11 (render / +anti-leak / referential integrity), TC-12 (registry round-trip through the +actual parser), TC-13..TC-16 (plan: Plane/Gitea completeness + dry-run with +zero mutations), TC-17..TC-18 (idempotent & safe apply). + +All tests are deterministic and offline (NFR-5): the Plane/Gitea clients are +replaced with in-memory fakes; git is replaced with a recording runner. The +script module is loaded via importlib (pattern: tests/test_staging_check_b6.py). +""" +import importlib.util +import json +import os +import re + +import pytest + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_SCRIPT_PATH = os.path.join(_REPO_ROOT, "scripts", "onboard_project.py") + + +def _load_module(): + spec = importlib.util.spec_from_file_location("onboard_project", _SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def mod(): + return _load_module() + + +_WEBHOOK_URL = "https://orchestrator.example.org/webhook/gitea" + + +def _params(**over): + p = { + "PROJECT_NAME": "Demo Project", + "PROJECT_DESCRIPTION": "Демо-проект для проверки онбординга", + "REPO": "demo-project", + "GITEA_OWNER": "admin", + "WORK_ITEM_PREFIX": "DEMO", + "PLANE_PROJECT_ID": "11111111-2222-3333-4444-555555555555", + "STACK": "Python 3.12 + FastAPI + SQLite", + "TEST_CMD": "pytest tests/ -q", + "PROD_PORT": "8600", + "STAGING_PORT": "8601", + } + p.update(over) + return p + + +def _step(report, step_id): + matches = [s for s in report.steps if s.id == step_id] + assert matches, f"report has no step {step_id!r}: {[s.id for s in report.steps]}" + return matches[0] + + +# --------------------------------------------------------------------------- # +# Fakes — the only network touchpoints of the script, replaced in-memory. +# --------------------------------------------------------------------------- # + +class FakePlane: + def __init__(self, mod, project=None, states=None, labels=None, + refuse_create_states=False, refuse_create_labels=False, + refuse_create_project=False): + self._mod = mod + self.project = project + self.states = list(states or []) + self.labels = list(labels or []) + self.mutations = [] + self.refuse_create_states = refuse_create_states + self.refuse_create_labels = refuse_create_labels + self.refuse_create_project = refuse_create_project + + # GET probes + def get_project(self, project_id): + if self.project and self.project.get("id") == project_id: + return self.project + return None + + def find_project_by_identifier(self, identifier): + if self.project and self.project.get("identifier") == identifier: + return self.project + return None + + def list_states(self, project_id): + return list(self.states) + + def list_labels(self, project_id): + return list(self.labels) + + # mutations + def create_project(self, name, identifier): + if self.refuse_create_project: + raise self._mod.ManualStep("Plane CE: projects API not available") + self.mutations.append(("create_project", name, identifier)) + self.project = {"id": "plane-uuid-created", "name": name, "identifier": identifier} + return self.project + + def create_state(self, project_id, name, group): + if self.refuse_create_states: + raise self._mod.ManualStep("Plane CE: states API not available") + self.mutations.append(("create_state", name, group)) + state = {"id": f"uuid-{name}", "name": name, "group": group} + self.states.append(state) + return state + + def create_label(self, project_id, name): + if self.refuse_create_labels: + raise self._mod.ManualStep("Plane CE: labels API not available") + self.mutations.append(("create_label", name)) + label = {"id": f"uuid-{name}", "name": name} + self.labels.append(label) + return label + + +class FakeGitea: + def __init__(self, repo=None, hooks=None, files=None): + self.repo = repo + self.hooks = list(hooks or []) + self.files = dict(files or {}) # repo path -> text (for verify) + self.mutations = [] + + def get_repo(self, owner, repo): + return self.repo + + def list_hooks(self, owner, repo): + return list(self.hooks) + + def create_repo(self, owner, name, description=""): + self.mutations.append(("create_repo", owner, name)) + self.repo = {"name": name, "owner": {"login": owner}, "empty": True} + return self.repo + + def create_hook(self, owner, repo, url, secret, events): + self.mutations.append(("create_hook", url, tuple(events))) + hook = {"id": 1, "active": True, "config": {"url": url}, "events": list(events)} + self.hooks.append(hook) + return hook + + # verify helpers + def get_file_text(self, owner, repo, path): + return self.files.get(path) + + def list_dir(self, owner, repo, path): + prefix = path.rstrip("/") + "/" + names = { + rel[len(prefix):].split("/", 1)[0] + for rel in self.files + if rel.startswith(prefix) + } + return sorted(names) or None + + +def _full_states(mod): + return [ + {"id": f"uuid-{name}", "name": name, "group": group} + for name, group in mod.STATE_GROUPS.items() + ] + + +def _full_labels(mod): + return [{"id": f"uuid-{name}", "name": name} for name in mod.label_names()] + + +# --------------------------------------------------------------------------- # +# TC-02 — materialisation live-copies the canon (BR-2 / D3) +# --------------------------------------------------------------------------- # + +def test_tc02_materialise_live_copies_canon(mod, tmp_path): + dest = tmp_path / "repo" + written = mod.materialize_kit(_params(), str(dest)) + assert written, "materialize_kit wrote nothing" + + templates = os.listdir(dest / "docs" / "_templates") + standards = os.listdir(dest / "docs" / "_standards") + assert len(templates) >= 16, f"expected >=16 canonical skeletons, got {len(templates)}" + assert len(standards) >= 3, f"expected >=3 standards, got {len(standards)}" + + # Verbatim copy — byte-equal to the live canon of the orchestrator checkout. + for rel in ("PIPELINE_DOCS.md", "HANDOFF_PROTOCOL.md", "TRACEABILITY.md"): + src_path = os.path.join(_REPO_ROOT, "docs", "_standards", rel) + with open(src_path, encoding="utf-8") as f: + canon = f.read() + with open(dest / "docs" / "_standards" / rel, encoding="utf-8") as f: + copied = f.read() + assert copied == canon, f"{rel} must be live-copied verbatim (BR-2)" + + +# --------------------------------------------------------------------------- # +# TC-09 / TC-10 — render: no unresolved placeholders, no orc leaks (AC-5) +# --------------------------------------------------------------------------- # + +def test_tc09_render_resolves_all_placeholders(mod): + rendered = mod.render_kit_in_memory(_params()) + assert rendered, "render_kit_in_memory returned nothing" + for rel, content in rendered.items(): + unresolved = mod.find_unresolved(content) + assert not unresolved, f"{rel} keeps unresolved placeholders: {unresolved}" + + +def test_tc10_no_orchestrator_specific_leaks(mod): + rendered = mod.render_kit_in_memory(_params()) + joined = "\n".join(rendered.values()) + assert re.search(r"ORCH-\d", joined) is None, ( + "rendered kit leaks an ORCH-NNN work-item literal where the project " + "prefix belongs (TC-10)" + ) + assert "8500" not in joined and "8501" not in joined, ( + "rendered kit leaks the orchestrator prod/staging ports" + ) + assert "self-hosting" not in joined.lower(), ( + "rendered kit leaks the orchestrator self-hosting rules" + ) + # The project's own parameters actually got substituted. + assert "DEMO-" in joined, "the project's work-item prefix was not substituted" + assert "demo-project" in joined, "the repo name was not substituted" + assert "8600" in joined and "8601" in joined, "ports were not substituted" + + +def test_render_is_a_pure_replace(mod): + text = "prefix {{WORK_ITEM_PREFIX}}-12 on port {{PROD_PORT}}" + out = mod.render(text, {"WORK_ITEM_PREFIX": "AB", "PROD_PORT": "9000"}) + assert out == "prefix AB-12 on port 9000" + assert mod.find_unresolved("a {{LEFT_OVER}} b") == ["{{LEFT_OVER}}"] + + +# --------------------------------------------------------------------------- # +# TC-11 — referential integrity of rendered prompts/AGENTS.md (AC-5) +# --------------------------------------------------------------------------- # + +_PATH_TOKEN = re.compile( + r"(?:docs/[\w./\-]+|\.openclaw/agents/[\w.\-]+|CLAUDE\.md|AGENTS\.md|" + r"CONTRIBUTING\.md|README\.md|CHANGELOG\.md|\.env\.example)" +) + + +def _static_paths(text: str) -> set[str]: + out = set() + for token in _PATH_TOKEN.findall(text): + token = token.rstrip(".,;:)`'\"") + # dynamic/illustrative tokens are not checkable paths + if any(ch in token for ch in "<>*{}") or "NNN" in token: + continue + out.add(token) + return out + + +def test_tc11_referenced_paths_exist_in_materialised_tree(mod, tmp_path): + dest = tmp_path / "repo" + mod.materialize_kit(_params(), str(dest)) + + sources = [ + os.path.join(dest, ".openclaw", "agents", f"{a}.md") + for a in ("analyst", "architect", "developer", "reviewer", "tester", "deployer") + ] + sources.append(os.path.join(dest, "AGENTS.md")) + + broken = [] + for src_file in sources: + with open(src_file, encoding="utf-8") as f: + for ref in _static_paths(f.read()): + target = os.path.join(dest, *ref.split("/")) + if not (os.path.isfile(target) or os.path.isdir(target.rstrip("/"))): + broken.append((os.path.relpath(src_file, dest), ref)) + assert not broken, f"kit references non-existent paths: {broken}" + + +# --------------------------------------------------------------------------- # +# TC-12 — registry round-trip through the ACTUAL parser (AC-6) +# --------------------------------------------------------------------------- # + +def test_tc12_registry_round_trip_through_actual_parser(mod): + from src.projects import _parse_projects_json + + existing = [ + { + "plane_project_id": "7a79f0a9-5278-49cd-9007-9a338f238f9c", + "repo": "enduro-trails", + "work_item_prefix": "ET", + "name": "enduro-trails", + }, + { + "plane_project_id": "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a", + "repo": "orchestrator", + "work_item_prefix": "ORCH", + "name": "orchestrator", + }, + ] + params = _params() + entry = mod.build_registry_entry(params) + standalone, merged = mod.merged_projects_json(entry, json.dumps(existing)) + + # standalone entry parses on its own + solo = _parse_projects_json(f"[{standalone}]") + assert solo is not None and len(solo) == 1 + + parsed = _parse_projects_json(merged) + assert parsed is not None and len(parsed) == 3, "merged registry must carry all 3" + # existing entries survive verbatim (no loss, no distortion) + for i, exp in enumerate(existing): + assert parsed[i].plane_project_id == exp["plane_project_id"] + assert parsed[i].repo == exp["repo"] + assert parsed[i].work_item_prefix == exp["work_item_prefix"] + assert parsed[i].name == exp["name"] + # the new entry carries the source params + new = parsed[2] + assert new.plane_project_id == params["PLANE_PROJECT_ID"] + assert new.repo == params["REPO"] + assert new.work_item_prefix == params["WORK_ITEM_PREFIX"] + assert new.name == params["PROJECT_NAME"] + + +def test_tc12_merge_is_idempotent_no_duplicates(mod): + from src.projects import _parse_projects_json + + params = _params() + entry = mod.build_registry_entry(params) + once = json.dumps([entry]) + _standalone, merged = mod.merged_projects_json(entry, once) + parsed = _parse_projects_json(merged) + assert parsed is not None and len(parsed) == 1, ( + "re-merging an already-registered project must not duplicate it" + ) + + +# --------------------------------------------------------------------------- # +# TC-13 — plan: exact Plane statuses (22) + groups + labels (AC-7) +# --------------------------------------------------------------------------- # + +def test_state_groups_match_plane_name_to_key(mod): + from src.plane_sync import _PLANE_NAME_TO_KEY + + assert set(mod.STATE_GROUPS) == set(_PLANE_NAME_TO_KEY), ( + "STATE_GROUPS must cover exactly the canonical Plane status names" + ) + # Code-critical constraints (ADR-001 D5): STOP joins the cancelled group + # (ORCH-090 fail-closed cancel); only Done/Cancelled/STOP are terminal — + # otherwise terminal-detection (ORCH-068) falsely terminates live tasks. + assert mod.STATE_GROUPS["STOP"] == "cancelled" + assert mod.STATE_GROUPS["Done"] == "completed" + assert mod.STATE_GROUPS["Cancelled"] == "cancelled" + terminal = {n for n, g in mod.STATE_GROUPS.items() if g in ("completed", "cancelled")} + assert terminal == {"Done", "Cancelled", "STOP"} + + +def test_tc13_plan_covers_all_statuses_and_labels(mod): + from src.plane_sync import _PLANE_NAME_TO_KEY + + plane = FakePlane(mod) + gitea = FakeGitea() + report = mod.run_plan(_params(), plane, gitea, webhook_url=_WEBHOOK_URL) + + for name in _PLANE_NAME_TO_KEY: + step = _step(report, f"plane.state:{name}") + assert step.status == mod.PLANNED, f"status {name!r} not planned: {step.status}" + stop = _step(report, "plane.state:STOP") + assert "cancelled" in stop.detail, "STOP step must pin the cancelled group" + + for label in mod.label_names(): + assert _step(report, f"plane.label:{label}").status == mod.PLANNED + assert set(mod.label_names()) == {"autoApprove", "autoDeploy", "Bug"} + + # known UI-only steps are flagged manual, never silently dropped (D5) + assert _step(report, "plane.board-order").status == mod.MANUAL + assert _step(report, "plane.workspace-webhook").status == mod.MANUAL + + +# --------------------------------------------------------------------------- # +# TC-14 — Plane API refusal degrades to manual-step, never a crash (AC-7) +# --------------------------------------------------------------------------- # + +def test_tc14_plane_refusal_becomes_manual_step(mod, tmp_path): + plane = FakePlane( + mod, + project={"id": _params()["PLANE_PROJECT_ID"], "identifier": "DEMO"}, + refuse_create_states=True, + refuse_create_labels=True, + ) + gitea = FakeGitea( + repo={"name": "demo-project", "empty": False}, + hooks=[{"id": 1, "active": True, "config": {"url": _WEBHOOK_URL}}], + ) + report = mod.run_apply( + _params(), plane, gitea, + webhook_url=_WEBHOOK_URL, git_runner=lambda cmd, cwd: 0, + workdir=str(tmp_path), + ) + state_steps = [s for s in report.steps if s.id.startswith("plane.state:")] + assert state_steps and all(s.status == mod.MANUAL for s in state_steps), ( + "refused Plane state creation must degrade to manual-step" + ) + for s in state_steps: + assert "ONBOARDING.md" in s.detail, "manual-step must link the runbook" + label_steps = [s for s in report.steps if s.id.startswith("plane.label:")] + assert label_steps and all(s.status == mod.MANUAL for s in label_steps) + assert report.exit_code == 2, "manual steps -> exit code 2" + + +# --------------------------------------------------------------------------- # +# TC-15 / TC-16 — plan: Gitea layer complete; dry-run mutates NOTHING (AC-8) +# --------------------------------------------------------------------------- # + +def test_tc15_plan_contains_gitea_repo_webhook_and_push(mod): + plane = FakePlane(mod) + gitea = FakeGitea() + report = mod.run_plan(_params(), plane, gitea, webhook_url=_WEBHOOK_URL) + + assert _step(report, "gitea.repo").status == mod.PLANNED + hook = _step(report, "gitea.webhook") + assert hook.status == mod.PLANNED + for event in ("push", "pull_request", "status"): + assert event in hook.detail, f"webhook plan must name event {event!r}" + assert "HMAC" in hook.detail or "secret" in hook.detail.lower(), ( + "webhook plan must mention the HMAC secret (kept out of git)" + ) + push = _step(report, "kit.push") + assert push.status == mod.PLANNED + assert "push" in push.detail.lower() + + +def test_tc16_plan_is_a_pure_dry_run(mod, monkeypatch): + plane = FakePlane(mod) + gitea = FakeGitea() + + def _boom(*_a, **_kw): # plan must never materialise or push + raise AssertionError("plan mode touched the disk / git") + + monkeypatch.setattr(mod, "materialize_kit", _boom) + monkeypatch.setattr(mod, "initial_push", _boom) + + report = mod.run_plan(_params(), plane, gitea, webhook_url=_WEBHOOK_URL) + assert plane.mutations == [], "plan made a Plane mutation" + assert gitea.mutations == [], "plan made a Gitea mutation" + assert report.steps, "plan produced an empty report" + + +def test_secret_never_leaks_into_report(mod): + plane = FakePlane(mod) + gitea = FakeGitea() + report = mod.run_plan( + _params(), plane, gitea, webhook_url=_WEBHOOK_URL, + webhook_secret="super-secret-hmac-value", + ) + dumped = json.dumps(report.to_dict(), ensure_ascii=False) + assert "super-secret-hmac-value" not in dumped, ( + "the webhook HMAC secret leaked into the report (NFR-3)" + ) + + +# --------------------------------------------------------------------------- # +# TC-17 — apply is idempotent: existing entities -> skipped(exists) (AC-9) +# --------------------------------------------------------------------------- # + +def test_tc17_second_apply_skips_everything_existing(mod, tmp_path): + params = _params() + plane = FakePlane( + mod, + project={"id": params["PLANE_PROJECT_ID"], "identifier": "DEMO"}, + states=_full_states(mod), + labels=_full_labels(mod), + ) + gitea = FakeGitea( + repo={"name": params["REPO"], "empty": False}, + hooks=[{"id": 7, "active": True, "config": {"url": _WEBHOOK_URL}}], + ) + git_calls = [] + report = mod.run_apply( + params, plane, gitea, webhook_url=_WEBHOOK_URL, + git_runner=lambda cmd, cwd: git_calls.append((cmd, cwd)) or 0, + workdir=str(tmp_path), + ) + + assert plane.mutations == [], "idempotent apply must not re-create Plane entities" + assert gitea.mutations == [], "idempotent apply must not re-create Gitea entities" + assert git_calls == [], "apply must NEVER push into a non-empty existing repo" + + assert _step(report, "plane.project").status == mod.SKIPPED + for name in mod.STATE_GROUPS: + assert _step(report, f"plane.state:{name}").status == mod.SKIPPED + for label in mod.label_names(): + assert _step(report, f"plane.label:{label}").status == mod.SKIPPED + assert _step(report, "gitea.repo").status == mod.SKIPPED + assert _step(report, "gitea.webhook").status == mod.SKIPPED + assert _step(report, "kit.push").status == mod.MANUAL, ( + "non-empty repo -> kit push degrades to a manual step, never an overwrite" + ) + + summary = report.to_dict() + for key in ("created", "skipped", "manual"): + assert key in summary["totals"], f"report totals lack {key!r}" + + +# --------------------------------------------------------------------------- # +# TC-18 — apply runs no restarts / no prod-.env edits / git only (NFR-2) +# --------------------------------------------------------------------------- # + +def test_tc18_fresh_apply_runs_git_only_inside_workdir(mod, tmp_path): + params = _params() + plane = FakePlane(mod) + gitea = FakeGitea() + calls = [] + + def recorder(cmd, cwd): + calls.append((list(cmd), cwd)) + return 0 + + report = mod.run_apply( + params, plane, gitea, webhook_url=_WEBHOOK_URL, + git_runner=recorder, workdir=str(tmp_path), + ) + + assert calls, "fresh empty repo: the initial push pipeline must run" + for cmd, cwd in calls: + assert cmd[0] == "git", f"only git may be executed, got: {cmd}" + assert cwd and str(tmp_path) in cwd, ( + f"git must run only inside the materialisation workdir, got cwd={cwd}" + ) + joined = " ".join(" ".join(c) for c, _ in calls) + assert "docker" not in joined and "restart" not in joined + + assert _step(report, "kit.push").status == mod.CREATED + assert ("create_repo", "admin", "demo-project") in gitea.mutations + hook_calls = [m for m in gitea.mutations if m[0] == "create_hook"] + assert hook_calls and hook_calls[0][1] == _WEBHOOK_URL + assert set(hook_calls[0][2]) == {"push", "pull_request", "status"} + + +def test_tc18_source_has_no_container_or_env_mutation_ops(mod): + with open(_SCRIPT_PATH, encoding="utf-8") as f: + source = f.read() + lowered = source.lower() + assert "docker" not in lowered, "the script must not touch containers (NFR-2)" + assert "systemctl" not in lowered + assert "compose" not in lowered + assert re.search(r"open\([^)]*\.env[^)]*['\"][wa]", source) is None, ( + "the script must never WRITE any .env (read-only access allowed)" + ) + + +# --------------------------------------------------------------------------- # +# verify — registry / states / labels / webhook / kit completeness (FR-5) +# --------------------------------------------------------------------------- # + +def _verify_files(mod): + params = _params() + rendered = mod.render_kit_in_memory(params) + files = dict(rendered) + for i in range(16): + files[f"docs/_templates/{i:02d}-skeleton.md"] = "x" + for name in ("PIPELINE_DOCS.md", "HANDOFF_PROTOCOL.md", "TRACEABILITY.md"): + files[f"docs/_standards/{name}"] = "x" + return files + + +def test_verify_all_green(mod): + params = _params() + plane = FakePlane( + mod, + project={"id": params["PLANE_PROJECT_ID"], "identifier": "DEMO"}, + states=_full_states(mod), + labels=_full_labels(mod), + ) + gitea = FakeGitea( + repo={"name": params["REPO"], "empty": False}, + hooks=[{"id": 1, "active": True, "config": {"url": _WEBHOOK_URL}}], + files=_verify_files(mod), + ) + entry = mod.build_registry_entry(params) + _, merged = mod.merged_projects_json(entry, "[]") + report = mod.run_verify( + params, plane, gitea, webhook_url=_WEBHOOK_URL, projects_raw=merged, + ) + gaps = [s for s in report.steps if s.status == mod.GAP] + assert not gaps, f"verify reported gaps on a fully onboarded project: {gaps}" + + +def test_verify_flags_missing_failclosed_statuses(mod): + params = _params() + states = [s for s in _full_states(mod) if s["name"] not in ("STOP", "Confirm Deploy")] + plane = FakePlane( + mod, + project={"id": params["PLANE_PROJECT_ID"], "identifier": "DEMO"}, + states=states, + labels=_full_labels(mod), + ) + gitea = FakeGitea( + repo={"name": params["REPO"], "empty": False}, + hooks=[{"id": 1, "active": True, "config": {"url": _WEBHOOK_URL}}], + files=_verify_files(mod), + ) + entry = mod.build_registry_entry(params) + _, merged = mod.merged_projects_json(entry, "[]") + report = mod.run_verify( + params, plane, gitea, webhook_url=_WEBHOOK_URL, projects_raw=merged, + ) + states_step = _step(report, "verify.plane.states") + assert states_step.status == mod.GAP + assert "STOP" in states_step.detail and "Confirm Deploy" in states_step.detail, ( + "verify must name the missing fail-closed statuses explicitly" + ) + assert report.exit_code == 2 diff --git a/tests/test_resolve_agent_effort.py b/tests/test_resolve_agent_effort.py index c48a7ba..833ad8b 100644 --- a/tests/test_resolve_agent_effort.py +++ b/tests/test_resolve_agent_effort.py @@ -42,6 +42,15 @@ def _clean_settings(monkeypatch): monkeypatch.setattr(settings, "agent_effort_default", "high") for a, e in CANON_EFFORT.items(): monkeypatch.setattr(settings, f"agent_effort_{a}", e) + # Hermeticity (mirrors test_resolve_agent_model's baseline): the flag-assembly + # tests below also read the MODEL/fallback fields, and the host env (prod .env; + # the merge-gate re-test runs under it) may legitimately set + # ORCH_AGENT_MODEL_* / ORCH_AGENT_FALLBACK_MODEL. These tests assert + # shipped-default behaviour, not the host config -> pin the shipped defaults. + monkeypatch.setattr(settings, "agent_model_default", "claude-opus-4-8") + for a in CANON_EFFORT: + monkeypatch.setattr(settings, f"agent_model_{a}", "") + monkeypatch.setattr(settings, "agent_fallback_model", "") monkeypatch.setattr(P.settings, "projects_json", "") reload_projects() yield diff --git a/tests/test_resolve_agent_model.py b/tests/test_resolve_agent_model.py index b3e170e..fb21feb 100644 --- a/tests/test_resolve_agent_model.py +++ b/tests/test_resolve_agent_model.py @@ -41,6 +41,9 @@ def _clean_settings(monkeypatch): monkeypatch.setattr(settings, "agent_model_default", "claude-opus-4-8") for a in ("analyst", "architect", "developer", "reviewer", "tester", "deployer"): monkeypatch.setattr(settings, f"agent_model_{a}", "") + # Hermeticity: the host env (prod .env; merge-gate re-test runs under it) + # may legitimately set ORCH_AGENT_FALLBACK_MODEL -> reset to shipped default. + monkeypatch.setattr(settings, "agent_fallback_model", "") # default registry (no per-project overrides) monkeypatch.setattr(P.settings, "projects_json", "") reload_projects() @@ -233,8 +236,12 @@ def test_valid_per_project_override_unchanged(monkeypatch): # ---- TC-09 / TC-11: G4 fallback is OFF (ADR-001 decision 3) ------------------ def test_fallback_model_disabled_by_default(): - # G4 not enabled: agent_fallback_model stays "" -> no --fallback-model flag. - assert settings.agent_fallback_model == "" + # G4 not enabled (ORCH-074 ADR-001, Решение 3): the SHIPPED default of + # agent_fallback_model is "" -> no --fallback-model flag out of the box. + # Assert the CLASS field default, not the runtime singleton: the host env + # may legitimately enable the fallback via ORCH_AGENT_FALLBACK_MODEL, and + # this test must stay hermetic (the merge-gate re-test runs under that env). + assert type(settings).model_fields["agent_fallback_model"].default == "" # never-break: the SAME predicate guards the inline fallback read in _spawn, # so a typo there would be rejected exactly like a model name. assert is_valid_model("claude-bad typo") is False