Files
houseplan-card/docs/specs/492-exact-candidate-and-input-manifest.md
T
2026-09-08 21:55:07 +00:00

187 lines
32 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# #492 — Точный кандидат интеграции и полный manifest входов selection/reuse
- **Issue:** https://github.com/Matysh/houseplan-card/issues/492
- **Тип / приоритет:** infra, tech-debt / P1
- **Трек:** полный — четыре поверхности протокола CI (слияние, реюз, классификация, отбор мутантов), без файлов класса A
- **Оценка:** ценность для разработки 9/10; сложность 6/10; риск 4/10 (протокол, который сам себя проверяет, — ошибка в нём делает зелёный ничего не значащим)
- **Связано:** аудит 2026-09-08 §11 п.3 (I1/I2); #208 (реюз), #312 (материал ревью), #364 (dev ушёл), #430 (оснастка смоков), #475 (отбор по гарду), #479 (тяжёлые гейты), #481 (журнал), `PROCESS.md` §7.2, §8, §10.3, §11.4
## 1. Проблема
Зелёный вердикт и зелёный Validate обязаны означать одно: *именно этот код* проверен *всеми проверками, чьи входы менялись*. Сейчас это верно не всегда — четыре механизма, подтверждённые чтением `ea6061e9`.
**1. Сливается не то дерево, что проверено.** `process.yml` фиксирует материал ревью (`steps.material`), после зелёного вердикта сверяет вершину ветки с материалом (#312), затем делает `git rebase origin/dev` и сразу `push HEAD:dev`. Если `dev` продвинулся за время ревью (28 августа — четыре раза за день), результат ребейза — новое дерево, которое никто не проверял: ни ревью (материал другой), ни Validate (на этот SHA он не бежал). Предупреждение #364 пишет комментарий и не блокирует. Минимальный git-эксперимент аудита: чистый ребейз, вердикт принят, поведение `20 → 40`.
**2. Реюз бэкенда не видит часть своих входов.** Job `backend` исполняет: pytest `tests_backend/`, `python -m unittest discover -s scripts/support-relay/tests`, ruff/mypy по `pyproject.toml`, порог покрытия. Тесты читают `scripts/sh3d-convert/golden/*.json`, `scripts/sh3d-convert/convert.mjs` (константы версий), `scripts/dump-config-schema.py`, `scripts/config-schema.json`. Ключ реюза (`HARNESS.backend` в `gate-reuse.mjs`) знает `tests_backend`, `custom_components/**/*.py`, `pytest.ini`, baseline и `pyproject.toml` — и **не знает** relay, converter, schema. Прямой случай: правка только `scripts/support-relay/relay.py` даёт `backend=true` в `changes` (регэксп классификатора), но ключ реюза не меняется — job отменяется как переиспользованная, и тесты relay не бегут. Классификатор `backend` при этом не видит `pyproject.toml` и `scripts/sh3d-convert/`: правка только их не запускает job вовсе.
**3. Golden и перф не видят протокол харнеса.** `demo/golden/run.mjs` импортирует `../serve.mjs` и `../bundle-freshness.mjs`; бенчмарки — `serve.mjs`, `editor-runtime-compat.mjs`, `bundle-freshness.mjs`; `serve.mjs` отдаёт страницу `demo/srv/demo.html`. Ключи `golden` и `performance_smoke` держат только `demo/golden/**` и `demo/performance/** + два benchmark`; `serve.mjs` есть лишь у `smoke` (#430), `demo/srv/demo.html` и compat-хелперы — ни у кого. Обратный перекос: `sourceFingerprint` (`src/**`) подмешан во **все** ключи, и любая правка UI сбрасывает реюз бэкенда, который UI не исполняет.
**4. Отбор мутантов теряет обёртки и новые определения.** `guardFiles()` берёт из строки гарда только токены-пути. 10 из 35 гардов `scripts/backend-test-guard.mjs` не передают третий аргумент и работают по умолчанию с `tests_backend/test_ha_import_export.py` — правка этого файла не отбирает их (#475 промахивается ровно так, как описано в аудите). `scripts/trail-resume-test-guard.mjs` держит пути тестов внутри себя — свидетели `vacuum-trail-*` не отбираются и не меняют отпечаток журнала (#481) при правке `test_trails.py`. Смоки импортируют `serve.mjs` и фикстуры — их изменение тоже не отбирает. При диффе только по `scripts/mutation-gate.mjs` job `changed_mutants` запускается (#475 r1), но `selectChangedMutants` пересекает дифф с файлами патчей и гардов — новый мутант, не трогающий их, не выбирается: «реестр изменился» не превращается в «новый свидетель прогнан».
**5. Ночной прогон рапортует об очереди, не о результате.** `nightly.yml` делает `gh workflow run validate.yml … -f full=true` и завершается успехом в момент постановки в очередь; красный полный прогон не делает ночной workflow красным.
## 1.1. Сценарий
Персоны: автор задачи, ревьюер, обслуживающий чат, владелец, читающий статусы. Все они читают «зелёный» как «проверено». Задача — сделать это чтение верным на четырёх швах, не удлиняя обычный путь: ветка, не пересекающаяся с движением `dev`, и правка, не трогающая входов job, проходят так же быстро, как сейчас.
## 1.2. Что человек увидит до и после
До: вердикт зелёный → `S8-merged`, в `dev` — ребейзнутое дерево без прогона; правка relay зелёная за секунды, потому что тесты relay не бежали; новый мутант в реестре зелёный, потому что не выбран.
После: если `dev` ушёл — конвейер публикует точный кандидат в ветку, ждёт Validate на этом SHA и только потом двигает `dev` с `--force-with-lease` на ожидаемую базу; в issue — строка «кандидат `abc1234` (ребейз на `dev@def5678`), Validate зелёный, слито». Правка relay/converter/schema/serve.mjs/demo.html запускает и не переиспользует те job, которые их исполняют. Новый или изменённый мутант бежит на первом же пуше. Ночной workflow красный, когда красный Validate.
## 2. Скоуп
1. **Слияние точного кандидата** (§4): проверка нового дерева перед push в `dev`, безопасная обработка повторного движения `dev`, равенство patch-id проверенного и сливаемого диффа.
2. **Единый manifest входов** (§5): один модуль объявляет входы каждой проверки по категориям source / tests / fixtures / config / toolchain / protocol; из него выводятся и классификация (`changes`), и ключ реюза; неизвестный исполняемый вход расширяет проверки; лишняя зависимость бэкенда от `src/**` снимается после доказанной полноты.
3. **Замыкание входов гарда мутанта** (§6): обёртки объявляют свои входы, импорты и фикстуры гардов входят в отбор и отпечаток; изменённые/новые определения реестра отбираются явно.
4. **Ночной прогон ждёт результат** (§7).
5. **Отрицательные тесты протокола** (§8): по каждому представительному входу доказано отсутствие false-green.
## 3. Не-скоуп
- Продуктовый код (`src/**`, `custom_components/**/*.py`) — если замыкание найдёт дефект продукта, он заводится отдельным issue.
- Перечень и содержание самих проверок Validate (какие job существуют, их бюджеты).
- Замена ревью-конвейера или изменение правил §7.2 о повторном ревью: при чистом ребейзе с равным patch-id вердикт остаётся в силе; при изменившемся диффе — повторное ревью по существующим правилам.
- Полный ночной набор (#479) остаётся полным; реюз на кандидатах по-прежнему невозможен (версия входит в manifest source).
- Отбор мутантов по транзитивным зависимостям **внутри `src/**`** — сторона патча остаётся точечной (§6.4).
## 4. Слияние точного кандидата
### 4.1. Решение выносится в скрипт
Логика шага «Слить ветку в dev» переезжает из shell в `scripts/merge-candidate.mjs` с чистой функцией `decideMerge(state)` и исполняющей обёрткой; `process.yml` вызывает скрипт. Состояние: `material` (SHA материала), `materialBase` (merge-base материала с `dev` на момент фиксации), `actual` (вершина ветки), `devNow`, `candidate` (результат ребейза `actual` на `devNow`), patch-id диффов `materialBase..material` и `devNow..candidate`, результат Validate на `candidate`, номер попытки.
### 4.2. Алгоритм
1. Проверка #312 как сейчас: `actual` = `material` либо `material` + один коммит документа ревью.
2. `devNow == materialBase` → `dev` не двигался, `candidate == actual` → push `HEAD:dev` c `--force-with-lease=refs/heads/dev:$devNow` (fast-forward). Как сейчас, плюс lease.
3. `dev` двигался → ребейз (конфликт — как сейчас, `S6-in-progress`). Сравнение patch-id: если дифф ветки после ребейза отличается от проверенного (контекст/содержимое патча изменились из-за соседних правок), кандидат публикуется в ветку, задача возвращается в `S7-code-review` с комментарием «дифф изменился при ребейзе, нужен новый заход ревью (§7.2)» — без слияния.
4. Patch-id равен → кандидат публикуется в ветку задачи (`--force-with-lease=refs/heads/$BRANCH:$actual`); пуш PAT-ом запускает Validate на `candidate`. Скрипт находит прогон по SHA (`gh run list --workflow validate.yml --commit`) и ждёт завершения (`gh run watch --exit-status`, лимит 45 мин; отсутствие прогона в течение 3 мин — ошибка, не «зелёный»).
5. Validate красный → `S6-in-progress`, комментарий с ссылкой на прогон: «кандидат после ребейза на `dev@…` красный».
6. Validate зелёный → `git push --force-with-lease=refs/heads/dev:$devNow candidate:dev`. Lease отклонён (`dev` двинулся снова) → `fetch`, новая попытка с п.3; не более **3** попыток, после — `S6-in-progress` с комментарием «dev движется быстрее слияния, повторить».
7. `S8-merged` — только после успешного push (инвариант «метка не врёт» сохраняется).
Что считается проверкой нового дерева: Validate на ветке — лёгкий набор плюс диффозависимые гейты (`changed_mutants` по `dev..candidate`, перф-профили по диффу). Тяжёлые гейты по #479 остаются за кандидатом релиза; это сознательно: слияние не превращает каждый ребейз в 20-минутный прогон, а проверяет ровно то, что проверяет обычный пуш той же дельты.
### 4.3. Что видно в issue
Один комментарий на слияние: `материал abc1234 · dev@def5678 → кандидат 0123abc · Validate <ссылка> зелёный · слито`. Комментарий #364 («dev продвинулся») остаётся — теперь он предваряет проверку, а не констатирует риск.
## 5. Единый manifest входов
### 5.1. Модуль
`scripts/check-inputs.mjs` экспортирует `CHECKS` — по одной записи на проверку Validate: `frontend`, `backend`, `changed_mutants`, `integration` (hacs + hassfest), `smoke`, `golden`, `performance_smoke`, `docs`. Каждая запись — категории входов, каждая категория — список путей/предикатов:
| категория | что это | примеры |
|---|---|---|
| `source` | код, который проверка исполняет или собирает | `src/**` для frontend/smoke/golden/perf; `custom_components/**/*.py` для backend/integration; `scripts/sh3d-convert/*.mjs`, `scripts/support-relay/**/*.py` для backend |
| `tests` | сами тесты и их обёртки | `test/**`, `tests_backend/**`, `scripts/support-relay/tests/**`, `scripts/*-guard.mjs` |
| `fixtures` | данные, которые тесты читают | `demo/fixtures/**`, `demo/golden/**` (сценарии и эталоны), `scripts/sh3d-convert/golden/**`, `scripts/config-schema.json` |
| `config` | конфиги инструментов | `pyproject.toml`, `pytest.ini`, `tsconfig*.json`, `rollup.config.mjs`, `scripts/backend-coverage-baseline.txt`, `demo/performance/budgets-*.json` |
| `toolchain` | что определяет среду и версии | `package.json`, `package-lock.json`, `tests_backend/requirements.txt`, сам `.github/workflows/validate.yml` (секция job — см. 5.4) |
| `protocol` | харнес и протокол job | `demo/serve.mjs`, `demo/srv/**` (кроме `assets/` класса D), `demo/bundle-freshness.mjs`, `demo/*-runtime-compat.mjs`, `demo/guard/**`, `scripts/gate-reuse.mjs`, `scripts/classify-changes.mjs`, `scripts/check-inputs.mjs`, `scripts/mutation-gate.mjs` для `changed_mutants` |
Функции: `inputsOf(check) → string[]` (развёрнутый по `git ls-files` список), `checksAffectedBy(files) → Set<check>`, `coverage(files) → { covered, unknown }`.
### 5.2. Классификация из manifest
`classify-changes.mjs` перестаёт держать свои регэкспы: выход `X = 'true'` ⇔ `checksAffectedBy(diff)` содержит X. Подклассификаторы `perf_iso` / `perf_interaction` остаются как есть (фильтры по `source` перф-смока). **Неизвестный исполняемый вход** — изменённый файл, который `coverage` не относит ни к одной проверке и который по расширению/пути исполняем (`*.mjs|*.js|*.ts|*.py|*.json|*.yml|*.yaml|*.html|*.toml|*.txt` под `scripts/`, `demo/`, `test/`, `tests_backend/`, `.github/`, `custom_components/`, `src/`) — включает **все** выходы (`classifyAll`) и пишет в summary «неизвестный вход: <файл> — расширен до полного набора». Документация (`docs/**`, `*.md` вне `docs`) и class-D копии бандла — известные *не*-входы, они не расширяют.
### 5.3. Ключ реюза из manifest
`gate-reuse.mjs`: ключ job = `job:` + хеш содержимого файлов `inputsOf(job)` (канонизация переводов строк как сейчас). `HARNESS` и подмешивание `sourceFingerprint` уходят; `sourceFingerprint` остаётся у бандла и скриншотов (#245) — там его место. Следствие: `backend` перестаёт зависеть от `src/**`, но это включается **последним коммитом**, после того как §8.3 (полнота) зелёный, и с записью в CHANGELOG-у-инфры (`docs/TESTING.md`). Маркер падения (#386) и маркеры успеха не меняют формат — меняется только способ вычисления ключа, старые маркеры просто не совпадут один раз.
### 5.4. Секция job в toolchain
Правка `validate.yml` меняет протокол только той job, чью секцию правят. `inputsOf` для категории `toolchain` включает `validate.yml` целиком — простое и честное решение: правка workflow гоняет всё. Отдельная «секция job» не вырезается (YAML-парсинг ради экономии одного прогона — лишняя сложность).
### 5.5. Полнота
`test/check-inputs.test.mjs` держит **лист покрытия**: каждый файл из `git ls-files`, исполняемый по критерию §5.2, обязан входить в manifest хотя бы одной проверки или в явный список `NOT_AN_INPUT` (с причиной: `demo/docs/**` — съёмка документации вне Validate, `scripts/release-*.mjs` — релизные, `scripts/*.md`). Новый скрипт в `scripts/` без записи в manifest — красный тест, не тихое расширение.
## 6. Замыкание входов гарда мутанта
### 6.1. Обёртки объявляют входы
Каждый `scripts/*-guard.mjs`, используемый в `guard:` реестра, экспортирует `export const GUARD_INPUTS = [...]` — файлы, которые он запускает или читает (для `backend-test-guard.mjs` — умолчание `tests_backend/test_ha_import_export.py` **плюс** третий аргумент, если передан). `guardInputs(guard)` = файлы-токены из строки (как `guardFiles`) ∪ `GUARD_INPUTS` каждой найденной обёртки (читаются статически регэкспом, без исполнения). Тест реестра: у каждой обёртки из гардов есть `GUARD_INPUTS`, и умолчание в коде совпадает с объявлением.
### 6.2. Импорты и фикстуры гарда
Для каждого файла гарда `.mjs` — транзитивное замыкание относительных `import`/`export … from` внутри `test/`, `demo/`, `scripts/` (не `src/`, не `node_modules`); для `.py` — `from tests_backend.x import`, `import tests_backend.x` и всегда `tests_backend/conftest.py`. Фикстуры — строковые литералы путей `demo/fixtures/…`, `demo/golden/…`, `scripts/sh3d-convert/golden/…`, `test/fixtures/…` в файлах замыкания. Замыкание кэшируется на прогон (реестр — 582 мутанта, сотни гардов; чтение файлов один раз).
### 6.3. Отбор и отпечаток
`selectChangedMutants` и `witnessFingerprint` используют `guardInputs` + замыкание §6.2. Отпечаток включает содержимое всех файлов замыкания — правка `test_trails.py` меняет отпечаток `vacuum-trail-*`, правка `serve.mjs` — всех смок-свидетелей.
### 6.4. Изменённые определения реестра
Если дифф содержит `scripts/mutation-gate.mjs`, отбор дополнительно берёт мутантов, чьё определение (`id`, `guard`, `patches`, `because`) добавлено или изменено относительно базы: реестр базы читается через `git show <base>:scripts/mutation-gate.mjs` во временный файл и импортируется как модуль (реестр — данные, побочных эффектов при импорте нет — это закреплено тестом). Удалённые мутанты — предупреждение в summary. Сторона патча остаётся точечной (не-скоуп): мутант, патчащий `a.ts`, не отбирается правкой `b.ts` — это зона полного прогона (ночь/кандидат), как и было.
## 7. Ночной прогон
`nightly.yml` после `gh workflow run` находит запущенный прогон (по `workflow_dispatch`, ветке `dev`, времени ≥ момента запуска; до 3 мин ожидания появления) и `gh run watch <id> --exit-status` с лимитом job 90 мин. Красный Validate → красный nightly; сообщение о падении в summary — ссылка на дочерний прогон.
## 8. Отрицательные тесты протокола
8.1. **Manifest**: для каждой проверки и каждой непустой категории — представительный реальный файл; тест подменяет его содержимое (через инъекцию `read` в функцию ключа) и утверждает: ключ реюза меняется, `checksAffectedBy` содержит проверку. Список представителей: `scripts/support-relay/relay.py`, `scripts/sh3d-convert/golden/*.json`, `scripts/config-schema.json`, `pyproject.toml`, `demo/serve.mjs`, `demo/srv/demo.html`, `demo/editor-runtime-compat.mjs`, `demo/golden/baselines/*.png`, `demo/performance/budgets-*.json`, `tests_backend/requirements.txt`, `.github/workflows/validate.yml`. **Обратная проба (AC6):** представитель `src/houseplan-card.ts` — ключ `backend` **не** меняется и `checksAffectedBy` **не** содержит `backend`; тест в `test/gate-reuse.test.mjs` и `test/classify-changes.test.mjs`, чтобы случайный возврат `sourceFingerprint` в ключ бэкенда был пойман, а не молча удлинял прогоны.
8.2. **Мутанты**: правка `tests_backend/test_ha_import_export.py` отбирает все 10 обёрток без третьего аргумента; правка `tests_backend/test_trails.py` отбирает `vacuum-trail-*` и меняет их отпечаток; правка `demo/serve.mjs` отбирает все смок-свидетели; дифф только по реестру с новым мутантом отбирает ровно его.
8.3. **Полнота**: лист покрытия §5.5 зелёный на текущем `git ls-files`.
8.4. **Слияние**: `decideMerge` — таблица случаев: `dev` не двигался → push; двигался, patch-id равен, Validate зелёный → push с lease; lease отклонён дважды → повтор, трижды → `S6`; patch-id отличается → `S7`; Validate красный → `S6`; прогон не найден → ошибка, не push. Плюс git-эксперимент аудита в temp-репозитории (`20 → 40`): скрипт требует Validate, не пушит.
8.5. **Мутанты на протокол** (реестр `mutation-gate.mjs`, каждый с отрицательным прогоном): `manifest-drops-protocol-category` (ключ без `protocol` → 8.1 падает), `classify-unknown-input-is-unaffected` (неизвестный вход не расширяет → 8.1 падает), `guard-inputs-ignore-wrapper-defaults` (→ 8.2 падает), `registry-diff-not-selected` (→ 8.2 падает), `merge-pushes-unvalidated-candidate` (→ 8.4 падает), `nightly-does-not-wait` — контрактный тест workflow (`test/nightly-workflow.test.mjs`) по образцу `validate-workflow.test.mjs`.
## 9. Совместимость и откат
Маркеры реюза старого формата ключа не совпадают — один лишний полный прогон после слияния, дальше как обычно. `process.yml` читается конвейером из ветки по умолчанию — **зеркало в `main` только после слияния в `dev`** (правило #454). Откат — revert коммитов инфры; продуктового кода нет.
## 10. Критерии приёмки
- AC1. При `dev`, продвинувшемся за время ревью, в `dev` попадает SHA, для которого есть зелёный Validate; без него слияния нет; `S8-merged` ставится только после push.
- AC2. Повторное движение `dev` между Validate и push не приводит к силовой перезаписи: lease, повтор, лимит 3, затем `S6-in-progress` с комментарием.
- AC3. Дифф с изменившимся patch-id после ребейза не сливается — задача в `S7-code-review`.
- AC4. Ключ реюза каждой job вычисляется из manifest; для каждого представителя §8.1 доказано изменение ключа и классификации.
- AC5. Неизвестный исполняемый вход расширяет прогон до полного набора и виден в summary; лист покрытия §5.5 зелёный.
- AC6. `backend` не зависит от `src/**` — включено последним коммитом после AC4–AC5; доказано обратной пробой §8.1 (правка `src/houseplan-card.ts` не меняет ключ `backend` и не классифицируется как `backend`).
- AC7. Обёртки гардов объявляют `GUARD_INPUTS`; отбор и отпечаток видят обёртки, импорты и фикстуры (§8.2).
- AC8. Новый/изменённый мутант при диффе только по реестру отбирается и бежит.
- AC9. `nightly.yml` красный при красном дочернем Validate.
- AC10. Шесть мутантов §8.5 пойманы штатным раннером; `docs/TESTING.md` и `PROCESS.md` §10.3 описывают точный кандидат и manifest.
## 10.1. UX, модель данных, i18n
Не затрагиваются.
## 10.2. Риски и меры
- Ожидание Validate внутри ревью-job удлиняет слияние на 5–8 мин **только** когда `dev` двигался; лимит ожидания 45 мин с возвратом в `S6`, не зависанием.
- Пуш кандидата в ветку PAT-ом запускает Validate — если PAT когда-нибудь заменят на `GITHUB_TOKEN`, прогон не стартует; шаг ждёт 3 мин и падает с явной ошибкой «прогон не появился», не с ложным зелёным.
- Замыкание импортов расширяет отбор мутантов: правка `serve.mjs` отберёт все смок-свидетели (десятки минут в 3 шардах). Это цена честности; журнал #481 не смягчает — отпечаток тоже меняется. Оценить фактическое время на первом прогоне; при необходимости — четвёртый шард отдельным issue.
- Лист покрытия начнёт краснеть на каждом новом скрипте — намеренно: запись в manifest занимает одну строку.
## 11. Release-артефакты
Не user-visible. `docs/TESTING.md` (реюз, manifest, отбор мутантов), `PROCESS.md` §10.3 (слияние точного кандидата), зеркало `process.yml`/`nightly.yml` в `main` после слияния.
## 12. Затронутые файлы
`scripts/check-inputs.mjs` (новый), `scripts/classify-changes.mjs`, `scripts/gate-reuse.mjs`, `scripts/mutation-gate.mjs` (guardInputs, замыкание, registry diff, шесть мутантов), `scripts/backend-test-guard.mjs`, `scripts/trail-resume-test-guard.mjs` (+ остальные обёртки — `GUARD_INPUTS`), `scripts/merge-candidate.mjs` (новый), `.github/workflows/process.yml`, `.github/workflows/nightly.yml`, `.github/workflows/validate.yml` (summary неизвестных входов), `test/check-inputs.test.mjs` (новый), `test/merge-candidate.test.mjs` (новый), `test/nightly-workflow.test.mjs` (новый), `test/gate-reuse.test.mjs`, `test/classify-changes.test.mjs`, `test/mutation-gate.test.mjs`, `docs/TESTING.md`, `PROCESS.md`.
## 12.1. Уточнение по реализации (S6)
Категории §5.1 в коде не перечисляются руками: `CHECKS` объявляет у проверки
корни (`roots`, что читается не из кода: `src/**` у сборки, `tests_backend/**` у
pytest, `demo/golden/**` у эталонов, `validate.yml` у всех) и точки входа
(`entries`), а source/tests/fixtures/config/protocol выводятся замыканием точек
входа по импортам (транзитивно) и строковым путям (как листья). Это даёт те же
представители §8.1, но без ручного списка, который отстаёт от кода. Реестр
мутантов — лист замыкания (его гарды называют сотни путей как данные, не как
зависимости); обёртки гардов объявляют `GUARD_INPUTS`, их собственные ссылки не
читаются, чтобы явный аргумент отменял умолчание.
## 13. Принятые предположения
- Validate на ветке (лёгкий набор + диффозависимые гейты) — достаточная «проверка нового дерева и затронутых взаимодействий» для слияния; тяжёлые гейты — за кандидатом релиза (#479).
- `HP_PROCESS_TOKEN` остаётся PAT (пуш им запускает workflow).
- Порядок реализации: §5 + §8.1/8.3 → §6 + §8.2 → §4 + §8.4 → §7 → AC6 последним. Один issue, одна ветка, одно ревью.